# CLAUDE.md — Design System

## What This Is

A React component library + design system + documentation website. It has three interconnected parts:

1. **Component Library** (`/src`) — React components built with Vite + TypeScript, published to npm as **`rift-ds`**. Each component has its own folder with implementation, scoped CSS, and Storybook stories. The website is an npm-workspace consumer: it depends on the package by name and imports through the same `exports` subpaths any consumer would (the in-repo exports point at `./src`, so it's live source — see **Registries** below for the generated barrel/exports surfaces). The official component list and count live in `src/components/registry.json`; never hardcode a count.
2. **Documentation Website** (`/website`) — A separate Next.js app that showcases every component with live, interactive examples. Each component has its own page under `website/src/app/components/[component-name]/`.
3. **AI Layer** (spans both) — the site answering questions about itself: the library's `ai` component category, the site-wide chat (the `SiteChat` panel, mounted from the root layout via `SiteChatMount`, the `useChat` hook, the Claude-backed `/api/chat` route — which resolves the composer's model pick through the allowlist in `website/src/lib/chat-model.ts` and a budget tier the guardrails compute from the day's spend, so the default steps down to the cheaper model as budget runs low and the dearer one locks near the cap — `/api/chat/followups`, which writes the suggestion chips under a finished answer with a smaller model, and `/api/chat/feedback`, which stores a visitor's thumbs verdict in Redis — rate-limited by the shared guardrails, disclosed on /privacy), the build-generated site corpus it reads (see the corpus rows and security boundary in **Registries** below) plus two deterministic lookup tools the model carries for what the corpus deliberately omits — the component prop API and the token registry — implemented once in `website/src/lib/site-tools.ts` and shared with the MCP endpoint, bounded by the route's tool loop (`MAX_MODEL_CALLS`) and surfaced to visitors only as trace points, the answer-quality eval in `evals/chat`, and `/api/mcp` — a public, auth-free Model Context Protocol endpoint (stateless Streamable HTTP via `mcp-handler`) whose deterministic tools serve the component prop API, the registries, install setup, and corpus search to any MCP client. It calls no model, so the chat's guardrails deliberately do not apply — and it is unmetered by choice (no auth, no rate limit): the tools are cheap in-memory reads, and that decision is recorded in the route's doc block, to revisit if a tool ever stops being one. Its security boundary is the corpus rule inherited whole — every tool reads only generated, already-published data. Advertised in llms.txt; the tool count stated in the README and on /overview is held to the route's actual registrations by `scripts/validate-mcp-tools.mjs` (which also holds the display roster in `website/src/lib/mcp-tools.ts` — blurbs and example prompts, rendered by the landing page and get-started — to the registrations in both directions). The same generated data also ships as two file surfaces for agents, the per-component prop markdown and the consumer agent skill, with a one-command installer for the latter (`npx rift-ds init`, the package's only bin) — see their paragraphs under **Registries**.

**Every chat suggestion is one chip, and a chip never wraps.** Conversation starters, follow-up questions, and the FAB summary panel's chips share the same row component, so they share one length budget: `SUGGESTION_MAX_CHARS` in `website/src/lib/chat-suggestions.ts`, set to what fits the message column on a small phone, where the panel fills the viewport. It is enforced at every point a suggestion enters the UI — the generator is told the number, the route drops a long one rather than clipping it, and `scripts/validate-chat-starters.mjs` and `scripts/validate-page-summaries.mjs` between them fail the build on written copy that exceeds it. A suggestion that will not fit is dropped, never truncated: half a question is not a question.

The design spec lives in [`design.md`](design.md) — read it before touching tokens, colors, or typography. The content style guide lives in [`content-design.md`](content-design.md) — read it before writing or editing any shipped prose (page copy, journal entries, descriptions, README, release notes, microcopy).

---

## Registries — counts are never hardcoded

**General rule:** any count of items displayed anywhere (components, skills, tokens, releases — anything countable) must derive from a registry that is the single source of truth for that collection, kept in sync with reality by a build-time validator. Never write a literal number (or a hand-maintained list that implies one) into page copy, stats, or docs.

Existing registries:

| Collection | Registry | Count export | Validator |
|---|---|---|---|
| Components | `src/components/registry.json` (`components` + `categories` + `docOnlyHelpers`) — each component entry carries `name`, `label`, `slug`, `description`, `category`, `client`, optionally `folder` when the implementation lives in a shared folder (the `Chart/` chart set; independent of the `recharts` flag — FunnelChart is recharts-backed in its own folder), and `recharts: true` when the module imports recharts and so exports from the charts barrel (held to the actual imports by `scripts/generate-library-barrel.mjs`); each category carries `id`, `label`, `description` (served through the chat corpus and the MCP tools; the index sections and sidebar accordions display only the label and count) | `COMPONENT_COUNT`, `componentMetadata`, `componentCategories`, `componentCategoryMetadata` from `src/components/registry.ts` | `scripts/validate-component-registry.mjs` — every folder registered, every entry has a folder, metadata well-formed (kebab-case unique slugs, unique labels, descriptions ≤160 chars ending in a full stop, known category), and **`client` matches whether the file actually declares `'use client'`** |
| Component website surfaces | `src/components/registry.json` (same registry) | — | `scripts/validate-website-surfaces.mjs` — every public component has a showcase page, a preview entry in `website/src/components/ComponentPreviews/ComponentPreviews.tsx` (both directions — an orphan preview key fails too), and a `###` spec section in `design.md`; every folder under `website/src/app/components/` is a registered slug (categories have no pages — they are index sections and sidebar accordions); also keeps `SECTION_OG_IMAGE_SEGMENTS` in `website/src/config/navigation.ts` in sync (both directions) with the section-level `opengraph-image.tsx` files, so sub-page share cards can't silently fall back to the root card. It also holds `/foundations/colour-mode` to the colour token registry in **both directions**, so a colour token can never exist without a swatch on the page that states it documents all of them. The sidebar nav entry and its alphabetical order are no longer checked because `componentsSidebarLinks` is **derived** from the registry — they cannot drift |
| Skills | `.claude/skills/registry.json` (`displayed` + `external` + `unlisted`) | `SKILL_COUNT` from `website/src/data/skills-registry.ts` | `scripts/validate-skills-registry.mjs` — every `.md` registered, every entry has a file, page list matches `displayed` + `external` |
| Release log | `website/src/data/release-log.json` (one entry per published npm version, newest first — 1:1 with the registry by rule: an entry is written at publish time and never otherwise) | `RELEASE_COUNT` + `latestRelease` from `website/src/data/release-log.ts` | `scripts/validate-release-log.mjs` — structure only (unique descending semver, valid dates, complete stories, no commit-hash dumps; an empty log is valid — it starts at zero); whether a published version has its entry is the `release` skill's job at publish time, never the build's |
| Loops | `website/src/data/loops.json` (the recurring agent loops documented on /loops — per loop: description, cadence, trigger, stages, guardrails, the skill slugs it is built on, and status) | `loops` + `LOOP_COUNT` from `website/src/data/loops.ts` | `scripts/validate-loops.mjs` — structure complete, unique slugs, known status, every referenced skill registered in `.claude/skills/registry.json`. The prose fields ship verbatim as page copy, so `validate-shipped-prose.mjs` scans them alongside the other shipped-copy registries; the corpus carries the registry through its own Loops section, so /loops stays covered with its prose in JSON |
| Page summaries | `website/src/data/page-summaries.json` (`routes` + `essays`) — the chat FAB's AI-summary panel content per page: a super-concise pre-written summary and 1–2 prompt chips that open the site chat mid-answer (AiButton's `summary` prop, wired in `SiteChatMount` — hover-capable devices only, mounted after hydration so the server HTML always carries the plain FAB; touch devices get the plain button). Hand-written for the static routes; component pages derive theirs from the component registry's descriptions, so those never need entries | — (nothing displays a count; `getPageSummary` from `website/src/data/page-summaries.ts` is the accessor) | `scripts/validate-page-summaries.mjs` — every route has a summary or is chromeless (parsed from `chromeless.ts`, its one home); both directions, so a renamed page or an essay synced in without a summary fails the build; texts stay TLDR-short and chip labels inside the suggestion budget |
| Semantic tokens | `src/tokens/registry.json` — **generated** from the semantic token CSS (`tokens-light.css` + `tokens-typography.css` + `tokens-motion.css`) by `scripts/generate-token-registry.mjs`, never hand-edited | `TOKEN_COUNT` + `TOKEN_COUNTS` (per category) from `src/tokens/registry.ts` | `scripts/validate-token-registry.mjs` — registry matches the CSS, light/dark colour parity; a token with an unknown prefix fails generation until its category is added deliberately |
| Theme presets | `website/src/lib/theme/presets.ts` (`THEME_PRESETS` — every lever a saved position, so a preset is a complete theme — and `presetOverrides(preset, theme)`, the ONE composer both the playground's live preview and the generated stylesheets call) plus the **generated** stylesheets in `src/tokens/presets/` — one `html[data-brand="<id>"]` sheet per preset with light and always-emitted dark blocks, and a `presets.css` aggregate that imports them all — written by `scripts/generate-preset-stylesheets.mjs`, never hand-edited. Shipped in the npm package under the `tokens/presets/` subpath and loaded site-wide by the root layout, so one `data-brand` attribute on `<html>` rethemes the whole site, light and dark, with zero runtime JS | — (no count export; `THEME_PRESETS` is the accessor, and `/foundations/themes` derives its own count from `themeSelectorTiles()`) | `scripts/validate-preset-stylesheets.mjs` — regenerates in memory and byte-compares every file, holds the folder to `THEME_PRESETS` in both directions (an orphan sheet fails), and parses one preset's CSS back into declarations against the composer as a parity proof. `scripts/validate-theme-presets.mjs` — the completeness gate: every override name resolves in the token registry or primitives, the action family is overridden or provably intended-default (its doc block owns the rule), the resolved action bg/text pairing holds WCAG AA 4.5:1 in both themes — a preset that deliberately cannot is pinned in `SANCTIONED_AA_GAPS` with its ratio and reason — and the density/type/motion/elevation levers are present on every preset |
| Ambient background | `website/src/data/shader-background.json` — the renderer switch, the eight shader parameters, and the eight blob definitions. **This is the site's config for a library component**: the renderer is `ShaderField` in `src/components/ShaderField/`, and the JSON's shapes are its published `ShaderParams`/`ShaderBlob` types, imported rather than restated. Hand-edited: tune a look live with the dev-only panel (`?tune=1` on any page in dev), then paste the panel's snippet back in. Setting `"mode"` to `"css"` reverts the whole site to the CSS blobs, which are always rendered underneath as the fallback | — (nothing displays a count; `shaderBackground` from `website/src/data/shader-background.ts` is the accessor) | `scripts/validate-shader-background.mjs` — mode is a known renderer, every parameter is inside the tuner's slider range, the blob count matches `BLOB_COUNT` in the library's shader source (a website config held to a library constant — the uniform arrays are fixed-size, so the two cannot drift), and **every blob's colour token exists in the token registry**, so a renamed token cannot leave the background sampling a property nothing defines. It also holds the parameter *set* to `DEFAULT_SHADER_PARAMS` in the library (both directions, so a new parameter cannot ship without a range) and holds every `"<N> parameters"` and `"<N> blurred CSS discs"` claim in this file, `README.md`, `design.md` and the get-started page to that count — the count is a countable fact, and the README ships in the npm tarball, so a doc restating it from memory is the one way it can reach a consumer wrong. `scripts/validate-single-background-mount.mjs` — `BlurBackground` is mounted exactly once, in the root layout: mounted per page it would rebuild the WebGL context on every navigation, and a second mount would stack a second canvas and context on the first |
| Dependency graph | `website/src/data/dependency-graph.generated.ts` — **generated** from the token and component registries, the token and component CSS, and the import statements in `website/src` by `scripts/generate-dependency-graph.mjs`, never hand-edited. The `/graph` page (chromeless, linked from /overview) renders it as five traceable columns — primitives, tokens, library, site UI, pages — and the same script is the CLI behind the page's hint lines: `npm run graph -- tree <name>` / `-- who-uses <name>` | `GRAPH_NODE_COUNT` + `GRAPH_EDGE_COUNT` from `website/src/data/dependency-graph.ts` | `scripts/validate-dependency-graph.mjs` — regenerates in memory and byte-compares (CI's drift guard catches a stale commit), plus the standard leak patterns |
| Site chat corpus | `website/src/data/site-corpus.generated.ts` — **generated** from the published site (every page's prose via the TypeScript AST, `corpus-facts()` data blocks, the data registries above, and the root specs — CLAUDE.md and design.md condensed, content-design.md in full) by `scripts/generate-site-corpus.mjs`, never hand-edited. **Page coverage is automatic**: the page list is the filesystem (`scripts/site-routes.mjs`), so a new page's prose reaches the corpus on the next build; deliberate absences live in `EXCLUDED_ROUTES` with a written reason. A route's prose is read from its whole folder, not just `page.tsx`, so splitting a long page into co-located section components cannot silently empty it; prose that rides in a JSX attribute is read too, but only from the names in `PROSE_ATTRIBUTES` — a figure caption and an Alert's title and body are what a visitor reads, `className` and `src` are not | `siteCorpus` + `siteCorpusApproxTokens` from the same file | `scripts/validate-site-corpus.mjs` — regenerates in memory and byte-compares, checks for leaked details (local paths, analytics ids, keys; email addresses are allowlisted against `corpus-facts()` blocks and otherwise fail), and re-checks the token budget. `scripts/validate-chat-coverage.mjs` — every golden-set fact in `evals/chat/golden-set.json` must be in the corpus, and every route must be covered by a section or excluded with a reason. `scripts/validate-corpus-coverage.mjs` — the one check that compares the corpus against what the pages **actually render**: it reads the prerendered HTML and fails when a covered route's `<main>` prose is largely absent from the corpus. The other two can both pass while the chat answers blind, because they compare the corpus to its own generator and to the route list; this catches prose the extractor cannot see. Deliberate shortfalls live in `CONDENSED_ROUTES` with a reason. Like `validate-rendered-spacing.mjs` it needs built HTML, so it runs after the website build in `verify` and CI, not in `validate-registry` |
| Component prop API | `website/src/data/component-api.generated.ts` — **generated** from `src/components/registry.json` plus the prop JSDoc in `src/components` by `scripts/generate-component-api.mjs`, never hand-edited. The docgen settings live in `scripts/component-docgen.mjs`, shared with `validate-prop-docs.mjs` and mirroring `.storybook/main.ts`, so the MCP surface, the build gate and Storybook's props tables all see the same parse. `/api/mcp` and the site chat's `get_component` tool both serve it verbatim, so an agent consuming the package and a visitor asking the chat read the exact contract the .d.ts ships | `componentApi` from the same file | `scripts/validate-component-api.mjs` — regenerates in memory and byte-compares, and screens for leaked details with the corpus's non-sanctionable patterns (the source JSDoc ships in the npm tarball, so a hit here is a leak in the published package too) |
| Accessibility coverage | `website/src/data/a11y-coverage.generated.ts` — **generated** from `src/components/registry.json`, the component and behaviour source in `src/`, and the Storybook story files by `scripts/generate-a11y-coverage.mjs`, never hand-edited. Holds the figures `/foundations/accessibility` displays: the story total, the components declaring ARIA and those naming themselves for assistive technology, the behaviour-layer module count, and the overlay roster — derived from the dismissal-stack import rather than listed, so a new overlay joins the page by existing. The page makes public claims a reader cannot check, which is exactly why none of its numbers may be typed into page copy | `STORY_COUNT`, `ARIA_COMPONENT_COUNT`, `ACCESSIBLE_NAME_COUNT`, `BEHAVIOUR_MODULE_COUNT`, `overlayComponents` from `website/src/data/a11y-coverage.ts` | `scripts/validate-a11y-coverage.mjs` — regenerates in memory and byte-compares, so adding a story, an ARIA attribute or a new overlay cannot land while the page still states the old number |

The `/releases` page renders this data as the release log; entries are written by the `release` skill as part of the publish ritual, one per version, so from this repo's first publish onward the page and npm can never tell different stories. (The log opens at 1.0.0, published 2026-09-26, which is this repo's first and npm's only release under the `rift-ds` name.) The full chain (`npm run validate-registry`) runs before every root build via `prebuild`/`prestorybook`/`prebuild-storybook`. The website's own `prebuild` runs a deliberate subset — the website-relevant generators and validators, skipping the library-only ones — and `website/package.json` is authoritative for which; CI and the root builds always run the full chain.

**The package barrels and exports map are generated surfaces.** `scripts/generate-library-barrel.mjs` (validate-registry chain) writes `src/index.ts` and `src/charts.ts` from `src/components/registry.json` — never hand-edit them. Modules that import recharts land in `charts.ts` automatically (recharts is an optional peer dependency; the main barrel must never force a bundler to resolve it). The `exports` field in package.json is owned by `scripts/package-manifest.mjs` (single source for the package name, version, subpaths and the init bin's `bin` entry — in-repo exports point at `./src` for workspace dogfooding, `npm run build:lib` writes the dist-form manifest that ships to npm); `scripts/validate-package-exports.mjs` fails the build if they drift (subpaths only — the bin's presence and shebang are publint's job in the package-publish lint). **The package name itself renames mechanically**: `PACKAGE_NAME` lives in `scripts/brand.mjs`, `scripts/rename-package.mjs` sweeps every specifier and install snippet from the old name to it, and `RETIRED_PACKAGE_NAMES` (same file) makes any surviving straggler a build error in `validate-package-exports.mjs` — the `PACKAGE_NAME` doc block in brand.mjs owns the rename-day recipe. `RETIRED_HOSTS` (same file, same scan) gives a domain the identical guarantee: a URL written from memory rather than derived from `SITE_URL` fails the build, which is what the 2026-09-26 move to rift-ds.com needed and did not have. The theme presets ship as generated CSS the same way: `scripts/generate-preset-stylesheets.mjs` writes `src/tokens/presets/` from `presetOverrides` in `website/src/lib/theme/presets.ts`, published as assets under the `./tokens/presets/*.css` subpath — never hand-edit them (see the Theme presets registry row).

**README.md is a generated surface for registry data.** `scripts/generate-readme-content.mjs` (also in the `validate-registry` chain) rewrites three marked regions — never hand-edit inside them, and commit README.md when a build regenerates it:
- `<!-- component-count -->` and `<!-- component-list:start/end -->` — from `src/components/registry.json`
- `<!-- npm-badge:start/end -->` — the npm version badge, built from `PACKAGE_NAME` in `scripts/package-manifest.mjs`, so a scope change can never leave the badge pointing at a package that doesn't exist

**The README banner is the one surface with no generator, and it rots.** `.github/readme-banner.jpg` is a screenshot of the site hero, so it states the brand, the install line and two counts in pixels no validator can read. On 2026-09-26 it was found still showing the old codename, the retired package name, 132 components and 8 themes, all of it wrong, and README.md ships inside the npm tarball with relative image paths rewritten against the repository field, so that image heads the package page. `scripts/capture-readme-banner.mjs` re-shoots it (Playwright, dark mode, 1800x880); it is deliberately **not** a build step, on the same footing as `sync-preset-fonts.mjs` and `sync-worldmap-land.mjs` — run it by hand whenever the hero changes what it says, and commit the result.

The same script fails the build if its Tech section names a different major version of React, Next.js, Storybook, or Vite than package.json, and if **either** install surface stops mentioning the package name — `README.md` or `src/stories/Configure.mdx` (the Storybook landing page). Both tell a stranger how to install the package, and they deploy separately, so a scope rename that reaches one but not the other leaves a live install snippet pointing at a package that does not exist. **The README also ships inside the npm tarball**, so anything inaccurate there reaches every consumer — treat both files' install/usage prose as production copy. `Configure.mdx` itself is drift-protected the registry way: every countable and URL on it is a **live import** (`COMPONENT_COUNT`, `TOKEN_COUNT`, the theme order, `SITE_URL` from the brand module), it sits in `scripts/validate-doc-refs.mjs`'s sources list, and Storybook's preview applies the shipped theme presets through the same `THEME_SELECTOR_ORDER` the site's switcher reads (the toolbar's Theme control, defaulting to the served look) — so the sandbox and the site cannot describe different systems.

When a new countable collection appears on the site (a loop list, a glossary, a changelog…): create a registry file next to the collection, export the count from a small accessor module, add a validator script chained into `validate-registry`, and pull every displayed number from the export. When adding a skill: write `.claude/skills/<name>/SKILL.md` and register the name in `.claude/skills/registry.json` (`displayed` if it appears on `/skills`, `unlisted` if internal, `external` for a skill that lives outside the repo — which additionally needs its published copy at `website/src/data/external-skills/<name>.md`) — that's all. The `/skills` page is fully data-driven: it maps over `website/src/data/skills-content.generated.ts`, which `scripts/generate-skills-content.mjs` builds from the SKILL.md files in registry order, so **never hand-add a card to `website/src/app/skills/page.tsx`**. `scripts/validate-skills-registry.mjs` fails the build if a skill file and the registry drift.

**The website's /blueprints pages are a generated surface too.** `scripts/sync-blueprints.mjs` (in the `validate-registry` chain) copies the root markdown specs into `website/public/` on every build — never hand-edit those copies; edit the root files. Its `FILES` array is the authoritative list, and `scripts/validate-website-surfaces.mjs` imports it to check every synced file has a `/blueprints/<name>` page, so a spec cannot be published as a raw download with no page to read it on; the same validator holds the llms.txt route's spec-download list to `FILES` in both directions, so an unpublished spec cannot stay advertised as a link that 404s.

**The per-component markdown pages are a generated surface.** `scripts/generate-component-md.mjs` (validate-registry chain and the website's predev/prebuild) writes one `website/public/components/<slug>.md` per public component — the prop contract as markdown, served beside the live docs page (append `.md` to a component URL) — from the same `assembleComponentApi()` pass that feeds Storybook's props tables, the shipped `.d.ts` and the `get_component` tool both model-facing surfaces share. Never hand-edit the files: `scripts/validate-component-md.mjs` regenerates in memory, byte-compares, and holds the folder to the registry in both directions. The "Copy for agents" button on component pages (`website/src/components/PageLinks/CopyPageMarkdown.tsx`) and the MCP response's `markdownUrl` both point at these files.

**The shadcn registry is a generated surface.** `scripts/generate-shadcn-registry.mjs` (validate-registry chain and the website's predev/prebuild) writes `website/public/r/` — a shadcn-compatible registry, the third install path beside the npm package and the cloned repo: one item per public component with its source files embedded (the relative-import closure of its folder), a shared `base` item (the token stylesheets and their closure, the generated theme presets, the icon font's CSS, the JS motion constants, `src/behaviors/`), a `registry.json` index, and the font binaries under `assets/` (JSON cannot embed them; the site serves them instead). Two deliberate design points, owned by the generator's doc block: file targets preserve the `src/` layout under a `rift/` folder so **no import specifier is ever rewritten**, and the one rewrite that does happen is `url(*.woff2)` in the CSS copies, pointed at `${SITE_URL}/r/assets/`. Cross-component imports become absolute-URL `registryDependencies`, so items install with zero namespace configuration (`npx shadcn@latest add <SITE_URL>/r/<slug>.json`). `scripts/validate-shadcn-registry.mjs` regenerates in memory and byte-compares both directions, screens every text file with the corpus's non-sanctionable leak patterns (item files hand library source verbatim to strangers' projects), and proves closure integrity — every relative import inside every embedded file must resolve to a target some item carries. Advertised in llms.txt and on get-started; end-to-end consumption needs the deployed site (the dependency URLs bake `SITE_URL`), which is why the local proof lives in a URL-rewritten copy, not the committed files.

**The consumer agent skill is a generated surface.** `scripts/generate-agent-skill.mjs` (validate-registry chain and the website's predev/prebuild) writes `website/public/skill/rift-design-system/` — a SKILL.md plus `references/components.md` that consumers of the package install into their own `.claude/skills/` so their coding agent knows the library every session — one command, `npx rift-ds init` (the bin in `src/cli/`, which fetches the pair from the live site so it is always deploy-fresh), or two curls by hand. Every fact derives from the registries, the prop JSDoc and the package manifest; props are deliberately not restated (the files point at the `.d.ts`, the per-component `.md` pages and the MCP endpoint). `scripts/validate-agent-skill.mjs` byte-compares both files, screens them for leaks, and holds them advertised in llms.txt and on the get-started page in both directions. This is a surface **for consumers of the package**: it is unrelated to this repo's own `.claude/skills/` and the `/skills` page, and lives under `/skill/` (singular) for exactly that reason.

**The site chat corpus is public-only and owner-authored-only, and both halves are security boundaries, not style choices.** `scripts/generate-site-corpus.mjs` may only read sources that are already published — page prose, the data registries, the blueprint specs. The corpus becomes the chat model's context, so anything in it can be repeated verbatim to any visitor who asks; keeping it public-only means the worst case of a successful prompt injection *out of the chat* is off-brand prose rather than a leak. The second half guards the opposite direction: text in the corpus is text the model treats as context, so third-party words — a client quote, a testimonial, a pulled-in review — would be an injection surface *into* the chat. Today every word on the site is the owner's, which is what makes the automatic page-prose extraction safe; the day a page carries third-party text, that content needs an explicit decision (and probably an exclusion) before the next build ships it to the model. The boundary is enforced as an allowlist, not remembered: a contact-shaped detail (an email address) may appear in the corpus only when a page deliberately published it through a `corpus-facts()` directive, and `scripts/validate-site-corpus.mjs` fails the build on any other route in. It still screens for the obvious slips (local paths, analytics ids, keys), but it cannot judge whether a new source was meant to be public — or whether the owner wrote it.

**Self-descriptions stay in sync.** The repo describes itself in prose in several places — `README.md`, `design.md`, this file, and the website's foundations/overview pages. Whenever a change makes a statement in any of them false (a new component category, a dropped dependency, a renamed part, a changed principle), update that prose in the same change — don't leave it for a future audit. If the drifting fact is *countable or mechanically checkable* (a count, a list, a version number), don't just fix the prose: route it through a registry + generator/validator in the `validate-registry` chain so it can never drift again (the README component section and Tech versions are the reference example).

**Prose & skill authoring rules.** The registry principle generalized: **every fact has exactly one authoritative home** — all other mentions derive from it (generated), are checked against it (validated), or point at it. Never restate a fact a registry, script, or source file already owns. Concretely:

- **Point, don't enumerate.** "The `validate-registry` entry in the root `package.json` is the authoritative list" beats a hand-copied list that goes stale.
- **Examples in skills are fictional.** Example findings use made-up component names — a factual claim about a real component inside an example rots silently.
- **No counts outside registries; no machine-local paths** — derive the repo root with `git rev-parse --show-toplevel`.
- **Off-token CSS values are sanctioned at the site**, never in a skill: `/* ds-allow(<category>): <reason> */` (file-wide: `ds-allow-file`), categories owned by `scripts/validate-css-directives.mjs`. The token-audit skill reads directives; it maintains no list.
- **References are build-checked**: `scripts/validate-doc-refs.mjs` fails the build when a skill or doc references a repo path, `npm run` script, or documented API symbol that doesn't exist (the `sources` list in that script is the authoritative set of docs — the root specs, SECURITY.md, the chat eval's README and SPEC.md, and the workspace README).
- **The one content rule that is mechanically checkable is build-checked too**: `scripts/validate-shipped-prose.mjs` fails the build on an em dash in shipped copy, which `content-design.md` bans outright. It reads page prose through the same `extractProse` the corpus generator uses, so "what counts as page prose" has one definition rather than two, and its doc block owns the scope — which surfaces are in, and why agent-facing markdown, synced essays, and noindex staging pages are out. Everything else in `content-design.md` needs a reader, and stays the `content-audit` skill's job.
- **A space that vanishes between source and render is build-checked**: `scripts/validate-rendered-spacing.mjs` fails on a closing inline tag butted against a word in the prerendered HTML. A space written after `</strong>` can be dropped when the text node that follows holds an HTML entity, because the transform re-chunks the node around the entity and trims the leading whitespace, so `<strong>Why so slow?</strong> Compensation` ships as one word. It reads built output rather than source, since the source pattern over-reports, which is why it runs after the website build in `verify` and in CI rather than inside `validate-registry`. The fix is always the same: use the literal character (’ “ ”) instead of the entity.

---

## Quick Start

```bash
npm install                    # once, at the root — the website is an npm workspace, so this installs both

# Storybook (interactive component showcase — the library's dev sandbox)
npm run storybook              # http://localhost:6006

# Documentation website (separate project)
cd website && npm run dev      # http://localhost:3000
```

Other useful commands:
```bash
npm run build           # type-check the library
npm run build:lib       # build the publishable package into dist/ (vite lib build + d.ts + assets + the init bin, origin-stamped)
npm run lint            # ESLint
npm run build-storybook # export static Storybook
npm run test            # run every Storybook story as a render test (headless Chromium), plus the interaction assertions in stories with a play function
npm run verify          # full local quality gate: lint + tests + the library, package, Storybook and website builds, plus the package-publish lint, the built-HTML checks, and the served-site checks (hydration smoke + page-level axe) — mirrors CI
npm run eval:chat       # site-chat answer-quality eval against a running dev server (see evals/chat/README.md — costs real API calls, never in CI)
npm run graph           # regenerate the /graph data (also runs via predev/prebuild); `npm run graph -- tree <name>` / `-- who-uses <name>` trace dependencies in the terminal

```

---

## CI & Local Verify

Every push to `main` and every PR runs `.github/workflows/ci.yml` (four jobs: library lint + build + the package-publish lint — `scripts/validate-package-publish.mjs`, publint and arethetypeswrong over `dist/`, the resolution modes the consumer smoke never exercises — story tests, Storybook build, and website lint + build + the checks that read or serve the finished build: the three built-HTML validators — rendered spacing, corpus coverage, and the internal-link check (`scripts/validate-internal-links.mjs` — every href in the prerendered HTML must lead to a page, route handler, public file, or redirect that exists) — then the hydration smoke and the page-level axe pass (`scripts/validate-website-a11y.mjs` — the served pages in both themes, mirroring the Storybook rule set including its settled contrast exclusion). The hydration smoke — `scripts/smoke-hydration.mjs` — serves the build and loads it in a real browser, because a green build and an HTTP 200 both held on 2026-09-06 while a hydration mismatch left every page invisible; the script's doc block owns the details, including its second life as the `ship` skill's post-deploy check against production. The same live check is built to run on a schedule — `.github/workflows/uptime.yml` smokes production, because ISR means the served site can change with no deploy (that is how the outage appeared on a deployment that had shipped green) — and its cron runs every four hours against production. The library job ends with a **drift guard** — `git diff --exit-code` after the generators run — so a registry change that lands without its regenerated README/skills/blueprint content fails CI.

**Story tests**: `npm run test` runs every Storybook story as a render test in headless Chromium (Vitest + `@storybook/addon-vitest`, configured in `vite.config.ts`). A story that throws on render — or whose `play` assertions fail — fails the suite, so every component variant is smoke-tested on every change, and behavior a story asserts (the overlay stories' focus, Escape, and stacking checks in `Dialog.stories.tsx`) is enforced the same way. **A11y checks run alongside in `'error'` mode — an axe violation fails the suite** (see `.storybook/preview.ts`). One rule, `color-contrast`, is switched off there by the owner's settled decision; the comment beside that override is its authoritative record and the single place its details belong. Read it before touching anything contrast-related, and never re-enable the rule, restyle what it covers, or raise it as a finding without asking the owner first. Everything else in AA is enforced.

**Releases** are manual and follow the `release` skill (`.claude/skills/release`). The `Release` workflow (`.github/workflows/release.yml`, workflow_dispatch, dry-run by default) builds `dist/`, runs `scripts/smoke-consumer.mjs` (packs the tarball into a scratch Vite consumer and builds it without recharts), then publishes **from `dist/`** with `npm publish --access public --provenance`. The root package.json stays `private` forever — only the generated dist manifest ships. **The version lives in three places and all must move together:** `PACKAGE_VERSION` in `scripts/package-manifest.mjs` is authoritative for what ships; the root package.json's `version` must be mirrored by hand; and `package-lock.json` records it too — refresh it with `npm install --package-lock-only` after a bump. `validate-package-exports.mjs` fails the build when any of the three disagree (a stale lockfile also dirties every fresh checkout's tree on plain `npm install`). **`rift-ds` 1.0.0 was published from this repo on 2026-09-26**, which is the whole of its registry history; the older versions listed below belong to the predecessor's scoped package. That first publish could not use OIDC at all: **a trusted publisher can only be attached to a package that already exists**, and npm says so explicitly, returning `404 ... OIDC token exchange error - package not found` from the exchange endpoint. The `bootstrap_token` input on the workflow exists for exactly that case and is off by default. Trusted Publishing must now be registered on the package's npmjs.com settings page, keyed to this repo plus the workflow filename, before the next release; a dry run will not catch a broken registration, because only a real publish authenticates. The versions below belong to the predecessor's scoped package, and are kept as the system's history rather than `rift-ds`'s: 0.1.0 shipped 2026-07-26, 0.2.0 on 2026-07-27 (the first release via Trusted Publishing), 0.3.0 on 2026-07-28, 0.4.0 on 2026-08-01 (the ai category), 0.5.0 on 2026-08-09 (the chat component set that powers the site-wide chat), 0.6.0 on 2026-08-11 (the composer radius token, ChatMessage's showActions, and the README stating the chat's UI-only boundary), 0.7.0 on 2026-08-15 (ShaderField, the WebGL2 ambient background, with Card's cover slot and the `--motion-duration-instant` token), 0.8.0 on 2026-08-16 (five components from the dashboard gap analysis: AgentPlan, ModelPicker, NotificationCenter, DataTable, EventCalendar), 0.9.0 on 2026-08-17 (nine components taking the registry past 100 — AnchorNav plus the Stepper, TagInput, NumberInput, TreeView, PinInput, CodeDiff, Sparkline and TimePicker set — and restored `'use client'` directives in the published dist), 0.10.0 on 2026-08-20 (the accessible teal split — the action colour becomes theme-dependent, a deep light-mode fill inverting to a light dark-mode one, with every action pairing at WCAG AA — plus SectionTitle's optional divider and AppSidebar link rows), 0.11.0 on 2026-08-23 (the maps category — Globe, MapCallout, MapLegend — plus CardStack and the `--font-overline-*` uppercase label face), 0.12.0 on 2026-08-26 (the dashboard set from the labs rebuild — Panel, LegendTile, FunnelChart, ComboChart — with live var() chart colours and bare mode, Stat's trend tokens and inline delta, borderless badges, and AppSidebar's floating variant, item badges, slots and rebuilt transition), 0.13.0 on 2026-08-29 (five composition gap-fillers — Gauge, FilterBar, SplitPane, StreamingText, AvatarGroup — plus the stream reveal's motion constant and the amended JS motion contract), 0.14.0 on 2026-08-29 (six components taking the registry to 120 — Banner, HoverCard, ImageCompare, Meter, Rating, SplitButton — plus LinkList's `newTab` opt-out; the same JSDoc now also feeds the site's public MCP endpoint), 0.15.0 on 2026-09-04 (UsageCard and SourceTrail joining the ai category, Composer's working glow, and the agent-docs surfaces around the package: per-component markdown contracts, the consumer agent skill, and the MCP roster with example prompts), 0.16.0 on 2026-09-06 (the shared overlay behavior layer under the modal components — with `useScrollLock` published as `./behaviors/useScrollLock` for host chrome — AiButton's summary panel, Composer's context note, the status icon step tokens, and overlay listener/timer fixes), 0.17.0 on 2026-09-09 (ThreadPanel, the session-history rail for chat products with AppSidebar's collapse choreography, plus EventCalendar's `selectedDate` and whole-cell interactivity on its own calendar colour family), 0.18.0 on 2026-09-11 (ThreadTabs, the animated strip of open chat sessions, with ThreadPanel's detail rows, pin marks and projects section, PromptSuggestions' pending shimmer and entrance stagger, and CommandPalette's trailing slot), 0.18.1 on 2026-09-12 (the section-divider rhythm fix — SectionTitle's rule clearance drops to `--padding-lg` (a token since renamed `--padding-500`) so every divider header runs heading, 20px, rule, 40px, content — with the rule width and Table's hairlines moved onto the border tokens), 0.19.0 on 2026-09-16 (GanttChart and RichDropdown, with the `--font-family-heading`/`--font-family-body` roles for split-face theming, AiButton's signature refresh, Dropdown's typeface preview, and the micro-animation pass — SegmentedControl's sliding pill and Button's trailing-icon nudge), 0.20.0 on 2026-09-18 (six components taking the registry to 132 — WorldMap, the flat companion to Globe, plus Toolbar, Lightbox, Waveform, AnimatedNumber and StatusDot — with Lightbox the first overlay born on the shared behavior layer and the count-up budget joining the motion constants), 0.21.0 on 2026-09-19 (the package's first bin: `init` fetches the consumer agent skill from the live site and prints the MCP connect line).

Two facts that bite on release day: **a published version can never be reused**, even after unpublishing, so a botched release costs a version number; and **the registry lags the workflow by minutes** — a 404 right after a green publish is propagation, not failure, so never re-run on it. Auth is **Trusted Publishing (OIDC)** — there is no npm token to expire or rotate. The registration on npmjs.com is keyed to the workflow **filename**, so renaming or moving `release.yml` breaks publishing until the registration is updated; and `permissions: id-token: write` is load-bearing for authentication, not just provenance. A dry run never authenticates, so only a real publish proves the auth path works.

**Infrastructure** (the facts the /overview pipeline describes — keep them in sync):
- **Deployment**: the site is live at the `scripts/brand.mjs` SITE_URL — `rift-ds.com`, the domain bought on 2026-09-26 (the Vercel project is still named `dragonspine`, root directory `website`, push-to-deploy from `main`; the project rename is cosmetic and externally gated). `.github/workflows/uptime.yml` smokes the origin every four hours, reading it from brand.mjs at run time rather than restating it. Storybook is a second Vercel project at the `STORYBOOK_URL` in brand.mjs, `storybook.rift-ds.com`. **`rift-ds` 1.0.0 is on npm as of 2026-09-26**, so every install snippet in the README, on get-started and in the agent surfaces now resolves. The rename that day moved the package off the predecessor's scoped name (the sole entry in `RETIRED_PACKAGE_NAMES`, which brand.mjs owns and every other file is forbidden to restate). Still outstanding from `first-deploy`: registering Trusted Publishing for the package, and GA.
- **Analytics**: off — `GA_ID` in `scripts/brand.mjs` is empty, which short-circuits the gtag snippet in `website/src/app/layout.tsx` entirely; when a GA property exists, the ID is a public G-… measurement ID (safe to commit, visible in page source by design).
- **Fonts**: Nunito Sans is **not** bundled anywhere — the website self-hosts it via `next/font/google` (fetched from Google Fonts at build time), Storybook loads it via a Google Fonts `<link>`, and package consumers bring their own (override `--font-family-primary`). Material Symbols Rounded ships as a self-hosted woff2 **inside the npm package** (`src/fonts/`), and so do the theme presets' faces: `scripts/sync-preset-fonts.mjs` (a deliberate by-hand network fetch, never in the build — its `FAMILIES` table is the authoritative download spec) writes `src/fonts/presets/` (latin + latin-ext woff2s, all OFL-1.1, plus `manifest.json`), and the preset stylesheet generator emits per-preset `@font-face` blocks from the manifest — so `import presets.css` + one `data-brand` attribute is the complete look with no Google request at runtime, and a preset face missing from the manifest fails generation until the sync is rerun. Two surfaces still load from Google at runtime, deliberately: the playground's typeface picker (its roster is wider than the shipped presets) and the `/api/mcp` browser landing page (a self-contained HTML string with its own font `<link>`).

`npm run verify` is the **single local mirror of CI**: lint, library build, package build, the package-publish lint, story tests, Storybook build, website lint, website build, then the checks that need the finished build — the three validators that read built HTML (rendered spacing, corpus coverage, internal links) and the two served-site checks (the hydration smoke, then the page-level axe pass) — in that order. The rule that keeps them in sync: **when CI gains a check (a11y is the worked example), add it to `verify` in the same change** — skills and docs reference `verify`, never individual commands, so nothing else needs updating. **Three deliberate exceptions. Chromatic** (`.github/workflows/chromatic.yml`, visual regression): every run bills cloud snapshots against a monthly budget, so it is `workflow_dispatch`-only, never part of `verify`, and never a reason to treat a green `verify` as proof the pixels are unchanged. **The dependency audit**: CI's library job runs `npm audit --audit-level=high`, which judges the tree against the registry's advisory feed rather than the code, so a failure there is news from outside, not something a local run could have caught earlier. **The drift guard**: CI's library job ends with `git diff --exit-code` after the generators run; `verify` cannot, because a local tree is dirty with the change in progress. Catching uncommitted regenerated content stays CI's job alone.

**Shipping vocabulary** — skills named for their end state, because once the Vercel project exists a push to `main` always deploys the live site (`.claude/skills/registry.json` is the authoritative list of what exists):
- **`ship`** — make it live. Full verify, merge branch work into `main` if needed, push, watch CI, then prove the deployed site renders (the hydration smoke against production — the outage class it exists for only reproduces on Vercel). Always ends deployed and verified live.
- **`super-ship`** — the bulletproof ship for structural work: run the full `drift-audit`, fix the broken and stale findings, then `ship` the combined result. Higher-order — it composes the other two skills rather than restating them, and invoking it is the sanctioned way to chain an audit into a deploy. For a small change, plain `ship` is enough.
- **`checkpoint`** — save progress to a remote branch and keep working. Never touches `main`, never deploys; if invoked on `main` it moves the work to a `wip/<topic>` branch first.
- **`park`** — checkpoint, then return to a clean `main`. The branch name is the resume handle.
- **`land`** — triage all pending work at once and resolve it. Sweeps worktrees, branches, the working tree and stashes, judges each as **land / keep / delete**, merges the approved into a **local, unpushed** `main`, and verifies the combined result. Anything deleted is archived to an `archive/*` tag first, so discarding unmerged work stays reversible. Deliberately never pushes, so a batch deploy stays an explicit `ship`.

The old `merge-and-push` skill is retired because its name didn't say which of these it meant, and the vocabulary has grown since. If asked to "merge and push", confirm the intended **end state** instead of guessing: `ship` (merge into `main`, push, deploy), `checkpoint` (push the branch, keep working), or `land` (combine several pieces of pending work into a local `main`, pushing nothing).

---

## Project Structure

```
/
├── design.md                  # Design spec — source of truth for tokens, colors, typography
├── content-design.md          # Content style guide — source of truth for voice, register, and prose rules
├── SECURITY.md                # Vulnerability-reporting policy (private GitHub advisories; latest version only)
├── scripts/                   # Generators + validators (the validate-registry chain), release tooling
├── evals/chat/                # Site-chat eval: golden set, promptfoo config, behaviour spec (SPEC.md), README (runs on demand via `npm run eval:chat`, never in CI)
├── src/
│   ├── index.ts               # GENERATED barrel — never hand-edit
│   ├── charts.ts              # GENERATED recharts barrel — never hand-edit
│   ├── cli/                   # The init bin's source (init.mjs, zero-dependency). build-package.mjs stamps __SITE_URL__ from the website's SITE_URL constant and ships it as dist/bin/rift-design-system.mjs; deliberately not an exports subpath
│   ├── behaviors/             # Internal overlay behavior layer: layer stack (topmost-only dismissal), focus scope (trap/restore/inert), counted scroll lock, shared focusable queries, SSR mount guard. Used by the modal components via relative imports (the shared focusable query also serves non-overlay keyboard work, Toolbar being the live example); NOT a published subpath, with one deliberate exception — the scroll lock (./behaviors/useScrollLock), published so host chrome joins the same counted body lock as the modals instead of fighting them over document.body (design.md's Components intro owns the contract)
│   ├── components/            # Component folders (each self-contained — see Component Anatomy for the behaviors exception) + registry.json (official list/count). WorldMap/land.ts is GENERATED-ONCE by scripts/sync-worldmap-land.mjs — a deliberate network fetch like the essay sync, never in the build; the script owns the projection constants WorldMap.tsx restates
│   ├── stories/               # Storybook foundation docs (tokens, typography, icons, logos, landing page)
│   ├── tokens/
│   │   ├── tokens.css               # Aggregate entry point consumers import
│   │   ├── tokens-primitives.css    # Raw hex/px values — never use directly in components
│   │   ├── tokens-light.css         # Semantic tokens, light theme
│   │   ├── tokens-dark.css          # Semantic tokens, dark theme
│   │   ├── tokens-typography.css    # Font size/weight/line-height scale
│   │   ├── tokens-motion.css        # Duration/easing scale + reduced-motion guard
│   │   ├── presets/                 # GENERATED theme stylesheets, one html[data-brand] sheet per preset + presets.css aggregate — never hand-edit
│   │   ├── motion.ts                # JS-timing constants (hover delays, auto-dismiss, autoplay…) — published as ./tokens/motion
│   │   └── registry.json            # GENERATED token registry — never hand-edit
│   └── fonts/                 # Material Symbols icon font (self-hosted); Nunito Sans is loaded via Google Fonts
├── .storybook/                # Storybook config (Storybook is the library's dev sandbox)
└── website/                   # Next.js docs site (npm workspace; consumes rift-ds by name)
    ├── public/                # Includes GENERATED copies of the root markdown specs (see /blueprints)
    ├── src/app/
    │   ├── components/        # One folder per component (page.tsx + page.module.css); the index renders registry-derived category sections over the shared ComponentPreviews map
    │   ├── foundations/       # Design tokens & layout doc pages, incl. foundations/themes — the theme gallery (every shipped look as a card: apply it live, open the playground, or copy the data-brand setup; cards drawn from the same themeSelectorTiles builder as the switchers)
    │   ├── templates/         # Template screens — complete pages built from the system alone; the index lists them (templatesSidebarLinks in website/src/config/navigation.ts is the authoritative list — the showcase carousel, sidebar, sitemap and llms.txt all derive from it), each renders full-viewport and chromeless; implementations live in website/src/components/templates/, sharing the TemplateAssistant mock panel where the screen isn't itself a chat surface — a screen that is one reaches the row two other ways, an inline mock of its own (the agent workbench's conversation pane) or the site's real `SiteChat` on the simulated transport (the payroll console, which is the pattern's own showcase) — and two of them share their shell with a labs origin, the marketing dashboard with /labs/marketing and the payroll console with /labs/payroll; **design.md's Template screens section owns the family's composition conventions** — read it before building or reworking one
    │   ├── docs/              # Docs hub: links out to overview/skills/journal; owns get-started (install + theming)
    │   ├── overview/          # How-it's-built pipeline page
    │   ├── skills/            # Skills page (data-driven from the generated skills content)
    │   ├── releases/          # Release log, one entry per npm version (release-log registry)
    │   ├── loops/             # The recurring agent loops page (maps over the loops registry — see the Registries table)
    │   ├── privacy/           # Privacy policy page (standalone; analytics + chat-log disclosure)
    │   ├── playground/        # The immersive re-theming tool: Components + Type + Chat views over one set of levers (chromeless; absorbed the old /robr0-gpt chat bench, which now redirects here). Dropping an image on it moves the colour levers to match the picture — `website/src/lib/theme/image-palette.ts` quantizes the pixels and maps them onto the action colour, the neutral tint and the ambient accents, entirely in the visitor's browser: the file is read through an object URL (which is why `img-src` in `website/next.config.ts` allows `blob:`), never uploaded and never stored
    │   ├── graph/             # The dependency-graph instrument (chromeless, linked from /overview): the system as five traceable columns — primitives, tokens, library, site UI, pages — over the generated graph data (see the Dependency graph registry row)
    │   ├── blueprints/        # Renders the public root-spec copies (CLAUDE.md, design.md, content-design.md)
    │   ├── labs/              # Hidden test pages rebuilding reference products from the system alone, to pressure-test fidelity (noindex, chromeless, in no nav/sitemap/corpus/canvas board — deliberately outside the IA); /labs/marketing is the first, and what it proved fed the dashboard rung set, Panel, LegendTile, FunnelChart, ComboChart and the trend/glass tokens; /labs/payroll followed as the agent panel's proving ground and graduated to /templates/payroll-console, both routes rendering the one implementation
    │   ├── llms.txt/          # Serves the public llms.txt agent index (a prose surface — see content-design.md's register table)
    │   └── api/               # Route handlers (github-contributions; chat, the widget's LLM backend; mcp, the public MCP endpoint over the generated registries)
    ├── src/config/            # navigation.ts (nav/sidebar/breadcrumb source of truth), chromeless.ts (routes with no shared chrome), anchor-nav.ts (which routes the floating anchor nav skips or leaves to the page), social.ts (canonical profile + project links)
    ├── src/data/              # Data registries and their accessors (the Registries table above is the authoritative list of those)
    ├── src/hooks/             # Client hooks (useChat — the chat widget's transport-agnostic state machine)
    ├── src/lib/               # Non-UI modules (the chat's transports, model allowlist, follow-ups and suggestion budget; site-tools.ts, the shared deterministic lookups behind the chat's tools and the MCP endpoint; the shared theme levers in lib/theme, imported by the playground and the DS landing hero; token-source.ts, the declared-var() chain reader behind inspect mode's primitive column and the playground Type view's family-role chips; the MCP tool roster and connect snippets; scroll lock — the site's owner-keyed wrapper over the library's shared counted body lock in src/behaviors/, so site chrome and library modals never unlock the page under each other; OG image, structured data)
    └── src/components/        # Shared Next.js UI (MegaNav header, Sidebar, SiteFooter + SiteChat + SitePalette — the site-wide Cmd+K palette over the library's CommandPalette, opened from the header's search button via a window event, and handing a typed query to the chat as a question through the provider it is mounted under (the ask-the-chat row — its label is the query itself, which is what keeps it past the palette's filter); footer, chat and palette all mounted once from the root layout, skipping the chromeless routes in src/config/chromeless.ts; SiteAnchorRail, the floating on-this-page nav — AnchorNav's floating variant fixed to the right viewport edge, sliding clear when the chat docks — mounted once from the layout too, reading each page's h2 headings after navigation so a new page gets one with no wiring, gated by src/config/anchor-nav.ts (index/landing pages skip it; the pages that mount FloatingAnchorNav themselves with server-derived items — the blueprints, skills, get-started — are listed there so they never carry two); BlurBackground composes the ambient background and is mounted once from the layout too — the document-top layer (absolute, so it scrolls away with the page opening rather than following the viewport), the CSS blob fallback, the config and the dev tuner, wrapped around the library's ShaderField, which owns the WebGL2 renderer itself. Four background modes exist per page: the default 540px band (450px of it the fade), the home page's extended band via ExtendedBackground, full-bleed via FullBleedBackground, and hidden via HiddenBackground — a data-bg-hidden marker that hides the layer without unmounting it, so the WebGL context survives navigation — which the immersive stages render beside DotBackground (website/src/components/DotBackground/), the dotted working-canvas ground their panels float over; /playground is the precedent)
```

---

## Token Architecture

Three tiers — **never skip a tier**:

```
tokens-primitives.css       --primitive-teal-08: #0E6E8F
        ↓
tokens-light/dark.css       --color-action-primary-bg: var(--primitive-teal-08)
        ↓
Component CSS               background-color: var(--color-action-primary-bg)
```

- **Primitives** (`--primitive-*`) — raw values. Source of truth. Never referenced in components.
- **Semantic tokens** (`--color-*`, `--radius-*`, `--gap-*`, `--padding-*`, `--border-*`, `--font-*`, `--motion-*`, `--icon-size-*`, `--shadow-*` — `CATEGORY_PREFIXES` in `scripts/generate-token-registry.mjs` is the authoritative list) — always use these in components.
- **Dark mode** is driven by `data-theme="dark"` on the root element. Every semantic token has a light and dark value — no `prefers-color-scheme` queries in components.

Key invariants:
- Teal `--color-action-primary-bg` (#0E6E8F light / #3CA5C6 dark — the action family is theme-split by design, see design.md) is **only** for primary CTA buttons, focus rings, active input borders, and the selected item of a mutually exclusive set (design.md's teal selection convention — SegmentedControl's active segment, the header's current-section pill), with design.md's one sanctioned data-viz exception: teal leads the chart series palette. Never decorative.
- Never hardcode hex values in component CSS — always a semantic token. (Deliberate off-token values are sanctioned *in place* with a `/* ds-allow(<category>): <reason> */` directive — `ds-allow-file(...)` for file-wide cases like ColorPicker's `hsl()` colour physics. Grep `ds-allow` to enumerate them; `scripts/validate-css-directives.mjs` owns the category set and build-enforces the grammar.)
- Never hardcode hex values in semantic colour tokens either: every `--color-*` value in `tokens-light/dark.css` must be a `var(--primitive-*)` (or `var(--color-*)`) reference — build-enforced by `scripts/validate-token-references.mjs`. This is what lets a consumer override a primitive and have it cascade through the whole system. (The same script holds the places colour values live outside CSS to their tokens: the chart palette's SSR fallbacks in `src/components/Chart/palette.ts` to the `--color-chart-series-*` tokens, every `getCSSVar('--color-…', '<fallback>')` literal in `src/components` to its token's light-theme resolution, the playground's `NEUTRALS` mirror in `website/src/lib/theme/theme-overrides.ts` (the shared theme levers, imported by the playground and the design-system landing page) to the neutral primitives, and the share card's `BLOB_HEX` mirror in `website/src/lib/ogImage.tsx` to the ambient background's blob tokens — the named mirrors in both directions, so a retuned neutral, series, or blob colour cannot ship without every mirror moving with it.) `scripts/validate-theme-mirrors.mjs` extends the same guarantee to every remaining hand mirror of token data: the playground's `CHROMATIC_RAMPS`, `ACTION_COLOR_PRESETS`, `ACTION_SEMANTIC_REFS` and `RADIUS_STEPS` tables, every `var()` name in `presets.ts`, InspectMode's prefix strings (held to the exported `CATEGORY_PREFIXES`), the spatial page's swatch rows, and every token name `design.md` mentions — a token rename that misses any of them fails the build.
- Every `var(--…)` a component references must actually resolve: `scripts/validate-token-usage.mjs` fails the build on a reference to a custom property nothing defines (a fallback value marks a deliberate consumer-override hook and is exempt). This is the guard that would have caught Dialog styling its title with a token family that never existed.
- Buttons are always `--radius-pill` (pill). Inputs are always `--radius-300` (12px). Sanctioned departures: Card/EntityCard navigation tiles and the chat's bubbles and card furniture use `--radius-600` (24px) — design.md's Border Radius Scale owns the full list — and the chat Composer's input shell holds its own component-local `--ds-composer-radius`, computed concentric with its send button from the live radius tokens (29px in the shipped theme — the geometry is specified in design.md's Composer section).
- Timings that live in JavaScript timers (hover show/hide delays, toast auto-dismiss, carousel autoplay, feedback resets, scroll settle, the streaming reveal's rate floor and drain window…) have one home too: the shared constants in `src/tokens/motion.ts`, published as `rift-ds/tokens/motion`. Never write a literal ms value into a component timer — import the constant, or add one there deliberately. Most are schedule timings the reduced-motion guard deliberately ignores; a constant that paces an animation must be guarded by its component in JS (design.md's Motion section owns the contract and the exceptions).

---

## Component Anatomy

Every component lives in its own folder under `src/components/`:

```
src/components/Button/
├── Button.tsx            # TypeScript implementation + exported interface
├── Button.css            # Scoped CSS using semantic token vars
└── Button.stories.ts     # Storybook stories (Meta + named Story exports)
```

One sanctioned exception to self-containment: the modal overlays (Dialog, AlertDialog, Drawer, CommandPalette, Lightbox) import the shared behavior hooks from `src/behaviors/` (dismissal stack, focus scope, scroll lock — see **Project Structure**) rather than hand-rolling their own document listeners; the non-modal overlays still own theirs, gated on open state, until they migrate onto the stack (design.md's Components intro owns the contract). New overlay work should use the hooks from the start.

Components are imported in the website through the package's public exports (deep subpaths; the barrel `import { Button } from 'rift-ds'` also works):
```tsx
import { Button } from 'rift-ds/components/Button/Button';
```

CSS class naming: `ds-{component}` base class, `ds-{component}--{modifier}` for variants. Example: `ds-button`, `ds-button--primary`, `ds-button--compact`.

**The props interface is a published contract.** Since `rift-ds` ships to npm, every component follows the same API shape — full details and code in the `new-component` skill; `Button.tsx` (button-or-anchor) and `Input.tsx` (form control) are the reference implementations:

- `'use client'` on the first line **only** when the component uses hooks, handlers, or browser APIs. Presentational components (e.g. `Table`) deliberately omit it so consumers can render them from a Server Component.
- Own props as a `type`, then `export interface XProps extends XOwnProps, Omit<React.ComponentPropsWithoutRef<'el'>, keyof XOwnProps> {}` — so native attributes pass through.
- `React.forwardRef` onto the primary node (the **panel** for portal components like Dialog/Drawer), plus `displayName`. Merge with any internal ref rather than replacing it.
- `{...rest}` spread first onto that node, so the component's own attributes win.
- Native event signatures keep the standard names. `onChange` is a `ChangeEventHandler`; the convenience callback is named for the value's shape — `onValueChange` (string/number), `onCheckedChange` (boolean), `onValuesChange` (array) — and both fire.
- `variant` not `priority`/`kind`; `disabled` as a real boolean. **Figma variant properties are not code props** — `hover`/`active` are CSS pseudo-classes, not state a consumer sets.
- Deprecate, never remove. Document intentional native-name collisions (`size`, `title`) in the prop's JSDoc.

**Prop documentation is build-enforced.** Every own prop needs a JSDoc description: it is the single source for Storybook's props tables and for the `.d.ts` that ships to consumers, and `scripts/validate-prop-docs.mjs` fails the build on a prop without one. Two rules follow from how that pipeline works:

- **Never put a prop description in a story's `argTypes`.** `argTypes` entries override docgen, so a description there shadows the JSDoc and silently drifts from it. Stories set `control` and `options`; the source owns the words.
- **On a deprecated prop, put a sentence *before* the `@deprecated` tag.** The docgen parser moves the tag and everything after it into a separate `tags` field, so a prop documented *only* with the tag has an empty description and renders as a blank cell. `` /** Legacy alias for `variant`. @deprecated Use `variant` instead. */ `` keeps both.

The parser settings in `.storybook/main.ts` and in `validate-prop-docs.mjs` are deliberately identical, so the validator sees exactly what the rendered table sees. Two of them are load-bearing and easy to break: `tsconfigPath` must point at `tsconfig.app.json`, because the root `tsconfig.json` is solution-style (`"files": []` plus references) and yields a program containing no files; and the parser must be `react-docgen-typescript`, because plain `react-docgen` finds no component at all in one that returns `createPortal(...)` with no direct JSX (AlertDialog, CommandPalette).

---

## How to Add a New Component

A new component is not done until it appears in **every** place the system documents itself: the library, Storybook, and all relevant sections of the showcase website. Do not skip registration steps.

1. **Create the folder**: `src/components/MyComponent/`
2. **Write `MyComponent.tsx`**: Export a named component + a TypeScript interface for props, following the published-contract shape in **Component Anatomy** above (`'use client'` when interactive, own-props split, `forwardRef` + `displayName`, `{...rest}` spread, native event signatures). Use semantic tokens in class names, never inline styles.
3. **Write `MyComponent.css`**: All CSS vars must be from `tokens-light/dark.css`. No hardcoded hex, px values from primitives, or magic numbers.
4. **Write `MyComponent.stories.tsx`**: Export a `meta` (with `title: 'Components/MyComponent'`, `tags: ['autodocs']`) and at least a `Default` story plus one per meaningful variant. (`.stories.ts` also works for stories with no JSX, but `.tsx` is the convention across the library.)
5. **Add a website showcase page**: Create `website/src/app/components/my-component/page.tsx` + `page.module.css` + `layout.tsx`. Follow the pattern in an existing page (e.g., `website/src/app/components/button/page.tsx`). The `layout.tsx` must be exactly `export const metadata = componentPageMetadata("my-component");` — title *and* description come from the registry, so the description lives in one place. A slug with no registry entry fails the website build.
6. **Register it** — one entry, and most surfaces follow automatically:
   - `src/components/registry.json` — add an object to `components` (alphabetical by `name`) with `name`, `label`, `slug`, `description`, `category` and `client`. **The sidebar nav entry, the sitemap, the breadcrumbs, the mega-nav and the page's title/description all derive from this** — do not hand-add a nav entry.
   - `website/src/components/ComponentPreviews/ComponentPreviews.tsx` — add a preview entry under the component's slug. This is the one surface still hand-maintained, because each preview is a bespoke miniature; the index's category section and the sidebar accordion both derive from the registry.
7. **Document it in `design.md`**: add a short component spec section (class name, tokens used, key behaviours).

Steps 5–7 are build-enforced by two validators: `scripts/validate-website-surfaces.mjs` fails the build if any public component is missing its showcase page, preview entry, or `design.md` spec (the nav entry and its order are derived from the registry, so they cannot drift); `scripts/validate-page-titles.mjs` fails it if the page has no `layout.tsx`, or if that layout does not derive its title via `componentPageMetadata("<slug>")`.

Checklist before shipping a component:
- [ ] Props follow the published-contract shape (own-props split, `forwardRef` + `displayName`, `{...rest}`, native event signatures)
- [ ] `'use client'` present if interactive — and **absent** if purely presentational
- [ ] All colors via semantic tokens
- [ ] Dark mode verified (toggle `data-theme="dark"` in Storybook)
- [ ] Disabled state at `opacity: 0.4`, `cursor: not-allowed` (one documented exception: a control held inert by a *loading* contract stays full-colour with `cursor: progress` — SplitButton's trigger; design.md's spec section owns it)
- [ ] Interactive elements have ARIA roles and keyboard navigation
- [ ] At least one Storybook story per variant
- [ ] Website showcase page added, with a `layout.tsx` title via `componentPageMetadata("<slug>")` (build-enforced)
- [ ] Added to `src/components/registry.json` with complete metadata, `client` matching whether the file declares `'use client'` (build-enforced)
- [ ] Preview entry added to `ComponentPreviews.tsx` under the component's slug (build-enforced). The index section, sidebar accordion, sitemap and breadcrumbs derive from the registry — nothing to add
- [ ] Spec section added to `design.md` (build-enforced)

---

## How to Add a New Token

Tokens also have multiple homes — a token that exists only in CSS is incomplete. When adding or changing a token:

1. **The file follows the category** (`SEMANTIC_FILES` in `scripts/generate-token-registry.mjs` is the authoritative list of what the registry reads): colour tokens go in `src/tokens/tokens-light.css` **and** `src/tokens/tokens-dark.css` (light/dark parity is build-enforced by `scripts/validate-token-registry.mjs`); spacing, radius, border and icon-size tokens in `tokens-light.css` alone; shadow tokens in **both** theme files (the elevation values differ per theme, and colour parity enforcement does not cover them — a shadow added to the light file alone ships silently unchanged in dark mode); typography in `tokens-typography.css`; motion in `tokens-motion.css`. Only colour and shadow are theme-split — `tokens-dark.css` carries nothing else, and a duplicate there is dead weight the build never notices. Add a primitive to `tokens-primitives.css` first if no suitable one exists; semantic colour tokens **must** reference primitives via `var()` (build-enforced by `scripts/validate-token-references.mjs`).
2. **Document it in `design.md`**: it's the source of truth for the design language — record the token's role and its light/dark values.
3. **Add it to the foundations doc pages** on the website, in the section matching its type:
   - Semantic colors → `website/src/app/foundations/colour-mode/page.tsx` (add a swatch data entry with per-theme primitive name/hex/RGB, and a new `SectionTitle` group if it's a new category). **Every colour token needs a swatch — this is build-enforced**, in both directions, by `scripts/validate-website-surfaces.mjs`: the page states it shows all of them and prints the count from `TOKEN_COUNTS`, so a token with no swatch would turn that sentence into a lie. Skipping a niche internal role is no longer an option
   - New primitives → `website/src/app/foundations/colour-primitives/page.tsx`
   - Spacing/radius/border → `website/src/app/foundations/spatial/page.tsx`
   - Shadows/depth → `website/src/app/foundations/elevation/page.tsx`
   - Typography → `website/src/app/foundations/typography/page.tsx`
   - Icon sizes → `website/src/app/foundations/icons/page.tsx` (the `--icon-size-*` scale table)
   - Motion (durations/easings) → `website/src/app/foundations/motion/page.tsx` (add a `MotionSwatch` entry to the matching token array)
4. **Update the Storybook token docs**: `src/stories/Tokens.stories.tsx` documents tokens by category (primitives, colours, status, chart, elevation, spacing, motion) — add the new token to the matching story, or a new story if it's a new category. (`src/stories/` also holds `Typography`, `Icons`, and `Logos` foundation docs.)
5. **Counts take care of themselves**: `src/tokens/registry.json` is regenerated from the token CSS on every build (see **Registries**), so displayed token counts update automatically — never hardcode one. If the token introduces a *new prefix*, generation fails until you add the prefix to `CATEGORY_PREFIXES` in `scripts/generate-token-registry.mjs` and give the category a home wherever counts are shown.

---

## Design Principles

These are stated at the level of **token roles**, deliberately: which colour, radius, typeface, or shadow a role resolves to is the theme owner's decision, lives in `design.md` and the token files, and can change without any of these sentences becoming false.

- **One typeface**, with heading hierarchy carried by weight contrast — consecutive heading levels never share a weight. The type scale chains through two family roles (`--font-family-heading` / `--font-family-body`), both resolving to `--font-family-primary` in this theme, so a consumer can split heading and body faces without touching the principle (design.md's Font Family section owns the split).
- **The primary-action token is reserved for actions**: primary CTAs, focus rings, active input borders, and the selected item of a mutually exclusive set. Never decorative, or it stops meaning "click here".
- **Shape is a per-element-type token, never a per-instance choice**: all buttons share one radius role, all inputs another. Change the token, not the component.
- **Five status roles** (`info`, `positive`, `warning`, `error`, `neutral`), shared by every status-bearing component through the same `--color-status-*` set.
- **Depth is token-owned**: surfaces step through the container ramp, and the only shadows are the elevation tokens the system defines. Components never add their own.
- **Icons sit on the `--icon-size-*` scale** — set the scale variable, never `font-size` on an icon.
- **Tables on doc pages are one style**: the library `Table` with `bordered`, which is the look the /blueprints pages give a markdown table (design.md's Table spec owns what the variant does). Reach for the component and the variant before writing table CSS — a page that styles its own table is a second table style, and the reader has no way to know the two mean the same thing.

---

## Key Files

| File | Purpose |
|---|---|
| [`design.md`](design.md) | Full design spec — colors, typography, spacing, all component rules |
| [`content-design.md`](content-design.md) | Content style guide — voice, register by surface, words and patterns to avoid |
| [`src/tokens/tokens-primitives.css`](src/tokens/tokens-primitives.css) | Raw hex/px values |
| [`src/tokens/tokens-light.css`](src/tokens/tokens-light.css) | Semantic token definitions (light) |
| [`src/tokens/tokens-dark.css`](src/tokens/tokens-dark.css) | Semantic token overrides (dark) |
| [`src/tokens/tokens-typography.css`](src/tokens/tokens-typography.css) | Font size/weight/line-height scale |
| [`src/tokens/tokens-motion.css`](src/tokens/tokens-motion.css) | Duration/easing tokens + reduced-motion guard |
| [`src/components/Button/Button.tsx`](src/components/Button/Button.tsx) | Reference implementation for a component |
| [`src/components/Button/Button.stories.ts`](src/components/Button/Button.stories.ts) | Reference for story structure (`.ts` because it holds no JSX — `.tsx` is the library-wide convention) |
| [`website/src/app/components/button/page.tsx`](website/src/app/components/button/page.tsx) | Reference for a website showcase page |
| [`website/src/config/navigation.ts`](website/src/config/navigation.ts) | Nav links — update when adding pages |
| [`.storybook/main.ts`](.storybook/main.ts) | Storybook config: addons, stories glob, and the docgen settings that generate every props table (see **Prop documentation** below) |

---

## Known Gaps

- Figma parity: the system originates in [robr0-ds26](https://www.figma.com/design/8NzqDS8iRsBTFPbNGj3Woj/robr0-ds26), and foundation/component pages carry `figmaUrl` deep links to specific frames — but the file lags the coded system badly enough that **every Figma link is currently hidden** behind `SHOW_FIGMA_LINKS` in `website/src/config/social.ts` (its doc comment lists the guarded surfaces; flip it to restore them all). Keeping the file and the coded tokens in sync is a manual process; there is no automated export pipeline
- Visual regression runs via Chromatic (`.github/workflows/chromatic.yml`, dispatch-only — see **CI & Local Verify** for why it is not part of `verify`); baseline accepted 2026-07-27 across both themes. A11y is enforced with one axe rule deliberately switched off (see `.storybook/preview.ts`, which is authoritative and is not a gap to close), and axe only catches roughly a third of WCAG issues, so keyboard order (outside the overlay stories' play functions, which assert it) and meaningful alt text still need human review
