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 and documentation website, in three connected parts:
- Component Library (
/src): React components built with Vite and TypeScript, published to npm asrift-ds. The website consumes it by name through the sameexportssubpaths any consumer would (in-repo they point at./src, so it is live source). The component list and count live insrc/components/registry.json; never hardcode a count. - Documentation Website (
/website): a Next.js app with a live page per component underwebsite/src/app/components/[component-name]/. - AI Layer (spans both): the site answering questions about itself. The chat widget (
rift-ds/chat, registrysrc/chat/registry.json) is the package's fourth kind of thing, between the components and the templates:ChatWidget,ChatProviderandChatLauncherover theuseChatengine, withrift-ds/chat/server,rift-ds/chat/actions(the action definitions a page and its route share, with no dependencies),rift-ds/chat/upstash(guardrails) andrift-ds/chat/demo. The site chat (website/src/components/SiteChat/) is a thin configuration of it;/api/chatiscreateChatHandlerconfigured inwebsite/src/lib/chat-model.ts, beside/api/chat/followupsand/api/chat/feedback, all rate-limited and disclosed on /privacy, as are the merchant sales template's/api/table-command,/api/merchant-chatand/api/merchant-chat/starters, which answer from that page's ledger alone (website/src/lib/order-ledger-tools.ts), read through the edits the page holds for the visit. Nothing there writes: the merchant chat proposes order changes (website/src/lib/order-actions.ts) that only the reader's Confirm on the card applies, and a typed "yes" never does. The deterministic lookups inwebsite/src/lib/site-tools.tsserve both the chat and/api/mcp, a public, auth-free, unmetered MCP endpoint that calls no model and reads only generated, published data. Agents also get the per-component markdown and JSON schema (website/public/schema/, with the tokens resolved per theme inrift-ds/tokens/valuesand as DTCG files), the agent starter (/AGENTS.md) and the consumer skills, installed bynpx rift-ds init(whoseapp,chatandblock <name>subcommands scaffold fromsrc/cli/templates/, never overwriting a file). Each route's and module's doc block owns its details.
Every chat suggestion is one chip, and a chip never wraps. Starters, follow-ups, the FAB summary chips and a QuestionCard's options share one length budget, SUGGESTION_MAX_CHARS in src/chat/suggestions.ts, sized so a chip fits the smallest phone. A suggestion that will not fit is dropped, never cut; a chip that still meets a narrower container ends in an ellipsis at its edge rather than running past it. A month or weekday is written short ("Oct", "Mon"). A suggestion asks about roles, never theme values ("the action colour", not the colour a theme gives it). scripts/validate-chat-starters.mjs and scripts/validate-page-summaries.mjs hold written copy to both rules.
The design spec lives in design.md: read it before touching tokens, colours or typography. The content style guide lives in content-design.md: read it before writing or editing any shipped prose (page copy, descriptions, README, release notes, microcopy). Dated decisions and incidents behind the rules below are recorded in HISTORY.md.
Registries: counts are never hardcoded
General rule: any count of items displayed anywhere (components, skills, tokens, releases, anything countable) derives 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.
Each validator's doc block owns what it checks; read it before changing a registry. One line per registry:
| Collection | Registry | Accessor | Validator |
|---|---|---|---|
| Components | src/components/registry.json | src/components/registry.ts | scripts/validate-component-registry.mjs |
| Component website surfaces | the component and chat registries | (derived nav) | scripts/validate-website-surfaces.mjs |
| Skills | .claude/skills/registry.json | website/src/data/skills-registry.ts | scripts/validate-skills-registry.mjs |
| Release log | website/src/data/release-log.json | website/src/data/release-log.ts | scripts/validate-release-log.mjs |
| Loops | website/src/data/loops.json | website/src/data/loops.ts | scripts/validate-loops.mjs |
| Page summaries | website/src/data/page-summaries.json | website/src/data/page-summaries.ts | scripts/validate-page-summaries.mjs |
| Semantic tokens | src/tokens/registry.json (generated) | src/tokens/registry.ts | scripts/validate-token-registry.mjs |
| Theme presets | website/src/lib/theme/presets.ts + src/tokens/presets/ (generated) | THEME_PRESETS | scripts/validate-preset-stylesheets.mjs, scripts/validate-theme-presets.mjs |
| Ambient background | website/src/data/shader-background.json | website/src/data/shader-background.ts | scripts/validate-shader-background.mjs |
| Site backgrounds | website/src/data/site-backgrounds.json | website/src/lib/shaders/background-storage.ts | scripts/validate-site-backgrounds.mjs |
| Dependency graph | website/src/data/dependency-graph.generated.ts | website/src/data/dependency-graph.ts | scripts/validate-dependency-graph.mjs |
| Site chat corpus | website/src/data/site-corpus.generated.ts | same file | scripts/validate-site-corpus.mjs, scripts/validate-chat-coverage.mjs, scripts/validate-corpus-coverage.mjs |
| Component prop API | website/src/data/component-api.generated.ts | same file | scripts/validate-component-api.mjs |
| Component schemas | website/public/schema/components/ + components.json (generated); the hand-written meta-schemas component.schema.json and components-index.schema.json beside them | (served as-is) | scripts/validate-component-schema.mjs, scripts/validate-json-schemas.mjs |
| Token values | src/tokens/values.json (generated; resolved per theme and mode; the hand-written meta-schema website/public/schema/token-values.schema.json) + the DTCG files in website/public/schema/tokens/ | src/tokens/values.ts (rift-ds/tokens/values) | scripts/validate-token-values.mjs, scripts/validate-json-schemas.mjs |
| Consumer agent skills | AGENT_SKILLS in scripts/brand.mjs + website/public/skill/manifest.json (generated) | website/src/data/agent-skills.ts | scripts/validate-agent-skill.mjs |
| Chat widget | src/chat/registry.json | src/chat/registry.ts | scripts/validate-chat-widget.mjs |
| Blocks | src/cli/templates/blocks/registry.json | website/src/data/blocks.ts | scripts/validate-blocks.mjs |
| Figma frames | website/src/data/figma-nodes.json | website/src/data/figma-nodes.ts | scripts/validate-figma-nodes.mjs |
| Accessibility coverage | website/src/data/a11y-coverage.generated.ts | website/src/data/a11y-coverage.ts | scripts/validate-a11y-coverage.mjs |
| Gestures | scripts/gestures.json (every drag grip a visitor can press) | (read by the script) | scripts/validate-gestures.mjs |
| Quality gate list (/skills/super-ship) | order from package.json and ci.yml, one line per check in website/src/data/quality-gate.json, written to website/src/data/quality-gate.generated.ts | same file | scripts/validate-quality-gate.mjs |
Notes an agent needs while editing them:
- Generated files are never hand-edited. Edit the source and rebuild; a byte-comparing validator fails on a hand edit.
- Release log: one entry per published npm version, written by the
releaseskill at publish time and never otherwise. - Page summaries: a new static page needs a summary and a starters pool, or must be chromeless; component pages derive theirs.
- Theme presets:
presetOverridesinpresets.tsis the one composer the stylesheets and the playground export are written from; every preset sets every lever and passes the contrast gate. - Ambient background: the site's config for the library's
ShaderField; tune it with?tune=1in dev.BlurBackgroundmounts once, in the root layout. - Site backgrounds: one row per pick the header's background menu offers, painted on every page and kept in localStorage like the theme pick; a row names a library
ShaderSceneand its tuning for each band, and the row with no scene is the field. The accessor's doc block owns the tuning rationale. - Gestures: every new drag grip on the site needs an entry with the setup that reaches it. No validator notices a missing one.
- Blocks are source the consumer owns, never components; design.md's Blocks section owns the conventions.
Built-HTML and served-site checks (validate-rendered-spacing, validate-corpus-coverage, validate-internal-links, the hydration smoke, the gesture check, the page-level axe pass) need the finished build, so they run in verify and CI after the website build, never in validate-registry.
Generated surfaces
Never hand-edit these; edit the source, and commit what a build regenerates. Each generator's doc block owns the details.
- Package barrels and exports:
src/index.tsandsrc/charts.tsbyscripts/generate-library-barrel.mjs(recharts modules only incharts.ts, since recharts is an optional peer). Name, version, subpaths and bin byscripts/package-manifest.mjs, held byscripts/validate-package-exports.mjs. - Renames are mechanical:
PACKAGE_NAME,RETIRED_PACKAGE_NAMESandRETIRED_HOSTSinscripts/brand.mjswithscripts/rename-package.mjs. Never restate a retired name or write a site URL from memory; derive it fromSITE_URL. - README.md regions (component count and list, npm badge, banner link, author credit) by
scripts/generate-readme-content.mjs, which also holds the Tech section's major versions and both install surfaces (README andsrc/stories/Configure.mdx). README ships in the npm tarball, so its prose is production copy. - The author credit:
AUTHOR_NAMEandAUTHOR_URLinscripts/brand.mjsare its only home. - Screenshot surfaces with no generator: re-shoot the README banner (
scripts/capture-readme-banner.mjs) and the playground's template stills (scripts/capture-template-thumbnails.mjs) by hand whenever what they show changes. - Website files: the /blueprints spec copies (
scripts/sync-blueprints.mjs, whoseFILESarray is the list), per-component markdown (scripts/generate-component-md.mjs), the per-component JSON schemas and their index (scripts/generate-component-schema.mjs, validated against the hand-written meta-schema beside them), the token values and DTCG files (scripts/generate-token-values.mjs), the shadcn registry inwebsite/public/r/(scripts/generate-shadcn-registry.mjs; fetches counted bywebsite/src/proxy.ts), per-theme DESIGN.md files (scripts/generate-theme-design-md.mjs), the consumer skills andAGENTS.md(scripts/generate-agent-skill.mjs; its manifest is the one home of the file list the init bin installs), and the /skills page data (scripts/generate-skills-content.mjs; never hand-add a card). - Schemas are generated twins, never a second source. A component's schema is written from its props interface and the token files from the token CSS; a schema is never hand-edited to say something the source does not. The meta-schemas (the
*.schema.jsonfiles at the top ofwebsite/public/schema/) are the only hand-written files there;component.schema.jsonholds the Component Anatomy vocabulary as data (tonevalues,disabled,locale,linkComponent,*Label), andscripts/validate-json-schemas.mjsproves every generated file against it and holds its tone list toscripts/validate-prop-vocabulary.mjs. A new sanctioned tone is a row there and a member here, in the same change./docs/schemasis the public reference.
The chat corpus security boundary
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 model's context, so anything in it can be repeated to any visitor; keeping it public-only means the worst case of a prompt injection out of the chat is off-brand prose, not a leak. The second half guards the other direction: third-party words (a client quote, a testimonial, a review) would be an injection surface into the chat. Today every word on the site is the owner's; the day a page carries third-party text, it needs an explicit decision (and probably an EXCLUDED_ROUTES entry) before the next build ships it to the model. Contact-shaped details may enter only through a page's corpus-facts() directive, and scripts/validate-site-corpus.mjs fails the build on any other route in. The /api/mcp tools inherit the same rule.
Keeping facts in one place
Every fact has exactly one authoritative home: all other mentions derive from it (generated), are checked against it (validated), or point at it.
- Point, don't enumerate. "The
validate-registryentry in the rootpackage.jsonis the authoritative list" beats a hand-copied list. - Self-descriptions stay in sync. When a change makes a statement in README.md, design.md, this file or the overview and foundations pages false, fix that prose in the same change. If the fact is countable or mechanically checkable, route it through a registry and validator instead.
- A new countable collection gets a registry file beside it, a small accessor module exporting the count, and a validator chained into
validate-registry(thenew-validatorskill walks it). - A new skill: write
.claude/skills/<name>/SKILL.mdand register it in.claude/skills/registry.json(displayed,unlisted, orexternal, which also needswebsite/src/data/external-skills/<name>.md). - Examples in skills are fictional. 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. Size claims in design.md are held byscripts/validate-design-counts.mjs. - 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. - References are build-checked:
scripts/validate-doc-refs.mjsfails on a repo path,npm runscript or API symbol in a doc that does not exist (itssourceslist is the set of docs). - Prose checks: no em dashes in shipped copy (
scripts/validate-shipped-prose.mjs; the rest ofcontent-design.mdis thecontent-auditskill's job), no width cap on a component page paragraph (scripts/validate-page-prose-width.mjs), and a literal ’ “ ” rather than an HTML entity after an inline closing tag (scripts/validate-rendered-spacing.mjs).
Quick Start
npm install # once, at the root: the website is an npm workspace, so this installs both
# Storybook (the library's dev sandbox)
npm run storybook # http://localhost:6006
# Documentation website
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/
npm run lint # ESLint
npm run build-storybook # export static Storybook
npm run test # every Storybook story as a render, axe and interaction test (headless Chromium), plus the unit tests (src/**/*.test.ts)
npm run verify # the full local quality gate, mirroring CI
npm run eval:chat # site-chat answer-quality eval against a running dev server (real API calls, never in CI)
npm run eval:starters # the generated starter chips held neutral and broad, against a running dev server (real API calls, never in CI)
npm run eval:merchant # the merchant assistant's focus and write tools, against a running dev server (real API calls, never in CI)
npm run registry-downloads # print the shadcn registry's download tally (read-only; needs KV credentials)
npm run graph # regenerate the /graph data; `-- tree <name>` / `-- who-uses <name>` trace dependencies
CI & Local Verify
.github/workflows/ci.yml runs on every push to main and every PR: the library (lint, build, the API compatibility gate, the package-publish lint, the dependency audit, the drift guard), the story tests, the Storybook build, and the website (lint, build, then the built-HTML checks and the served-site checks). The full chain (npm run validate-registry) runs before every root build via prebuild/prestorybook/prebuild-storybook; the website's own prebuild runs a subset, and website/package.json is authoritative for which.
npm run verify is the single local mirror of CI. When CI gains a check, add it to verify in the same change; skills and docs reference verify, never individual commands. Labs take a lighter path: a change confined to website/src/app/labs/ and the files a lab registers in (scripts/labs-only-change.mjs owns the allowlist) skips the story tests, the Storybook build, the package steps and the site-wide gesture and axe sweeps in CI, and npm run verify:labs mirrors that path locally; the validator chain, lint, both builds and the hydration smoke still run. Three deliberate exceptions stay CI-only:
- Chromatic (
.github/workflows/chromatic.yml): bills cloud snapshots, so it is dispatch-only and never proof the pixels are unchanged. - The dependency audit (
npm audit --audit-level=high --omit=dev): news from outside, not something a local run could catch; dev-only tooling is outside it by a decision the workflow comment records. - The drift guard (
git diff --exit-codeand an untracked-file check after the generators run): a local tree is dirty with work in progress.
Story tests run every story as a render test, with axe in 'error' mode. One rule, color-contrast, is switched off by the owner's settled decision: the comment beside that override in .storybook/preview.ts is its only record. Never re-enable it, restyle what it covers, or raise it as a finding without asking the owner first. Everything else in AA is enforced.
The served-site checks exist because a green build and an HTTP 200 have both held while the live site was blank: scripts/smoke-hydration.mjs (also the ship skill's post-deploy check, and run against production every four hours by .github/workflows/uptime.yml), scripts/validate-gestures.mjs (every grip in scripts/gestures.json under a throttled pointer flood), and scripts/validate-website-a11y.mjs (axe on the served pages in both themes, plus forced colors). Each doc block owns its details.
The published API only grows. scripts/validate-api-compat.mjs compares the working tree with the latest v* tag: no prop, export, wildcard file or bin may vanish or narrow, and no file under website/public/schema/ or property of a meta-schema there may disappear. A deliberate break is a row in its SANCTIONED_BREAKS table. It runs in verify and CI, never in validate-registry.
Releases
Manual, following the release skill. The Release workflow (.github/workflows/release.yml, dry-run by default) builds dist/, runs scripts/smoke-consumer.mjs, then publishes from dist/ with provenance. The root package.json stays private forever.
- The version lives in three places that move together:
PACKAGE_VERSIONinscripts/package-manifest.mjs(authoritative), the root package.json'sversion, andpackage-lock.json(refresh withnpm install --package-lock-only).validate-package-exports.mjsfails when they disagree. - A published version can never be reused, even after unpublishing.
- The registry lags the workflow by minutes: a 404 right after a green publish is propagation; never re-run on it.
- Auth is Trusted Publishing (OIDC), keyed to the workflow filename: renaming
release.ymlbreaks publishing, andpermissions: id-token: writeis load-bearing. A dry run never authenticates.
Infrastructure
- Deployment: the site is live at
SITE_URLinscripts/brand.mjs(Vercel, root directorywebsite, push-to-deploy frommain). Storybook is a second Vercel project atSTORYBOOK_URL. What is still outstanding for go-live is thefirst-deployskill's checklist to state, and only its. - Analytics:
GA_IDinscripts/brand.mjs(emptying it is the off switch); /privacy discloses it, so the two change together. Site events live only inwebsite/src/lib/analytics.ts(SITE_EVENTS,trackEvent), held byscripts/validate-analytics-events.mjs. A call never carries a visitor's text. - Fonts: the base theme's face is not bundled: the website loads it through
next/font, Storybook through a Google Fonts link, and consumers bring their own. Material Symbols and the presets' faces ship as self-hosted woff2 inside the package;scripts/sync-preset-fonts.mjsis a deliberate by-hand fetch, never in the build, and owns the contract.
Shipping vocabulary
Skills are named for their end state, because a push to main deploys the live site (.claude/skills/registry.json lists what exists):
ship: make it live. Full verify, merge intomainif needed, push, watch CI, prove the deployed site renders.super-ship: the bulletproof ship for structural work. Fulldrift-audit, a scopedapi-consistency, a coverage gate, fix the findings, aresponsive-sweepof every page at every breakpoint width with its breaks fixed, thenship. For a small change, plainshipis enough.checkpoint: save progress to a remote branch. Never touchesmain, never deploys.park: checkpoint, then return to a cleanmain.land: triage all pending work (worktrees, branches, stashes) as land / keep / delete, merge into a local, unpushedmain, verify. Deleted work is archived to anarchive/*tag first.
If asked to "merge and push" (a retired skill name), confirm the intended end state (ship, checkpoint or land) instead of guessing.
Project Structure
design.md,content-design.md,HISTORY.md,SECURITY.md: the specs, the history and the security policy.scripts/: generators and validators (thevalidate-registrychain) and release tooling.evals/chat/: the chat eval.src/components/: one folder per component, plusregistry.json.src/index.tsandsrc/charts.tsare GENERATED barrels.src/chat/: the chat widget over its engine, with its own registry and hand-written barrels.src/cli/: the init bin and its scaffold templates.src/format/:rift-ds/format, the pure money (decimal string) and relative or zoned date helpers, with no React and a hand-written barrel; its*.test.tsrun in theunitVitest project.src/behaviors/: the internal behaviour layer (layer stack, focus scope, scroll lock, anchored position, top layer, link provider, locale provider, keyboard shortcuts, reading direction,ScrollRegion,warnOnce, the visually-hidden class, andshell.css, the app shell recipe). Not a published subpath, except the scroll lock, the link provider, the locale provider and the shortcut listener (scripts/package-manifest.mjsis the list); each module's doc block owns its contract.src/tokens/: the token CSS, the GENERATEDpresets/,registry.jsonandvalues.json(every theme resolved, published asrift-ds/tokens/valuesthroughvalues.ts), andmotion.tsandbreakpoints.ts.src/fonts/: Material Symbols and the presets' faces.src/stories/: Storybook foundation docs and the props-contract story.website/(npm workspace):website/src/app/pages;website/src/config/(navigation.tsis the nav source of truth,chromeless.tsthe routes without shared chrome);website/src/data/registries and accessors;website/src/lib/non-UI modules (chat model, site tools, the theme levers, analytics);website/src/components/site UI (header, sidebar, footer, SiteChat, the Cmd+K palette, the anchor rail, the backgrounds, the error fallback, the template screens).
Before working in an area:
- Templates:
templatesSidebarLinksinwebsite/src/config/navigation.tsis the list; design.md's Template screens section owns the conventions (thenew-templateskill). - Playground (
website/src/app/playground/): the theme export, image palette and DESIGN.md importer are pure functions inwebsite/src/lib/theme/whose doc blocks own every rule. Dropped files are read in the browser and never uploaded. It never changes the system: no token is added and no colour skips a tier. - Page backgrounds:
BlurBackgroundmounts once from the root layout and its doc block owns the four background modes; pick exactly one per page. - Labs (
website/src/app/labs/): hidden, noindex, chromeless fidelity tests, deliberately outside the nav, sitemap and corpus. - A drag renders nothing until the pointer lets go: write size or position to the DOM per frame and commit React state once on release. Per-move state has crashed the live site; every new grip follows this rule and gets a
scripts/gestures.jsonentry.
Token Architecture
Three tiers. Never skip a tier (a theme re-keys the primitives and re-points the semantic layer, but the tiers never change):
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. Never referenced in components. - Semantic tokens (
--color-*,--radius-*,--gap-*,--font-*,--motion-*,--layer-*and the rest;CATEGORY_PREFIXESinscripts/generate-token-registry.mjsis the list): always use these in components. - Dark mode is driven by
data-theme="dark"on the root element. Every colour token has a light and dark value; noprefers-color-schemequeries in components.
Key invariants, each build-enforced (the validator's doc block owns the sanctioned exceptions):
- The action colour (
--color-action-primary-bg) is only for primary CTAs, focus rings, active input borders, the checked or on state of a form control, the selected item of a mutually exclusive set, and the hover signal of a surface that is itself a control; charts reach it through--color-chart-series-1. Never decorative.scripts/validate-action-colour.mjs. - No literal values in component CSS: colour, motion timing, type and off-scale spacing or radius go through tokens, or carry a
ds-allowdirective.scripts/validate-raw-values.mjs. - Semantic colour tokens reference primitives through
var(), never a hex, so a primitive override cascades:scripts/validate-token-references.mjs. Every hand mirror of token data outside CSS is held to its tokens by that script,scripts/validate-theme-mirrors.mjsandscripts/validate-foundation-mirrors.mjs. - Every
var(--…)a component references must resolve (a fallback marks a deliberate override hook):scripts/validate-token-usage.mjs. - Buttons are always
--radius-pill; inputs are always--radius-300. The token is the rule, its value the theme's. design.md's Border Radius Scale owns the sanctioned departures. - JavaScript timer delays come from
src/tokens/motion.ts(published asrift-ds/tokens/motion), never a literal:scripts/validate-js-timings.mjs. - Breakpoints come from
src/tokens/breakpoints.ts(andwebsite/src/config/breakpoints.tsfor the site); JS builds queries withmediaBelow/mediaAbove, CSS keeps a literal from the set:scripts/validate-breakpoints.mjs. - Every z-index of 10 or more names a
--layer-*token (or--layer-site-*in the site):scripts/validate-stacking.mjs.
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)
The only exceptions to self-containment are imports from src/behaviors/. Modal overlays take its dismissal stack, focus scope and scroll lock; non-modal popups take the stack with light dismiss and are placed by src/behaviors/useAnchoredPosition.ts; no component adds a document or window key or press listener of its own, and every app-level key binding is a useShortcut call over the one listener in src/behaviors/shortcuts.ts, in the library and the site alike (scripts/validate-overlay-listeners.mjs). A component that handles Escape itself calls event.preventDefault(), so a Dialog around it stays open. design.md's Components intro owns the contract.
Shared primitives are drawn once, and surfaces belong to families. The form-control indicators, CheckIcon and MinusIcon, ChosenMark, ds-visually-hidden, CircularButton for every close and SegmentedControl for every exclusive set are composed, never redrawn (scripts/validate-shared-primitives.mjs). A component takes its surface family's fill, border, radius, hover, selected and disabled treatment, and a departure is a sentence in its design.md spec (the component-consistency-audit skill reviews it). The chat cards are the one family with a recipe file: every value they share lives in src/behaviors/chat-card.css, which each card imports and reads, and scripts/validate-chat-cards.mjs holds new ai components to it. design.md's Components intro owns both lists.
Components are imported in the website through the package's public exports (the barrel also works):
import { Button } from 'rift-ds/components/Button/Button';
CSS class naming: ds-{component} base class, ds-{component}--{modifier} for variants (ds-button, ds-button--primary). scripts/validate-class-names.mjs holds it.
The props interface is a published contract. Full details are in the new-component skill; Button.tsx (button-or-anchor) and Input.tsx (form control) are the reference implementations. scripts/validate-component-contract.mjs and the props-contract story in src/stories/Contract.stories.tsx enforce the mechanical half; the api-consistency skill audits the rest.
'use client'on the first line only when the component uses hooks, handlers or browser APIs. Presentational components omit it so they render from a Server Component.- Own props as a
type, thenexport interface XProps extends XOwnProps, Omit<React.ComponentPropsWithoutRef<'el'>, keyof XOwnProps> {}. - A ref onto the primary node (the panel for portal components): React 19's plain
refprop for new components; existingforwardRef+displayNameones stay.{...rest}goes on the same node before the component's own attributes; a consumer-overridable default goes before the spread. - Native event signatures keep their names.
onChangeis aChangeEventHandler; the convenience callback isonValueChange,onCheckedChangeoronValuesChange, and both fire. - State a consumer can own is a controlled/uncontrolled pair:
x,defaultX,onXChange(value,checked,values,openand their partners). A component fully controlled by design says so in its design.md spec.scripts/validate-prop-vocabulary.mjs. - Every English string the component writes, announced or visible, is a prop with the English text as its default (
*Labelfor accessible names), so it can be localised.scripts/validate-accessible-names.mjs,scripts/validate-visible-text.mjs. variantnotpriority/kind;disabledas a real boolean. Figma variant properties such as hover and active are CSS states, not props.variantis emphasis,toneis colour.primary/secondary/tertiaryridevariant;default/neutral/destructiveridetone. Status-bearing components keep the five status roles onvariant; a data entry marks danger withdestructive?: boolean. A moved value stays as a@deprecatedalias, and internal callers migrate in the same change.sizeisdefault/compact.- A link goes through the link component, never a raw
<a href>: an optionallinkComponentprop, resolved withuseLinkComponentfromsrc/behaviors/LinkProvider.tsxin a client component, orlinkComponent ?? 'a'in a server one. - A number, a date or a time is written in the resolved locale, never the runtime's: an optional
localeprop, resolved withuseLocalefromsrc/behaviors/LocaleProvider.tsxin a client component, orlocale ?? DEFAULT_LOCALEin a server one. It formats and never translates (scripts/validate-locale-formatting.mjs; design.md's "Numbers and dates are written in the consumer's locale"). Money is a decimal string, parsed, written and added throughrift-ds/format(design.md's "Money is a decimal string"). - Layout follows the reading direction: logical properties, inline motion multiplied by
--ds-inline-sign, arrow keys throughlogicalArrowKey, popups asked forstart/end, and directional glyphs mirrored; a side that belongs to the screen carriesds-allow(physical)(scripts/validate-logical-properties.mjs; design.md's "Layout follows the reading direction"). - Deprecate, never remove (
scripts/validate-api-compat.mjs). Document intentional native-name collisions (size,title) in the prop's JSDoc.
Every own prop needs a JSDoc description: it feeds Storybook's props tables, the shipped .d.ts, the published component schema and the MCP surface (scripts/validate-prop-docs.mjs, whose doc block and scripts/component-docgen.mjs own the parser settings). Three rules follow:
- Never put a prop description in a story's
argTypes: it shadows the JSDoc. Stories setcontrolandoptionsonly. - A re-export in a component file goes below the component, or the parser names it after the component and empties the props table.
- On a deprecated prop, put a sentence before the
@deprecatedtag, or the description renders blank.
The props interface is also the published schema. website/public/schema/components/<slug>.json is written from it by scripts/generate-component-schema.mjs on every build and validated against website/public/schema/component.schema.json, which holds the vocabulary rules above as data; a new prop that breaks one of them (a tone value outside the sanctioned sets, a non-boolean disabled, a *Label whose default is not text) fails the build. Nothing in a schema is hand-typed, and a component's page gets its For agents dialog, with the markdown and the schema as tabs, from PageLinks with no page work.
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 the website. The new-component skill has the operational detail.
- Create the folder:
src/components/MyComponent/ - Write
MyComponent.tsx: a named component and its props interface, following the contract in Component Anatomy. Class names use semantic tokens, never inline styles. - Write
MyComponent.css: semantic token vars only. No hardcoded hex, primitive px values or magic numbers. - Write
MyComponent.stories.tsx: ametawithtitle: 'Components/MyComponent'andtags: ['autodocs'], aDefaultstory and one per meaningful variant. - Add a website showcase page:
website/src/app/components/my-component/page.tsx,page.module.cssandlayout.tsx, followingwebsite/src/app/components/button/page.tsx. The layout is exactlyexport const metadata = componentPageMetadata("my-component");. - Register it:
src/components/registry.json: an entry incomponents(alphabetical byname) withname,label,slug,description,categoryandclient. Nav, sitemap, breadcrumbs, mega-nav and page metadata derive from it.website/src/components/ComponentPreviews/ComponentPreviews.tsx: a preview entry under the slug (the one hand-maintained surface).
- Document it in
design.md: a short spec section (class name, tokens used, key behaviours).
Steps 5 to 7 are build-enforced by scripts/validate-website-surfaces.mjs and scripts/validate-page-titles.mjs.
Checklist before shipping a component:
- Every rule in Component Anatomy holds: the props contract, links, ownable state, English strings as props, numbers and dates in the resolved locale, layout from the reading direction (checked with Storybook's Direction toolbar), overlays through
useLayer, keys throughuseShortcut, shared primitives and its surface family -
'use client'present if interactive, and absent if purely presentational - All colours via semantic tokens
- Dark mode verified (toggle
data-theme="dark"in Storybook) - Disabled state at
opacity: 0.4,cursor: not-allowed(a control held inert by a loading contract keeps full colour withcursor: progress; design.md owns the exception) - Interactive elements have ARIA roles and keyboard navigation
- At least one Storybook story per variant
- Steps 5 to 7 done, with
clientin the registry matching the'use client'directive -
node scripts/validate-json-schemas.mjspasses: the component's generated schema validates against the meta-schema
How to Add a New Token
A token that exists only in CSS is incomplete. The new-token skill has the operational detail.
- The file follows the category (
SEMANTIC_FILESinscripts/generate-token-registry.mjsis the list):- Colour:
src/tokens/tokens-light.cssandsrc/tokens/tokens-dark.css(parity is build-enforced), referencing a primitive throughvar(). Add the primitive totokens-primitives.cssfirst if none fits. - Shadow: both theme files (parity is not enforced for shadows, so a light-only shadow ships unchanged in dark mode).
- Spacing, radius, border, icon size, control height:
tokens-light.cssonly (a control-height step is also a row ofCONTROL_HEIGHT_STEPSinwebsite/src/lib/theme/theme-overrides.ts). - Typography:
tokens-typography.css(a new type style is also a row ofTYPE_STYLESintheme-overrides.ts). Motion:tokens-motion.css. - Only colour and shadow are theme-split; anything else in
tokens-dark.cssis dead weight.
- Colour:
- Document it in
design.md: its role and how it relates to its neighbours, never its colour value (design.md is theme-agnostic). - Add it to the foundations pages (the printed values are build-checked against the CSS, so type what the token resolves to):
- Semantic colours:
website/src/app/foundations/colour-mode/page.tsx(every colour token needs a swatch, build-enforced in both directions) - Primitives:
website/src/app/foundations/colour-primitives/page.tsx - Spacing, radius, border, control height:
website/src/app/foundations/spatial/page.tsx - Shadows:
website/src/app/foundations/elevation/page.tsx - Typography: a type style is a row in
website/src/app/foundations/typography/TypographyView.tsx; a new ladder step needs nothing - Icon sizes:
website/src/app/foundations/icons/page.tsx - Motion:
website/src/app/foundations/motion/page.tsx - Stacking (
--layer-*): nothing to add; write the trailing commentscripts/validate-stacking.mjsrequires
- Semantic colours:
- Update the Storybook token docs:
src/stories/Tokens.stories.tsx, in the matching story. - Counts and values take care of themselves:
src/tokens/registry.json,src/tokens/values.json(every theme, both modes, resolved) and the DTCG files underwebsite/public/schema/tokens/regenerate on every build. A new prefix fails generation until it is added toCATEGORY_PREFIXES; a token whose namefamilyOfinscripts/generate-token-values.mjscannot place in a family, or whose value has no mapper inTYPE_MAPPERSfor that family, fails it until both exist.
Design Principles
Stated at the level of token roles: which colour, radius, typeface or shadow a role resolves to is the theme's decision, and can change without any of these becoming false. design.md owns each in full.
- One face per family role, one weight per tier. Heading and body faces chain through
--font-family-headingand--font-family-body; which weight each tier carries is the theme's call. - The logo and each type tier can take a tone apart from the action colour; a display or heading rule never uses the primary text colour (
scripts/validate-tier-colours.mjs). - The primary-action token is reserved for actions, or it stops meaning "click here".
- Shape is a per-element-type token, never a per-instance choice. Change the token, not the component.
- Five status roles (
info,positive,warning,error,neutral), shared through the--color-status-*set. - Depth is token-owned: surfaces separate through the container ramp, and the only shadows are the elevation tokens.
- Platform fidelity is the one sanctioned departure from the type and depth rules: the iOS and Android variants of the mobile navigation components, under the
platformdirective category. - Icons sit on the
--icon-size-*scale: set the scale variable, neverfont-sizeon an icon. - Every AI chat follows the chat widget's responsive pattern (docked, modal overlay, full-viewport takeover, at the thresholds in
src/chat/queries.ts), withChatLauncheras the reference; a host reserves the panel's width only at the dock threshold. design.md's ChatWidget spec owns placement and the exceptions. - Every app shell is one of three modes (
flush,floating,inset), drawn from one recipe,src/behaviors/shell.css, by AppLayout, AppSidebar, ChatWidget'slayout, TemplateShell and the app-shell block (scripts/validate-shell-recipe.mjs). The edge belongs to the mode: floating and inset are raised, flush never is. Only a chat takes a backdrop (blur when it rests above something, solid when it is its own page); app layouts and templates are always the floor. design.md's App shells section owns it. - File-format colours are fixed, not themed: the
--color-file-*roles only fill an attachment's type mark. - Tables are one family, drawn from one set of tokens (
--color-table-*). On a doc page, useTablewithborderedor markdown throughMarkdownTable, never new table CSS (scripts/validate-table-styles.mjs).
Key Files
| File | Purpose |
|---|---|
HISTORY.md | Release history, the build journal, and the dated decisions behind these rules |
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 and line-height scale |
src/tokens/tokens-motion.css | Duration and 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 |
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, docgen settings |
Known Gaps
- Figma parity: the Rift DS kit (
FIGMA_FILE_URLinscripts/brand.mjs) has a frame per component, and every docs page links its frame fromwebsite/src/data/figma-nodes.jsonthrough PageLinks, all behindSHOW_FIGMA_LINKSinwebsite/src/config/social.ts. Code Connect is unavailable on the Figma plan, and nothing pushes the DTCG files underwebsite/public/schema/tokens/into the file, so keeping the kit in step with the code, and a rebuilt frame's node id in the map, is manual. - Visual regression: Chromatic runs dispatch-only, but this repo has no Chromatic project yet (the
first-deployskill owns that step). axe catches roughly a third of WCAG issues, so keyboard order outside the asserted overlay stories and meaningful alt text still need human review.