The codebase context for builders
Project structure, token architecture, component anatomy, and a step-by-step guide to adding new components. Copied verbatim from the repo on every build: hand it to any builder, human or AI, and they can extend the system without exploring.
What This Is
A React component library + design system + documentation website. It has three interconnected parts:
- Component Library (
/src) — React components built with Vite + TypeScript, published to npm asrift-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 sameexportssubpaths 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 insrc/components/registry.json; never hardcode a count. - Documentation Website (
/website) — A separate Next.js app that showcases every component with live, interactive examples. Each component has its own page underwebsite/src/app/components/[component-name]/. - AI Layer (spans both) — the site answering questions about itself: the library's
aicomponent category, the site-wide chat (theSiteChatpanel, mounted from the root layout viaSiteChatMount, theuseChathook, the Claude-backed/api/chatroute — which resolves the composer's model pick through the allowlist inwebsite/src/lib/chat-model.tsand 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 inwebsite/src/lib/site-tools.tsand 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 inevals/chat, and/api/mcp— a public, auth-free Model Context Protocol endpoint (stateless Streamable HTTP viamcp-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 byscripts/validate-mcp-tools.mjs(which also holds the display roster inwebsite/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 — read it before touching tokens, colors, or typography. The content style guide lives in 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. (Today the log is empty by design while npm still carries the predecessor repo's releases — see Releases under CI & Local Verify.) 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 -->— fromsrc/components/registry.json<!-- npm-badge:start/end -->— the npm version badge, built fromPACKAGE_NAMEinscripts/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-registryentry in the rootpackage.jsonis 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 byscripts/validate-css-directives.mjs. The token-audit skill reads directives; it maintains no list. - References are build-checked:
scripts/validate-doc-refs.mjsfails the build when a skill or doc references a repo path,npm runscript, or documented API symbol that doesn't exist (thesourceslist 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.mjsfails the build on an em dash in shipped copy, whichcontent-design.mdbans outright. It reads page prose through the sameextractProsethe 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 incontent-design.mdneeds a reader, and stays thecontent-auditskill's job. - A space that vanishes between source and render is build-checked:
scripts/validate-rendered-spacing.mjsfails 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> Compensationships 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 inverifyand in CI rather than insidevalidate-registry. The fix is always the same: use the literal character (’ “ ”) instead of the entity.
Quick Start
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:
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). This repo has published nothing yet: the release history below was cut from the predecessor repo, and npm's Trusted Publishing registration is keyed to repo + workflow filename, so it must be re-registered for THIS repo before its first publish — a dry run will not catch that; only a real publish authenticates. The version history below belongs to the predecessor's scoped package, not to rift-ds, which has no published version at all: 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.mjsSITE_URL —rift-ds.com, the domain bought on 2026-09-26 (the Vercel project is still nameddragonspine, root directorywebsite, push-to-deploy frommain; the project rename is cosmetic and externally gated)..github/workflows/uptime.ymlsmokes the origin every four hours, reading it from brand.mjs at run time rather than restating it. Storybook is a second Vercel project at theSTORYBOOK_URLin brand.mjs,storybook.rift-ds.com. This repo has published nothing to npm, and the package name now has no published version at all: the 2026-09-26 rename moved it from the predecessor's scoped name (the sole entry inRETIRED_PACKAGE_NAMES, which brand.mjs owns and every other file is forbidden to restate) to the unscopedrift-ds, registered to nobody. Every install snippet in the README, on get-started and in the agent surfaces therefore points at a package that does not exist yet — the first real publish is what makes them true, and it is the single most load-bearing step left. Thefirst-deployskill owns the rest of go-live (npm, GA, chat wiring, repo visibility). - Analytics: off —
GA_IDinscripts/brand.mjsis empty, which short-circuits the gtag snippet inwebsite/src/app/layout.tsxentirely; 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 — itsFAMILIEStable is the authoritative download spec) writessrc/fonts/presets/(latin + latin-ext woff2s, all OFL-1.1, plusmanifest.json), and the preset stylesheet generator emits per-preset@font-faceblocks from the manifest — soimport presets.css+ onedata-brandattribute 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/mcpbrowser 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 intomainif 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 fulldrift-audit, fix the broken and stale findings, thenshipthe 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, plainshipis enough.checkpoint— save progress to a remote branch and keep working. Never touchesmain, never deploys; if invoked onmainit moves the work to awip/<topic>branch first.park— checkpoint, then return to a cleanmain. 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, unpushedmain, and verifies the combined result. Anything deleted is archived to anarchive/*tag first, so discarding unmerged work stays reversible. Deliberately never pushes, so a batch deploy stays an explicitship.
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_PREFIXESinscripts/generate-token-registry.mjsis 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 — noprefers-color-schemequeries 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'shsl()colour physics. Grepds-allowto enumerate them;scripts/validate-css-directives.mjsowns the category set and build-enforces the grammar.) - Never hardcode hex values in semantic colour tokens either: every
--color-*value intokens-light/dark.cssmust be avar(--primitive-*)(orvar(--color-*)) reference — build-enforced byscripts/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 insrc/components/Chart/palette.tsto the--color-chart-series-*tokens, everygetCSSVar('--color-…', '<fallback>')literal insrc/componentsto its token's light-theme resolution, the playground'sNEUTRALSmirror inwebsite/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'sBLOB_HEXmirror inwebsite/src/lib/ogImage.tsxto 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.mjsextends the same guarantee to every remaining hand mirror of token data: the playground'sCHROMATIC_RAMPS,ACTION_COLOR_PRESETS,ACTION_SEMANTIC_REFSandRADIUS_STEPStables, everyvar()name inpresets.ts, InspectMode's prefix strings (held to the exportedCATEGORY_PREFIXES), the spatial page's swatch rows, and every token namedesign.mdmentions — a token rename that misses any of them fails the build. - Every
var(--…)a component references must actually resolve:scripts/validate-token-usage.mjsfails 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 asrift-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):
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, thenexport interface XProps extends XOwnProps, Omit<React.ComponentPropsWithoutRef<'el'>, keyof XOwnProps> {}— so native attributes pass through. React.forwardRefonto the primary node (the panel for portal components like Dialog/Drawer), plusdisplayName. 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.
onChangeis aChangeEventHandler; the convenience callback is named for the value's shape —onValueChange(string/number),onCheckedChange(boolean),onValuesChange(array) — and both fire. variantnotpriority/kind;disabledas a real boolean. Figma variant properties are not code props —hover/activeare 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.argTypesentries override docgen, so a description there shadows the JSDoc and silently drifts from it. Stories setcontrolandoptions; the source owns the words. - On a deprecated prop, put a sentence before the
@deprecatedtag. The docgen parser moves the tag and everything after it into a separatetagsfield, 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.
- Create the folder:
src/components/MyComponent/ - 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. - Write
MyComponent.css: All CSS vars must be fromtokens-light/dark.css. No hardcoded hex, px values from primitives, or magic numbers. - Write
MyComponent.stories.tsx: Export ameta(withtitle: 'Components/MyComponent',tags: ['autodocs']) and at least aDefaultstory plus one per meaningful variant. (.stories.tsalso works for stories with no JSX, but.tsxis the convention across the library.) - 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). Thelayout.tsxmust be exactlyexport 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. - Register it — one entry, and most surfaces follow automatically:
src/components/registry.json— add an object tocomponents(alphabetical byname) withname,label,slug,description,categoryandclient. 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.
- 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 withcursor: 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.tsxtitle viacomponentPageMetadata("<slug>")(build-enforced) - Added to
src/components/registry.jsonwith complete metadata,clientmatching whether the file declares'use client'(build-enforced) - Preview entry added to
ComponentPreviews.tsxunder 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:
- The file follows the category (
SEMANTIC_FILESinscripts/generate-token-registry.mjsis the authoritative list of what the registry reads): colour tokens go insrc/tokens/tokens-light.cssandsrc/tokens/tokens-dark.css(light/dark parity is build-enforced byscripts/validate-token-registry.mjs); spacing, radius, border and icon-size tokens intokens-light.cssalone; 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 intokens-typography.css; motion intokens-motion.css. Only colour and shadow are theme-split —tokens-dark.csscarries nothing else, and a duplicate there is dead weight the build never notices. Add a primitive totokens-primitives.cssfirst if no suitable one exists; semantic colour tokens must reference primitives viavar()(build-enforced byscripts/validate-token-references.mjs). - 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. - 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 newSectionTitlegroup if it's a new category). Every colour token needs a swatch — this is build-enforced, in both directions, byscripts/validate-website-surfaces.mjs: the page states it shows all of them and prints the count fromTOKEN_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 aMotionSwatchentry to the matching token array)
- Semantic colors →
- Update the Storybook token docs:
src/stories/Tokens.stories.tsxdocuments 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 holdsTypography,Icons, andLogosfoundation docs.) - Counts take care of themselves:
src/tokens/registry.jsonis 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 toCATEGORY_PREFIXESinscripts/generate-token-registry.mjsand 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-primaryin 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, neverfont-sizeon an icon. - Tables on doc pages are one style: the library
Tablewithbordered, 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 | Full design spec — colors, typography, spacing, all component rules |
content-design.md | Content style guide — voice, register by surface, words and patterns to avoid |
src/tokens/tokens-primitives.css | Raw hex/px values |
src/tokens/tokens-light.css | Semantic token definitions (light) |
src/tokens/tokens-dark.css | Semantic token overrides (dark) |
src/tokens/tokens-typography.css | Font size/weight/line-height scale |
src/tokens/tokens-motion.css | Duration/easing tokens + reduced-motion guard |
src/components/Button/Button.tsx | Reference implementation for a component |
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 | Reference for a website showcase page |
website/src/config/navigation.ts | Nav links — update when adding pages |
.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, and foundation/component pages carry
figmaUrldeep links to specific frames — but the file lags the coded system badly enough that every Figma link is currently hidden behindSHOW_FIGMA_LINKSinwebsite/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 ofverify); 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