diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index ed73031..9c407c6 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -50,7 +50,9 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# `--branch` must match the project's production branch for this to
# publish to the live domain rather than to a preview URL.
+ # TanStack Start writes the prerendered site to `dist/client`; the
+ # server build beside it in `dist/server` only runs the prerender.
command: >-
- pages deploy packages/docs/dist
+ pages deploy packages/docs/dist/client
--project-name=keyrove
--branch=main
diff --git a/.gitignore b/.gitignore
index b3c7653..d14262f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@ dist/
.tmp
.pnpm-store/
+
+# TanStack Router writes its generator scratch files here during dev and build.
+.tanstack/
diff --git a/.prettierignore b/.prettierignore
index bd2ac2a..7438261 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,6 @@ coverage/
pnpm-lock.yaml
*.log
CHANGELOG.md
+
+# Generated by TanStack Router on every dev start and build.
+packages/docs/src/routeTree.gen.ts
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index d3e8146..5b5a503 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -132,22 +132,22 @@ leaving a document that stands on its own when fetched in isolation.
### Documentation search
-The header search button and `Cmd+K` / `Ctrl+K` open a native dialog. MiniSearch
-and `search-index.json` load on first use. The index contains docs page leads
-and individual sections, with heading IDs allocated by the same parser as the
-rendered pages; landing and `noindex` pages are excluded. Titles and headings
-rank above body matches, with prefix matching and typo tolerance enabled.
-
-The results are a keyrove group: the site navigating with its own library.
-Each result is a link and keeps its own tab stop, so Tab walks the results as
-well as the arrows; `keyRove` moves focus between the links, looping at the
-ends. keyrove leaves a text field its caret keys, so the box
-focuses an end of the list itself to hand focus over, and a character typed on
-a result sends focus back to the box.
-
-The Vite plugin generates the index in development and production. Markdown
-edits invalidate the development cache and reload the page, including search.
-Run the search indexing tests with `pnpm --filter @mixedrays/keyrove/docs test`.
+The header search button and `Cmd+K` / `Ctrl+K` open a dialog. The dialog's
+code, MiniSearch and `search-index.json` load on first use. The index contains
+docs page leads and individual sections, with heading IDs allocated by the
+same parser as the rendered pages; landing and `noindex` pages are excluded.
+Titles and headings rank above body matches, with prefix matching and typo
+tolerance enabled.
+
+The dialog is Base UI's, with its autocomplete inside: focus stays in the
+box while the arrows move a highlight through the results, and Enter follows
+the highlighted result — the first one until the reader moves it. Each result
+is a link, so a modified click still opens it in a new tab.
+
+The content plugin generates the index in development and production.
+Markdown edits invalidate the development cache and reload the page,
+including search. Run the search indexing tests with
+`pnpm --filter @mixedrays/keyrove/docs test`.
### Sidebar navigation
@@ -159,10 +159,10 @@ opens DevTools). The shortcuts use `focusKeys` to return to each group's tab
stop. The left group starts on the current page; the right one follows the
active section while focus is outside it.
The left shortcut opens the mobile drawer when needed; shortcuts leave an
-open search dialog alone. The closed drawer is `visibility: hidden`, so its
-links are out of the Tab order. Each sidebar shows its shortcut beside its
-first heading on hover, and the arrow keys as well while keyboard focus is
-inside it.
+open search dialog alone. The drawer is a Base UI sheet: it exists only while
+open, traps focus while it is, and opens on the current page's link. Each
+sidebar shows its shortcut beside its first heading on hover, and the arrow
+keys as well while keyboard focus is inside it.
### Live demos
@@ -180,7 +180,10 @@ Layout the demo needs but the library does not teach — the grid's columns, the
long list's scroll box — goes in `data-demo-class` on the placeholder rather
than in the fragment, so what a reader copies is markup they can paste as-is.
[packages/docs/src/demos.ts](packages/docs/src/demos.ts) wires the behaviour
-markup cannot carry: the keydown listener, the move log, and the copy button.
+markup cannot carry: the keydown listener and the move log. React draws the
+panel around a demo, with its code tabs and copy button, but leaves the live
+preview as the fragment's own markup, which keyrove rearranges as keys are
+pressed.
For a compact **demo panel** on any docs page, add a label:
@@ -207,18 +210,51 @@ are required. Code-only examples can use a
`
` around adjacent fenced blocks,
with blank lines between the wrapper and the fences.
-### Build
+### UI components
+
+The site is a [TanStack Start](https://tanstack.com/start) app, and its UI
+components are [shadcn/ui](https://ui.shadcn.com) on
+[Base UI](https://base-ui.com) primitives, in
+[packages/docs/src/components/ui](packages/docs/src/components/ui). They keep
+shadcn's API but not its styles: each passes the class style.css already draws
+that part of the site with, since a Tailwind utility on the component would
+outrank every rule in the stylesheet's components layer. `components.json`
+is set up for the shadcn CLI, so `pnpm dlx shadcn@latest add `
+works from `packages/docs`.
-[packages/docs/vite-plugin-docs.ts](packages/docs/vite-plugin-docs.ts) drives
-both modes from one renderer. In dev a middleware renders on request; in the
-build, Vite bundles `index.html` once and every page is stamped out of the
-result, so all pages share one set of hashed asset URLs.
+Keyboard navigation is split by who has it built in. Base UI owns it where its
+component does — the code tabs and the landing page's pattern tabs, the search
+results, the dialog's and drawer's focus trap. keyrove owns everything Base UI
+has no component for: the sidebar and "On this page" rails, the 404's links,
+the landing hero and every demo.
+
+### Build
-Pages are emitted as `dist/docs/api.html` and served at extensionless URLs
-(`/docs/api`), which is what lets `.md` be appended. Cloudflare Pages redirects
-`/docs/api/` there, so the URL that links, canonical tags and the sitemap name
-answers directly. Extensionless URLs rule out relative asset paths, so links
-are absolute to `base` — set `DOCS_BASE` when deploying to a subpath:
+[packages/docs/vite-plugin-content.ts](packages/docs/vite-plugin-content.ts)
+hands the content to the app as virtual modules: the sidebar and each page's
+metadata in one, each page's rendered body in a chunk of its own, and the
+generated files — the `.md` twins, `llms.txt`, the sitemap — in a third that
+only the server routes under
+[packages/docs/src/routes](packages/docs/src/routes) import. Markdown renders
+through the same code in dev and in the build. A page's body reaches the
+browser as a tree of elements built at build time rather than as HTML, so
+there is no HTML parser in the bundle and nothing for hydration to disagree
+about.
+
+`pnpm build` prerenders every page, every `.md` twin and every generated file
+into `dist/client`, which is what Cloudflare Pages serves; nothing runs on a
+server. The first page a reader opens is that HTML. Following a link from there
+swaps the page in place, loading only that page's chunk — hovering the link
+starts the download.
+
+Pages are emitted as `dist/client/docs/api.html` and served at extensionless
+URLs (`/docs/api`), which is what lets `.md` be appended. Cloudflare Pages
+redirects `/docs/api/` there, so the URL that links, canonical tags and the
+sitemap name answers directly. The not-found page is prerendered to
+`404.html`, which Pages serves for any URL it has no file for; the router then
+hydrates it as the page it was rendered as. Extensionless URLs rule out
+relative asset paths, so links are absolute to `base` — set `DOCS_BASE` when
+deploying to a subpath:
```sh
DOCS_BASE=/keyrove/ pnpm --filter @mixedrays/keyrove/docs build
diff --git a/packages/bench/README.md b/packages/bench/README.md
index fed4662..4e717d7 100644
--- a/packages/bench/README.md
+++ b/packages/bench/README.md
@@ -112,7 +112,7 @@ to see reports saved since.
| `src/types.ts` | The shape of a report, as `--json` writes it. |
| `results/` | Saved reports. `recorded.json` is the run the Results below come from. |
-The UI uses the docs site's stylesheet and theme toggle directly from
+The UI uses the docs site's stylesheet and theme helpers directly from
`packages/docs/src`, so it looks like the site with no copy to keep in step.
The reports in `results/` and the modules in `src/` other than `main.ts` do
diff --git a/packages/bench/src/main.ts b/packages/bench/src/main.ts
index 42ecc78..3618cf6 100644
--- a/packages/bench/src/main.ts
+++ b/packages/bench/src/main.ts
@@ -1,4 +1,4 @@
-import { mountThemeToggle } from '../../docs/src/theme.ts';
+import { themeLabel, toggleTheme } from '../../docs/src/theme.ts';
import { icon } from './icons.ts';
import { CASE_COUNT, DEFAULT_OPTIONS } from './plan.ts';
import { toComparisonMarkdown, toMarkdown } from './report.ts';
@@ -81,6 +81,7 @@ const $ = (selector: string) =>
document.querySelector(selector)!;
const ui = {
+ themeToggle: $('[data-theme-toggle]'),
form: $('[data-run-form]'),
run: $('[data-run]'),
stop: $('[data-stop]'),
@@ -573,7 +574,6 @@ const startRun = async () => {
/* ---------- wiring ---------- */
-mountThemeToggle();
renderChecks();
fillSettings(DEFAULT_OPTIONS);
for (const element of document.querySelectorAll('[data-case-count]')) {
@@ -593,6 +593,9 @@ document.addEventListener('visibilitychange', () => {
if (controller && document.hidden) wasHidden = true;
});
+ui.themeToggle.addEventListener('click', () =>
+ ui.themeToggle.setAttribute('aria-label', themeLabel(toggleTheme())),
+);
ui.settingsToggle.addEventListener('click', () =>
setSettingsOpen(ui.settingsToggle.getAttribute('aria-expanded') !== 'true'),
);
diff --git a/packages/docs/build/content.ts b/packages/docs/build/content.ts
index 66fe648..24ddddc 100644
--- a/packages/docs/build/content.ts
+++ b/packages/docs/build/content.ts
@@ -11,7 +11,7 @@ import { loadLastModified } from './git.ts';
*
* Nothing here renders anything — this module answers "what pages exist, what
* do they claim about themselves, and how do they group in the sidebar", and
- * the render step in `layout.ts` takes it from there.
+ * the content plugin in `vite-plugin-content.ts` takes it from there.
*/
export const CONTENT_DIR = fileURLToPath(
diff --git a/packages/docs/build/head.ts b/packages/docs/build/head.ts
new file mode 100644
index 0000000..8482ec4
--- /dev/null
+++ b/packages/docs/build/head.ts
@@ -0,0 +1,122 @@
+import type { NavGroup, Page } from './content.ts';
+import { META } from './meta.ts';
+import { createHrefResolver, routeToPath, toMarkdownPath } from './paths.ts';
+import { toStructuredData } from './structured-data.ts';
+
+/**
+ * A page's ``, in the shape a TanStack Router `head()` returns: the
+ * title, the description, and what a crawler and a link unfurler need.
+ *
+ * Worked out at build time beside the page's HTML, so the browser bundle never
+ * carries the manifests or the site's origin around just to rebuild it.
+ */
+
+export type HeadMeta =
+ | { title: string }
+ | { name: string; content: string }
+ | { property: string; content: string }
+ | { 'script:ld+json': Record };
+
+export type HeadLink = { rel: string; href: string; type?: string };
+
+export type Head = { meta: HeadMeta[]; links: HeadLink[] };
+
+/**
+ * The social card, reused by every page: 1200x630, served from `public/`.
+ *
+ * One image rather than one per page — the card carries the library's name and
+ * what it does, which is what an unfurled link needs to say whichever page was
+ * shared.
+ */
+const OG_IMAGE = {
+ path: '/og.png',
+ width: 1200,
+ height: 630,
+ alt: 'keyrove — framework-agnostic keyboard navigation for lists, grids and trees.',
+} as const;
+
+/**
+ * Every page hangs its own name off the wordmark, except the landing page,
+ * whose name is the wordmark — leaving it to describe itself at a length no
+ * search result or link unfurl will show. `titleTag` in frontmatter is how
+ * either falls back to a line written for the slot.
+ */
+const toTitle = (page: Page) =>
+ page.titleTag ??
+ (page.layout === 'landing'
+ ? `keyrove — ${page.description}`
+ : `${page.title} — keyrove`);
+
+/**
+ * Which URL is the canonical one, and what to show when the page is shared.
+ *
+ * Every route has a `.md` twin at a sibling path, so which of the two is the
+ * indexable one has to be said outright rather than left to be guessed — and
+ * the twin is named as an alternate, so an agent holding the page finds the
+ * markdown without guessing at its URL. A page marked `noindex` skips all of it
+ * and says so instead — it is served at every dead URL, so it has no canonical
+ * URL of its own to claim.
+ */
+export const toHead = (page: Page, nav: NavGroup[], base: string): Head => {
+ const title = toTitle(page);
+ const meta: HeadMeta[] = [
+ { title },
+ { name: 'description', content: page.description },
+ ];
+
+ if (page.noindex) {
+ return {
+ meta: [...meta, { name: 'robots', content: 'noindex, follow' }],
+ links: [],
+ };
+ }
+
+ const resolveHref = createHrefResolver(base);
+ const toUrl = (route: string) =>
+ `${META.siteUrl}${resolveHref(routeToPath(route))}`;
+ const url = toUrl(page.route);
+ const image = `${META.siteUrl}${resolveHref(OG_IMAGE.path)}`;
+
+ return {
+ meta: [
+ ...meta,
+ {
+ property: 'og:type',
+ content: page.layout === 'landing' ? 'website' : 'article',
+ },
+ { property: 'og:site_name', content: 'keyrove' },
+ // The prose is American — "license", "color", "behavior". That is also
+ // what an absent og:locale is taken to mean, so this only says it outright.
+ { property: 'og:locale', content: 'en_US' },
+ { property: 'og:title', content: title },
+ { property: 'og:description', content: page.description },
+ { property: 'og:url', content: url },
+ { property: 'og:image', content: image },
+ { property: 'og:image:width', content: String(OG_IMAGE.width) },
+ { property: 'og:image:height', content: String(OG_IMAGE.height) },
+ { property: 'og:image:alt', content: OG_IMAGE.alt },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: page.description },
+ { name: 'twitter:image', content: image },
+ {
+ 'script:ld+json': toStructuredData({
+ page,
+ nav,
+ toUrl,
+ imageUrl: image,
+ }),
+ },
+ ],
+ links: [
+ { rel: 'canonical', href: url },
+ // `type` names the format. The twin is served as `text/plain` so that a
+ // browser shows it rather than downloading it — see public/_headers.
+ {
+ rel: 'alternate',
+ type: 'text/markdown',
+ href: `${META.siteUrl}${resolveHref(toMarkdownPath(page.route))}`,
+ },
+ ],
+ };
+};
diff --git a/packages/docs/build/html-tree.ts b/packages/docs/build/html-tree.ts
new file mode 100644
index 0000000..11c703d
--- /dev/null
+++ b/packages/docs/build/html-tree.ts
@@ -0,0 +1,93 @@
+import {
+ attributesToProps,
+ domToReact,
+ type DOMNode,
+ type Element as DomElement,
+} from 'html-react-parser';
+import { DomUtils, parseDocument } from 'htmlparser2';
+
+/**
+ * A page's rendered HTML as a tree React can draw without parsing anything.
+ *
+ * Parsed here, at build time, so the browser never has to: parsing there
+ * would mean shipping an HTML parser, its entity tables and an attribute map
+ * to every reader — more code than draws the chrome. The page's chunk carries
+ * the result instead: every element as its tag, the props React takes for its
+ * attributes, and its children. Server and browser render the one tree, so
+ * hydration has nothing to disagree about.
+ *
+ * html-react-parser does the conversion — attribute names, inline styles,
+ * whitespace React rejects inside tables — with JSON standing in for React
+ * elements, so the tree is exactly the elements it would have built.
+ * src/components/markdown.tsx walks it.
+ */
+
+export type HtmlProps = Record;
+
+/** Text, or `[tag, props, children]`. */
+export type HtmlNode = string | HtmlElement;
+
+export type HtmlElement = [tag: string, props: HtmlProps, children: HtmlNode[]];
+
+const toChildren = (children: unknown): HtmlNode[] =>
+ children === undefined || children === null
+ ? []
+ : Array.isArray(children) && !isElement(children)
+ ? (children as HtmlNode[])
+ : [children as HtmlNode];
+
+const isElement = (value: unknown): value is HtmlElement =>
+ Array.isArray(value) &&
+ typeof value[0] === 'string' &&
+ typeof value[1] === 'object' &&
+ Array.isArray(value[2]);
+
+/**
+ * React's element API, writing JSON. The `key` html-react-parser numbers
+ * siblings with is dropped: the walker keys each element by its position as
+ * it draws it, so the tree need not carry one on every node.
+ */
+const library = {
+ createElement: (
+ tag: string,
+ { key: _key, ...props }: HtmlProps = {},
+ children?: unknown,
+ ) => [tag, props, toChildren(children)] satisfies HtmlElement,
+ cloneElement: (element: HtmlElement) => element,
+ isValidElement: isElement,
+};
+
+const hasClass = (element: DomElement, name: string) =>
+ (element.attribs.class ?? '').split(/\s+/).includes(name);
+
+/**
+ * Elements whose contents React draws once and never touches again: a demo's
+ * live preview, which keyrove and the demo's own script rearrange as the
+ * reader presses keys, and the readouts they write to. They are kept as HTML,
+ * for `dangerouslySetInnerHTML`, rather than as a tree React would reconcile.
+ */
+const isOpaque = (element: DomElement) =>
+ hasClass(element, 'demo-preview') ||
+ hasClass(element, 'demo-panel-preview') ||
+ 'data-demo-readout' in element.attribs ||
+ 'data-hero-readout' in element.attribs;
+
+export const toHtmlTree = (html: string): HtmlNode[] => {
+ const options = {
+ library,
+ replace: (node: DOMNode) => {
+ if (node.type !== 'tag' || !isOpaque(node as DomElement)) return;
+ const element = node as DomElement;
+
+ return library.createElement(element.name, {
+ ...attributesToProps(element.attribs, element.name),
+ dangerouslySetInnerHTML: { __html: DomUtils.getInnerHTML(element) },
+ });
+ },
+ };
+
+ const document = parseDocument(html, { lowerCaseAttributeNames: false });
+ return toChildren(
+ domToReact(document.children as DOMNode[], options as never),
+ );
+};
diff --git a/packages/docs/build/icons.ts b/packages/docs/build/icons.ts
index 293b77c..2ee1ae3 100644
--- a/packages/docs/build/icons.ts
+++ b/packages/docs/build/icons.ts
@@ -3,9 +3,6 @@ import {
ArrowRight,
ArrowRightToLine,
Bold,
- BookOpen,
- Braces,
- Check,
Circle,
CircleCheck,
CircleDashed,
@@ -13,7 +10,6 @@ import {
CircleSlash,
Code,
Command,
- Copy,
FileCode,
FileDown,
FileJson,
@@ -25,14 +21,10 @@ import {
Hand,
Inbox,
Italic,
- Keyboard,
Layers,
- LayoutGrid,
Link,
Mail,
- Menu,
MessageSquare,
- Moon,
MousePointer2,
PenTool,
Pin,
@@ -40,76 +32,36 @@ import {
RefreshCw,
Reply,
Scan,
- Search,
Send,
ShieldAlert,
SkipForward,
Sparkles,
Square,
- Sun,
TextCursorInput,
Trash2,
Type,
Underline,
- X,
- type IconNode,
} from 'lucide';
-/**
- * Icons, rendered into the HTML at build time.
- *
- * They used to be `` placeholders that main.ts swapped for
- * glyphs on load, which meant every navigation showed a row of empty boxes
- * until the bundle ran. Serialising them here costs nothing at runtime — the
- * icon data is plain arrays — and takes `lucide` out of the shipped bundle
- * entirely.
- */
+import {
+ CHROME_ICONS,
+ fromLucide,
+ LUCIDE_STROKE_WIDTH,
+ type IconDef,
+} from '../src/icons.ts';
/**
- * An icon's geometry, independent of the size it is drawn at.
+ * Icons, rendered into the page HTML at build time.
*
- * `strokeWidth` is in the icon's own viewBox units rather than pixels, so an
- * icon drawn on a larger grid can still be given the same visual weight as the
- * rest — which is the only reason this is per-icon and not a constant. A mark
- * drawn as a fill rather than a stroke sets it to `null` and carries its own
- * `fill` on the path.
+ * They used to be `` placeholders that the bundle swapped for
+ * glyphs on load, which meant every navigation showed a row of empty boxes
+ * until it ran. Serialising them here costs nothing at runtime — the icon data
+ * is plain arrays — and keeps all but the chrome's own icons (src/icons.ts)
+ * out of the shipped bundle.
*/
-type IconDef = {
- viewBox: string;
- strokeWidth: number | null;
- body: string;
-};
-
-const renderChild = ([tag, attrs]: IconNode[number]) => {
- const rendered = Object.entries(attrs)
- .map(([key, value]) => `${key}="${String(value).replace(/"/g, '"')}"`)
- .join(' ');
-
- return `<${tag} ${rendered} />`;
-};
-
-/** Lucide's default 2px stroke reads heavy next to 14px nav text. */
-const LUCIDE_STROKE_WIDTH = 1.75;
-
-/** Lucide ships every icon on a 24-unit grid at a nominal 2px stroke. */
-const fromLucide = (node: IconNode): IconDef => ({
- viewBox: '0 0 24 24',
- strokeWidth: LUCIDE_STROKE_WIDTH,
- body: node.map(renderChild).join(''),
-});
const ICONS = {
- search: fromLucide(Search),
- close: fromLucide(X),
- menu: fromLucide(Menu),
- book: fromLucide(BookOpen),
- keyboard: fromLucide(Keyboard),
- grid: fromLucide(LayoutGrid),
- braces: fromLucide(Braces),
- copy: fromLucide(Copy),
- check: fromLucide(Check),
- sun: fromLucide(Sun),
- moon: fromLucide(Moon),
+ ...CHROME_ICONS,
'arrow-right': fromLucide(ArrowRight),
// The landing page's feature cards and next steps, placed from the markdown
@@ -159,45 +111,6 @@ const ICONS = {
'circle-dot': fromLucide(CircleDot),
'circle-check': fromLucide(CircleCheck),
archive: fromLucide(Archive),
-
- /*
- * The markdown mark — a filled glyph on a 16-unit grid, so it opts out of the
- * stroke attributes the rest of the set is drawn with.
- */
- markdown: {
- viewBox: '0 0 16 16',
- strokeWidth: null,
- body: '',
- },
-
- /*
- * The npm mark, from simple-icons. A brand mark, so — like the GitHub one
- * below — it is not from lucide, which dropped them in v1.
- *
- * Drawn as a fill rather than a stroke: the glyph is a solid square with the
- * wordmark knocked out of it, which is what makes it read as npm at 16px.
- */
- npm: {
- viewBox: '0 0 24 24',
- strokeWidth: null,
- body: '',
- },
-
- /*
- * The GitHub mark, from svgrepo.com/show/504388. Lucide dropped brand marks
- * in v1, so this is the one icon on the site that is not from the set.
- *
- * Drawn on a 192-unit grid rather than 24, so its stroke is scaled to match:
- * 14/192 is the same ratio as the 1.75/24 the lucide icons use, which is what
- * keeps it from reading lighter than its neighbours in the header. The
- * source's own `stroke="#000000"` is dropped — the wrapper below sets
- * `currentColor`, without which the mark would stay black in dark mode.
- */
- github: {
- viewBox: '0 0 192 192',
- strokeWidth: 14,
- body: '',
- },
} satisfies Record;
export type IconName = keyof typeof ICONS;
diff --git a/packages/docs/build/layout.ts b/packages/docs/build/layout.ts
deleted file mode 100644
index 20a8ecc..0000000
--- a/packages/docs/build/layout.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-import path from 'node:path';
-
-import { CONTENT_DIR, type NavGroup, type Page } from './content.ts';
-import { icon } from './icons.ts';
-import type { Heading } from './markdown.ts';
-import { META } from './meta.ts';
-import { renderStructuredData } from './structured-data.ts';
-
-/**
- * The HTML around a rendered markdown body: header, sidebar, "On this page"
- * rail, pager, footer.
- *
- * Every one of these is emitted at build time rather than assembled in the
- * browser, so a page arrives complete — no nav flash, and the sidebar is real
- * markup for crawlers and for anyone reading with scripting off.
- */
-
-/** Where the source markdown lives, for the "View source" link. */
-const SOURCE_BASE = `${META.repoUrl}/blob/main/packages/docs/content`;
-
-/** The site icon, emitted at the root beside `robots.txt`. */
-export const FAVICON_FILE = 'favicon.svg';
-
-/**
- * Placeholders the shell in index.html reserves.
- *
- * Vite builds that file once — bundling the entry, hashing the assets, and
- * leaving these untouched — and every page is then stamped out of the result,
- * so all pages share one script URL without this module having to know what
- * it turned out to be. The stylesheet is the exception: which one a page links
- * depends on the page, so its URL is passed in rather than read off the shell.
- */
-const SLOTS = {
- head: '',
- styles: '',
- body: '',
-} as const;
-
-const escapeHtml = (value: string) =>
- value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-
-/**
- * Site-absolute hrefs, honouring the deploy base.
- *
- * Pages are served at extensionless URLs (`/docs/api`, from
- * `dist/docs/api.html`) so that appending `.md` lands on the source
- * beside it. Relative hrefs cannot be resolved consistently against a URL with
- * no trailing slash, hence absolute paths and an explicit base.
- */
-export const createHrefResolver =
- (base: string) =>
- (href: string): string =>
- `${base}${href.replace(/^\//, '')}`;
-
-export type HrefResolver = ReturnType;
-
-/** `docs/examples/basic` → `/docs/examples/basic`; the landing page → `/`. */
-export const routeToPath = (route: string) => `/${route}`;
-
-/** The `.md` twin of a page: `docs/api` → `docs/api.md`, the landing page → `index.md`. */
-export const toMarkdownPath = (route: string) =>
- route === '' ? 'index.md' : `${route}.md`;
-
-const link = (href: string, label: string, className: string, extra = '') =>
- `${label}`;
-
-const renderHeader = (resolveHref: HrefResolver, page: Page) => {
- // The sidebar toggle only has anything to open on a docs page.
- const menuButton =
- page.layout === 'docs'
- ? ``
- : '';
-
- const rowClass =
- page.layout === 'landing' ? 'header-row header-row-narrow' : 'header-row';
-
- return `
-
- `;
-};
-
-const renderSearch =
- () => ``;
-
-const renderSidebar = (
- nav: NavGroup[],
- current: Page,
- resolveHref: HrefResolver,
-) => {
- const groups = nav
- .map((group) => {
- // Pages first, then whatever the group carries that is not one — the
- // generated llms.txt, which has no route to mark as current.
- const items = [
- ...group.pages.map((page) =>
- link(
- resolveHref(routeToPath(page.route)),
- escapeHtml(page.title),
- 'sidebar-link',
- page.route === current.route ? ' aria-current="page"' : '',
- ),
- ),
- ...group.links.map((entry) =>
- link(
- resolveHref(entry.href),
- escapeHtml(entry.label),
- 'sidebar-link',
- ),
- ),
- ];
-
- return `
-
${escapeHtml(group.label)}
-
- ${items.map((item) => `
${item}
`).join('\n ')}
-
-
`;
- })
- .join('\n ');
-
- return `
- `;
-};
-
-const renderToc = (headings: Heading[]) => {
- // Nothing to list on a page with no headings, and the rail carries nothing
- // else, so it is left out of the markup entirely rather than sitting empty.
- if (headings.length === 0) return '';
-
- return ``;
-};
-
-/**
- * The page's own source, in three forms: the markdown twin, the same file on
- * the clipboard, and the file in the repo.
- *
- * These sit between the title and the lead rather than at the foot of the "On
- * this page" rail, which is hidden below `xl` — where they used to live, a
- * narrow screen lost them along with the rail.
- */
-const renderPageActions = (page: Page, resolveHref: HrefResolver) => {
- const markdownHref = resolveHref(toMarkdownPath(page.route));
- // From the file rather than the route: `docs/examples/index.md` is served
- // at `docs/examples`, and the route alone would name a file that is not there.
- const sourceHref = `${SOURCE_BASE}/${path.relative(CONTENT_DIR, page.file)}`;
-
- // Every label is wrapped, icons are not: the underline is drawn on the span
- // so it stops at the text instead of running under the glyph beside it.
- return `
`;
-};
-
-const renderPager = (
- prev: Page | undefined,
- next: Page | undefined,
- resolveHref: HrefResolver,
-) => {
- if (!prev && !next) return '';
-
- const side = (page: Page | undefined, label: string, align: string) =>
- page
- ? link(
- resolveHref(routeToPath(page.route)),
- `${label}${escapeHtml(page.title)}`,
- `pager-link ${align}`,
- )
- : '';
-
- return ``;
-};
-
-/**
- * The foot of every page: what the package is, and the places a reader is
- * most likely to want next, including llms.txt, which is otherwise linked
- * only from the sidebar. The row takes the header's width, so the two frame
- * the same column.
- */
-const renderFooter = (resolveHref: HrefResolver, page: Page) => {
- const rowClass =
- page.layout === 'landing' ? 'footer-row header-row-narrow' : 'footer-row';
-
- const links = [
- link(resolveHref('/docs/introduction'), 'Docs', 'footer-link'),
- link(resolveHref('/docs/examples'), 'Examples', 'footer-link'),
- link(resolveHref('/docs/api'), 'API', 'footer-link'),
- link(META.repoUrl, 'GitHub', 'footer-link'),
- link(META.npmUrl, 'npm', 'footer-link'),
- link(resolveHref('/llms.txt'), 'llms.txt', 'footer-link'),
- ];
-
- return ``;
-};
-
-export type PageRender = {
- page: Page;
- html: string;
- headings: Heading[];
- nav: NavGroup[];
- /** Sidebar order, flattened — the pager's prev/next come off this. */
- readingOrder: Page[];
- resolveHref: HrefResolver;
- /** The stylesheet's final URL — hashed in a build. */
- stylesheet: string;
-};
-
-/**
- * The stylesheet twice: a preload, then the link that applies it.
- *
- * The preload is for Cloudflare Pages rather than for the browser, which finds
- * the link on its own. Pages turns a page's `` tags into
- * `Link` headers and sends them as a 103 Early Hint, so the stylesheet is
- * already downloading while the HTML is still on its way.
- *
- * Neither tag carries `crossorigin`, which Vite puts on the tags it injects.
- * Pages skips any `` with an attribute beyond `rel`, `href` and `as`, and
- * a preload only serves a request made in the same CORS mode, so the link has
- * to match it. The sheet is same-origin, so the attribute bought nothing.
- */
-const renderStylesheet = (href: string) =>
- [
- ``,
- ``,
- ].join('\n ');
-
-const renderDocsBody = ({
- page,
- html,
- headings,
- nav,
- readingOrder,
- resolveHref,
-}: PageRender) => {
- const index = readingOrder.findIndex((entry) => entry.route === page.route);
- // A page outside the sidebar — the 404 — has no siblings to page between,
- // and index -1 would otherwise offer the first page as its "next".
- const pager =
- index === -1
- ? ''
- : renderPager(
- readingOrder[index - 1],
- readingOrder[index + 1],
- resolveHref,
- );
-
- return `${renderHeader(resolveHref, page)}
-
- ${renderFooter(resolveHref, page)}`;
-};
-
-const renderLandingBody = ({ page, html, resolveHref }: PageRender) =>
- `${renderHeader(resolveHref, page)}
-
-
-
- ${html}
-
-
- ${renderFooter(resolveHref, page)}`;
-
-/**
- * The social card, reused by every page: 1200x630, served from `public/`.
- *
- * One image rather than one per page — the card carries the library's name and
- * what it does, which is what an unfurled link needs to say whichever page was
- * shared.
- */
-const OG_IMAGE = {
- path: '/og.png',
- width: 1200,
- height: 630,
- alt: 'keyrove — framework-agnostic keyboard navigation for lists, grids and trees.',
-} as const;
-
-const meta = (attribute: 'name' | 'property', key: string, content: string) =>
- ``;
-
-/**
- * What a crawler and a link unfurler need: which URL is the canonical one, and
- * what to show when the page is shared.
- *
- * Every route has a `.md` twin at a sibling path, so which of the two is the
- * indexable one has to be said outright rather than left to be guessed — and
- * the twin is named as an alternate, so an agent holding the page finds the
- * markdown without guessing at its URL. A page marked `noindex` skips all of it
- * and says so instead — it is served at every dead URL, so it has no canonical
- * URL of its own to claim.
- */
-const renderIndexingTags = (
- { page, nav, resolveHref }: PageRender,
- title: string,
-) => {
- if (page.noindex) return [meta('name', 'robots', 'noindex, follow')];
-
- const toUrl = (route: string) =>
- `${META.siteUrl}${resolveHref(routeToPath(route))}`;
- const url = toUrl(page.route);
- const markdownUrl = `${META.siteUrl}${resolveHref(toMarkdownPath(page.route))}`;
- const image = `${META.siteUrl}${resolveHref(OG_IMAGE.path)}`;
-
- return [
- ``,
- // `type` names the format. The twin is served as `text/plain` so that a
- // browser shows it rather than downloading it — see public/_headers.
- ``,
- meta(
- 'property',
- 'og:type',
- page.layout === 'landing' ? 'website' : 'article',
- ),
- meta('property', 'og:site_name', 'keyrove'),
- // The prose is American — "license", "color", "behavior". That is also
- // what an absent og:locale is taken to mean, so this only says it outright.
- meta('property', 'og:locale', 'en_US'),
- meta('property', 'og:title', title),
- meta('property', 'og:description', page.description),
- meta('property', 'og:url', url),
- meta('property', 'og:image', image),
- meta('property', 'og:image:width', String(OG_IMAGE.width)),
- meta('property', 'og:image:height', String(OG_IMAGE.height)),
- meta('property', 'og:image:alt', OG_IMAGE.alt),
- meta('name', 'twitter:card', 'summary_large_image'),
- meta('name', 'twitter:title', title),
- meta('name', 'twitter:description', page.description),
- meta('name', 'twitter:image', image),
- renderStructuredData({ page, nav, toUrl, imageUrl: image }),
- ];
-};
-
-/**
- * Comments explain the templates to whoever edits them; the browser has no use
- * for them. The shell in index.html and the header above carry ~1.5 KB of
- * rationale between them, and it would otherwise be sent 20 times over — once
- * per page — for about a tenth of each page's compressed weight.
- *
- * A regex is enough because nothing that reaches here opens a comment it does
- * not close: a code block showing markup arrives from shiki with its `<`
- * already escaped (`<!--`), so an example that documents a comment keeps
- * it. Leading indentation and the trailing newline go too, so a comment that
- * had a line to itself does not leave a blank one behind.
- *
- * Runs after the slots are filled — they are comments themselves.
- */
-const stripComments = (html: string) =>
- html.replace(/[ \t]*\n?/g, '');
-
-/** Stamps one page out of the built shell. */
-export const renderPage = (template: string, render: PageRender) => {
- for (const slot of [SLOTS.styles, SLOTS.body]) {
- if (!template.includes(slot)) {
- throw new Error(`[docs] index.html is missing the ${slot} placeholder.`);
- }
- }
-
- const { page } = render;
- const body =
- page.layout === 'landing'
- ? renderLandingBody(render)
- : renderDocsBody(render);
-
- // Every page hangs its own name off the wordmark, except the landing page,
- // whose name is the wordmark — leaving it to describe itself at a length no
- // search result or link unfurl will show. `titleTag` in frontmatter is how
- // either falls back to a line written for the slot.
- const title =
- page.titleTag ??
- (page.layout === 'landing'
- ? `keyrove — ${page.description}`
- : `${page.title} — keyrove`);
-
- const head = [
- `${escapeHtml(title)}`,
- ``,
- ``,
- ...renderIndexingTags(render, title),
- ].join('\n ');
-
- return stripComments(
- template
- .replace(SLOTS.head, head)
- .replace(SLOTS.styles, renderStylesheet(render.stylesheet))
- .replace(SLOTS.body, `${body}${renderSearch()}`),
- );
-};
diff --git a/packages/docs/build/markdown.ts b/packages/docs/build/markdown.ts
index 7938e2d..6b83346 100644
--- a/packages/docs/build/markdown.ts
+++ b/packages/docs/build/markdown.ts
@@ -90,8 +90,9 @@ const wantsCopy = (info: string) => hasFlag(info, 'copy');
/**
* The copy button a code panel carries, shared with the demos' source blocks.
- * It copies the visible block beside it in the same container, which
- * src/copy-code.ts looks up on the click.
+ * It copies the visible block beside it in the same container. The markup
+ * marks where it goes and what it is called; src/components/markdown.tsx swaps
+ * in the component that copies.
*/
export const copyButton = (label: string) =>
[
@@ -244,11 +245,12 @@ const createRenderer = async () => {
* package managers. Nothing marks a group but the adjacency itself — any
* prose between two blocks keeps them apart.
*
- * The tabs are stamped here, so the panel arrives finished and
- * src/code-tabs.ts only has to switch it. The first tab is the one selected
- * unless a later fence is flagged `selected` — the landing page's point is
- * the script, and the markup is only what it runs on. Ids are numbered per
- * page through `env`, which a render gets fresh.
+ * The tabs are stamped here as ARIA markup — which tab is selected, what
+ * each is called — and src/components/markdown.tsx hands that to Base UI's
+ * tabs, which switch them and own their arrow keys. The first tab is the
+ * one selected unless a later fence is flagged `selected` — the landing
+ * page's point is the script, and the markup is only what it runs on. Ids
+ * are numbered per page through `env`, which a render gets fresh.
*
* A fence flagged `copy` gets a copy button in the top corner, over the block
* or at the end of the tab strip. One flagged block is enough to give its
diff --git a/packages/docs/build/paths.ts b/packages/docs/build/paths.ts
new file mode 100644
index 0000000..292ad69
--- /dev/null
+++ b/packages/docs/build/paths.ts
@@ -0,0 +1,28 @@
+/**
+ * How a page's route turns into the URLs the site serves it at.
+ *
+ * Pages are served at extensionless URLs (`/docs/api`, from
+ * `dist/client/docs/api.html`) so that appending `.md` lands on the source
+ * beside it. Relative hrefs cannot be resolved consistently against a URL with
+ * no trailing slash, hence absolute paths and an explicit base.
+ *
+ * Nothing here touches the filesystem, so the browser bundle imports it too.
+ */
+
+/** The site icon, emitted at the root beside `robots.txt`. */
+export const FAVICON_FILE = 'favicon.svg';
+
+/** Site-absolute hrefs, honouring the deploy base. */
+export const createHrefResolver =
+ (base: string) =>
+ (href: string): string =>
+ `${base}${href.replace(/^\//, '')}`;
+
+export type HrefResolver = ReturnType;
+
+/** `docs/examples/basic` → `/docs/examples/basic`; the landing page → `/`. */
+export const routeToPath = (route: string) => `/${route}`;
+
+/** The `.md` twin of a page: `docs/api` → `docs/api.md`, the landing page → `index.md`. */
+export const toMarkdownPath = (route: string) =>
+ route === '' ? 'index.md' : `${route}.md`;
diff --git a/packages/docs/build/plaintext.ts b/packages/docs/build/plaintext.ts
index 395b0ff..7350940 100644
--- a/packages/docs/build/plaintext.ts
+++ b/packages/docs/build/plaintext.ts
@@ -1,7 +1,7 @@
import type { NavGroup, Page } from './content.ts';
import { expandDemos, type Demos } from './demos.ts';
import { stripIcons } from './icons.ts';
-import { routeToPath, toMarkdownPath } from './layout.ts';
+import { routeToPath, toMarkdownPath } from './paths.ts';
import { expandMeta, META } from './meta.ts';
/**
diff --git a/packages/docs/build/structured-data.ts b/packages/docs/build/structured-data.ts
index d3fc30c..c206878 100644
--- a/packages/docs/build/structured-data.ts
+++ b/packages/docs/build/structured-data.ts
@@ -131,25 +131,20 @@ const breadcrumbList = ({ page, nav, toUrl }: Context) => {
};
/**
- * The page's nodes as one ``, not for HTML
- // entities, so the JSON is escaped at `<` alone. Running it through
- // escapeHtml would turn every quote in it into an entity and break the parse.
- const json = JSON.stringify(graph).replace(/${json}`;
-};
+export const toStructuredData = (
+ context: Context,
+): Record => ({
+ '@context': 'https://schema.org',
+ '@graph':
+ context.page.layout === 'landing'
+ ? [webSite(context), softwareSourceCode(context)]
+ : [techArticle(context), breadcrumbList(context)],
+});
diff --git a/packages/docs/components.json b/packages/docs/components.json
new file mode 100644
index 0000000..36ecc57
--- /dev/null
+++ b/packages/docs/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/style.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/packages/docs/index.html b/packages/docs/index.html
deleted file mode 100644
index f7dd679..0000000
--- a/packages/docs/index.html
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 6d07657..c3ab50c 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@mixedrays/keyrove/docs",
- "version": "2.11.0",
+ "version": "3.0.0",
"private": true,
"description": "Documentation site and landing page for keyrove.",
"license": "MIT",
@@ -13,15 +13,27 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@base-ui/react": "^1.8.0",
"@mixedrays/keyrove": "workspace:*",
- "minisearch": "^7.2.0"
+ "@tanstack/react-router": "^1.170.41",
+ "@tanstack/react-start": "^1.168.60",
+ "class-variance-authority": "^0.7.1",
+ "cn": "^0.4.0",
+ "lucide": "^1.34.0",
+ "minisearch": "^7.2.0",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0"
},
"devDependencies": {
"@shikijs/markdown-it": "^4.4.3",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.3.0",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
"gray-matter": "^4.0.3",
- "lucide": "^1.34.0",
+ "html-react-parser": "^6.1.8",
+ "htmlparser2": "^12.0.0",
"markdown-it": "^15.0.0",
"shiki": "^4.4.3",
"tailwindcss": "^4.3.3",
diff --git a/packages/docs/src/app.css b/packages/docs/src/app.css
new file mode 100644
index 0000000..983c2a7
--- /dev/null
+++ b/packages/docs/src/app.css
@@ -0,0 +1,11 @@
+/*
+ * The site's one stylesheet: the chrome and prose, then the demos.
+ *
+ * Pages used to link one of two sheets — this one on a page with a demo, the
+ * site alone on a page without — because every navigation was a full page
+ * load and the demo styles are most of the site's CSS. Navigation is client
+ * side now, and a sheet picked per page would have to be swapped mid-session,
+ * so every page links the whole of it once and keeps it.
+ */
+@import './style.css';
+@import './demos.css';
diff --git a/packages/docs/src/code-tabs.ts b/packages/docs/src/code-tabs.ts
deleted file mode 100644
index 7d5fcfa..0000000
--- a/packages/docs/src/code-tabs.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { keyRove } from '@mixedrays/keyrove';
-
-/**
- * Code panels with a tab per block.
- *
- * The tabs are markup already — build/markdown.ts stamps the tab list and the
- * panels, one of them selected — so this only switches between them. The
- * landing page's layout switcher is written in the same markup by hand, a
- * live demo per panel, and is switched here too.
- *
- * The arrows are keyrove's own: the tab list is a horizontal group with a tab
- * per item, and a move selects the tab it lands on, which is the selection
- * following focus that the ARIA tabs pattern describes. The one tab stop moves
- * with the selection, so Tab leaves the list for the panel it shows.
- */
-export const mountCodeTabs = () => {
- const groups = document.querySelectorAll('[data-code-tabs]');
-
- for (const group of groups) {
- const list = group.querySelector(':scope > [role="tablist"]');
- if (!list) continue;
-
- const tabs = Array.from(
- list.querySelectorAll(':scope > [role="tab"]'),
- );
-
- const select = (selected: Element) => {
- for (const tab of tabs) {
- const isSelected = tab === selected;
- tab.setAttribute('aria-selected', String(isSelected));
- tab.tabIndex = isSelected ? 0 : -1;
-
- const panel = document.getElementById(
- tab.getAttribute('aria-controls') ?? '',
- );
- panel?.toggleAttribute('hidden', !isSelected);
- }
- };
-
- list.addEventListener('keydown', (e) =>
- keyRove(e, { onMove: ({ to }) => select(to) }),
- );
-
- list.addEventListener('click', (e) => {
- const tab = (e.target as Element).closest('[role="tab"]');
- if (tab) select(tab);
- });
- }
-};
diff --git a/packages/docs/src/components/copy-button.tsx b/packages/docs/src/components/copy-button.tsx
new file mode 100644
index 0000000..fea1f1f
--- /dev/null
+++ b/packages/docs/src/components/copy-button.tsx
@@ -0,0 +1,98 @@
+import type { MouseEvent, ReactNode } from 'react';
+
+import { Icon } from '@/components/icon.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import { useFlash } from '@/lib/utils.ts';
+
+/** The copy glyph and the tick it turns into; style.css shows one at a time. */
+const Glyphs = () => (
+ <>
+
+
+ >
+);
+
+/**
+ * "Copy code" — the button on a demo's source and on any block whose fence
+ * asks for one (see build/markdown.ts).
+ *
+ * It copies the block's own text, rather than a second copy of it held in a
+ * prop, so what lands on the clipboard is what is on screen. The button sits
+ * beside the code in one container; where that container is a tabbed panel,
+ * the block is whichever tab is showing, so it is looked up on the click
+ * rather than once.
+ */
+export function CopyCodeButton({ label }: { label: string }) {
+ const [copied, flash] = useFlash(false);
+
+ const copy = async (e: MouseEvent) => {
+ const code = Array.from(
+ e.currentTarget.parentElement?.querySelectorAll('pre') ?? [],
+ ).find((pre) => !pre.closest('[hidden]'));
+ if (!code) return;
+
+ try {
+ await navigator.clipboard.writeText(code.textContent ?? '');
+ } catch {
+ // Clipboard access can be refused outright; the code is on the page
+ // either way, so there is nothing to fall back to.
+ return;
+ }
+
+ flash(true);
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * A button that puts a command on the clipboard — the install line on the
+ * landing page.
+ *
+ * The command is written out beside the button, so this only saves the
+ * selecting: with scripting off, or the clipboard refused, it can still be
+ * copied by hand. It is drawn as the code blocks' copy button is, a copy
+ * glyph that turns into a tick, so the page has one way of saying "copied".
+ */
+export function CopyCommandButton({
+ command,
+ label,
+ children,
+}: {
+ command: string;
+ label: string;
+ children: ReactNode;
+}) {
+ const [copied, flash] = useFlash(false);
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(command);
+ } catch {
+ // Clipboard access can be refused outright; the command is on the
+ // page either way, so there is nothing to fall back to.
+ return;
+ }
+
+ flash(true);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/docs-layout.tsx b/packages/docs/src/components/docs-layout.tsx
new file mode 100644
index 0000000..3c9b9ff
--- /dev/null
+++ b/packages/docs/src/components/docs-layout.tsx
@@ -0,0 +1,57 @@
+import { useRouter } from '@tanstack/react-router';
+import { useCallback, useEffect, useState, type ReactNode } from 'react';
+
+import { useSearchOpen } from '@/components/search.tsx';
+import {
+ Sidebar,
+ SidebarDrawer,
+ SidebarToggle,
+} from '@/components/sidebar.tsx';
+import { SiteFooter, SiteHeader } from '@/components/site-chrome.tsx';
+import { Sheet } from '@/components/ui/sheet.tsx';
+
+/**
+ * Sidebar, content, rail — and the header and footer around them. Every docs
+ * page and the 404 render in here.
+ */
+export function DocsLayout({ children }: { children: ReactNode }) {
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const closeDrawer = useCallback(() => setDrawerOpen(false), []);
+ const searching = useSearchOpen();
+ const router = useRouter();
+
+ // Search opens over everything; two modal layers would each try to keep
+ // focus, so the drawer steps aside for it.
+ useEffect(() => {
+ if (searching) setDrawerOpen(false);
+ }, [searching]);
+
+ // A page loaded from its URL starts with focus on nothing, which is what
+ // lets its first demo take it. A client-side navigation would otherwise keep
+ // it on the sidebar link that was followed, so it is handed back as the
+ // navigation starts — before the new page renders and its demo mounts.
+ useEffect(
+ () =>
+ router.subscribe('onBeforeNavigate', ({ pathChanged }) => {
+ if (pathChanged && document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+ }),
+ [router],
+ );
+
+ return (
+
+ } />
+ {/* The centre column is `minmax(0, 1fr)` rather than `1fr` so a wide
+ code block or table scrolls inside itself instead of stretching the
+ grid past the viewport. */}
+
+ setDrawerOpen(true)} />
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/docs/src/components/docs-page.tsx b/packages/docs/src/components/docs-page.tsx
new file mode 100644
index 0000000..94252a5
--- /dev/null
+++ b/packages/docs/src/components/docs-page.tsx
@@ -0,0 +1,49 @@
+import { Suspense } from 'react';
+import { pages } from 'virtual:docs/site';
+
+import { Markdown } from '@/components/markdown.tsx';
+import { PageActions } from '@/components/page-actions.tsx';
+import { Pager } from '@/components/pager.tsx';
+import { Toc } from '@/components/toc.tsx';
+import { usePage } from '@/content.ts';
+
+function DocsPageContent({ route }: { route: string }) {
+ const { tree, headings } = usePage(route);
+ const page = pages[route];
+
+ return (
+ <>
+
+ {/* The page itself, title to last line; the pager after it is
+ navigation between pages, not part of this one. */}
+
+
{page.title}
+
+ {page.description ?
{page.description}
: null}
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * One docs page, between the sidebar and the edge of the layout.
+ *
+ * Suspended on its own, so that on the first paint the chrome around it
+ * hydrates while the page's chunk is still on its way — see src/content.ts.
+ *
+ * Keyed by its route, so moving to another page draws it afresh. Two pages
+ * have demos at the same places in their trees, and React would otherwise
+ * keep one page's demo components for the next — swapping in the new demo's
+ * markup without ever mounting its behaviour.
+ */
+export function DocsPage({ route }: { route: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/docs/src/components/icon.tsx b/packages/docs/src/components/icon.tsx
new file mode 100644
index 0000000..368c4b4
--- /dev/null
+++ b/packages/docs/src/components/icon.tsx
@@ -0,0 +1,39 @@
+import { CHROME_ICONS, type ChromeIconName } from '@/icons.ts';
+
+/**
+ * One of the chrome's icons, drawn as build/icons.ts draws the content's:
+ * `className` carries the size, and the glyph is decorative.
+ */
+export function Icon({
+ name,
+ className,
+}: {
+ name: ChromeIconName;
+ className?: string;
+}) {
+ const { viewBox, strokeWidth, body } = CHROME_ICONS[name];
+
+ // A filled mark carries `fill` on its own path; a stroked one is drawn by
+ // the wrapper, so the two need different attribute sets.
+ const stroke =
+ strokeWidth === null
+ ? {}
+ : {
+ stroke: 'currentColor',
+ strokeWidth,
+ strokeLinecap: 'round' as const,
+ strokeLinejoin: 'round' as const,
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/markdown.tsx b/packages/docs/src/components/markdown.tsx
new file mode 100644
index 0000000..52fceb3
--- /dev/null
+++ b/packages/docs/src/components/markdown.tsx
@@ -0,0 +1,236 @@
+import { keyRove } from '@mixedrays/keyrove';
+import {
+ createElement,
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from 'react';
+
+import type {
+ HtmlElement,
+ HtmlNode,
+ HtmlProps,
+} from '../../build/html-tree.ts';
+import {
+ CopyCodeButton,
+ CopyCommandButton,
+} from '@/components/copy-button.tsx';
+import { SiteLink } from '@/components/site-link.tsx';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs.tsx';
+import { mountDemo } from '@/demos.ts';
+import { mountHero } from '@/hero.ts';
+
+/**
+ * A page's rendered markdown, as React.
+ *
+ * build/markdown.ts renders the HTML and build/html-tree.ts turns it into a
+ * tree of elements at build time; this draws the tree, swapping in a component
+ * wherever the markup stands for one: a link to another page becomes a router
+ * link, a group of code blocks becomes Base UI tabs, a copy button gets its
+ * click, and a demo gets its behaviour.
+ */
+
+const classes = (props: HtmlProps) =>
+ typeof props.className === 'string' ? props.className.split(/\s+/) : [];
+
+const hasClass = (props: HtmlProps, name: string) =>
+ classes(props).includes(name);
+
+const elements = (children: HtmlNode[]) =>
+ children.filter((child): child is HtmlElement => typeof child !== 'string');
+
+const textOf = (nodes: HtmlNode[]): string =>
+ nodes
+ .map((node) => (typeof node === 'string' ? node : textOf(node[2])))
+ .join('');
+
+const str = (value: unknown) => (typeof value === 'string' ? value : '');
+
+/**
+ * Code blocks written back to back, as build/markdown.ts stamps them — or the
+ * landing page's pattern tabs, written in the same markup by hand. The markup
+ * says which tab starts selected and what each is called; Base UI's tabs take
+ * it from there.
+ */
+function CodeTabs({
+ props,
+ children,
+}: {
+ props: HtmlProps;
+ children: HtmlNode[];
+}) {
+ const parts = elements(children);
+ const list = parts.find(([, child]) => child.role === 'tablist');
+ if (!list) return createElement('div', props, ...render(children));
+
+ const tabs = elements(list[2]).filter(([, tab]) => tab.role === 'tab');
+ const panels = parts.filter(([, child]) => child.role === 'tabpanel');
+ const copy = parts.find(
+ ([tag, child]) => tag === 'button' && hasClass(child, 'code-copy'),
+ );
+ const selected = Math.max(
+ 0,
+ tabs.findIndex(([, tab]) => tab['aria-selected'] === 'true'),
+ );
+
+ return (
+
+ {copy && }
+
+ {tabs.map(([, tab, label], index) => (
+
+ {textOf(label)}
+
+ ))}
+
+ {panels.map(([, panel, content], index) => (
+ // Not a tab stop of its own: the code block inside already is, and a
+ // demo's items are.
+
+ {render(content)}
+
+ ))}
+
+ );
+}
+
+/**
+ * Runs `mount` on the element once React has drawn it, and undoes it when the
+ * element goes — on navigation, or React's second mount in development.
+ */
+const useMount = (
+ mount: (element: HTMLElement, signal: AbortSignal) => void,
+) => {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ if (ref.current) mount(ref.current, controller.signal);
+ return () => controller.abort();
+ // Mounted once per element; `mount` is a module function.
+ }, []);
+
+ return ref;
+};
+
+/** A demo build/demos.ts stamped: the panel, its live preview, its source. */
+function Demo({ props, children }: { props: HtmlProps; children: HtmlNode[] }) {
+ const ref = useMount(mountDemo);
+
+ return (
+
+ {render(children)}
+
+ );
+}
+
+/**
+ * The landing page's hero demo. Its effect runs before any demo's, since React
+ * reaches it first — which is what lets it take the page's first focus.
+ */
+function Hero({ props, children }: { props: HtmlProps; children: HtmlNode[] }) {
+ const ref = useMount(mountHero);
+
+ return (
+
+ {render(children)}
+
+ );
+}
+
+/**
+ * The component an element stands for, or undefined for a plain element.
+ * `key` is the element's place among its siblings.
+ */
+const replace = (
+ [tag, props, children]: HtmlElement,
+ key: number,
+): ReactNode => {
+ const href = str(props.href);
+
+ if (tag === 'a' && href.startsWith('/')) {
+ return (
+
+ {render(children)}
+
+ );
+ }
+
+ if ('data-code-tabs' in props) {
+ return ;
+ }
+
+ if ('data-demo' in props) {
+ return ;
+ }
+
+ if (hasClass(props, 'hero-demo')) {
+ return ;
+ }
+
+ if (tag === 'button' && hasClass(props, 'code-copy')) {
+ return ;
+ }
+
+ if (tag === 'button' && 'data-copy-command' in props) {
+ return (
+
+ {render(children)}
+
+ );
+ }
+
+ // The 404's list of places to go instead, on the arrows like everything
+ // else. It is written in markdown, which cannot mark its links as items, so
+ // `items` names them instead. Nothing takes focus on arrival: the heading
+ // saying the page is missing is what a screen reader should reach first, so
+ // the hint under the list says how to get in.
+ if ('data-not-found-links' in props) {
+ return (
+
+ );
+ }
+
+ return undefined;
+};
+
+const render = (nodes: HtmlNode[]): ReactNode[] =>
+ nodes.map((node, key) => {
+ if (typeof node === 'string') return node;
+
+ const [tag, props, children] = node;
+ return (
+ replace(node, key) ??
+ createElement(tag, { ...props, key }, ...render(children))
+ );
+ });
+
+export function Markdown({ tree }: { tree: HtmlNode[] }) {
+ const content = useMemo(() => render(tree), [tree]);
+
+ return <>{content}>;
+}
diff --git a/packages/docs/src/components/nav-keys.tsx b/packages/docs/src/components/nav-keys.tsx
new file mode 100644
index 0000000..7d6458a
--- /dev/null
+++ b/packages/docs/src/components/nav-keys.tsx
@@ -0,0 +1,123 @@
+import {
+ type KeyCombo,
+ type KeyRoveOptions,
+ type RovingTabindexOptions,
+ initRovingTabindex,
+ keyRove,
+ matchesCombo,
+} from '@mixedrays/keyrove';
+import { useHydrated } from '@tanstack/react-router';
+import { useEffect, type RefObject } from 'react';
+
+import { isApple, isShown } from '@/lib/utils.ts';
+
+/**
+ * Keyboard navigation for the docs sidebar and the "On this page" rail, which
+ * have no Base UI component to give them any: keyrove's arrows within each,
+ * and an Alt+Shift chord to each one's tab stop from anywhere on the page.
+ *
+ * The chords are Alt+Shift because browsers claim most Ctrl+Shift letters —
+ * Ctrl+Shift+I is DevTools — and Firefox gives Alt+Shift to the page's own
+ * access keys.
+ */
+
+export type NavShortcut = { combo: KeyCombo; label: string };
+
+/** A sidebar's group: its links, looping, with one tab stop. */
+export const navGroup = (items: string): KeyRoveOptions => ({
+ items,
+ loop: true,
+ rovingTabindex: true,
+});
+
+/**
+ * Moves the group's tab stop to `initial` — the current page, the section in
+ * view — whenever `key` changes, so Tab and the chord land where the reader
+ * is. Left alone while focus is inside, where the stop is the reader's.
+ */
+export const useRovingStop = (
+ root: RefObject,
+ group: RovingTabindexOptions,
+ initial: string,
+ key: unknown,
+) => {
+ useEffect(() => {
+ const element = root.current;
+ if (!element || element.contains(document.activeElement)) return;
+ initRovingTabindex(element, {
+ ...group,
+ initial: element.querySelector(initial),
+ });
+ // `group` is a constant per caller; `key` is what moves the stop.
+ }, [root, initial, key]);
+};
+
+/**
+ * The chord that jumps to a group's tab stop. `reveal` runs first, for a
+ * group that is off-screen until something opens it — the mobile drawer opens
+ * and focuses its own current link, so it returns `true` to say it is handled.
+ */
+export const useNavShortcut = (
+ root: RefObject,
+ group: KeyRoveOptions,
+ shortcut: NavShortcut,
+ reveal?: () => boolean,
+) => {
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.defaultPrevented ||
+ event.isComposing ||
+ !matchesCombo(event, shortcut.combo) ||
+ document.getElementById('docs-search')
+ )
+ return;
+
+ if (reveal?.()) {
+ event.preventDefault();
+ return;
+ }
+
+ const element = root.current;
+ if (!element || !isShown(element)) return;
+ const target = element.querySelector('[tabindex="0"]');
+ if (target) {
+ keyRove(event, { ...group, focusKeys: { [shortcut.combo]: target } });
+ }
+ };
+
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [root, group, shortcut, reveal]);
+};
+
+const APPLE_KEYS = new Map([
+ ['Alt', '⌥'],
+ ['Shift', '⇧'],
+]);
+
+/**
+ * The hint beside a sidebar's first heading: its chord, and the arrows that
+ * move within it. Printed as the keycaps are — `⌥ ⇧ E` on Apple keyboards,
+ * `Alt Shift E` elsewhere — the way the search chip is. The stylesheet decides
+ * when it shows; screen readers get `aria-keyshortcuts` instead.
+ *
+ * Drawn only once the bundle has run, so nothing advertises a chord without a
+ * handler behind it.
+ */
+export function KeysHint({ label }: { label: string }) {
+ const hydrated = useHydrated();
+ if (!hydrated) return null;
+
+ const apple = isApple();
+ const keys = label
+ .split('+')
+ .map((key) => (apple && APPLE_KEYS.get(key)) || key);
+
+ return (
+
+ ↑ ↓
+ {keys.join(' ')}
+
+ );
+}
diff --git a/packages/docs/src/components/page-actions.tsx b/packages/docs/src/components/page-actions.tsx
new file mode 100644
index 0000000..7a44ff4
--- /dev/null
+++ b/packages/docs/src/components/page-actions.tsx
@@ -0,0 +1,75 @@
+import type { PageInfo } from 'virtual:docs/site';
+
+import { createHrefResolver, toMarkdownPath } from '../../build/paths.ts';
+import { Icon } from '@/components/icon.tsx';
+import { SiteLink } from '@/components/site-link.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import { useFlash } from '@/lib/utils.ts';
+
+const resolveHref = createHrefResolver(import.meta.env.BASE_URL);
+
+/**
+ * "Copy page" — fetches the page's own `.md` twin and puts it on the clipboard.
+ *
+ * Fetching rather than serialising the DOM means what lands on the clipboard is
+ * the same document an agent would get from the URL, not a reconstruction of it.
+ */
+function CopyPageButton({ href }: { href: string }) {
+ const [label, flash] = useFlash('Copy page');
+
+ const copy = async () => {
+ try {
+ const response = await fetch(resolveHref(href));
+ if (!response.ok) throw new Error(String(response.status));
+
+ await navigator.clipboard.writeText(await response.text());
+ flash('Copied');
+ } catch {
+ // Clipboard access can be refused outright; the link beside this button
+ // still gets the reader to the same file.
+ flash('Copy failed');
+ }
+ };
+
+ // Both glyphs are in the button; `data-copied` is what picks between them.
+ return (
+
+ );
+}
+
+/**
+ * The page's own source, in three forms: the markdown twin, the same file on
+ * the clipboard, and the file in the repo.
+ *
+ * These sit between the title and the lead rather than at the foot of the "On
+ * this page" rail, which is hidden below `xl` — where they used to live, a
+ * narrow screen lost them along with the rail.
+ *
+ * Every label is wrapped, icons are not: the underline is drawn on the span so
+ * it stops at the text instead of running under the glyph beside it.
+ */
+export function PageActions({ page }: { page: PageInfo }) {
+ const markdownHref = `/${toMarkdownPath(page.route)}`;
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/pager.tsx b/packages/docs/src/components/pager.tsx
new file mode 100644
index 0000000..9c6fecd
--- /dev/null
+++ b/packages/docs/src/components/pager.tsx
@@ -0,0 +1,41 @@
+import { pages, readingOrder } from 'virtual:docs/site';
+
+import { SiteLink } from '@/components/site-link.tsx';
+
+function Side({
+ route,
+ label,
+ align,
+}: {
+ route: string | undefined;
+ label: string;
+ align: string;
+}) {
+ if (route === undefined) return ;
+
+ return (
+
+ {label}
+ {pages[route].title}
+
+ );
+}
+
+/** Previous and next, in sidebar order. */
+export function Pager({ route }: { route: string }) {
+ const index = readingOrder.indexOf(route);
+ // A page outside the sidebar — the 404 — has no siblings to page between,
+ // and index -1 would otherwise offer the first page as its "next".
+ if (index === -1) return null;
+
+ const prev = readingOrder[index - 1];
+ const next = readingOrder[index + 1];
+ if (prev === undefined && next === undefined) return null;
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/search-dialog.tsx b/packages/docs/src/components/search-dialog.tsx
new file mode 100644
index 0000000..8c4ad5b
--- /dev/null
+++ b/packages/docs/src/components/search-dialog.tsx
@@ -0,0 +1,260 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ type MouseEvent,
+ type RefObject,
+} from 'react';
+
+import { Icon } from '@/components/icon.tsx';
+import { SiteLink } from '@/components/site-link.tsx';
+import {
+ Autocomplete,
+ AutocompleteInput,
+ AutocompleteItem,
+ AutocompleteList,
+ AutocompleteStatus,
+} from '@/components/ui/autocomplete.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+} from '@/components/ui/dialog.tsx';
+import type { SearchDocument } from '@/search-model.ts';
+
+/**
+ * The search dialog itself, in a chunk of its own: src/components/search.tsx
+ * loads it the first time search is opened, or a pointer settles on the
+ * button that opens it.
+ *
+ * The engine and `search-index.json` load the first time it opens, too. Base
+ * UI's autocomplete owns the keys inside: focus stays in the input, the arrows
+ * move a highlight through the results, and Enter follows the highlighted one
+ * — the first, until the reader moves it.
+ */
+
+type Find = (query: string) => SearchDocument[];
+
+let engine: Promise | undefined;
+
+/** Loads once and is shared; a failure is forgotten so Retry can try again. */
+const loadEngine = () => {
+ engine ??= Promise.all([
+ import('@/search-engine.ts'),
+ fetch(`${import.meta.env.BASE_URL}search-index.json`).then((response) => {
+ if (!response.ok) throw new Error(`Search index: ${response.status}`);
+ return response.text();
+ }),
+ ])
+ .then(([module, serialized]) => module.loadSearch(serialized))
+ .catch((error) => {
+ engine = undefined;
+ throw error;
+ });
+ return engine;
+};
+
+/** Show the words around a match. */
+const excerpt = (text: string, query: string) => {
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
+ const positions = terms
+ .map((term) => text.toLowerCase().indexOf(term))
+ .filter((position) => position >= 0);
+ const start = Math.max(
+ 0,
+ (positions.length ? Math.min(...positions) : 0) - 50,
+ );
+ const end = Math.min(text.length, start + 180);
+ return `${start ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
+};
+
+type Results =
+ | { status: 'loading' }
+ | { status: 'error' }
+ | { status: 'ready'; query: string; found: SearchDocument[] };
+
+const statusText = (results: Results) => {
+ if (results.status === 'loading') return 'Loading search…';
+ if (results.status === 'error') {
+ return 'Search could not load. Check your connection and try again.';
+ }
+
+ const { query, found } = results;
+ if (!query) return 'Search pages, API methods, and examples.';
+ if (found.length === 0) return `No results for “${query}”. Try another term.`;
+
+ return found.length === 12
+ ? 'Showing the top 12 results.'
+ : `${found.length} result${found.length === 1 ? '' : 's'}.`;
+};
+
+export default function SearchDialog({
+ open,
+ onOpenChange,
+ opener,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Where focus goes back to when the dialog closes. */
+ opener: RefObject;
+}) {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState({ status: 'loading' });
+ const [attempt, setAttempt] = useState(0);
+ const input = useRef(null);
+ // Following a result moves to another page, where the opener is gone.
+ const followed = useRef(false);
+
+ useEffect(() => {
+ if (!open) return;
+
+ let current = true;
+ const trimmed = query.trim();
+ loadEngine()
+ .then((find) => {
+ if (!current) return;
+ setResults({
+ status: 'ready',
+ query: trimmed,
+ found: trimmed ? find(trimmed) : [],
+ });
+ })
+ .catch(() => {
+ if (current) setResults({ status: 'error' });
+ });
+
+ return () => {
+ current = false;
+ };
+ }, [open, query, attempt]);
+
+ const found = results.status === 'ready' ? results.found : [];
+
+ // Leave modified clicks to the browser (for example, opening a new tab).
+ const follow = (event: MouseEvent) => {
+ if (
+ event.button !== 0 ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey
+ )
+ return;
+ followed.current = true;
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/search.tsx b/packages/docs/src/components/search.tsx
new file mode 100644
index 0000000..619888b
--- /dev/null
+++ b/packages/docs/src/components/search.tsx
@@ -0,0 +1,114 @@
+import { useHydrated } from '@tanstack/react-router';
+import {
+ Suspense,
+ createContext,
+ lazy,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+
+import { Icon } from '@/components/icon.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import { isApple } from '@/lib/utils.ts';
+
+/**
+ * The documentation search: a dialog over the page, opened from the header or
+ * with ⌘K / Ctrl+K anywhere. The dialog, its engine and its index all load on
+ * first use rather than with the page — see src/components/search-dialog.tsx.
+ */
+
+const loadDialog = () => import('@/components/search-dialog.tsx');
+const SearchDialog = lazy(loadDialog);
+
+const SearchContext = createContext<{ open: boolean; show: () => void }>({
+ open: false,
+ show: () => {},
+});
+
+/** Holds whether search is open, and opens it on ⌘K / Ctrl+K. */
+export function SearchProvider({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ // Mounted from the first open on, so that closing it keeps the query and
+ // reopening it is instant.
+ const [mounted, setMounted] = useState(false);
+ // Where focus goes back to on close: wherever it was when search opened.
+ const opener = useRef(null);
+
+ const show = () => {
+ if (document.activeElement instanceof HTMLElement) {
+ opener.current = document.activeElement;
+ }
+ setMounted(true);
+ setOpen(true);
+ };
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.defaultPrevented ||
+ event.isComposing ||
+ event.altKey ||
+ event.shiftKey ||
+ !(event.metaKey || event.ctrlKey) ||
+ event.key.toLowerCase() !== 'k'
+ )
+ return;
+ event.preventDefault();
+ if (!event.repeat) show();
+ };
+
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, []);
+
+ return (
+
+ {children}
+ {mounted && (
+
+
+
+ )}
+
+ );
+}
+
+/** Whether search is open, for whatever has to step aside for it. */
+export const useSearchOpen = () => useContext(SearchContext).open;
+
+/**
+ * The header's search button. Hidden until the bundle has run, since there is
+ * no search without it; the chip prints the shortcut as the keycaps do.
+ */
+export function SearchTrigger() {
+ const { open, show } = useContext(SearchContext);
+ const hydrated = useHydrated();
+ const [shortcut, setShortcut] = useState('Ctrl K');
+
+ useEffect(() => {
+ if (isApple()) setShortcut('⌘ K');
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/sidebar.tsx b/packages/docs/src/components/sidebar.tsx
new file mode 100644
index 0000000..6eefdfb
--- /dev/null
+++ b/packages/docs/src/components/sidebar.tsx
@@ -0,0 +1,158 @@
+import { followFocus, initRovingTabindex, keyRove } from '@mixedrays/keyrove';
+import { useLocation } from '@tanstack/react-router';
+import { useCallback, useEffect, useRef } from 'react';
+import { nav, pages } from 'virtual:docs/site';
+
+import { Icon } from '@/components/icon.tsx';
+import {
+ KeysHint,
+ navGroup,
+ useNavShortcut,
+ useRovingStop,
+ type NavShortcut,
+} from '@/components/nav-keys.tsx';
+import { SiteLink } from '@/components/site-link.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import { SheetContent, SheetTrigger } from '@/components/ui/sheet.tsx';
+
+/**
+ * The docs navigation: a sticky column from `lg` up, and below it a drawer the
+ * header's menu button slides in. Both draw the same groups — derived from
+ * the content, so adding a page adds its link — and both move on keyrove's
+ * arrows with one tab stop, which starts on the current page.
+ */
+
+const SHORTCUT: NavShortcut = { combo: 'alt+shift+KeyE', label: 'Alt+Shift+E' };
+
+const group = navGroup('.sidebar-link');
+
+/** Tailwind's `lg`, where the column replaces the drawer. */
+const DESKTOP = '(min-width: 64rem)';
+
+function SidebarNav() {
+ return (
+
+ );
+}
+
+/** The column, from `lg` up. Hidden below it, where the drawer stands in. */
+export function Sidebar({ onReveal }: { onReveal: () => void }) {
+ const root = useRef(null);
+ const pathname = useLocation({ select: (location) => location.pathname });
+
+ useRovingStop(root, group, '[aria-current="page"]', pathname);
+ useNavShortcut(root, group, SHORTCUT, () => {
+ if (matchMedia(DESKTOP).matches) return false;
+ onReveal();
+ return true;
+ });
+
+ return (
+
+ );
+}
+
+/**
+ * The drawer, below `lg`. Base UI's sheet traps focus while it is open, closes
+ * on Escape and on the backdrop, and hands focus back to the menu button; it
+ * opens on the current page's link.
+ */
+export function SidebarDrawer({ onClose }: { onClose: () => void }) {
+ const root = useRef(null);
+
+ // The drawer's links exist only while it is open, so its tab stop is set
+ // as they arrive rather than on navigation.
+ const attach = useCallback((element: HTMLDivElement | null) => {
+ root.current = element;
+ if (element) {
+ initRovingTabindex(element, {
+ ...group,
+ initial: element.querySelector('[aria-current="page"]'),
+ });
+ }
+ }, []);
+
+ // The drawer is the narrow layout's alone; widening the window past `lg`
+ // with it open would leave a backdrop over the column that replaced it.
+ useEffect(() => {
+ const query = matchMedia(DESKTOP);
+ const onChange = () => query.matches && onClose();
+ query.addEventListener('change', onChange);
+ return () => query.removeEventListener('change', onChange);
+ }, [onClose]);
+
+ return (
+
+ root.current?.querySelector('[aria-current="page"]') ??
+ true
+ }
+ onKeyDown={(event) => keyRove(event, group)}
+ onFocus={(event) => followFocus(event, group)}
+ // Navigating within the drawer should not leave it covering the page it
+ // just moved to.
+ onClick={(event) => {
+ if ((event.target as Element).closest('a')) onClose();
+ }}
+ >
+
+
+ );
+}
+
+/** The header's menu button, which only has a drawer to open below `lg`. */
+export function SidebarToggle({ open }: { open: boolean }) {
+ return (
+
+ }
+ >
+
+
+ );
+}
diff --git a/packages/docs/src/components/site-chrome.tsx b/packages/docs/src/components/site-chrome.tsx
new file mode 100644
index 0000000..f068038
--- /dev/null
+++ b/packages/docs/src/components/site-chrome.tsx
@@ -0,0 +1,133 @@
+import type { ReactNode } from 'react';
+import { meta } from 'virtual:docs/site';
+
+import { Icon } from '@/components/icon.tsx';
+import { SearchTrigger } from '@/components/search.tsx';
+import { SiteLink } from '@/components/site-link.tsx';
+import { ThemeToggle } from '@/components/theme-toggle.tsx';
+
+/**
+ * The header and footer every page shares.
+ *
+ * The landing page's content is narrower than the docs grid; the header and
+ * footer rows track whichever the page between them uses.
+ */
+
+type Layout = 'docs' | 'landing';
+
+export function SiteHeader({
+ layout,
+ menu,
+}: {
+ layout: Layout;
+ /** The drawer toggle, on a docs page. */
+ menu?: ReactNode;
+}) {
+ return (
+
+
+
+ {menu}
+
+
+ keyrove
+
+
+
+
+
+ {/* The nav is hidden on narrow screens, so the repo keeps an
+ icon-only stop in the header there rather than dropping out of
+ it entirely. */}
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * The foot of every page: what the package is, and the places a reader is
+ * most likely to want next, including llms.txt, which is otherwise linked
+ * only from the sidebar. The row takes the header's width, so the two frame
+ * the same column.
+ */
+export function SiteFooter({ layout }: { layout: Layout }) {
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/site-link.tsx b/packages/docs/src/components/site-link.tsx
new file mode 100644
index 0000000..1041ca0
--- /dev/null
+++ b/packages/docs/src/components/site-link.tsx
@@ -0,0 +1,48 @@
+import { Link } from '@tanstack/react-router';
+import type { ComponentProps } from 'react';
+
+import { createHrefResolver } from '../../build/paths.ts';
+
+const resolveHref = createHrefResolver(import.meta.env.BASE_URL);
+
+/**
+ * A file the site serves rather than a page — `/llms.txt`, `/docs/api.md` —
+ * which the router has nothing to render for.
+ */
+const isFile = (path: string) => /\.\w+$/.test(path);
+
+/**
+ * A site-absolute link, the way content and chrome both write them:
+ * `/docs/api#followfocus`.
+ *
+ * A page goes through the router, so following it swaps the page in place and
+ * hovering it loads the page ahead of the click. A file stays a plain link the
+ * browser fetches. Either way the deploy base is prefixed here, once.
+ */
+export function SiteLink({
+ href,
+ ...props
+}: { href: string } & Omit, 'href'>) {
+ const [path, hash] = href.split('#');
+
+ if (!href.startsWith('/') || isFile(path)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/theme-toggle.tsx b/packages/docs/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..57f274c
--- /dev/null
+++ b/packages/docs/src/components/theme-toggle.tsx
@@ -0,0 +1,28 @@
+import { useState } from 'react';
+
+import { Icon } from '@/components/icon.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import { themeLabel, toggleTheme } from '@/theme.ts';
+
+/**
+ * The header theme toggle. The inline script in the root route applies the
+ * stored choice before the page paints (see src/theme.ts); this handles
+ * clicks.
+ */
+export function ThemeToggle() {
+ const [label, setLabel] = useState('Switch between light and dark theme');
+
+ // Both glyphs ship; style.css shows one per theme. Picking in script would
+ // mean an empty button until the bundle ran, and the theme is not known
+ // until the inline head script has run anyway.
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/toc.tsx b/packages/docs/src/components/toc.tsx
new file mode 100644
index 0000000..49fbfc9
--- /dev/null
+++ b/packages/docs/src/components/toc.tsx
@@ -0,0 +1,117 @@
+import { followFocus, keyRove } from '@mixedrays/keyrove';
+import { useEffect, useRef, useState } from 'react';
+
+import type { Heading } from '../../build/markdown.ts';
+import {
+ KeysHint,
+ navGroup,
+ useNavShortcut,
+ useRovingStop,
+ type NavShortcut,
+} from '@/components/nav-keys.tsx';
+
+const SHORTCUT: NavShortcut = { combo: 'alt+shift+KeyO', label: 'Alt+Shift+O' };
+
+const group = navGroup('.toc-link');
+
+/**
+ * Clears the sticky header plus a little breathing room, so a heading counts
+ * as current from the moment it settles under the bar.
+ */
+const OFFSET = 96;
+
+/**
+ * The "On this page" rail, which highlights the section currently under the
+ * header.
+ *
+ * A scroll position read beats `IntersectionObserver` here: the answer is
+ * "which heading did we last pass", and short trailing sections never grow tall
+ * enough to satisfy an observer threshold at all.
+ */
+export function Toc({ headings }: { headings: Heading[] }) {
+ const root = useRef(null);
+ const [active, setActive] = useState();
+
+ useEffect(() => {
+ const targets = headings
+ .map((heading) => document.getElementById(heading.id))
+ .filter((element): element is HTMLElement => element !== null);
+ if (targets.length === 0) return;
+
+ const update = () => {
+ const scrolledToBottom =
+ window.innerHeight + window.scrollY >= document.body.scrollHeight - 2;
+
+ // The last section can be too short to ever reach the offset, so the
+ // bottom of the page selects it outright.
+ const current = scrolledToBottom
+ ? targets[targets.length - 1]
+ : ([...targets]
+ .reverse()
+ .find((target) => target.getBoundingClientRect().top <= OFFSET) ??
+ targets[0]);
+
+ setActive(current.id);
+ };
+
+ let queued = false;
+ const onScroll = () => {
+ if (queued) return;
+ queued = true;
+ requestAnimationFrame(() => {
+ queued = false;
+ update();
+ });
+ };
+
+ addEventListener('scroll', onScroll, { passive: true });
+ addEventListener('resize', onScroll, { passive: true });
+ update();
+
+ return () => {
+ removeEventListener('scroll', onScroll);
+ removeEventListener('resize', onScroll);
+ };
+ }, [headings]);
+
+ // The tab stop follows the highlight, so Tab and the chord land on the
+ // section in view — until focus is in the rail, where it is the reader's.
+ useRovingStop(root, group, '[data-active]', active);
+ useNavShortcut(root, group, SHORTCUT);
+
+ // Nothing to list on a page with no headings, and the rail carries nothing
+ // else, so it is left out entirely rather than sitting empty.
+ if (headings.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/ui/autocomplete.tsx b/packages/docs/src/components/ui/autocomplete.tsx
new file mode 100644
index 0000000..d769933
--- /dev/null
+++ b/packages/docs/src/components/ui/autocomplete.tsx
@@ -0,0 +1,45 @@
+import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete';
+
+/**
+ * A text input with a list of suggestions, on Base UI's autocomplete — shadcn
+ * has no component for it, so this follows the shape of the ones it does.
+ *
+ * Base UI owns the keys: focus stays in the input while the arrows move a
+ * highlight through the list, and Enter activates the highlighted item.
+ * Unstyled here; the search passes the classes style.css draws it with.
+ */
+
+/** Context only — it renders no element, so there is nothing to mark. */
+const Autocomplete = AutocompletePrimitive.Root;
+
+function AutocompleteInput(props: AutocompletePrimitive.Input.Props) {
+ return (
+
+ );
+}
+
+function AutocompleteList(props: AutocompletePrimitive.List.Props) {
+ return (
+
+ );
+}
+
+function AutocompleteItem(props: AutocompletePrimitive.Item.Props) {
+ return (
+
+ );
+}
+
+function AutocompleteStatus(props: AutocompletePrimitive.Status.Props) {
+ return (
+
+ );
+}
+
+export {
+ Autocomplete,
+ AutocompleteInput,
+ AutocompleteItem,
+ AutocompleteList,
+ AutocompleteStatus,
+};
diff --git a/packages/docs/src/components/ui/button.tsx b/packages/docs/src/components/ui/button.tsx
new file mode 100644
index 0000000..43bf33c
--- /dev/null
+++ b/packages/docs/src/components/ui/button.tsx
@@ -0,0 +1,43 @@
+import { Button as ButtonPrimitive } from '@base-ui/react/button';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+/**
+ * The site's buttons, on Base UI's.
+ *
+ * The variants name the classes style.css already draws these buttons with,
+ * rather than restating them as utilities here: a utility outranks every rule
+ * in the components layer, so shadcn's default classes would have repainted
+ * the header and the code panels. With no utilities to reconcile, the classes
+ * are joined rather than merged, which keeps tailwind-merge out of the bundle.
+ */
+const buttonVariants = cva('', {
+ variants: {
+ variant: {
+ /** No look of its own: the class the caller passes carries it. */
+ bare: '',
+ /** The square, bordered buttons in the header and the search panel. */
+ icon: 'icon-button',
+ /** The copy button in a code panel's corner. */
+ copy: 'code-copy',
+ },
+ },
+ defaultVariants: {
+ variant: 'bare',
+ },
+});
+
+function Button({
+ className,
+ variant,
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/docs/src/components/ui/dialog.tsx b/packages/docs/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..ab87852
--- /dev/null
+++ b/packages/docs/src/components/ui/dialog.tsx
@@ -0,0 +1,38 @@
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+
+/**
+ * A modal on Base UI's dialog, which handles what the native `