diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8e376aa..6ec5448 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,6 +22,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/README.md b/README.md index 8d3de1f..253e4ed 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ only needs `light`. - **Brand colors** - nebari-design's OKLCH tokens mapped onto Starlight's theme variables in both light and dark mode. Starlight's WCAG-tuned gray scale is kept for accessible body and muted text. -- **Typography** - Poppins for headings, Atkinson Hyperlegible for body, Fira - Code for code, all self-hosted (no external font requests at runtime). +- **Typography** - Geist for body and headings, IBM Plex Mono for code, both + self-hosted (no external font requests at runtime). - **Logo, favicon, and footer** - the Nebari mark in the header, an inlined symbol favicon, and a branded footer on every page. - **Search** - Starlight's built-in Pagefind, ready to merge additional pack diff --git a/bun.lock b/bun.lock index c037138..80fda14 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/starlight": { "name": "@nebari/starlight", - "version": "0.1.6", + "version": "0.2.1", "devDependencies": { "@astrojs/starlight": "^0.41.3", "astro": "^7.0.6", diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 217aae9..0434e0c 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -6,7 +6,16 @@ export default defineConfig({ integrations: [ starlight({ title: 'Nebari Starlight', - plugins: [nebari()], + description: 'Shared Starlight theme for Nebari documentation sites.', + plugins: [ + nebari({ + nav: [ + { label: 'Docs', href: '/' }, + { label: 'Guides', href: '/guides/authoring-content/' }, + { label: 'Reference', href: '/reference/configuration/' }, + ], + }), + ], sidebar: [ { label: 'Getting Started', @@ -56,6 +65,7 @@ export default defineConfig({ link: '/reference/configuration/', }, { label: 'Components', link: '/reference/components/' }, + { label: 'Kitchen Sink', link: '/reference/kitchen-sink/' }, ], }, ], diff --git a/docs/e2e/theme-search-a11y.spec.ts b/docs/e2e/theme-search-a11y.spec.ts index eec9085..07f63dc 100644 --- a/docs/e2e/theme-search-a11y.spec.ts +++ b/docs/e2e/theme-search-a11y.spec.ts @@ -79,23 +79,186 @@ test('search returns the seeded token', async ({ page }) => { }); }); +test('the mobile drawer exposes nav tabs and keeps accessible names', async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto('/guides/deployment/build/'); + + await expect(page.locator('.nbr-nav-tabs--header')).toBeHidden(); + const menu = page.locator('starlight-menu-button'); + await expect(page.locator('.nbr-nav-tabs--drawer')).toBeHidden(); + await menu.locator('button').click(); + await expect(menu).toHaveAttribute('aria-expanded', 'true'); + + const drawerTabs = page.locator('.nbr-nav-tabs--drawer'); + await expect(drawerTabs).toBeVisible(); + await expect(drawerTabs.locator('a[aria-current="page"]')).toHaveCount(1); + + const summaries = page.locator('#starlight__sidebar summary'); + for (let i = 0; i < (await summaries.count()); i++) { + expect((await summaries.nth(i).innerText()).trim().length).toBeGreaterThan( + 0, + ); + } +}); + test('home and content pages have no serious/critical a11y violations', async ({ page, }) => { // Cover the splash home plus a component-heavy guide and a table-heavy // reference page, so the a11y sweep exercises the full docs layout. - for (const path of [ - '/', - '/guides/authoring-content/', - '/reference/components/', + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }); + for (const [width, height] of [ + [1440, 900], + [375, 800], + ]) { + await page.setViewportSize({ width, height }); + for (const path of [ + '/', + '/guides/authoring-content/', + '/reference/components/', + '/reference/kitchen-sink/', + ]) { + await page.goto(path); + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + colorScheme, + ); + await page.waitForFunction(() => + [...document.querySelectorAll('pre')].every( + (el) => + el.scrollWidth <= el.clientWidth || el.hasAttribute('tabindex'), + ), + ); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + const serious = results.violations.filter( + (v) => v.impact === 'serious' || v.impact === 'critical', + ); + expect( + serious, + `${colorScheme} ${width}px ${path}: ${JSON.stringify( + serious.map((v) => v.id), + )}`, + ).toEqual([]); + } + } + } +}); + +test('wide viewports centre the content panel when a TOC is present', async ({ + page, +}) => { + await page.setViewportSize({ width: 1896, height: 940 }); + await page.goto('/getting-started/quickstart/'); + + const [left, right] = await page.evaluate(() => { + const panel = document.querySelector('main > .content-panel'); + const container = panel?.querySelector('.sl-container'); + if (!panel || !container) return [NaN, NaN]; + const pr = panel.getBoundingClientRect(); + const cr = container.getBoundingClientRect(); + return [cr.left - pr.left, pr.right - cr.right]; + }); + expect(left).toBeGreaterThan(0); + expect(Math.abs(left - right)).toBeLessThan(1); +}); + +test('tables stay in column and scroll locally', async ({ page }) => { + for (const width of [375, 620, 900]) { + await page.setViewportSize({ width, height: 800 }); + + for (const path of [ + '/reference/configuration/', + '/reference/kitchen-sink/', + ]) { + await page.goto(path); + await page.waitForFunction( + () => document.querySelector('.nbr-table-scroll') !== null, + ); + const layout = await page.evaluate(() => { + const wrappers = [ + ...document.querySelectorAll('.nbr-table-scroll'), + ]; + return { + pageWidth: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + wrappers: wrappers.map((wrapper) => { + const table = wrapper.querySelector('table'); + const row = table?.querySelector('tr'); + return { + client: wrapper.clientWidth, + scroll: wrapper.scrollWidth, + tabIndex: wrapper.tabIndex, + tableWidth: table?.clientWidth ?? 0, + rowWidth: row?.getBoundingClientRect().width ?? 0, + }; + }), + }; + }); + const label = `${width}px ${path}`; + expect(layout.pageWidth, label).toBe(layout.viewport); + expect( + layout.wrappers.every( + (wrapper) => wrapper.tableWidth <= wrapper.rowWidth + 2, + ), + `${label} table grid must meet its border`, + ).toBe(true); + expect( + layout.wrappers.every((wrapper) => + wrapper.scroll > wrapper.client + ? wrapper.tabIndex === 0 + : wrapper.tabIndex <= 0, + ), + `${label} wrapper must be keyboard-reachable when it scrolls`, + ).toBe(true); + } + } +}); + +test('an unbreakable table value scrolls inside its cell', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/reference/configuration/'); + await page.waitForFunction( + () => document.querySelector('.nbr-table-cell-scroll') !== null, + ); + + const overflow = await page.evaluate(() => { + const cell = document.querySelector('.sl-markdown-content td'); + const scroller = cell?.querySelector('.nbr-table-cell-scroll'); + const content = scroller?.querySelector('.nbr-table-cell-content'); + if (!scroller || !content) return null; + content.textContent = 'x'.repeat(200); + return { + pageWidth: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + cellScroll: scroller.scrollWidth, + cellClient: scroller.clientWidth, + }; + }); + expect(overflow).not.toBeNull(); + expect(overflow?.pageWidth).toBe(overflow?.viewport); + expect((overflow?.cellScroll ?? 0) > (overflow?.cellClient ?? 0)).toBe(true); +}); + +test('exactly one "Site" nav landmark is exposed at each width', async ({ + page, +}) => { + for (const [width, height] of [ + [1440, 900], + [375, 800], ]) { - await page.goto(path); - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa']) - .analyze(); - const serious = results.violations.filter( - (v) => v.impact === 'serious' || v.impact === 'critical', + await page.setViewportSize({ width, height }); + await page.goto('/guides/authoring-content/'); + const exposed = await page.evaluate( + () => + [...document.querySelectorAll('nav[aria-label="Site"]')].filter( + (n) => (n as HTMLElement).offsetParent !== null, + ).length, ); - expect(serious, JSON.stringify(serious.map((v) => v.id))).toEqual([]); + expect(exposed, `${width}px exposed ${exposed} "Site" navs`).toBe(1); } }); diff --git a/docs/package.json b/docs/package.json index 31707f5..49d6c8c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,9 +3,10 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", - "build:base": "astro build --base /demo-pack", + "theme": "bun run --filter '@nebari/starlight' build", + "dev": "bun run theme && astro dev", + "build": "bun run theme && astro build", + "build:base": "bun run theme && astro build --base /demo-pack", "preview": "astro preview", "e2e": "playwright test" }, diff --git a/docs/src/content/docs/getting-started/quickstart.mdx b/docs/src/content/docs/getting-started/quickstart.mdx index 55584c3..ca4e774 100644 --- a/docs/src/content/docs/getting-started/quickstart.mdx +++ b/docs/src/content/docs/getting-started/quickstart.mdx @@ -100,8 +100,8 @@ title: Hello, Nebari description: My first themed page. --- -Welcome to my pack. This paragraph already uses Inter, and code blocks use Fira -Code: +Welcome to my pack. This paragraph already uses Geist, and code blocks use IBM +Plex Mono: ```py print("themed code, no extra setup") diff --git a/docs/src/content/docs/guides/authoring-content.mdx b/docs/src/content/docs/guides/authoring-content.mdx index e02427f..49f9350 100644 --- a/docs/src/content/docs/guides/authoring-content.mdx +++ b/docs/src/content/docs/guides/authoring-content.mdx @@ -83,8 +83,8 @@ This is a tip. ## Code blocks -Code blocks are styled with Fira Code and support titles, line highlighting, and -diff markers via Expressive Code (bundled with Starlight): +Code blocks are styled with IBM Plex Mono and support titles, line highlighting, +and diff markers via Expressive Code (bundled with Starlight): ```js title="astro.config.mjs" {3} del={4} ins={5} starlight({ diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 2a32c0f..32eacaa 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -36,11 +36,11 @@ accessible, on-brand site out of the box. New here? Jump to the OKLCH design tokens from nebari-design map onto Starlight's variables: a - magenta-violet accent with slate-tinted surfaces, tuned for light and dark. + magenta accent with slate-tinted surfaces, tuned for light and dark. - Inter for body, Space Grotesk for headings, Lora for page titles, and Fira - Code for code — all self-hosted, no CDN calls, no layout shift. + Geist for body and headings, IBM Plex Mono for code — all self-hosted, no + CDN calls, no layout shift. Starlight's built-in Pagefind search is enabled and styled to match, ready diff --git a/docs/src/content/docs/reference/kitchen-sink.mdx b/docs/src/content/docs/reference/kitchen-sink.mdx new file mode 100644 index 0000000..253830f --- /dev/null +++ b/docs/src/content/docs/reference/kitchen-sink.mdx @@ -0,0 +1,159 @@ +--- +title: Kitchen Sink +description: Every content block the docs theme styles, on one page, for visual regression checking against the design. +--- + +import { Aside, Badge, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; + +This page contains every content block the docs theme supports. Use this to verify +typography, spacing, and color tokens across components. + +## Overview + +Authentication in Nebari is handled by Keycloak, the identity service bundled with +every deployment. When a user visits the hub, unauthenticated requests are +redirected to the configured provider, and the returned identity is mapped onto +Nebari roles and groups. + +:::note +Provider changes take effect after you run `nebari deploy`. Existing user sessions +are preserved, but new logins use the updated configuration. +::: + +## Configure a provider + +Set the provider under `security.authentication.type` and supply its credentials in +your `nebari-config.yaml` file. + +A tabbed frame with an `OUTPUT` frame joined directly beneath it — authored as two +adjacent blocks with no blank-line-separated content between them: + + + + ```py + print("Hello World!") + ``` + + + ```js + console.log('Hello World!'); + ``` + + +```text title="OUTPUT" frame="terminal" +Hello World! +``` + +A titled frame with terminal chrome — `frame="terminal"` plus a `title`: + +```sh title="nebari-config.yaml" frame="terminal" +nebari init aws --project nebari-demo --domain nebari.quansight.dev +nebari deploy --config nebari-config.yaml +nebari destroy --config nebari-config.yaml +``` + +A plain frame, no title: + +```yaml +# GitHub OAuth provider +security: + authentication: + type: GitHub + config: + client_id: "Iv1.a1b2c3d4e5f6" + client_secret: "${GITHUB_CLIENT_SECRET}" + # callback URL is generated by Nebari +``` + +## Supported providers + +Nebari ships with adapters for the most common identity providers. Password auth is +available for local testing but not recommended in production. + +| Provider | Protocol | Status | +| -------- | --------- | ---------------------------------------------- | +| GitHub | OAuth 2.0 | | +| Auth0 | OIDC | | +| Google | OAuth 2.0 | | +| Password | Local | | + +## Callouts & tabs + +:::tip +Use `nebari validate` before deploying — it catches most configuration mistakes +locally. +::: + +:::caution +Changing the authentication type logs out all users. Schedule provider migrations +outside working hours. +::: + +:::danger +Deleting the Keycloak realm removes all users, groups, and client secrets. This +cannot be undone. +::: + + + + Deploying to AWS requires credentials with EKS, VPC, and S3 permissions. Set + your region before running `nebari init`. + + + Deploying to GCP requires a service account with GKE and Cloud Storage roles. + + + Deploying to Azure requires a subscription with AKS and Blob Storage enabled. + + + Local deployments run on `kind` and need no cloud credentials. + + + +## Media & lists + +Markdown images do not emit a `
`, so captioned figures are authored as +literal HTML in `.mdx`: + +
+ + + Keycloak + + + JupyterHub + +
Figure 1 — Keycloak brokers identity for JupyterHub and every Nebari service.
+
+ +- Cloud providers + - AWS — full support, including GPU node groups + - GCP — full support + - Azure — full support +- Existing Kubernetes clusters +- Local (kind) — testing and development only + +1. Install the CLI: `pip install nebari` +2. Generate a config: `nebari init` +3. Deploy: `nebari deploy -c nebari-config.yaml` + +## Steps + + + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `` around your ordered list items. + + + +## Aside with a component title + + diff --git a/docs/test/base-path.test.ts b/docs/test/base-path.test.ts index 1af6934..a77537f 100644 --- a/docs/test/base-path.test.ts +++ b/docs/test/base-path.test.ts @@ -22,3 +22,13 @@ test('assets and links are prefixed with the base path', () => { // No bare-root asset references that would 404 behind the Worker subpath. expect(html).not.toMatch(/href="\/_astro\//); }); + +test('nav tab and footer hrefs carry the base exactly once', () => { + const html = readFileSync(HOME, 'utf8'); + const tabs = html.match( + /