diff --git a/.github/screenshots/dark.png b/.github/screenshots/dark.png
index 72ae782..192b660 100644
Binary files a/.github/screenshots/dark.png and b/.github/screenshots/dark.png differ
diff --git a/.github/screenshots/light.png b/.github/screenshots/light.png
index e2025cc..5d0b59f 100644
Binary files a/.github/screenshots/light.png and b/.github/screenshots/light.png differ
diff --git a/.gitignore b/.gitignore
index 422d090..25068ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -125,6 +125,9 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
+# Editor/AI assistant local config
+.cursor/
+
# pnpm
.pnpm-store
diff --git a/README.md b/README.md
index 253e4ed..2c1acb1 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,8 @@ export default defineConfig({
```
That's it - the plugin wires in the theme CSS, self-hosted fonts, the branded
-`SiteTitle`/`Head`/`Footer` components, and a default GitHub social link.
+header, splash footer, and a default GitHub social link. Pass `nav` if you want
+top-level Docs / Guides / Reference tabs.
### Serving under a subpath
@@ -106,9 +107,12 @@ only needs `light`.
- **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
- indexes for portal-wide multisite search.
+ symbol favicon, and a branded multi-column footer on splash pages (home, 404).
+ Doc pages with a sidebar end at their content.
+- **Nav tabs** - optional `nav` items render as header tabs and in the mobile
+ drawer. Omit the option and the header stays stock Starlight.
+- **Search** - Starlight's built-in Pagefind, styled to the Docs theme, ready to
+ merge additional pack indexes for portal-wide multisite search.
Everything is overridable: your own `customCss`, `components`, and `social`
entries are merged after the theme's, so a consumer always wins.
@@ -146,6 +150,19 @@ Run the test suite (builds the package and docs, then runs the tests):
bun test
```
+The root `test` script covers `packages/starlight/test` and `docs/test` (the
+`--base /demo-pack` build). End-to-end checks live in `docs/`:
+
+```sh
+cd docs && bun run e2e
+```
+
+Regenerate the README screenshots (out of CI, 1440×900):
+
+```sh
+cd docs && bun run screenshots
+```
+
### Linting and formatting
[Biome](https://biomejs.dev) handles both formatting and linting (including
@@ -196,9 +213,20 @@ This package follows [EffVer](https://jacobtomlinson.dev/effver/)
your build unexpectedly:
```jsonc
-{ "dependencies": { "@nebari/starlight": "^0.1.0" } }
+{ "dependencies": { "@nebari/starlight": "^0.3.0" } }
```
+### 0.3.0
+
+EffVer meso. Four behaviours change without an opt-in:
+
+1. The footer no longer renders on doc pages — splash pages only.
+2. `lastUpdated` defaults to `true`, so a date appears on pages that had none.
+3. Nav tabs appear only if `nav` is set; absent, the header is byte-identical.
+4. Tables scroll rather than wrap. Long values scroll inside the cell or the
+ table instead of breaking across lines. With JavaScript disabled, a wide
+ table can overflow the page.
+
## Releasing
Releases are published to npm automatically from GitHub Releases via
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 0434e0c..e9df874 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -11,7 +11,7 @@ export default defineConfig({
nebari({
nav: [
{ label: 'Docs', href: '/' },
- { label: 'Guides', href: '/guides/authoring-content/' },
+ { label: 'Guides', href: '/guides/' },
{ label: 'Reference', href: '/reference/configuration/' },
],
}),
@@ -32,6 +32,7 @@ export default defineConfig({
{
label: 'Guides',
items: [
+ { label: 'Overview', link: '/guides/' },
{
label: 'Authoring Content',
link: '/guides/authoring-content/',
diff --git a/docs/e2e/screenshots.spec.ts b/docs/e2e/screenshots.spec.ts
new file mode 100644
index 0000000..3ebe9ab
--- /dev/null
+++ b/docs/e2e/screenshots.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from '@playwright/test';
+
+test.describe('README screenshots', () => {
+ test.skip(
+ !process.env.SCREENSHOTS,
+ 'set SCREENSHOTS=1 to regenerate README screenshots',
+ );
+
+ test('capture light and dark homepage screenshots', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto('/');
+
+ for (const theme of ['light', 'dark'] as const) {
+ await page
+ .locator('html')
+ .evaluate((el, next) => el.setAttribute('data-theme', next), theme);
+ await expect(page.locator('.hero')).toBeVisible();
+ await page.screenshot({
+ path: `../.github/screenshots/${theme}.png`,
+ });
+ }
+ });
+});
diff --git a/docs/e2e/theme-search-a11y.spec.ts b/docs/e2e/theme-search-a11y.spec.ts
index 07f63dc..0a1471b 100644
--- a/docs/e2e/theme-search-a11y.spec.ts
+++ b/docs/e2e/theme-search-a11y.spec.ts
@@ -74,7 +74,9 @@ test('search returns the seeded token', async ({ page }) => {
// The probe token lives in the Components reference page; Pagefind surfaces it
// under that page's title.
- await expect(page.locator('text=Components').first()).toBeVisible({
+ await expect(
+ page.locator('#starlight__search .pagefind-ui__result-link').first(),
+ ).toBeVisible({
timeout: 15_000,
});
});
@@ -117,9 +119,11 @@ test('home and content pages have no serious/critical a11y violations', async ({
await page.setViewportSize({ width, height });
for (const path of [
'/',
+ '/guides/',
'/guides/authoring-content/',
'/reference/components/',
'/reference/kitchen-sink/',
+ '/this-page-does-not-exist/',
]) {
await page.goto(path);
await expect(page.locator('html')).toHaveAttribute(
@@ -132,8 +136,16 @@ test('home and content pages have no serious/critical a11y violations', async ({
el.scrollWidth <= el.clientWidth || el.hasAttribute('tabindex'),
),
);
+ const pageOverflow = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ client: document.documentElement.clientWidth,
+ }));
+ expect(
+ pageOverflow.scroll,
+ `${colorScheme} ${width}px ${path} overflow`,
+ ).toBe(pageOverflow.client);
const results = await new AxeBuilder({ page })
- .withTags(['wcag2a', 'wcag2aa'])
+ .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
const serious = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical',
@@ -149,6 +161,58 @@ test('home and content pages have no serious/critical a11y violations', async ({
}
});
+// Nothing in the suite asserted computed type before this: build.test.ts greps class
+// names and token mappings, and axe checks contrast. A 13px TOC entry where the design
+// binds 14px therefore passed every gate. Size and weight only, never colour — Chromium
+// returns colours in their authored space, as the note above explains.
+const TYPE_SCALE: Array<{
+ selector: string;
+ fontSize?: string;
+ fontWeight?: string;
+}> = [
+ { selector: '#starlight__on-this-page', fontSize: '11px', fontWeight: '500' },
+ { selector: '.right-sidebar starlight-toc a', fontSize: '14px' },
+ {
+ selector: '.right-sidebar starlight-toc a[aria-current="true"]',
+ fontWeight: '500',
+ },
+ { selector: '.pagination-links a', fontSize: '12px', fontWeight: '500' },
+ { selector: '.pagination-links .link-title', fontSize: '16px' },
+ { selector: '.nbr-page-header h1', fontSize: '34px', fontWeight: '700' },
+ { selector: '.nbr-page-meta', fontSize: '13px' },
+ {
+ selector: '.sidebar-content .group-label .large',
+ fontSize: '14px',
+ fontWeight: '400',
+ },
+];
+
+test('page chrome renders at the design type scale', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto('/guides/deployment/build/');
+
+ // starlight-toc marks the current entry client-side on first intersection, so
+ // the aria-current row does not exist in the served HTML.
+ await expect(
+ page.locator('.right-sidebar starlight-toc a[aria-current="true"]'),
+ ).toHaveCount(1);
+
+ for (const { selector, fontSize, fontWeight } of TYPE_SCALE) {
+ const target = page.locator(selector).first();
+ await expect(target, `${selector} is missing`).toBeAttached();
+ const actual = await target.evaluate((el) => {
+ const style = getComputedStyle(el);
+ return { fontSize: style.fontSize, fontWeight: style.fontWeight };
+ });
+ if (fontSize) {
+ expect(actual.fontSize, `${selector} font-size`).toBe(fontSize);
+ }
+ if (fontWeight) {
+ expect(actual.fontWeight, `${selector} font-weight`).toBe(fontWeight);
+ }
+ }
+});
+
test('wide viewports centre the content panel when a TOC is present', async ({
page,
}) => {
@@ -262,3 +326,98 @@ test('exactly one "Site" nav landmark is exposed at each width', async ({
expect(exposed, `${width}px exposed ${exposed} "Site" navs`).toBe(1);
}
});
+
+test('the TOC current entry tracks the last heading at the bottom of the page', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto('/reference/components/');
+
+ const current = page.locator(
+ '.right-sidebar starlight-toc a[aria-current="true"]',
+ );
+ await expect(current).toHaveCount(1);
+
+ await page.evaluate(() => {
+ const el = document.scrollingElement ?? document.documentElement;
+ el.scrollTop = el.scrollHeight;
+ });
+ await expect(current).toHaveCount(1);
+ await expect(current).toHaveJSProperty('hash', '#guide-cards');
+
+ await page.locator('#card-grids-at-a-glance').evaluate((el) => {
+ const nav =
+ document.querySelector('header')?.getBoundingClientRect().height ?? 0;
+ window.scrollTo(
+ 0,
+ el.getBoundingClientRect().top + window.scrollY - nav - 32,
+ );
+ });
+ await expect(current).toHaveCount(1);
+ await expect(current).not.toHaveJSProperty('hash', '#guide-cards');
+
+ await page.evaluate(() => {
+ const el = document.scrollingElement ?? document.documentElement;
+ el.scrollTop = el.scrollHeight;
+ });
+ await expect(current).toHaveJSProperty('hash', '#guide-cards');
+
+ await page.locator('#badge-variants').evaluate((el) => {
+ const nav =
+ document.querySelector('header')?.getBoundingClientRect().height ?? 0;
+ window.scrollTo(
+ 0,
+ el.getBoundingClientRect().top + window.scrollY - nav - 32,
+ );
+ });
+ await expect(current).toHaveCount(1);
+
+ await page.evaluate(() => {
+ document
+ .querySelector('.right-sidebar-panel starlight-toc a[href="#full-docs"]')
+ ?.setAttribute('aria-current', 'true');
+ });
+ await expect(current).toHaveCount(1);
+});
+
+test('a page that does not scroll does not force the last TOC entry', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1440, height: 3000 });
+ await page.goto('/guides/deployment/build/');
+ const current = page.locator(
+ '.right-sidebar starlight-toc a[aria-current="true"]',
+ );
+ await expect(current).toHaveCount(1);
+ await expect(current).toHaveJSProperty('hash', '#_top');
+});
+
+test('the mobile TOC label tracks the last heading at the bottom of the page', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto('/reference/components/');
+ await page.evaluate(() => {
+ const el = document.scrollingElement ?? document.documentElement;
+ el.scrollTop = el.scrollHeight;
+ });
+ await expect(page.locator('.display-current')).toHaveText('Guide cards');
+});
+
+test('guide chips filter the list in place', async ({ page }) => {
+ await page.goto('/guides/');
+ const visible = page.locator('.nbr-guide-card:visible');
+ await expect(visible).toHaveCount(6);
+ const url = page.url();
+
+ await page.locator('label[for="gf-getting-started"]').click();
+ await expect(visible).toHaveCount(2);
+ expect(page.url()).toBe(url);
+
+ await page.locator('label[for="gf-deployment"]').click();
+ await expect(visible).toHaveCount(2);
+
+ await page.locator('label[for="gf-all"]').click();
+ await expect(visible).toHaveCount(6);
+ expect(page.url()).toBe(url);
+});
diff --git a/docs/package.json b/docs/package.json
index 49d6c8c..142349a 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -8,7 +8,8 @@
"build": "bun run theme && astro build",
"build:base": "bun run theme && astro build --base /demo-pack",
"preview": "astro preview",
- "e2e": "playwright test"
+ "e2e": "playwright test",
+ "screenshots": "SCREENSHOTS=1 playwright test e2e/screenshots.spec.ts"
},
"dependencies": {
"@astrojs/starlight": "^0.41.3",
diff --git a/docs/src/content/docs/404.md b/docs/src/content/docs/404.md
new file mode 100644
index 0000000..c220862
--- /dev/null
+++ b/docs/src/content/docs/404.md
@@ -0,0 +1,19 @@
+---
+title: Page not found
+description: That page does not exist.
+template: splash
+tableOfContents: false
+hero:
+ tagline: The page you were looking for does not exist. It may have been moved or the link is out of date.
+ actions:
+ - text: Back to home
+ link: /
+ variant: primary
+ - text: Browse the guides
+ link: /guides/
+ variant: secondary
+---
+
+
diff --git a/docs/src/content/docs/getting-started/configuration.mdx b/docs/src/content/docs/getting-started/configuration.mdx
index 54d90df..5a98902 100644
--- a/docs/src/content/docs/getting-started/configuration.mdx
+++ b/docs/src/content/docs/getting-started/configuration.mdx
@@ -36,6 +36,24 @@ nebari({ logoHref: 'https://packs.nebari.dev/' });
See the [Configuration Options reference](/reference/configuration/) for the full
option table and defaults.
+### `nav`
+
+Top-level header tabs (Docs / Guides / Reference on this site). When omitted,
+the header is byte-identical to stock Starlight. The active tab is the one
+that shares the most leading path segments with the current URL (the root
+tab is a fallback, not a prefix match). A tab can point at a leaf page and
+still light for every page in that section:
+
+```js
+nebari({
+ nav: [
+ { label: 'Docs', href: '/' },
+ { label: 'Guides', href: '/guides/' },
+ { label: 'Reference', href: '/reference/configuration/' },
+ ],
+});
+```
+
## Starlight options worth setting
The theme leaves these to you — they're per-pack decisions.
diff --git a/docs/src/content/docs/guides/customizing.mdx b/docs/src/content/docs/guides/customizing.mdx
index 2b830c9..ca322a5 100644
--- a/docs/src/content/docs/guides/customizing.mdx
+++ b/docs/src/content/docs/guides/customizing.mdx
@@ -67,9 +67,10 @@ Set light and dark values separately. The theme defines tokens under both
## Add your own components overrides
Starlight lets you swap individual layout components. The theme already overrides
-`SiteTitle`, `Head`, `Footer`, and `ThemeSelect`. You can override *others*
-freely, but if you override one the theme also sets, list yours **after** the
-plugin so it wins:
+eight: `SiteTitle`, `Head`, `Footer`, `Sidebar`, `ThemeSelect`, `PageTitle`,
+`LastUpdated`, and `MarkdownContent`. You can override *others* freely, but if
+you override one the theme also sets, list yours **after** the plugin so it
+wins:
```js title="astro.config.mjs"
starlight({
@@ -82,10 +83,11 @@ starlight({
```
:::danger
-Overriding `SiteTitle`, `Head`, `Footer`, or `ThemeSelect` replaces the theme's
-versions of them — you lose the branded logo, self-hosted fonts, footer, or the
-Nebari theme toggle respectively. Only do this if you're deliberately replacing
-that piece.
+Overriding `SiteTitle`, `Head`, `Footer`, `Sidebar`, `ThemeSelect`, `PageTitle`,
+`LastUpdated`, or `MarkdownContent` replaces the theme's version — you lose the
+branded logo, fonts, splash footer, nav tabs, theme toggle, breadcrumbs, or
+table scrolling and TOC bottom-tracking respectively. Only do this if you're deliberately replacing that
+piece.
:::
## Where files go
diff --git a/docs/src/content/docs/guides/index.mdx b/docs/src/content/docs/guides/index.mdx
new file mode 100644
index 0000000..d5ac741
--- /dev/null
+++ b/docs/src/content/docs/guides/index.mdx
@@ -0,0 +1,50 @@
+---
+title: Guides
+description: Task-oriented walkthroughs for installing, authoring, customizing, and deploying a Nebari docs pack.
+tableOfContents: false
+---
+
+import GuideCard from '@nebari/starlight/components/GuideCard.astro';
+import GuideFilter from '@nebari/starlight/components/GuideFilter.astro';
+
+How to get a pack's docs on-brand and shipped. Start with install, then pick the
+task that matches what you're doing.
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
index 32eacaa..ee13c7e 100644
--- a/docs/src/content/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -7,72 +7,70 @@ hero:
actions:
- text: Get started
link: /getting-started/installation/
- icon: right-arrow
variant: primary
- text: Read the guides
- link: /guides/authoring-content/
- icon: open-book
- variant: minimal
- - text: View on GitHub
- link: https://github.com/nebari-dev/starlight
- icon: external
- variant: minimal
+ link: /guides/
+ variant: secondary
---
-import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
+import { Card, CardGrid } from '@astrojs/starlight/components';
-Every Nebari pack ships documentation, and every set of docs should look like it
-belongs to the same platform. `@nebari/starlight` is the shared theme that makes
-that automatic: add the plugin, write your content, and get a polished,
-accessible, on-brand site out of the box. New here? Jump to the
-[installation guide](/getting-started/installation/).
+## Explore the documentation
-## Why teams use it
+Everything the theme styles, from a one-line install through to the kitchen sink.
-
-
- Register the `nebari()` plugin in `astro.config.mjs` and the entire theme —
- colors, fonts, logo, footer, and social links — is wired up for you.
-
-
- OKLCH design tokens from nebari-design map onto Starlight's variables: a
- magenta accent with slate-tinted surfaces, tuned for light and dark.
-
-
- 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
- for multisite merging across packs.
-
-
+
+
+
+ Register the `nebari()` plugin in `astro.config.mjs` and the entire theme —
+ colors, fonts, logo, footer, and social links — is wired up for you.
-## Start here
+ [Install the theme](/getting-started/installation/)
+
+
+ OKLCH design tokens from nebari-design map onto Starlight's variables: a
+ magenta accent with slate-tinted surfaces, tuned for light and dark.
-
-
-
-
-
-
+ [Customize tokens](/guides/customizing/)
+
+
+ Geist for body and headings, IBM Plex Mono for code — all self-hosted, no
+ CDN calls, no layout shift.
-This site is also a working template. It runs on `@nebari/starlight` itself, so
-everything you see — the hero above, the cards, the callouts, the sidebar
-groups — is the theme in action. Fork it as the starting point for your own pack.
+ [See it in content](/guides/authoring-content/)
+
+
+ Starlight's built-in Pagefind search is enabled and styled to match, ready
+ for multisite merging across packs.
+
+ [Open the components](/reference/components/)
+
+
+ Plugin options, the Starlight settings the theme manages, and what a pack
+ still owns.
+
+ [Read the reference](/reference/configuration/)
+
+
+ Every content surface on one page — callouts, tabs, tables, steps, figures
+ — so you can check the theme against the design.
+
+ [Browse the sink](/reference/kitchen-sink/)
+
+
+
+
+## Popular pages
+
+
+
+- [Installation](/getting-started/installation/)
+- [Quickstart](/getting-started/quickstart/)
+- [Authoring content](/guides/authoring-content/)
+- [Customizing the theme](/guides/customizing/)
+- [Configuration](/reference/configuration/)
+- [Kitchen sink](/reference/kitchen-sink/)
+- [GitHub](https://github.com/nebari-dev)
+- [nebari.dev](https://nebari.dev)
+
+
diff --git a/docs/src/content/docs/reference/components.mdx b/docs/src/content/docs/reference/components.mdx
index f3f6a0e..3546acc 100644
--- a/docs/src/content/docs/reference/components.mdx
+++ b/docs/src/content/docs/reference/components.mdx
@@ -5,9 +5,9 @@ description: The Starlight components available in Nebari docs packs, with impor
import { Badge, Card, CardGrid } from '@astrojs/starlight/components';
-Nebari Starlight styles the standard Starlight component set — it doesn't add new
-components or dependencies. Everything below is imported from
-`@astrojs/starlight/components` and works in any `.mdx` page.
+Nebari Starlight styles the standard Starlight component set and adds one
+layout helper, `GuideCard`, for the guides landing page. Everything else is
+imported from `@astrojs/starlight/components` and works in any `.mdx` page.
```js
import { Card, CardGrid, Tabs, TabItem, Steps, FileTree, Badge, Aside }
@@ -27,6 +27,8 @@ import { Card, CardGrid, Tabs, TabItem, Steps, FileTree, Badge, Aside }
| `Aside` | Callouts (also via `:::note` syntax) | |
| `LinkButton` | Prominent call-to-action link | |
| `Icon` | Built-in icon set | |
+| `GuideCard` | Guides-landing row (badge, read time, arrow) | |
+| `GuideFilter` | Category chips that filter a stack of cards | |
## Badge variants
@@ -77,3 +79,33 @@ ships search enabled.
For prop-level detail on any component, see the upstream
[Starlight component reference](https://starlight.astro.build/components/using-components/).
This theme changes their appearance, not their API.
+
+## Guide cards
+
+`GuideCard` is the one extra component. Import it from the theme, not from
+Starlight. Wrap a stack in `GuideFilter` to get the category chips; it also
+supplies `.nbr-guide-list`, so the page drops breadcrumbs, the meta row, and
+the title hairline.
+
+```js
+import GuideCard from '@nebari/starlight/components/GuideCard.astro';
+import GuideFilter from '@nebari/starlight/components/GuideFilter.astro';
+```
+
+```mdx
+
+
+
+```
+
+`categories` must match each card's `category` prop. The read time is derived
+from the linked page, so it stays in step with the article's own meta row; pass
+`time` to override it. See the [guides
+landing](/guides/) for a working example, and the [layout
+classes](/reference/configuration/#layout-classes) for the other landing
+contracts (`.nbr-grid-3`, `.nbr-cols-2`, `.nbr-chip`).
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index 4fabdc9..0ff32b7 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -14,14 +14,20 @@ sets on your behalf. For task-oriented guidance, see
Options passed to `nebari(options)`:
-| Option | Type | Default | Description |
-| ----------- | ------------------ | -------------------------- | --------------------------------------------------------------------------- |
-| `logoHref` | `string` | site base (`BASE_URL`) | URL the header logo links to. Set to a portal root for multi-pack domains. |
+| Option | Type | Default | Description |
+| ---------- | ---------------------------------------- | ---------------------- | --------------------------------------------------------------------------- |
+| `logoHref` | `string` | site base (`BASE_URL`) | URL the header logo links to. Set to a portal root for multi-pack domains. |
+| `nav` | `{ label: string; href: string }[]` | unset | Top-level header tabs. Absent, the header is byte-identical to Starlight. |
```ts
interface NebariThemeOptions {
/** URL the header logo links to. Defaults to the site's own base. */
logoHref?: string;
+ /**
+ * Top-level header tabs. When omitted, the header is byte-identical to
+ * stock Starlight.
+ */
+ nav?: Array<{ label: string; href: string }>;
}
```
@@ -30,27 +36,48 @@ interface NebariThemeOptions {
When you register `nebari()`, it updates your Starlight config as follows. All of
these are merged **before** your own config, so your values take precedence.
-| Setting | What the theme adds | Overridable |
-| ----------- | ------------------------------------------------------------------------------------------------------- | ----------- |
-| `customCss` | Font faces, Nebari tokens, theme mapping, and component styles — prepended before your own `customCss`. | Yes |
-| `components` | Overrides `SiteTitle`, `Head`, `Footer`, and `ThemeSelect`. | Yes |
-| `social` | Prepends a GitHub link to `github.com/nebari-dev`. | Yes |
+| Setting | What the theme adds | Overridable |
+| ------------ | ------------------------------------------------------------------------------------------------------- | ----------- |
+| `customCss` | Font faces, Nebari tokens, theme mapping, and component styles — prepended before your own `customCss`. | Yes |
+| `components` | Overrides the eight components listed below. | Yes |
+| `social` | Prepends a GitHub link to `github.com/nebari-dev`. | Yes |
+| `lastUpdated`| Defaults to `true`, so a date appears on pages that had none. | Yes |
### Overridden components
-| Component | Status | Why the theme overrides it |
-| ------------- | --------------------------------- | --------------------------------------------------- |
-| `SiteTitle` | | Renders the Nebari logo in the header. |
-| `Head` | | Injects self-hosted font preloads. |
-| `Footer` | | Adds the branded Nebari footer. |
-| `ThemeSelect` | | Swaps the `` for the icon toggle. |
+| Component | Status | Why the theme overrides it |
+| ---------------- | ------------------------------------------ | --------------------------------------------------------------- |
+| `SiteTitle` | | Renders the Nebari logo in the header. |
+| `Head` | | Injects self-hosted font preloads. |
+| `Footer` | | Adds the branded Nebari footer on splash pages. |
+| `Sidebar` | | Injects the `nav` tabs into the mobile drawer. |
+| `ThemeSelect` | | Swaps the `` for the icon toggle. |
+| `PageTitle` | | Renders breadcrumbs and the updated / read-time meta row. |
+| `LastUpdated` | | Suppresses the duplicate date Starlight prints in the footer. |
+| `MarkdownContent`| | Wraps tables so wide grids and long cells scroll instead of wrap, and tracks the last TOC heading at the bottom of the page. |
:::caution
-Setting any of the four components above in your own `components` map replaces the
-theme's version and drops the corresponding branding. See
+Setting any of the eight components above in your own `components` map replaces
+the theme's version and drops the corresponding feature. See
[Customizing the Theme](/guides/customizing/).
:::
+## Layout classes
+
+These class names are a documented CSS contract. Use them in Markdown so a pack
+gets the landing and guides layouts without a versioned component API.
+
+| Class | Where | What it does |
+| ---------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
+| `.nbr-grid-3` | wrapping a `` | Three-up cards that ladder to two, then one. |
+| `.nbr-cols-2` | wrapping a Markdown list | Two-column popular-pages list, column-major, with trailing arrows. |
+| `.nbr-chip` | a `` next to a radio | Filter chip. The checked radio paints the filled accent pill. |
+| `.nbr-guide-list`| wrapping ``s | Stacked guide cards; also hides breadcrumbs, meta, and the title hairline. |
+| `.nbr-404` | wrapping the 404 footnote | Splash 404 layout: centres the page, hides the footer, paints the wash. |
+| `.nbr-table-scroll` | injected around tables | Horizontal scroll for a table that does not fit the column. |
+
+Import `GuideCard` from `@nebari/starlight/components/GuideCard.astro`.
+
## Design tokens
The theme maps Nebari's OKLCH tokens onto Starlight's semantic variables. Override
diff --git a/package.json b/package.json
index 03a6e0d..ea11fc0 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"scripts": {
"dev": "bun run --filter '@nebari/starlight-docs' dev",
"build": "bun run --filter '@nebari/starlight' build",
- "test": "bun test packages/starlight/test",
+ "test": "bun test && cd docs && bun test",
"sync-tokens": "bun packages/starlight/scripts/sync-tokens.ts",
"check": "biome check .",
"check:fix": "biome check --write .",
diff --git a/packages/starlight/src/components/GuideCard.astro b/packages/starlight/src/components/GuideCard.astro
new file mode 100644
index 0000000..aacf0aa
--- /dev/null
+++ b/packages/starlight/src/components/GuideCard.astro
@@ -0,0 +1,109 @@
+---
+import { getEntry } from 'astro:content';
+import { readingTimeMinutes } from '../utils/reading-time';
+
+interface Props {
+ title: string;
+ href: string;
+ description: string;
+ category: string;
+ /** Overrides the read time derived from the linked page. */
+ time?: string;
+}
+
+const { title, href, description, category, time } = Astro.props;
+
+// Derive the read time from the page the card links to, so it can't drift from
+// the `N min read` the article itself shows. Root-relative hrefs map onto
+// collection ids; anything else (external links, missing pages) renders no time.
+const entry = time
+ ? undefined
+ : await getEntry('docs', href.replace(/^\/|\/$/g, '')).catch(() => undefined);
+const minutes = entry?.body ? readingTimeMinutes(entry.body) : undefined;
+const readTime =
+ time ?? (minutes ? Astro.locals.t('nebari.guideTime', { minutes }) : undefined);
+---
+
+
+
+
+ {category}
+ {readTime && {readTime} }
+ →
+
+
+
+
diff --git a/packages/starlight/src/components/GuideFilter.astro b/packages/starlight/src/components/GuideFilter.astro
new file mode 100644
index 0000000..76a6327
--- /dev/null
+++ b/packages/starlight/src/components/GuideFilter.astro
@@ -0,0 +1,35 @@
+---
+interface Props {
+ categories: string[];
+}
+const { categories } = Astro.props;
+const idFor = (c: string) => `gf-${c.toLowerCase().replace(/\s+/g, '-')}`;
+const filterCss = categories
+ .map(
+ (c) =>
+ `.nbr-guide-filter:has(#${idFor(c)}:checked) .nbr-guide-card:not([data-category="${c}"]){display:none}`,
+ )
+ .join('');
+---
+
+
+${filterCss}`} />
diff --git a/packages/starlight/src/components/MarkdownContent.astro b/packages/starlight/src/components/MarkdownContent.astro
index 206a4c5..00e80c6 100644
--- a/packages/starlight/src/components/MarkdownContent.astro
+++ b/packages/starlight/src/components/MarkdownContent.astro
@@ -47,4 +47,127 @@ import Default from '@astrojs/starlight/components/MarkdownContent.astro';
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(wrapTables);
});
+
+ let forcingBottom = false;
+ let queued = false;
+ const linksByHost = new Map();
+ const observers: MutationObserver[] = [];
+
+ function atBottom(): boolean {
+ const scroller = document.scrollingElement ?? document.documentElement;
+ if (scroller.scrollHeight <= scroller.clientHeight + 2) return false;
+ return (
+ scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2
+ );
+ }
+
+ function linkAboveScanLine(links: HTMLAnchorElement[]): HTMLAnchorElement {
+ const nav =
+ document.querySelector('header')?.getBoundingClientRect().height ?? 0;
+ const top = nav + 32;
+ let current = links[0];
+ for (const link of links) {
+ const id = decodeURIComponent(link.hash.replace(/^#/, ''));
+ const heading = id ? document.getElementById(id) : null;
+ if (heading && heading.getBoundingClientRect().top <= top) current = link;
+ }
+ return current;
+ }
+
+ function setCurrent(
+ host: Element,
+ target: HTMLAnchorElement,
+ links: HTMLAnchorElement[],
+ ) {
+ const unique =
+ target.getAttribute('aria-current') === 'true' &&
+ links.every(
+ (link) => link === target || !link.hasAttribute('aria-current'),
+ );
+ if (!unique) {
+ for (const link of links) {
+ if (link === target) link.setAttribute('aria-current', 'true');
+ else link.removeAttribute('aria-current');
+ }
+ }
+ const label = host.querySelector('.display-current');
+ if (label) label.textContent = target.textContent;
+ }
+
+ function syncToc() {
+ const bottom = atBottom();
+ if (bottom) forcingBottom = true;
+ else if (!forcingBottom) return;
+ else forcingBottom = false;
+ for (const [host, links] of linksByHost) {
+ if (!links.length) continue;
+ setCurrent(
+ host,
+ bottom ? links[links.length - 1] : linkAboveScanLine(links),
+ links,
+ );
+ }
+ }
+
+ function onMutate(host: Element, records: MutationRecord[]) {
+ const links = linksByHost.get(host) ?? [];
+ let incoming: HTMLAnchorElement | undefined;
+ for (let i = records.length - 1; i >= 0; i--) {
+ const target = records[i].target;
+ if (
+ target instanceof HTMLAnchorElement &&
+ target.getAttribute('aria-current') === 'true'
+ ) {
+ incoming = target;
+ break;
+ }
+ }
+ if (incoming && !atBottom()) {
+ forcingBottom = false;
+ setCurrent(host, incoming, links);
+ return;
+ }
+ syncToc();
+ }
+
+ function observeToc() {
+ forcingBottom = false;
+ for (const observer of observers) observer.disconnect();
+ observers.length = 0;
+ linksByHost.clear();
+ for (const host of document.querySelectorAll(
+ 'starlight-toc, mobile-starlight-toc',
+ )) {
+ linksByHost.set(host, [
+ ...host.querySelectorAll('a[href^="#"]'),
+ ]);
+ const observer = new MutationObserver((records) =>
+ onMutate(host, records),
+ );
+ observer.observe(host, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ['aria-current'],
+ });
+ observers.push(observer);
+ }
+ syncToc();
+ }
+
+ function onScrollOrResize() {
+ if (queued) return;
+ queued = true;
+ requestAnimationFrame(() => {
+ queued = false;
+ syncToc();
+ });
+ }
+
+ observeToc();
+ document.addEventListener('astro:page-load', observeToc);
+ document.addEventListener('scroll', onScrollOrResize, {
+ passive: true,
+ capture: true,
+ });
+ window.addEventListener('resize', onScrollOrResize);
diff --git a/packages/starlight/src/index.ts b/packages/starlight/src/index.ts
index bc238e5..29126bc 100644
--- a/packages/starlight/src/index.ts
+++ b/packages/starlight/src/index.ts
@@ -51,6 +51,10 @@ export interface NebariThemeOptions {
* returns users to `packs.nebari.dev/`.
*/
logoHref?: string;
+ /**
+ * Top-level header tabs. When omitted, the header is byte-identical to
+ * stock Starlight.
+ */
nav?: Array<{ label: string; href: string }>;
/**
* URL the header GitHub icon links to. Defaults to the Nebari org
@@ -198,6 +202,7 @@ export function nebari(options: NebariThemeOptions = {}): StarlightPlugin {
'nebari.breadcrumbLabel': 'Breadcrumb',
'nebari.updated': 'Updated',
'nebari.readTime': '{{minutes}} min read',
+ 'nebari.guideTime': '{{minutes}} min',
},
});
},
diff --git a/packages/starlight/src/styles/chrome.css b/packages/starlight/src/styles/chrome.css
index a355575..0bad2e5 100644
--- a/packages/starlight/src/styles/chrome.css
+++ b/packages/starlight/src/styles/chrome.css
@@ -352,6 +352,9 @@ main > .content-panel:last-child > .sl-container > footer {
--sl-content-margin-inline: auto;
width: calc(100% - 17rem);
}
+ .right-sidebar-panel .sl-container {
+ width: calc(17rem - 2 * var(--sl-sidebar-pad-x));
+ }
.content-panel:not(:has(.hero)) {
padding-inline: 2rem;
}
@@ -377,6 +380,13 @@ main > .content-panel:last-child > .sl-container > footer {
starlight-menu-button[aria-expanded="true"] button {
background-color: var(--nbr-surface-subtle);
}
+ .sidebar-content .group-label .large {
+ font-size: var(--nbr-text-eyebrow);
+ line-height: 1rem;
+ font-weight: 500;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ }
}
.nbr-nav-tabs--drawer {
@@ -402,19 +412,22 @@ main > .content-panel:last-child > .sl-container > footer {
}
.pagination-links a {
- border-radius: var(--nbr-radius-md);
+ border-radius: var(--nbr-radius-lg);
border-color: var(--nbr-border);
box-shadow: none;
- padding-block: 0.8125rem;
- color: var(--sl-color-gray-3);
- font-size: var(--sl-text-sm);
+ padding: 0.875rem 1rem;
+ color: var(--nbr-muted-foreground);
+ font-size: 0.75rem;
+ line-height: 1rem;
+ font-weight: 500;
}
.pagination-links a:hover {
border-color: var(--nbr-primary);
}
.pagination-links .link-title {
color: var(--sl-color-text-accent);
- font-size: var(--sl-text-lg);
+ font-size: 1rem;
+ line-height: 1.75rem;
font-weight: 600;
}
@@ -427,6 +440,7 @@ main > .content-panel:last-child > .sl-container > footer {
border-color: var(--nbr-input);
background-color: var(--nbr-card);
}
+#starlight__mobile-toc .toggle:hover,
#starlight__mobile-toc[open] .toggle {
border-color: var(--nbr-primary);
}
@@ -435,3 +449,93 @@ main > .content-panel:last-child > .sl-container > footer {
border-radius: var(--nbr-radius-md);
background-color: var(--nbr-card);
}
+
+main:has(.hero) .pagination-links {
+ display: none;
+}
+main:has(.hero) {
+ overflow-x: clip;
+}
+
+#starlight__search {
+ --pagefind-ui-primary: var(--nbr-foreground);
+ --pagefind-ui-text: var(--nbr-muted-foreground);
+ --pagefind-ui-background: var(--nbr-card);
+ --pagefind-ui-border: var(--nbr-border);
+ --pagefind-ui-border-width: 0;
+ --pagefind-ui-tag: var(--nbr-muted);
+ --sl-search-corners: var(--nbr-radius-md);
+}
+site-search dialog {
+ background-color: var(--nbr-card);
+ border-color: var(--nbr-border);
+ box-shadow: var(--sl-shadow-md);
+}
+site-search dialog::backdrop {
+ background-color: var(--nbr-scrim);
+}
+#starlight__search .pagefind-ui__search-input {
+ border: 0;
+ border-radius: 0;
+ border-block-end: 1px solid var(--sl-color-hairline);
+ background-color: transparent;
+ box-shadow: none;
+}
+#starlight__search .pagefind-ui__form {
+ border-block-end: 0;
+}
+#starlight__search .pagefind-ui__form::after {
+ content: "↵ open esc close";
+ display: block;
+ margin-block-start: 0.75rem;
+ padding-block-start: 0.75rem;
+ border-block-start: 1px solid var(--sl-color-hairline);
+ font-size: 0.75rem;
+ line-height: 1rem;
+ color: var(--nbr-muted-foreground);
+}
+@media (min-width: 50rem) {
+ site-search .dialog-frame {
+ padding: 0;
+ }
+ #starlight__search .pagefind-ui__form {
+ padding: 1.5rem;
+ }
+}
+#starlight__search
+ .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
+#starlight__search .pagefind-ui__result-nested {
+ background-color: transparent;
+}
+#starlight__search
+ .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
+#starlight__search
+ .pagefind-ui__result-title:not(
+ :where(.pagefind-ui__result-nested *)
+ ):focus-within,
+#starlight__search .pagefind-ui__result-nested:hover,
+#starlight__search .pagefind-ui__result-nested:focus-within {
+ outline: none;
+ background-color: transparent;
+}
+#starlight__search .pagefind-ui__result {
+ position: relative;
+}
+#starlight__search .pagefind-ui__result:hover,
+#starlight__search .pagefind-ui__result:focus-within {
+ background-color: var(--nbr-callout-note);
+ border-radius: var(--nbr-radius-md);
+}
+#starlight__search .pagefind-ui__result:focus-within {
+ outline: 2px solid var(--nbr-ring);
+ outline-offset: 2px;
+}
+#starlight__search .pagefind-ui__result:hover::after,
+#starlight__search .pagefind-ui__result:focus-within::after {
+ content: "↵";
+ position: absolute;
+ inset-block-start: 50%;
+ inset-inline-end: 1rem;
+ transform: translateY(-50%);
+ color: var(--nbr-muted-foreground);
+}
diff --git a/packages/starlight/src/styles/components.css b/packages/starlight/src/styles/components.css
index f51646f..21ab74b 100644
--- a/packages/starlight/src/styles/components.css
+++ b/packages/starlight/src/styles/components.css
@@ -5,23 +5,26 @@
* @layer styles without needing !important.
*/
-/* ── Cards ───────────────────────────────────────────────────────────────
- * Rounder corners, subtle surface fill, soft shadow, and a gentle hover lift. */
.card {
border-radius: var(--nbr-radius-md);
- background-color: var(--nbr-surface-subtle);
+ background-color: var(--nbr-card);
border-color: var(--nbr-border);
- box-shadow: var(--sl-shadow-sm);
- transition:
- transform var(--nbr-duration-base) var(--nbr-ease-standard),
- box-shadow var(--nbr-duration-base) var(--nbr-ease-standard);
-}
-.card:hover {
- transform: translateY(-2px);
- box-shadow: var(--sl-shadow-md);
+ padding: 1.375rem;
+}
+.card .title {
+ font-size: 1.125rem;
+ line-height: 1.625rem;
+}
+.card .body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ color: var(--nbr-muted-foreground);
}
-.card .icon {
- color: var(--sl-color-accent);
+.card .body > :last-child {
+ margin-block-start: auto;
}
/* ── Callouts / asides ── */
@@ -168,6 +171,11 @@
background-color: var(--nbr-background);
box-shadow: inset 0 0 0 1px var(--nbr-border);
}
+/* Upstream draws the guideline on every item; the design stops it between
+ steps, so drop the dangling tail on the last one. */
+.sl-markdown-content .sl-steps > li:last-child::after {
+ display: none;
+}
/* ── Figures ── */
.sl-markdown-content figure:not(.frame) > :is(img, picture, svg) {
@@ -186,9 +194,12 @@
}
.sl-markdown-content a:not(:where(.not-content *)):hover {
- color: var(--sl-color-text-accent);
text-decoration: underline;
}
+.sl-markdown-content
+ a:not(:where(.not-content *, .nbr-guide-card *, .nbr-cols-2 *)):hover {
+ color: var(--sl-color-text-accent);
+}
.sl-markdown-content :not(pre) > code:not(:where(.not-content *)) {
margin-block: 0;
@@ -249,40 +260,108 @@ starlight-tabs [role="tabpanel"] > .expressive-code:only-child .header {
margin-top: -1px;
}
-/* ── Splash hero ─────────────────────────────────────────────────────────
- * A soft accent wash behind the hero. */
.hero {
- background-image: radial-gradient(
- 60% 120% at 50% 0%,
- var(--sl-color-accent-low) 0%,
- transparent 70%
- );
- border-radius: var(--nbr-radius-md);
+ margin-inline: calc(50% - 50vw);
+ padding-inline: calc(50vw - 50%);
+ overflow: hidden;
+ padding-block: 3rem;
+ background-color: var(--nbr-card);
+ background-image:
+ radial-gradient(
+ ellipse 80% 90% at 0% 50%,
+ var(--nbr-wash-1),
+ transparent 70%
+ ),
+ radial-gradient(
+ ellipse 70% 80% at 90% 10%,
+ var(--nbr-wash-2),
+ transparent 65%
+ ),
+ radial-gradient(
+ ellipse 70% 80% at 90% 90%,
+ var(--nbr-wash-3),
+ transparent 65%
+ );
}
-/* Trim the hero's default top padding (Starlight's media-query rule ramps it up
- * to 10rem, leaving a big gap under the header). Selector out-specifies that
- * `.hero:where(...)` rule so it wins regardless of source order. */
.sl-container .hero {
- padding-block: clamp(1.5rem, 3vw, 2.75rem);
+ padding-block: 3rem;
}
-
-/* ── Splash landing: full-width content ──────────────────────────────────
- * Let the hero and page content span the full column on the home/splash page.
- * Scoped via :has(.hero) so ONLY splash pages (which render a hero) widen —
- * regular doc pages keep their centered measure. A capped,
- * centered measure (~80rem) reads like a landing page — roomy hero, four cards
- * across with room to spare — without sprawling to unreadable line lengths on
- * ultra-wide screens. Drop the panel's top padding so the hero's accent wash
- * meets the header with no gap, and give the content breathing room L/R. */
-.content-panel:has(.hero) .sl-container {
- max-width: 80rem;
+.hero h1 {
+ font-size: 2rem;
+ line-height: 2.375rem;
+ font-weight: 700;
+ letter-spacing: -0.6px;
+ color: var(--nbr-foreground);
}
-.content-panel:has(.hero) {
- padding-block-start: 0;
- padding-inline: clamp(1.5rem, 5vw, 4rem);
+.hero .copy > * {
+ max-width: 44rem;
+ margin-inline: auto;
}
.hero .tagline {
- color: var(--sl-color-gray-2);
+ max-width: none;
+ font-size: 1.125rem;
+ line-height: 1.875rem;
+ color: var(--nbr-muted-foreground-strong);
+}
+.hero .actions {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+@media (min-width: 50rem) {
+ .hero,
+ .sl-container .hero {
+ padding-block: 9rem;
+ }
+ .hero {
+ grid-template-columns: 100%;
+ }
+ .hero .stack {
+ text-align: center;
+ }
+ .hero .copy {
+ align-items: center;
+ }
+ .hero h1 {
+ font-size: 3rem;
+ line-height: 3.25rem;
+ }
+ .hero .tagline {
+ max-width: 37.5rem;
+ }
+ .hero .actions {
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ }
+}
+.sl-link-button {
+ border-radius: var(--nbr-radius-md);
+ padding: 0.375rem 0.875rem;
+ font-size: var(--sl-text-sm);
+ line-height: 1.25rem;
+ font-weight: 500;
+}
+.hero .actions .sl-link-button {
+ width: 100%;
+ justify-content: center;
+}
+@media (min-width: 50rem) {
+ .hero .actions .sl-link-button {
+ width: auto;
+ }
+}
+.sl-link-button.primary {
+ background-color: var(--nbr-primary);
+ border-color: var(--nbr-primary);
+ color: var(--nbr-primary-foreground);
+}
+.sl-link-button.secondary {
+ background-color: var(--nbr-card);
+ border-color: var(--nbr-input);
+ color: var(--nbr-foreground);
}
.sl-link-button:hover,
.sl-link-button:focus-visible {
@@ -296,3 +375,209 @@ starlight-tabs [role="tabpanel"] > .expressive-code:only-child .header {
background-color: var(--nbr-accent);
border-color: var(--nbr-muted-foreground);
}
+
+.content-panel:has(.hero) .sl-container {
+ max-width: 70rem;
+}
+.content-panel:has(.hero) {
+ padding-block-start: 0;
+ padding-inline: 1.25rem;
+}
+@media (min-width: 50rem) {
+ .content-panel:has(.hero) {
+ padding-inline: clamp(1.5rem, 5vw, 4rem);
+ }
+}
+
+.nbr-grid-3 .card-grid {
+ grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
+ gap: 1.25rem;
+}
+
+.nbr-cols-2 ul {
+ columns: 2;
+ column-gap: 3rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.sl-markdown-content .nbr-cols-2 ul li {
+ position: relative;
+ justify-content: space-between;
+ break-inside: avoid;
+ display: flex;
+ align-items: center;
+ min-height: 3.5rem;
+ padding: 0.75rem 0.5rem;
+ border-block-end: 1px solid var(--sl-color-hairline);
+}
+.sl-markdown-content .nbr-cols-2 ul li::after {
+ content: "→";
+ color: var(--nbr-muted-foreground);
+}
+.sl-markdown-content .nbr-cols-2 ul a {
+ display: inline;
+ width: auto;
+ color: var(--nbr-foreground);
+ text-decoration: none;
+}
+.sl-markdown-content .nbr-cols-2 ul a::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+}
+.sl-markdown-content .nbr-cols-2 ul li:has(a:hover) a {
+ text-decoration: underline;
+}
+/* `a::before` makes the whole row clickable, so ring the row rather than the
+ inline text box the global `:focus-visible` rule would target. */
+.sl-markdown-content .nbr-cols-2 ul li:has(a:focus-visible) {
+ outline: 2px solid var(--nbr-ring);
+ outline-offset: 2px;
+}
+.sl-markdown-content .nbr-cols-2 ul a:focus-visible {
+ outline: none;
+}
+.nbr-grid-3 .card .body > p:last-child a {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ font-weight: 500;
+ color: var(--sl-color-text-accent);
+ text-decoration: none;
+}
+.nbr-grid-3 .card .body > p:last-child a:hover {
+ text-decoration: underline;
+}
+.nbr-grid-3 .card .body > p:last-child a::after {
+ content: "→";
+}
+@media not all and (min-width: 50rem) {
+ .nbr-cols-2 ul {
+ columns: 1;
+ }
+}
+
+.nbr-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin: 0;
+ margin-block: 1.5rem;
+ border: 0;
+ padding: 0;
+}
+.nbr-chips input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ pointer-events: none;
+}
+.nbr-chip {
+ display: inline-flex;
+ align-items: center;
+ height: 1.5rem;
+ padding-inline: 0.625rem;
+ border: 1px solid var(--nbr-border);
+ border-radius: var(--nbr-radius-full);
+ background-color: var(--nbr-card);
+ font-size: 0.75rem;
+ line-height: 1rem;
+ font-weight: 500;
+ color: var(--nbr-foreground);
+ text-decoration: none;
+ cursor: pointer;
+}
+.nbr-chip:hover {
+ text-decoration: underline;
+}
+.nbr-chips input:checked + .nbr-chip {
+ background-color: var(--nbr-primary);
+ border-color: var(--nbr-primary);
+ color: var(--nbr-primary-foreground);
+}
+.nbr-chips input:focus-visible + .nbr-chip {
+ outline: 2px solid var(--nbr-ring);
+ outline-offset: 2px;
+}
+
+.nbr-guide-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+main:has(.nbr-guide-list) .nbr-breadcrumbs,
+main:has(.nbr-guide-list) .nbr-page-meta {
+ display: none;
+}
+main:has(.nbr-guide-list) .nbr-page-header {
+ border-block-end: none;
+ padding-block-end: 0;
+}
+
+main:has(.nbr-404) > .content-panel,
+main:has(.nbr-404) > .content-panel > .sl-container {
+ flex: 0 0 auto;
+}
+main:has(.nbr-404) {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: calc(100vh - var(--sl-nav-height));
+ background-image:
+ radial-gradient(
+ ellipse 80% 90% at 0% 50%,
+ var(--nbr-wash-1),
+ transparent 70%
+ ),
+ radial-gradient(
+ ellipse 70% 80% at 90% 10%,
+ var(--nbr-wash-2),
+ transparent 65%
+ ),
+ radial-gradient(
+ ellipse 70% 80% at 90% 90%,
+ var(--nbr-wash-3),
+ transparent 65%
+ );
+}
+main:has(.nbr-404) .hero {
+ min-height: 0;
+ padding-block: 2rem 1rem;
+ background: none;
+}
+main:has(.nbr-404) .hero .actions {
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+main:has(.nbr-404) .hero .actions .sl-link-button {
+ width: auto;
+}
+main:has(.nbr-404) .nbr-footer {
+ display: none;
+}
+main:has(.nbr-404) .hero h1 {
+ font-size: var(--sl-text-h1);
+ line-height: 2.5rem;
+ font-weight: 700;
+ letter-spacing: -0.4px;
+}
+main:has(.nbr-404) .hero .copy::before {
+ content: "404";
+ font-family: var(--sl-font-mono);
+ font-size: 4.5rem;
+ line-height: 1;
+ font-weight: 700;
+ color: var(--nbr-primary);
+}
+.nbr-404 {
+ margin-block-start: 1.5rem;
+ text-align: center;
+ font-size: 0.875rem;
+ color: var(--nbr-muted-foreground-strong);
+}
diff --git a/packages/starlight/src/styles/theme.css b/packages/starlight/src/styles/theme.css
index 202ac59..460f904 100644
--- a/packages/starlight/src/styles/theme.css
+++ b/packages/starlight/src/styles/theme.css
@@ -34,6 +34,9 @@
--nbr-radius-full: 9999px;
--nbr-callout-note: var(--nbr-primary-magenta-100);
+ --nbr-wash-1: var(--nbr-primary-magenta-100);
+ --nbr-wash-2: var(--nbr-accent-teal-100);
+ --nbr-wash-3: var(--nbr-highlight-yellow-100);
--sl-badge-default-bg: var(--nbr-muted);
--sl-badge-default-border: var(--nbr-border);
@@ -109,6 +112,9 @@
--sl-color-text-accent: var(--sl-color-accent-high);
--nbr-callout-note: var(--nbr-primary-magenta-900);
+ --nbr-wash-1: var(--nbr-primary-magenta-900);
+ --nbr-wash-2: var(--nbr-accent-teal-900);
+ --nbr-wash-3: var(--nbr-highlight-yellow-900);
}
.nbr-page-header h1,
diff --git a/packages/starlight/test/build.test.ts b/packages/starlight/test/build.test.ts
index cc6fc38..c25e1ad 100644
--- a/packages/starlight/test/build.test.ts
+++ b/packages/starlight/test/build.test.ts
@@ -106,7 +106,7 @@ test('exactly one nav tab is marked aria-current per page', () => {
test('the active tab is the section the page belongs to', () => {
expect(navTabsMarkup('guides/deployment/build/index.html')).toMatch(
- /href="\/guides\/authoring-content\/"[^>]*aria-current="page"/,
+ /href="\/guides\/"[^>]*aria-current="page"/,
);
expect(navTabsMarkup('reference/components/index.html')).toMatch(
/href="\/reference\/configuration\/"[^>]*aria-current="page"/,
@@ -241,3 +241,23 @@ test('Expressive Code frame chrome uses the muted surface and a bottom tab indic
expect(css).toMatch(/--ec-frm-trmTtbBg:\s*var\(--nbr-muted\)/);
expect(css).toMatch(/--ec-frm-edActTabIndBtmCol:\s*var\(--nbr-primary\)/);
});
+
+test('the landing, guides index and 404 emit their layout markers', () => {
+ const home = readFileSync(join(DIST, 'index.html'), 'utf8');
+ expect(home).toContain('nbr-grid-3');
+ expect(home).toContain('nbr-cols-2');
+ const guides = readFileSync(join(DIST, 'guides/index.html'), 'utf8');
+ expect(guides).toContain('nbr-guide-list');
+ expect(guides).toContain('nbr-chip');
+ const notFound = readFileSync(join(DIST, '404.html'), 'utf8');
+ expect(notFound).toContain('hero');
+ expect(notFound).toContain('nbr-404');
+});
+
+test('guide filter CSS hides cards that do not match the checked category', () => {
+ const css =
+ allText('.css') + readFileSync(join(DIST, 'guides/index.html'), 'utf8');
+ expect(css).toContain(
+ '.nbr-guide-filter:has(#gf-getting-started:checked) .nbr-guide-card:not([data-category="Getting started"])',
+ );
+});