Skills
Reusable AI instructions, tuned for this project
These 30 skill files live in .claude/skills/ in this repo and encode this project’s conventions: component patterns, token rules, navigation wiring, and more. Invoke any skill by name in Claude Code and it follows the exact steps without re-explanation each session. Expand any skill to read the full file, and copy it to adapt it for your own project. The recurring ones run as loops: see Loops.
new-componentScaffolds a new design system component (a typed React component, a token-only CSS stylesheet, and a Storybook stories file), then registers it in the build-enforced component registry and design.md. Enforces the ds- BEM naming prefix, semantic token usage, and the correct stories format without needing reminders.
new-component.mdmd---
name: new-component
description: Scaffold a new design system component with all required files and registration steps. Use when asked to add, create, or scaffold a new design system component.
---
# new-component
Scaffold a new design system component: the three component files plus the build-enforced registration steps.
## When invoked
Use this skill any time you are asked to add or create a new component to the design system — phrases like "add a [Name] component", "create a [Name] component", "scaffold [Name]".
Not for a widget: the chat (`src/chat/`) is a kind of its own, with its own registry (`src/chat/registry.json`) and validator (`scripts/validate-chat-widget.mjs`), and does not go through this flow. The one way the two meet: when the chat widget starts composing a new component, that component's name joins `parts` in the chat registry, which the validator holds to the widget's own imports in both directions.
## Instructions
1. **Ask** for the component name (PascalCase) and a one-sentence description of its purpose, if not already provided.
2. **Read these reference files before writing anything:**
- `src/components/Button/Button.tsx` — structural reference for a button-or-anchor component (own-props split, ref forwarding, rest spread, BEM class usage, conditional rendering)
- `src/components/Input/Input.tsx` — structural reference for a form control (native `onChange` + `onValueChange` convenience callback, label/helper/error wiring)
- `src/components/Badge/Badge.css` — CSS token reference (no raw hex/pixels, semantic token usage)
- `src/components/Badge/Badge.stories.tsx` — stories file reference (`satisfies Meta`, `StoryObj`, autodocs)
- `website/public/schema/component.schema.json` — the component contract as data. CLAUDE.md's Component Anatomy paragraph **The props interface is also the published schema** owns what it locks and why; `node scripts/validate-json-schemas.mjs` is the check. The component's own schema under `website/public/schema/components/` and its page's For agents dialog derive from the props interface and need no work.
- `src/tokens/registry.json` — the generated index of every semantic token by category (which CSS file owns which category is the `new-token` skill's step 2, backed by `SEMANTIC_FILES` in `scripts/generate-token-registry.mjs`; the registry is the one place that lists all of them)
- `design.md`'s Components intro, the two paragraphs **Shared primitives, never redrawn** and **Surfaces by family** — the one drawing of every small thing a component needs (the three control indicators, the chosen mark, the visually-hidden class, the close button, the exclusive set) and the surface treatment each family of component takes; `src/components/Checkbox/Checkbox.tsx` is the reference for exporting an indicator beside its control, and `src/components/SelectionCard/SelectionCard.tsx` for a host composing one
- `.claude/skills/design-qa/SKILL.md` — read its "The craft checks" rubric **before writing any CSS** (and "The eye" for when the component lands on a page): the component faces that pass as a gate before it can register (step 4), and polish built in beats polish patched on after
3. **Create the directory** `src/components/ComponentName/` and write these three files. For an **overlay component** (anything that dismisses, traps focus, or locks scroll), first read CLAUDE.md's Component Anatomy exception and design.md's Components-intro overlay contract: import the shared behavior hooks from `src/behaviors/` rather than registering your own document listeners — Dialog is the reference modal consumer, and Popover the reference light-dismiss one (`useLayer` with `dismissOnOutsidePress` and `getElements`, no focus scope). A component that handles Escape in its own keydown handler (a listbox closing its list) calls `event.preventDefault()`, which the stack respects, so a Dialog around it stays open. `scripts/validate-overlay-listeners.mjs` fails the build on a document key or press listener outside its sanctioned table. A panel anchored to a control (a menu, a listbox, a picker, a popover, a tooltip) is placed by `src/behaviors/useAnchoredPosition.ts`, never by `position: absolute` CSS, which a scrolling table or a sidebar clips: give it the preferred side, key its entrance on the `data-side` the behaviour writes, and add a story to `src/stories/AnchoredPopups.stories.tsx` that opens it inside a clipping box (design.md's "Popups are placed by one behaviour" owns the contract; Dropdown is the reference list, ThemePicker the reference menu). Three files is the norm, not a limit: a component may split extra modules out beside them (`ShaderField` keeps its hook and its shader source in sibling `.ts` files). If you do, read the packaging note in step 5 — a sibling `.ts` module does **not** get a deep-import subpath for free. One family-shaped exception: when the component belongs to an established shared-folder family (most of the recharts-backed charts live as single `.tsx` files inside `src/components/Chart/`, and the Inspector panel controls inside `src/components/Inspector/` sharing `Inspector.css`; the registry entries' `folder` values are the authoritative membership lists), add the file to that folder instead of creating a new directory, and set the registry entry's `folder` field to the shared folder's name — the doc block of `scripts/validate-component-registry.mjs` documents the field, and the existing `Chart/` entries are the pattern to copy. Two independent axes, not one: `folder` records where the implementation lives, and the registry's `recharts` flag records which barrel it ships from — FunnelChart is the precedent for a recharts-backed chart in its own folder (it composes the family's `Chart.css` chrome without living in `Chart/`). Joining the shared folder is a fit call, not a dependency test. A multi-series chart takes its default colours from `getChartSeriesColors()` in `Chart/palette.ts` — which emits `var()` references to the `--color-chart-series-*` ramp, so series colours follow a live theme switch — never a local palette array. A recharts-backed chart declares `'use client'` (recharts calls client-only React APIs and ships no boundary of its own) and sizes its tick and legend text with `readTokenPx('--font-caption-size', '12px')` from `Chart/palette.ts`, never a literal `fontSize`. It also spreads `useChartAnimation(data)` from `Chart/useChartAnimation.ts` into every series, so it animates on mount and on new data and never on a resize (design.md's chart-set spec owns the contract, the ScatterChart timer case included). And every panel-scale chart — recharts-backed or not — wears the family's shared card chrome by importing `Chart.css` and composing its `ds-chart` wrapper/header/body classes, and takes a `bare` prop (chrome off: no border, padding, or fill) so it can drop straight into a Panel that supplies the surface; a new chart ships the same way (only inline-scale pieces like a sparkline or a legend tile stay chromeless).
### File 1: `ComponentName.tsx`
- Named export (not default)
- BEM class naming with `ds-componentname` root prefix (e.g. `ds-button`, `ds-badge`)
- Modifier classes follow `ds-componentname--variant` pattern
- Imports CSS: `import "./ComponentName.css"`
- If the component renders as `<a>` when an `href` prop is passed, follow the Button pattern of conditional element rendering
- A continuous pointer gesture (a drag, a resize grip, a scrub, a move) writes the DOM per frame and commits React state once, on release: the move handler sets the style, the custom property or an SVG path's `d` straight on the node, and the pointer-up handler does the one `setState`. Per-move state re-renders everything under that state on every frame, and React counts a commit that ends with the next move already queued as a nested update, so a real drag over a re-animating sibling can hit the update-depth limit and unmount the root (the site lost a whole page to exactly that). The floating card's move in `src/chat/ChatLauncher.tsx` is the reference implementation (the position variables written per frame, one `setFloatPos` on release); its widen grip is the pure-DOM case, where the width lives on `<html>` and nothing needs committing. A grip a visitor can press on a served page also gets an entry in `scripts/gestures.json`, with the setup that reaches it, so the gesture stress check drives it in `verify` and CI (CLAUDE.md's Gestures row owns the registry). One exception: when the subtree the per-move update re-renders is small (a fictional `GadgetKnob` whose state drives only its own dial), per-move state is fine, and a comment beside the handler says so, so the next reader knows it was judged rather than missed
**The component API contract — every one of these, no exceptions.** This package is published to npm, so the props interface is a public contract. Getting it wrong is a breaking change later. `src/components/Button/Button.tsx` (button-or-anchor) and `src/components/Input/Input.tsx` (form control) are the reference implementations. Items 2-4 are build-enforced (`scripts/validate-component-contract.mjs` reads the syntax, and the props-contract story in `src/stories/Contract.stories.tsx` renders the component with a ref and a `data-testid` and fails unless both reach one element), so a slip fails the build rather than shipping; the rest are yours to get right. State a consumer can own follows the controlled/uncontrolled pair convention in CLAUDE.md's Component Anatomy (`x`/`defaultX`/`onXChange`), and every accessible name the component writes in English is a `*Label` prop with that text as its default.
1. **`'use client'` on the first line** — if and only if the component uses hooks, event handlers, or browser APIs. **Purely presentational components must NOT have it** (see `src/components/Table/Table.tsx`), or consumers lose the ability to render them from a React Server Component. "Uses hooks" counts what the component imports too: a shared hook file or a package that renders client-only (recharts) needs the directive on the component, and `scripts/validate-component-registry.mjs` fails the build when one is missing.
2. **Split the props type in two.** Own props as a `type`, then an exported `interface` that merges in the native element's props:
```ts
type ComponentNameOwnProps = { /* ...props this component owns... */ };
export interface ComponentNameProps
extends ComponentNameOwnProps,
Omit<React.ComponentPropsWithoutRef<'div'>, keyof ComponentNameOwnProps> {}
```
Add `| 'type'` (or any other attribute the component hardcodes) to the `Omit` list.
3. **Take a ref onto the primary DOM node**, in one of two forms the validator accepts. A new component uses React 19's form: a named function component that destructures `ref` from its props and passes it to the node, with the props interface built on `ComponentPropsWithRef<'div'>` instead of `ComponentPropsWithoutRef` (the package's react peer is `^19`, so every consumer has ref-as-prop; the function's name is its DevTools name, so it needs no `displayName`):
```tsx
export function ComponentName({ className = '', ref, ...rest }: ComponentNameProps) {
return <div {...rest} ref={ref} className={['ds-component-name', className].filter(Boolean).join(' ')} />;
}
```
The existing components use `React.forwardRef` with `ComponentName.displayName = 'ComponentName'` after it, which stays valid. The two are identical to a consumer, so there is no migration: leave a forwardRef component alone unless you are rewriting it anyway. If the component already keeps an internal ref (focus trap, click-outside, picker trigger), merge them:
```ts
const setRef = (node: HTMLDivElement | null) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
};
```
4. **Spread `{...rest}` onto that same node**, placed *before* the component's own attributes so those win; a consumer-overridable default (a landmark's `aria-label`) goes before the spread instead, so a passed value wins (see Accessibility below). This is what makes `data-testid`, `aria-*`, `autoComplete`, `maxLength` and form-library registration work.
5. **Event handlers keep native React signatures.** `onChange` must be `React.ChangeEventHandler`, never `(value: string) => void` — that shape breaks react-hook-form, Formik and TanStack Form. Put the convenience callback under a name matching the value's shape, and fire both:
| Value shape | Convenience prop |
|---|---|
| string / number | `onValueChange` |
| boolean | `onCheckedChange` |
| array | `onValuesChange` |
```ts
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onValueChange?.(e.target.value);
};
```
6. **Never invent a prop that shadows a native one with different semantics.** Prefer `variant` (not `priority`/`kind`) and `disabled` (a real boolean, never a value inside a `state` enum). **Figma variant properties are not code props** — `hover` and `active` belong to CSS pseudo-classes, so a `state` prop that includes them has two sources of truth. Where a collision is unavoidable and intentional (`size` vs the native character-width attribute, `title` vs the native tooltip), say so in the prop's JSDoc.
**`variant` is emphasis, `tone` is colour** (CLAUDE.md's Component Anatomy states the rule; design.md's Components intro owns the details). Never put a colour value in an emphasis union: a fictional `GadgetPill` that needs a red delete look takes `variant?: 'primary' | 'secondary' | 'tertiary'` plus `tone?: 'default' | 'neutral' | 'destructive'`, never `variant: 'destructive'`. A status-bearing component keeps the five status roles on `variant` instead, and a data entry the component renders (a menu item, a command row) marks danger with `destructive?: boolean`. If the component is a rework that already shipped a mixed union, the moved value stays as a `@deprecated` alias that renders unchanged (item 8). `scripts/validate-prop-vocabulary.mjs` enforces the shape, and fails any internal caller still passing a deprecated prop or a legacy value.
7. **Discard props that would be invalid on the rendered node** rather than spreading them — e.g. `maxLength` on a `<div role="combobox">`. Destructure with a `_` prefix (`maxLength: _maxLength`) and document why in the JSDoc; the ESLint config allows `^_`. A form control's `name` is never one of these: it reaches the form, through a hidden input when the control has no native one (design.md, "A control reaches a form through `name`").
8. **Deprecate, never remove.** Keep the old prop working and mark it `@deprecated` with the replacement named — and put a description sentence **before** the tag, or the prop renders as a blank cell (see 9). `className` stays wherever it already is (usually the wrapper) — moving it is a silent visual break.
9. **A link renders through the link component, never a raw `<a href>` — build-enforced.** A component that takes an `href` (its own, or an item's) adds an optional `linkComponent?: LinkComponent` prop and renders every link with the resolved component, so a consumer's router (Next.js's `Link`) navigates without a reload. In a client component, resolve it with `const Link = useLinkComponent(linkComponent);` from `src/behaviors/LinkProvider.tsx` at the top of the render, before any early return (prop, else the nearest `LinkProvider`, else `<a>`); in a server-renderable one, `const Link = linkComponent ?? 'a';`, since context cannot reach it, and say so in the prop's JSDoc. Button.tsx and Breadcrumb.tsx are the two patterns, and design.md's Components intro owns the contract. A link that is genuinely not navigation (an in-page jump the component scrolls itself) is a row in `SANCTIONED_ANCHORS` in `scripts/validate-component-contract.mjs`, with the reason.
**A number, a date or a time is written in the resolved locale, never the runtime's — build-enforced.** The same shape as the link: an optional `locale?: string` prop, resolved with `const locale = useLocale(localeProp);` from `src/behaviors/LocaleProvider.tsx` in a client component, or `locale = DEFAULT_LOCALE` (from `src/behaviors/locale.ts`) in a server-renderable one, then every `Intl` format, collator and `toLocale*String` call takes that locale. A fictional `GadgetMeter` printing `1,204 widgets` formats the number in `locale` and keeps "widgets" in a `*Label` prop: the locale formats, it never translates. A calendar reads its first weekday from `weekStartOf(locale)` and its names from `weekdayNamesIn` rather than a hand-written list. `scripts/validate-locale-formatting.mjs` fails the build on a call with no locale, a hand-written month or weekday name, or a server-renderable component reading the provider; design.md's Components intro owns the contract.
**A key the component answers to from outside its own focused element goes through `useShortcut` — build-enforced.** A fictional `GadgetViewer` overlay whose arrows step pages binds them with `useShortcut('arrowright', next, { scope: panelRef, enabled: open })` from `src/behaviors/shortcuts.ts`, never a document listener, so they stay quiet in text fields and behind other overlays and never take Escape from the layer stack. A shortcut the component only displays is a `keys?: Shortcut` prop in the shortcut grammar (`mod+shift+p`), drawn through Kbd's `keys` or `formatShortcut`, with a `keyLabels` prop for other keyboards; keep a literal display prop the component already ships. `scripts/validate-overlay-listeners.mjs` fails the build on a document key listener; design.md's "Keyboard shortcuts go through one listener" owns the rules.
**Lay it out from the reading direction — build-enforced.** Write every side logically (`margin-inline-start`, `inset-inline-end`, `border-start-start-radius`, `text-align: start`); multiply any horizontal transform by `var(--ds-inline-sign, 1)` and import `src/behaviors/direction.css`; switch on `logicalArrowKey(event.key, event.currentTarget)` from `src/behaviors/direction.ts` for left and right; measure a drag from the inline start with `isRtl`; ask a popup for `start`/`end`; and give a glyph that points along the reading direction the `ds-mirror-rtl` class (an icon a consumer names takes `mirrorClass(name)`). A fictional `GadgetSlider` whose thumb slides by value multiplies its translate by the sign and reads ArrowLeft as an increase in Arabic. A side that genuinely belongs to the screen (a map, a chart axis, a pointer position) takes `/* ds-allow(physical): <reason> */`. `scripts/validate-logical-properties.mjs` fails the build otherwise; check the component with Storybook's Direction toolbar set to right to left.
10. **Compose the shared primitives; never redraw one — build-enforced.** A component that offers a choice renders `CheckboxIndicator`, `RadioIndicator` or `SwitchIndicator` (exported beside Checkbox, RadioButton and ToggleSwitch) and carries the role, the state and the `ds-control-host` class on its own root; a list or menu row marks its selected item with `ChosenMark` (from the Dropdown module); text that is read but not seen takes `ds-visually-hidden`; a header's close is a compact tertiary `CircularButton`; a mutually exclusive set of pills is a `SegmentedControl`. A fictional `GadgetPicker` that wants a tick beside each row renders the checkbox indicator, never a `check_box` glyph or a bordered `<span>` of its own. `scripts/validate-shared-primitives.mjs` fails the build on a second drawing of any of these; design.md's Components intro ("Shared primitives, never redrawn") is the list, and a departure with a reason is a row in that script's `SANCTIONED` table.
11. **Give every own prop a JSDoc description — this is build-enforced.** That JSDoc is the single source for Storybook's props table, the `.d.ts` consumers get, and the machine-readable prop API the site's `/api/mcp` route serves to any agent and the site chat's `get_component` tool repeats to visitors (`website/src/data/component-api.generated.ts`) — so a prop description is public copy strangers' agents and site visitors both read, and `scripts/validate-component-api.mjs` leak-screens it (a hit there is a leak in the npm tarball too). All three consumers see the same parse: the settings live in `scripts/component-docgen.mjs`, mirroring `.storybook/main.ts`. `scripts/validate-prop-docs.mjs` fails the build on a prop without a description. Two traps it catches:
```ts
/** Visual treatment */
variant?: 'primary' | 'secondary';
/**
* Legacy alias for `variant`. ← without this line the row renders blank:
* the parser moves the tag and everything
* @deprecated Use `variant` instead. after it into a separate `tags` field
*/
priority?: 'primary' | 'secondary';
```
And **never write a prop `description` into the story's `argTypes`.** Those entries override docgen, so a description there shadows the JSDoc and drifts from it. Stories set `control` and `options` only; the source owns the words.
**If the component is a labelled form control, compose it inside `Field`** (`src/components/Field/Field.tsx`) rather than re-implementing the scaffolding. Field owns the label and its `htmlFor`, the required marker, the helper/error text, the generated ids, and the `aria-describedby` / `aria-invalid` wiring — several components each rolled their own before it existed, and two of them silently diverged (Dropdown announced neither its helper text nor its error state). Pass `className` (your own root classes), `label`, `helperText`, `error`, `required`, `disabled`, `size` and `id`; render the control as children. For a set of controls with no single labelable element (a checkbox or radio group), add `group`, which renders the label as a `<span id>` for the group node to point `aria-labelledby` at — CheckboxGroup and RadioGroup are the live adopters. Field owns no layout, so your root class keeps its own flex/gap.
Two details worth knowing before you reach for it:
- `htmlFor` only associates with **labelable** elements (input, select, textarea, button…). If your control is a composite built from a `div` — a `role="combobox"` trigger, say — point `aria-labelledby` at `` `${id}-label` `` instead, which is the id Field puts on its label.
- Content that belongs *opposite* the helper text (a character counter, a unit) goes in Field's `aside` prop, not a hand-rolled footer.
### Accessibility — what axe will not catch
The story tests run axe in `'error'` mode, but axe only catches roughly a third of WCAG issues, and it passes every rule below while a screen-reader user hears the wrong thing. Write each one in from the start:
- **Everything the eye is told, the ear is told.** When a visual mark is `aria-hidden` (a badge, a check, a status dot, an icon standing for a state), its meaning needs a text equivalent: a visually hidden span or a suffix on the owning control's label. Cover **every shape the prop accepts**. A `badge?: number | true` that announces the count but not the dot is the classic miss, and so is a step indicator whose "complete" check is hidden with nothing said in its place.
- **Name every landmark and every named-content region.** A `<nav>`, an `<aside>`, a labelled list of steps, a figure: each gets an accessible name, because a page with two unnamed navs gives a screen-reader user two identical entries. Prefer `aria-labelledby` pointing at the component's own visible heading (ids from `useId()`, never hard-coded, so two instances cannot collide) and `aria-describedby` at its subtitle. When there is no visible heading, set a sensible default `aria-label`.
- **Every default label is overridable, by the consumer's own attribute.** Write the default *before* `{...rest}` (`<nav aria-label="Main" {...rest}>`), so a passed `aria-label` wins with no extra prop. A string the component writes itself and the consumer cannot reach (an overflow button's "More", a close button's "Close") needs a prop of its own, named for the control (`navigationLabel`, `overflowLabel`): hard-coded English cannot be localised.
- **A shape the type accepts is never silently dropped.** When one prop type feeds two renderings (inline actions and an overflow menu, say), check that every field survives both. If a sub-surface cannot honour a field (a menu row that cannot be a link, a row that draws no badge), narrow that surface's type with `Omit<>` so the compiler says so. A JSDoc "ignored in the menu" note still ships a trap.
- **Hidden copies leave the accessibility tree.** A duplicate kept for animation (a collapsing title, a cross-fade) is `aria-hidden` while it is the invisible one, and a region collapsed or minimised out of view is `inert` so its controls leave the tab order too. Exactly one copy of any text is announced at a time.
- **Native semantics first, ARIA second, and never ARIA for coverage.** Nothing counts ARIA: `/foundations/accessibility` dropped its ARIA census because it read a correct native element as a shortfall. A `<table>`, `<blockquote>` or heading needs nothing added, and an `aria-label` on a plain `div` or `span` is prohibited by ARIA 1.2. If the component is correct with no ARIA, leave it with none.
### File 2: `ComponentName.css`
- CSS custom properties exclusively — **no hardcoded hex colours**, no raw `rgb()`/`rgba()`
- **The action colour is reserved, and the build holds the line.** Outside a focus `outline`, a component that references `--color-action-primary-bg` fails `scripts/validate-action-colour.mjs` until it is added to that script's `SANCTIONED` table with the role the use plays (design.md lists the roles). Decide the role first; if none fits, the use is decoration and wants a different token
- Icons are sized by setting `--icon-size` on the icon element to a step from the `--icon-size-*` scale (`src/tokens/registry.json` is the list) — never `font-size` or raw pixel dimensions on an icon
- Transitions/animations compose `--motion-duration-*` with `--motion-ease-*` from `tokens-motion.css` — never literal timings like `0.2s ease` (new code must use the motion tokens from the start)
- The same rule has a TypeScript half: a JS timer (a hover delay, an auto-dismiss, a settle timeout) takes its default from the shared constants in `src/tokens/motion.ts` — import the matching constant, or add a named one there deliberately, never a literal ms value in the component. Schedule timings are deliberately left alone by the reduced-motion guard; a constant that paces an animation must be guarded by its component in JS instead (design.md's Motion section records the contract and the exceptions)
- All other spacing, padding, gap, border-radius, font sizes must use semantic tokens from `tokens-light.css` / `tokens-typography.css`
- **A control that can share a row takes the row height.** A field, a button, a picker, a segmented set or a filter chip sets `height: var(--control-height-default)` (and `--control-height-compact` at `size="compact"`) with `box-sizing: border-box` and horizontal padding only, so it lines up with every control beside it at both sizes; design.md's Control Height Scale owns the roster and the exceptions (a tag, a keyboard hint). Never a literal 40px or 32px
- A z-index of 10 or more is a stacking layer: `var(--layer-*)` for the role (`--layer-dropdown` for an anchored menu, `--layer-floating` for a hover card, `--layer-modal` for a dialog; `tokens-light.css` lists them lowest first), or `calc()` off one when the surface sits exactly a step from it. Order inside the component stays a plain integer under 10; a larger local number takes `ds-allow(stacking)` directly above it (`scripts/validate-stacking.mjs` enforces it). A tooltip takes no layer of its own choosing: it calls `useTopLayer` from `src/behaviors/useTopLayer.ts`, which paints it above every layer, and the same validator fails a tooltip that does not
- A viewport `@media` query switches at a width from `src/tokens/breakpoints.ts` (`max-width: N`, or `min-width: N + 1` for the same step seen from above), and a JS query is built from that module's `mediaBelow` / `mediaAbove`, never a literal string. A content-driven width outside the set takes a `ds-allow(breakpoint)` directive directly above the query; `scripts/validate-breakpoints.mjs` fails the build otherwise. Prefer a container query, or measuring the component's own width, when the layout depends on where the component sits rather than on the viewport
- **If a value genuinely cannot use a token** (colour-space physics, a glyph inside a control's geometry, decorative timing tuned by eye), sanction it *at the site*: a `/* ds-allow(<category>): <reason> */` line inside a comment at the value (`ds-allow-file(...)` in the header for file-wide cases), plus a sentence in the component's design.md spec. The category set is the closed list in `scripts/validate-css-directives.mjs` (grammar is build-enforced). **Never add an exception to the token-audit skill** — it reads the directives; it maintains no list
- **The surface is the family's, not the component's.** Before writing a fill, border, radius, hover, selected or disabled rule, find the component's family in design.md's Components intro ("Surfaces by family") and take its fill, border, radius, hover, selected and disabled tokens from there, never from memory or from this page. A fictional `GadgetRow` that invents a fourth radius for "a row like the others" is the drift the `component-consistency-audit` skill exists to find; a departure the design calls for is a sentence in the component's design.md spec, written in the same change
- **A card a chat reply carries joins the chat card family.** Add its root class to the `:where()` list in `src/behaviors/chat-card.css`, import that file from the component module, and read its `--ds-chat-card-*` properties for the fill, border, corner, inset, edge, rhythm and one-row height instead of the tokens behind them; design.md's "The chat cards are one family" is the rule set it starts from. `scripts/validate-chat-cards.mjs` fails an ai component that draws a card shell of its own outside the list, and a member that restates a value the recipe owns.
- Section comments grouping related rules (e.g. `/* Base */`, `/* Variants */`, `/* States */`)
- **No dark-theme overrides and no `prefers-color-scheme` queries** — dark mode comes entirely from the semantic tokens (every token has a light and dark value). The rare sanctioned exception is recorded in the component's own `design.md` spec, never assumed
### File 3: `ComponentName.stories.tsx`
- Import: `import type { Meta, StoryObj } from '@storybook/react-vite'`
- Meta uses `satisfies Meta<typeof ComponentName>`, and sets `component: ComponentName`. The props-contract story finds the component through that field and borrows its props from the meta's `args` or the first story with `args`, so a component the meta does not name, or whose stories supply its required props only through a `render` function, fails as uncovered. When the stories cannot carry the props as args, add a `FIXTURES` entry in `src/stories/Contract.stories.tsx`
- `title: 'Components/ComponentName'`
- `tags: ['autodocs']`
- `parameters: { layout: 'centered' }` for compact components; `'padded'` for full-width ones (cards, layouts, nav)
- One named `StoryObj` export per meaningful variant or state combination
- Story names are descriptive (e.g. `Default`, `WithIcon`, `Disabled`, `Small`)
- A component whose contract is *behavior* (dismissal, focus, keyboard) gets a `play` function asserting it — `Dialog.stories.tsx` is the reference (stories run as tests, so the assertion is CI). End the play in the component's resting state so snapshots stay stable
4. **Pass design QA before registering.** Invoke the `design-qa` skill on the new component (its "Inside new-component" section defines gate mode): render every story in both themes, magnify the joins, corners, and states, line the component up against its nearest rendered siblings, and loop on fixes until a full pass yields no defect or polish findings. One mechanical note for this pass: `npm run storybook` cannot cold-start here, because its `prestorybook` hook runs the `validate-registry` chain, which correctly fails on the still-unregistered folder — use a Storybook that is already running, or start the `storybook-raw` configuration in `.claude/launch.json` (port 6016; it invokes Storybook directly, bypassing the `prestorybook` hook, and exists for exactly this pre-registration case). Fold any open direction calls into the hand-off in step 7. Rendering correctly is necessary and nowhere near sufficient — this step is where "the buttons aren't good enough" gets caught without anyone having to say it.
The same gate has an accessibility half, run on the rendered stories rather than the source. For every story, list what assistive tech is actually given: each landmark with its name, each interactive element with its accessible name and state (`aria-current`, `aria-expanded`), and any text inside `aria-hidden` or `inert` regions. A short script in the browser that queries `nav, aside, [role], a, button` and reads `aria-label`, `aria-labelledby`, `innerText` and `closest('[inert]')` produces that list. Check it against the Accessibility rules under File 1. Every state a story shows must appear in the list, and no text may appear twice. Then run the `accessibility-audit` skill's structural audit (its step 3, the **[manual]** items) on the component. Fix and re-list until both are clean. A behaviour the list proves (a badge announced, a hidden copy muted) is worth a `play` assertion, so that it stays proven in CI.
The gate's last half is the API and the family: run the `api-consistency` skill scoped to the new component and its nearest family (its shared folder or registry category), then the `component-consistency-audit` skill scoped the same way. A new prop that names a size, a colour or a callback differently from its siblings, or a surface that takes a fill, radius or hover its family does not, is cheapest to fix before anything consumes it. Fix every finding before registering.
5. **Register the component** in `src/components/registry.json` — add an **object** to the `components` array (alphabetical by `name`):
```json
{ "name": "MyComponent", "label": "My component", "slug": "my-component",
"description": "One line, ending in a full stop, under 160 characters.",
"category": "forms", "client": true }
```
The `description` is shipped copy (it feeds the sidebar, page metadata, and README): a verbless one-line fragment ending in a full stop, written per the register table in `content-design.md`. `category` must be one of the registry's `categories` — add a new one deliberately rather than inventing one per component. The `label` has a build-enforced length ceiling from an unexpected direction: `scripts/validate-page-summaries.mjs` derives a "How do I use <label>?" chip for every component's TLDR panel and holds it to the suggestion budget, so a long label fails the build there — keep labels short and the failure never appears. `client` must match whether the file declares `'use client'`; the validator compares them and fails on a mismatch, so the registry can never document a component as server-renderable when it isn't. A component that imports recharts also sets `"recharts": true` — the flag records which barrel it ships from, and the doc block in `src/components/registry.ts` owns the field list. **The sidebar nav entry, sitemap, breadcrumbs, mega-nav and the page's title and description all derive from this entry** — never hand-add a nav link. This file is the single source of truth for the component count; `scripts/validate-component-registry.mjs` runs before every build and **fails if the folder is unregistered**. Confirm the registration itself with `node scripts/validate-component-registry.mjs` — the full `npm run validate-registry` chain stays red by construction until the website page and `design.md` section exist (`scripts/validate-website-surfaces.mjs` requires both for every registered component), so the full chain runs at the end of step 7, not here. Its leading generator scripts (the authoritative list is the `validate-registry` entry in the root `package.json`) regenerate every derived surface a new component touches: the README's component count/list, the package barrels (`src/index.ts`, or `src/charts.ts` if the component imports recharts — the generator routes by detecting the import and **fails when the registry entry's `recharts` flag disagrees with what the module actually imports**, so set the flag when the import is real; never hand-edit either barrel), the component prop API (`website/src/data/component-api.generated.ts`), the component's markdown twin (`website/public/components/<slug>.md`), its JSON schema and the schema index (`website/public/schema/components/<slug>.json`, `website/public/schema/components.json`; `scripts/validate-json-schemas.mjs` then proves the new file against the meta-schema), the shadcn registry item (`website/public/r/<slug>.json`, plus its `registry.json` index), the consumer agent skills' component catalogue (`references/components.md` in the library skill's folder under `website/public/skill/` — `AGENT_SKILLS` in `scripts/brand.mjs` owns the folder names), and the site chat's corpus (`website/src/data/site-corpus.generated.ts` — it carries the component site map and every registry description), and the dependency graph's data (`website/src/data/dependency-graph.generated.ts` — the new component becomes a node, with its token uses and imports as edges), and the accessibility coverage figures (`website/src/data/a11y-coverage.generated.ts` — its story total and storied-component share move with the new component, and an overlay joins its rosters by import). **Commit every file those generators rewrote in the same push as the registration** — alongside it, or as a separate `chore(generated):` commit when the regeneration would bury the real change (both are sanctioned; the `ship` skill's step 2 owns the rule). CI's drift guard diffs the tree after the generators run, so a regenerated file left uncommitted fails the build. The component is then automatically part of the published `rift-ds` API — both the barrel and the `./components/*` deep-import subpath. **That wildcard maps `.tsx` only.** A sibling `.ts` module in the same folder (a hook, a data table, a shader source) reaches consumers through the barrel if the `.tsx` re-exports it (a value re-export from a `.tsx` trips the `react-refresh/only-export-components` lint rule, so it sits under an `eslint-disable` for that rule with the reason written beside it, as `ShaderField.tsx` does; `export type` needs none), but has no deep-import path of its own until you add one — and a subpath has three homes, not one: `SUBPATHS` in `scripts/package-manifest.mjs` (the authoritative record of the ones that exist), the root `package.json` `exports` field it is mirrored into by hand, and the dist entry list in `vite.lib.config.ts` (the `new-token` skill's step 5 is the same recipe). `scripts/validate-package-exports.mjs` fails the build if any of the three disagree, so a subpath cannot reach npm with no built module behind it. Decide deliberately: a module that is genuinely internal wants no subpath, and one you document as an escape hatch needs one. `scripts/validate-package-exports.mjs` checks the answer either way — an unexported `.ts` sibling must be re-exported by the component or named in that script's `INTERNAL_MODULES` list with a reason. A sibling `.tsx` is the other way round: the `./components/*` wildcard and the barrel publish it the moment it exists, so a part that is not public API (a grid two pickers share, a toggle one component owns) goes in `INTERNAL_PARTS` in `scripts/package-manifest.mjs` with its reason, which closes its deep path with a `null` export and keeps it out of the barrel; copy `sourceExports()` into the root `package.json` after adding one. If the component needs a new runtime dependency, stop and ask: the package's runtime deps are the react peer and the optional peers (`peerDependenciesMeta` in the root `package.json` is the list: recharts for the charts, and the chat widget's own for its subpaths), and adding one is a packaging decision.
One exception: a **docs-only helper** (a component that exists purely for the website/Storybook docs — the registry's `docOnlyHelpers` array is the authoritative list; note the public `Swatch` component is *not* one of them) goes in the registry's `docOnlyHelpers` array instead of `components` — it gets no showcase page, no barrel export, and doesn't count. Putting it in `components` fails the build for a missing website page.
6. **Document it in `design.md`** — add a short component spec section (class name, tokens used, key behaviours), following the format of the existing component sections.
7. **Hand off the website work.** A component is not done until it has a showcase page: a `page.tsx`, a `page.module.css`, a `layout.tsx` containing exactly `export const metadata = componentPageMetadata("<slug>");`, and a preview entry under its slug in `website/src/components/ComponentPreviews/ComponentPreviews.tsx`. The preview is the only surface still hand-maintained — each entry holds a bespoke live miniature. Everything else (the index's category section, sidebar accordion, sitemap, breadcrumbs, title, description) derives from the registry entry. All of it is build-enforced by `scripts/validate-website-surfaces.mjs` and `scripts/validate-page-titles.mjs`. Ask the owner: "Should I add the website documentation page now? (invokes the `component-doc-page` skill)" — and whoever does that work must complete every registration above, then run `npm run validate-registry`: with the page and the `design.md` section in place, this is the first point in the flow where the whole chain can go green.
Two surfaces are curated and not build-enforced: the /components featured row (`website/src/components/FeaturedComponents/FeaturedComponents.tsx`, a few component pages picked by hand, never a list to add to) and the playground's Components view, whose full list is staged from the per-category section files in `website/src/app/playground/sections/` and whose collage is `website/src/app/playground/views/CollageView.tsx`. Decide with the owner whether the new component earns a place there; nothing fails if it is left out, so the decision has to be made on purpose.
new-tokenWalks a new design token through every surface it must reach: the token file its category lives in (both theme files for colour), the generated registry, the foundations pages, the Storybook token docs, and design.md. Covers the extra packaging steps a TypeScript-side token needs, and the validators that hold each home in sync.
new-token.mdmd---
name: new-token
description: Add a new design token (or token family) to the system, with every home it needs to reach. Use when asked to add a token, a new colour or motion token, or a token category.
---
# new-token
Add a design token so it exists in every home the system requires — a token that exists only in CSS is incomplete.
## When invoked
Use this skill when asked to add or change a design token or token family — phrases like "add a token", "add a `--color-…` token", "new motion duration", "extend the chart ramp".
## Instructions
CLAUDE.md's **How to Add a New Token** section is the authoritative checklist — read it first and follow its numbered steps. This skill adds the operational detail the checklist compresses.
1. **Decide the tier.** A primitive (`--primitive-*` in `tokens-primitives.css`) is a raw value; a semantic token is a role. New roles reference existing primitives via `var()` — add a primitive first only when no suitable one exists. Semantic colour tokens must chain to a primitive or another `--color-*` token; `scripts/validate-token-references.mjs` fails the build on a literal.
2. **Put it in the file its category lives in** — `SEMANTIC_FILES` in `scripts/generate-token-registry.mjs` is the authoritative list of what the registry reads. Colour: both `src/tokens/tokens-light.css` and `src/tokens/tokens-dark.css`, always; the registry validator enforces light/dark parity for colour, so identical values in both is fine and a missing side is not. Shadow: both files too — the elevation tokens carry different values per theme — but parity is build-enforced for **colour only**, so a shadow token added to the light file alone ships silently unchanged in dark mode; check `tokens-dark.css` yourself. Spacing, radius, border, icon size, control height: `tokens-light.css` alone. A control-height step is also a row of `CONTROL_HEIGHT_STEPS` in `website/src/lib/theme/theme-overrides.ts`, which the density lever scales and `scripts/validate-theme-mirrors.mjs` holds to the primitives in both directions. Typography: `tokens-typography.css`. Motion: `tokens-motion.css`. Stacking (`--layer-*`): `tokens-light.css`, in its ladder lowest to highest, with a trailing comment saying what sits on the layer (`/foundations/layout` prints it; `scripts/validate-stacking.mjs` requires it and the order). Nothing but colour and shadow is theme-split; a typography or spacing token copied into `tokens-dark.css` registers from the light file anyway and leaves a dead duplicate no validator reports. A colour token that carries a **state** a reader must see (a checked or selected fill, a count, anything whose meaning is its fill) also needs a value in `src/tokens/tokens-forced-colors.css`, a system colour (`Highlight`, `Mark`…): high contrast modes flatten every other fill to the page, and design.md's Forced colors section owns which roles are re-pointed. The page-level check in `scripts/validate-website-a11y.mjs` (`FORCED_CHECKS`) proves the ones on the table bench; a new state outside it needs a row there.
3. **Check the prefix.** If the token starts a *new* prefix, generation fails until the prefix is added to `CATEGORY_PREFIXES` in `scripts/generate-token-registry.mjs` — deliberately, so a new category gets a display home at the same time. An existing prefix (`--color-`, `--motion-`, `--radius-`, …) needs nothing here; the registry regenerates on every build.
4. **Give it its documentation homes** (the build enforces the first, a drift audit catches the rest):
- Colour tokens need a swatch on `/foundations/colour-mode` — build-enforced in both directions by `scripts/validate-website-surfaces.mjs`. The values those pages print beside a swatch are build-checked too (`scripts/validate-foundation-mirrors.mjs`), so a caption must state what the token actually resolves to. Other categories go on their matching foundations page (CLAUDE.md's checklist maps category → page).
- The matching Storybook doc: `src/stories/Tokens.stories.tsx`, whose story exports are the list (spacing's story carries the control heights too); `src/stories/Typography.stories.tsx` for a type style (add it to the matching tier story, `DisplayStyles`, `HeadingStyles` or `BodyTextStyles`, and to `AllStyles`). A new type style also needs a `TYPE_STYLES` row in `website/src/lib/theme/theme-overrides.ts` (style, tier, weight, tracking): `scripts/validate-theme-mirrors.mjs` holds the table to the CSS in both directions and requires the display and heading tiers to hold one weight each, so decide the tier before picking the weight; a body-tier row is shifted by the body weight lever along with the paragraph, so its shipped weight is its distance from the paragraph's, not a fixed value.
- A sentence in `design.md` recording the role and how it relates to its neighbours (never a colour value: design.md is theme-agnostic), in the section that owns the token's subject. If that section states the family's size in words ("a four-step size scale"), `scripts/validate-design-counts.mjs` holds the words to the token files, so a new step fails the build until the sentence moves with it.
5. **A TypeScript-side token** (a shared constant, like the JS timing constants in `src/tokens/motion.ts`) has packaging steps CSS tokens do not:
- A subpath entry in `SUBPATHS` in `scripts/package-manifest.mjs`, mirrored by hand into the root `package.json` `exports` (`scripts/validate-package-exports.mjs` fails the build if they drift), plus a matching entry in `vite.lib.config.ts`.
- Or, if it is internal-only, an entry in `INTERNAL_MODULES` in `scripts/validate-package-exports.mjs` with the reason.
- If component code mirrors the token's value outside CSS (a `var()` fallback, a serialized config), route the mirror through a validator so it cannot drift — the chart palette guard in `scripts/validate-token-references.mjs` is the pattern to copy.
6. **Verify**: `npm run validate-registry` must pass end to end (it regenerates `src/tokens/registry.json`, `src/tokens/values.json` with the DTCG files under `website/public/schema/tokens/` (`scripts/validate-token-values.mjs` holds them; CLAUDE.md's How to Add a New Token step 5 says what fails when a token has no family or its value no mapper) and the other derived surfaces that carry token names or values, the consumer skills' token reference among them, and re-checks every home; the generator scripts at the front of the `validate-registry` entry in the root `package.json` are the authoritative list, so commit every file the chain rewrites), then `npm run build` for the type-check. Displayed counts update themselves — never hardcode one.
## Guardrails
- Never hand-edit `src/tokens/registry.json`, `src/tokens/values.json` or `website/public/schema/tokens/` — they regenerate from the CSS
- Never skip a tier: components reference semantic tokens, semantic tokens reference primitives
- A token with no consumer is not a token — if nothing will reference it, don't add it
new-presetWalks a new theme preset from a brand colour to a complete shipped look: every lever declared in the preset registry, the accent sextet curated, the stylesheet generated into the package, and the completeness gate passed, including the contrast check on every action fill and what sits on it, resting, hover and pressed, in both themes. Ends with the theme live behind one data-brand attribute, in the home page's selector and the playground's picker with no extra wiring, and with a specimen page of its own beside a downloadable DESIGN.md.
new-preset.mdmd---
name: new-preset
description: Add a complete theme preset to the system — every lever declared, the generated stylesheet shipped, the AA gate passed. Use when asked to add a theme, a preset, or a new site look.
---
# new-preset
Add a theme preset so one `data-brand` attribute delivers the complete look — site-wide and in the shipped package.
## When invoked
Use this skill when asked to add a theme, a preset, or a new look — phrases like "add a forest theme", "new preset", "give the site a corporate look".
## The governing idea
A preset is a **complete theme, not a tint**: every lever holds a saved position, and `scripts/validate-theme-presets.mjs` is the completeness gate that makes "one attribute, full theme" a guarantee rather than a hope. `THEME_PRESETS` in `website/src/lib/theme/presets.ts` is the single source; the generated stylesheets in `src/tokens/presets/` compile from `presetOverrides` in that file, and the playground's live preview composes the same lever functions (`website/src/lib/theme/theme-overrides.ts`) in a memo of its own that mirrors it. Nothing holds the two compositions to each other, so a change to how the levers combine has to be made in both. The playground's theme export is a third consumer of the composer, and `scripts/validate-theme-export.mjs` holds it to `presetOverrides` for every preset in both themes, so a new preset is checked there too.
## Instructions
### 1. Declare the preset
Add an entry to `THEME_PRESETS` in `website/src/lib/theme/presets.ts`. The `ThemePreset` type is the checklist — every field it requires is a decision, not a default to skip:
- **`brand`** (and `brandDark` when one key cannot serve both themes): the action colour. The lever derives the full action family from it, so pick the key with the AA gate in mind (step 3).
- **Neutral tint** (`tintOn`/`tintSeed`/`tintStrength`): whether the greys lean toward the brand. The strength is not applied evenly: light mode's surface steps take only a share of it (`LIGHT_SURFACE_TINT_RATIO` in `website/src/lib/theme/theme-overrides.ts` owns the rule and the reason), so judge the strength in both themes, never from light mode alone.
- **Shape** (`radiusScale`, `pill`): the corner language.
- **The feel levers** (`density`, `typeScale`, `motionScale`, `elevation`): 100/100/100/default is a legitimate position, but state it deliberately. `motionScale` is stored as a percentage of the shipped durations, so a lower number is a *faster* theme; the playground slider and /foundations/themes show it as speed through `motionSpeedPercent`, so a stored 80 reads 120% there.
- **Type weight and tracking** (`displayWeight`, `headingWeight`, `bodyWeight`, `tracking`): one weight per tier (the body lever shifts its tier rather than setting it, so emphasis and overline keep their distance above the paragraph) and a letter-spacing offset added to every style's shipped tracking. The `SHIPPED_*` constants in `website/src/lib/theme/theme-overrides.ts` are the base look's positions. A single style that should differ from its tier goes in `extraOverrides`. Keep all three weights inside `TYPE_WEIGHT_RANGE` (same file) and inside the axis range `FAMILIES` in `scripts/sync-preset-fonts.mjs` syncs for the face: the playground previews from a wider Google request than the package ships, so a weight past the synced range previews correctly and ships clamped. Widen the `FAMILIES` entry and rerun the sync first.
- **Icons** (`iconWeight`, `iconFill`): the icon font's stroke weight and fill, written to its axis hooks. A heavy text weight usually wants a heavier icon beside it.
- **Tones** (`logoTone`, `logoEndTone`, `displayTone`, `headingTone`, `textTone`): the colours set apart from the action colour. `"action"`/`"matched"`/`"ink"` keep a role on its usual colour, which is the right position for most themes; a family name tones it, and the composer picks the step per mode that holds the role's contrast (`tierToneOverrides` in `website/src/lib/theme/theme-overrides.ts` owns the rule). A role the extras name instead pins its step over the lever, as Signal's display tier does; the `extraOverrides` JSDoc on `ThemePreset` in `website/src/lib/theme/presets.ts` owns that rule. The completeness gate checks the toned roles against the page and the card fill in both themes.
- **Faces** (`fontLabel`, optionally `headingFontLabel`): the type pairing; the picker previews these, so labels must match `FONT_OPTIONS`/`HEADING_FONT_OPTIONS` entries in `website/src/lib/theme/theme-overrides.ts` byte-exactly (some labels carry a parenthetical that is part of the label, `(serif)` and `(rounded)` today; copy the label from the array).
- **`accents`**: the ambient sextet. These feed chart series 2–7 unless `extraOverrides` re-points the series (a look with grey accents can still chart in colour), and are what the background blobs follow by default, so curate them as one palette around the key. `SHIPPED_ACCENTS` in `website/src/lib/theme/theme-overrides.ts` shows the default set's shape.
- **`ambient`** (optional): six hexes for the background blobs alone, in `--color-ambient-1` to `-6` order, for a look whose field should sit closer to its key colour than its chart palette can. Leave it out and each blob follows its accent. The list is one set for both themes; a blob that needs a different colour in dark mode goes in `extraOverridesDark` (Smoke is the precedent). design.md's Ambient section owns the token roles.
- **`ambientField`** (optional): `"fade"` draws the ambient field from the action colour's fade; `presetOverrides` in `presets.ts` owns what it sets.
- **`aiGradient`** (optional): `"tonal"` draws all three AI gradient stops from the action key's ramp; `"fade"` is the action colour fading toward the page, the rule for an imported theme whose source states no gradient; leave it out to keep the base look's red and blue stops, or leave it out and state all three stops in `extraOverrides` for a gradient that crosses two tones. design.md's AI section owns when each suits a key colour.
- **`advanced`** ramp rebases and `extraOverrides` where the derived look needs correcting — the existing presets are the worked examples of when each is warranted (a lifted label for contrast, a re-keyed ramp for harmony, one style stepped off its tier's weight, such as a sub-display a step lighter than the display tier) — plus `extraOverridesDark` for the roles that cannot hold one value across both themes (the usual repair when the dark cell fails the AA gate in step 3).
**An override that moves a fill moves the whole state set.** Inverting a button (an ink fill under a coloured label, say) means overriding the resting, hover and pressed fills *and* the labels that sit on them, `--color-action-primary-text` and `--color-action-primary-text-active`. Active icons alias the active label, so they follow without a declaration of their own. Hover and pressed fills should step **away from their label**: brighter under a dark label, deeper under a light one. That is the rule the lever's derivation follows, and stepping toward the label is how a pressed state drops below AA.
**Write its one-line description.** Add the id to `THEME_TAGLINES` in the same file: one sentence on the theme's character (its mood, then the two or three choices that make it, such as the key colour, the faces or the corners), shown under the theme's name on its specimen page. It is shipped copy, so `content-design.md` applies; `scripts/validate-theme-presets.mjs` fails the build on a theme without one, on an em dash, or on more than one sentence.
Existing entries in the file are the reference implementations; read two before writing one.
### 2. Decide where it appears
Add the id to `HUE_ORDER` (same file) in its curated position — the array's own comment owns the ordering rule, so read it rather than guessing where the entry belongs. `THEME_SELECTOR_ORDER` is derived from it (the served theme first, then the rest in hue order), and every theme-picking surface walks that list — the home hero's tile row and the header's theme switcher, the playground's preset picker, the theme gallery, get-started's preset list, Storybook's Theme toolbar — so one edit places it everywhere; there is nothing to wire. The theme's specimen page, `/foundations/themes/<id>`, is generated from the same list (`generateStaticParams` in `website/src/app/foundations/themes/[id]/page.tsx`), and the gallery card links to it.
**Making it the served theme** (the look every visitor gets before choosing one) is a separate decision for the owner. Its one home is `SERVED_THEME_ID` in `website/src/lib/theme/brand.ts`: change it there and never restate the id elsewhere, since the root layout and Storybook's preview both read the constant. The selector order leads with the served theme by derivation, so there is no second edit; then run `npm run verify`.
### 3. Generate, gate, verify
```bash
node scripts/generate-preset-stylesheets.mjs
node scripts/validate-preset-stylesheets.mjs
node scripts/validate-theme-presets.mjs
node scripts/validate-theme-export.mjs
node scripts/generate-theme-design-md.mjs
node scripts/validate-theme-design-md.mjs
```
The generator writes the preset's `html[data-brand]` stylesheet and refreshes the `presets.css` aggregate; they ship in the npm package. Downstream generators re-embed the preset CSS too (the shadcn registry's base item among them — the `validate-registry` entry in the root `package.json` is the authoritative list), so commit every file the chain regenerates, not just the two named here. The completeness gate then holds every override to a real token, primitive or icon axis hook (the script's doc block owns the rule), requires the action family and all six accents, and checks **every action fill against what components draw on it, in every state and both themes**: labels at WCAG AA 4.5:1, icons and strokes at 3:1. `ACTION_PAIRINGS` in the script is the authoritative table, and each failure names the fill, the foreground, the ratio and the component that draws it. A failing pairing means the key or an override needs to move (deepen or lighten the key, step a hover or pressed fill away from its label, or lift the label through `extraOverrides`) — pinning a gap in `SANCTIONED_AA_GAPS` is deliberate acceptance with a written reason, never a shortcut, and needs the owner's sign-off.
A face the presets have never shipped needs `node scripts/sync-preset-fonts.mjs` first — a deliberate by-hand fetch, never part of the build; its `FAMILIES` table is the download spec, and its doc block owns the contract (CLAUDE.md's Fonts entry points there). The stylesheet generator fails naming the script otherwise, and the downloaded woff2s commit with the preset.
The last two write and check the theme's own DESIGN.md (`website/public/themes/<id>/DESIGN.md`), the file the specimen page offers for download: the validator reads it back through the playground's importer and fails when it does not land on the preset's own levers. A difference the format cannot carry goes in that script's `SANCTIONED_DRIFT` table with the reason; commit the generated file with the preset.
Then `npm run verify` — the mirror guards and the site build exercise everything those scripts do not.
### 4. Prove it live
Pick the new theme from the home page's selector and walk a component page, a chart, and the chat surfaces in both light and dark and under each background in the header menu (Horizon reads the preset's `--color-ambient-*` roles on every page, so the ambient sextet has a second ground to hold up on), plus a doc page with an h2 over an h3 (or the playground's Type view) when the two tier weights are equal or close, since size is then carrying the hierarchy alone. Then open its specimen page, `/foundations/themes/<id>`, in both modes: the header, the description, the shader banner and every swatch, type row and control on it are drawn in the theme, so it is the quickest single look at the whole theme, and the hex values on its swatches are the ones the theme actually resolves to. The completeness gate proves resolution; only eyes prove the look holds together.
## Guardrails
- Never hand-edit `src/tokens/presets/*.css` — they are generated; the byte-compare validator rejects a hand edit anyway
- Never pin `SANCTIONED_AA_GAPS` to pass the gate without the owner's explicit decision
- A preset's portrait (swatch shape, faces) derives from its declaration — never restyle a picker row by hand
new-backgroundWalks a new shader scene or site background through every home it has: the scene in the library with its params, passes and colour roles, the playground's Shaders view template, the site's background registry row with its per-band tuning, the shaded chats that stand on it, the gesture list, and the visual pass that proves it on every page and in every theme.
new-background.mdmd---
name: new-background
description: Add a shader scene to the library, or a site background the header menu offers, with every surface that tracks them moved in the same change. Use when asked to add a scene, add a background, add a shader preset, or retune a background's look.
---
# new-background
Add a scene the library paints, or a background the site offers, so one row reaches every surface that shows it.
## When invoked
Use this skill when asked to add a shader scene, add a site background, add a shader preset to the playground, or retune one that exists. Three sizes of change share the ritual: a new library scene (the largest), a new site background on an existing scene, and a retune of a background's look.
## Where everything lives
Read the owners before changing anything; this skill holds no copy of their facts.
- **The scenes** are `src/components/ShaderScene/scenes.ts`: each declares its id, the colour roles it reads (the `--color-ambient-*` set, so a theme recolours it), its params with ranges, and its GLSL. The finishing passes are `src/components/ShaderScene/passes.ts`. design.md's ShaderScene spec owns the component's contract.
- **The playground's Shaders view** stages a scene from a template in `website/src/lib/shaders/templates.ts` (one entry per scene, only what differs from the defaults); the rail's card still is baked from the scene itself. The theme export and the DESIGN.md importer read the same templates.
- **The site's backgrounds** are `website/src/data/site-backgrounds.json` (CLAUDE.md's Registries table): one row per pick the header menu offers, with the scene and its tuning for each band BlurBackground paints. `website/src/lib/shaders/background-storage.ts` is the accessor and owns the tuning rationale; `scripts/validate-site-backgrounds.mjs` holds every row to the scenes, their params and passes, and the token registry. The header menu, the pre-paint script in the root layout and BlurBackground all read the list, so a row is the whole of adding a background.
- **The shaded chats** stand on a scene through one recipe, `website/src/components/StagedShader/ShadedChat.module.css`: the playground's chat card, the chat home template and the home reel's recording mode. A background change reaches them through the recipe, never through a copy.
- **Grips**: a slider or drag the change adds to the site needs a row in `scripts/gestures.json`; no validator notices a missing one.
## Instructions
1. **Decide the size.** A retune changes a row's `home` or `page` tuning in the registry, nothing else. A new background on an existing scene is one registry row plus step 4. A new scene starts at step 2.
2. **Add the scene** (new scene only). Declare it in `scenes.ts` beside the others with its colour roles, its params and their ranges, and its GLSL; the component picks it up from `SCENES`. Add its template to `templates.ts` so the Shaders view can stage it and the theme export can name it. Give it a story in `src/components/ShaderScene/ShaderScene.stories.tsx` and a stage on the ShaderScene page under `website/src/app/components/shader-scene/`, and add a line to the ShaderScene spec in design.md. The story tests and `validate-site-backgrounds.mjs` then hold its colour roles to the token registry.
3. **Add the background row** (new background). One row in `website/src/data/site-backgrounds.json`: id, a one-word label, a short description with no full stop, a Material Symbol icon, the scene, and a `home` and `page` tuning. Tune the two bands apart: the tall bands sit under a hero and the standard band under a page title, and a setting that reads well in one rarely reads well in the other. Write the reasons for the tuning in the accessor's doc block, since JSON carries no comments. Run `node scripts/validate-site-backgrounds.mjs`.
4. **Prove it live.** With the dev server up, pick the background from the header menu and walk the home page, a component page, a foundations page and `/templates/chat-home`, in light and dark, and in a theme with a real floating shadow and one without (the presets differ). Scroll an inner page: the band sinks with the scroll. Send a message on the chat home and in the playground's Chat view with the scene picked: the frost closes over it and the board still scrolls in the inset shell. Reload: the pick comes back before first paint. Then the `visual-review` skill for the full pass.
5. **Re-shoot what shows it.** The playground's template stills (`scripts/capture-template-thumbnails.mjs`) if a template's ground changed, and the README banner (`scripts/capture-readme-banner.mjs`) if the home page's first screen did. Both are by-hand refreshes with no generator, by design; CLAUDE.md's Screenshot surfaces entry owns that rule.
6. **Finish.** `npm run validate-registry`, then the `ship` skill, which also offers the visual review for a background change.
## Guardrails
- Never hand-edit a generated file: the shadcn registry entry for the chat, the schemas, the blueprints and the dependency graph regenerate from the sources you changed.
- Never put a colour value in a scene or a row: a scene reads token roles, and a row carries only params, so every theme recolours both.
- A new param or pass goes through the component, with a range, never through a site-only override; the validator rejects a key the scene does not declare.
- Examples in this skill are fictional; the registry and the scenes are the facts.
new-pageCreates a new website page by mirroring a live exemplar page's layout shell, then wires it into every place the site tracks pages: the section sidebar and breadcrumbs, with the sitemap deriving automatically. Prevents the common mistake of adding a route without registering it.
new-page.mdmd---
name: new-page
description: Add a new page to the website with the standard layout shell and correct navigation wiring. Use when asked to add or create a new page on the site.
---
# new-page
Add a new page to the website with the standard layout shell and correct navigation wiring.
## When invoked
Use this skill when asked to add or create a new page on the website — phrases like "add a page for [X]", "create a [section] page", "add [X] to the site".
For a **component documentation page**, use the `component-doc-page` skill instead — it covers the variant showcase and component-specific registrations. For a **template screen**, use the `new-template` skill — it owns the family's composition conventions and the full registration checklist.
## Instructions
1. **Gather requirements** if not already provided:
- Page URL path (e.g. `/foundations/motion`)
- Which section it belongs to — the sidebar arrays in `website/src/config/navigation.ts` are the authoritative list of sections (components, foundations, the docs cluster, templates; standalone pages like `/playground` live in no sidebar array and declare their metadata as a literal). **Components are the exception**: `componentsSidebarLinks` is derived from `src/components/registry.json` (merged with the chat widget's `src/chat/registry.json` through `website/src/data/component-pages.ts`), so a component page is registered by adding a registry entry, not by editing the array — use the `component-doc-page` skill for those. Component categories have no pages of their own; a registry category entry becomes an index section and a sidebar accordion automatically. **`/blueprints/<slug>` pages are another exception**: each reads its body from a synced spec copy in `website/public/`, so a new one starts by registering the root spec (see step 4).
- Page title, a short `subDisplay` tagline, and a 1–2 sentence description (for metadata and the intro block)
- Figma URL and Storybook path (optional) — for `PageLinks`
2. **Read the exemplars before writing anything.** The live pages are the source of truth for structure — mirror them rather than writing a shell from memory:
- `website/src/app/skills/page.tsx` — a standard content page (layout shell, sidebar wiring, header/intro blocks, entry animations)
- `website/src/app/components/button/page.tsx` + `page.module.css` + `layout.tsx` — the richest example, with `PageLinks` and per-page CSS
- `website/src/config/navigation.ts` — nav config (single source of truth for sidebars, mega menu, and breadcrumbs; the sitemap derives its routes from the sidebar configs)
3. **Create the directory** `website/src/app/<path>/` with three files, mirroring the exemplar:
### File 1: `page.tsx`
- Copy the exemplar's shell exactly — same components, same nesting, same class names. Don't improvise structure.
- Invariants the exemplar can't teach:
- `subDisplay` is a *tagline* inside the intro block (e.g. the Skills page's "Reusable AI instructions, tuned for this project") — not the section name; the breadcrumb already shows where you are
- All copy on the page (tagline, intro, body, metadata description) follows `content-design.md` — voice, register, and the words-to-avoid tables
- Sidebar links come from `getSidebarLinks(<section>SidebarLinks, "<your path>")`
- Include `PageLinks` only if Figma/Storybook URLs exist
- **The on-this-page rail is automatic, not part of the shell.** `SiteAnchorRail` in the root layout reads every page's h2 headings after navigation and floats the rail on the right viewport edge, so a new page gets one with no wiring — never mount an anchor nav of your own by default. The gates live in `website/src/config/anchor-nav.ts`, whose doc block owns the rules: an index or landing page registers in `ANCHOR_NAV_EXCLUDED_ROUTES`; a page whose items must be derived server-side (markdown-extracted sections, labels that are not headings) mounts `FloatingAnchorNav` itself and registers in `ANCHOR_NAV_SELF_MANAGED_ROUTES` so it never carries two — the blueprints are the exemplar; and an immersive or chromeless surface also registers in `ANCHOR_NAV_EXCLUDED_ROUTES` even though chromeless routes are skipped anyway, so the intent survives if its chrome status ever changes (`/playground` and `/graph` are the precedent). Demo or furniture h2s that are not sections of the page sit inside `data-anchor-ignore` (or an `<aside>`); the discovery rules live in the doc block of `website/src/components/FloatingAnchorNav/SiteAnchorRail.tsx`
- **Do not render a background.** `BlurBackground` is mounted once in the root layout and covers every route; adding it per page would build a second canvas and a second GL context on top of the first. `scripts/validate-single-background-mount.mjs` fails the build if you do
### File 2: `page.module.css`
- Copy the exemplar's layout classes; add page-specific classes as needed
- Semantic design tokens only — no hardcoded colours or magic values
- Page assembly is a spec, not taste: design.md's **Composition** section owns the rhythm ladder and the assembly rules (parent owns spacing, one level of chrome, dividers last, prose never width-capped) — read it before laying out sections; the exemplar shows the pattern, the spec owns the rules
- No `ch`-based `max-width` on prose — doc paragraphs run the full content column; the layout column is the only width constraint (build-enforced by `scripts/validate-page-titles.mjs`)
- Mobile type and section rhythm collapse at the **token layer** (display sizes and section-gap tokens step down system-wide — `design.md`'s responsive spec owns the breakpoint) — do not add per-page `@media` overrides for tokenized values; when a page genuinely needs a breakpoint, use a width from `src/tokens/breakpoints.ts` (or the site families in `website/src/config/breakpoints.ts`); a content-driven width outside the set needs `/* ds-allow(breakpoint): <reason> */` directly above the query, or `scripts/validate-breakpoints.mjs` fails the build
### File 3: `layout.tsx`
- Sidebar-registered pages export `metadata` via the shared helper: `export const metadata = pageMetadata("<your path>", "<one-line description>")` (import from `@/config/navigation`)
- Standalone pages (`/playground`, `/graph` — pages in no sidebar array) export a literal `Metadata` object instead, with an explicit `alternates.canonical` — see `website/src/app/playground/layout.tsx`
- **Deliberately hidden pages are the exception to both rules** — and "hidden" has two independent halves. *Hidden from crawlers*: sets `robots: { index: false, follow: false }`, skips the canonical *and* the sitemap entirely, and records why in a comment beside that metadata. *Hidden from visitors*: appears in no nav surface. A page can take one without the other — the labs pages take both (`/labs/marketing` is the live example). Either way the page records why — in its `layout.tsx` (`/labs/marketing` is the precedent), or in the `page.tsx` itself when the page needs no layout of its own. A full-viewport or immersive page that should also render none of the shared chrome (the layout-mounted footer, chat panel, and command palette) additionally adds its route to `CHROMELESS_ROUTES` in `website/src/config/chromeless.ts` — the set in that file is the authoritative list of what has taken the exception. A hidden page also needs an entry in `EXCLUDED_ROUTES` in `scripts/generate-site-corpus.mjs` with a written reason, or `validate-chat-coverage.mjs` fails the build for an uncovered route. Suppressing chrome does **not** make the background full-bleed: by default every page gets the fixed-height band that fades into the page floor (the doc block of `website/src/components/BlurBackground/BlurBackground.tsx` owns the modes and the number). An immersive page must also render `<FullBleedBackground />` (exported from `website/src/components/BlurBackground/BlurBackground.tsx`), a hidden marker that CSS in `globals.css` reads to drop the fade and fill the viewport. Skip it and the page is chrome-free but band-limited, which looks wrong with nothing to explain why. The immersive stages take a third mode: a page whose panels float on the dotted working-canvas ground renders `<DotBackground />` (`website/src/components/DotBackground/`) beside `<HiddenBackground />` (exported from the same BlurBackground module), so the ambient layer stands down instead of glowing through the dots — `/playground` and `/graph` are the precedent (that doc block owns the background-mode contract). Pick exactly one: the default band, the home page's extended band (`<ExtendedBackground />`, same marker mechanism), full-bleed ambient, or hidden-plus-dots
- Only **component** pages are build-enforced (`scripts/validate-page-titles.mjs` requires `componentPageMetadata("<slug>")` there); for everything else the helper is convention, not a gate — follow it anyway
- Default export wraps `{children}` in a fragment
4. **Register the page everywhere the site tracks pages:**
- **Sidebar** (only if the page belongs to a section): add `{ href, label }` to the section's array in `website/src/config/navigation.ts`, matching that array's existing order convention (foundations and docs are curated in reading order; templates keep the index link first, then one entry per screen; components are derived from the registry — see the exception in step 1). A `/templates/<slug>` screen also renders full-viewport, so it takes a `CHROMELESS_ROUTES` entry and a corpus `EXCLUDED_ROUTES` reason — both covered in File 3
- **Blueprint pages are a second exception**: a `/blueprints/<slug>` page reads its body from `website/public/<name>.md` at build time via `fs.readFileSync`, and that copy only exists once the root spec is added to `FILES` in `scripts/sync-blueprints.mjs` (the authoritative list; `scripts/validate-website-surfaces.mjs` holds it to the pages in both directions, so a synced file without a page, or a page without a synced file, fails the build)
- **Standalone pages** (no sidebar section): no array to edit — but the sitemap then knows nothing about the route, so add it as a top-level literal in `website/src/app/sitemap.ts` (the `/playground` entry is the pattern), and if the page should be reachable from a header section's mega panel, add it to that section's `mega.groups` in `buildNavSections()` (same file) and widen the section's `isActive` predicate to match the new path — the drawer, footer, palette, breadcrumbs and sitemap all derive from `getNavSections()`/`getSectionItems()`, so a new item goes into a section's groups, never into a second list. If it is a top-level site page a visitor should reach from anywhere, also add it to the `siteLinks` array in `website/src/components/SiteFooter/SiteFooter.tsx` — the one footer column that is hand-maintained (of the other four, three derive from the nav config and Elsewhere from `PROJECT_LINKS` in `website/src/config/social.ts`) — and to the command palette's hand-written `navigation` group in `website/src/components/SitePalette/SitePaletteMount.tsx`, or the page is unreachable from Cmd+K (a docs-cluster page instead rides `docsSidebarLinks` automatically there; no validator covers either). Crawler-hidden pages (see File 3) never enter the sitemap; whether they enter the nav is a separate call — a fully hidden page registers nowhere, while a crawler-hidden page a visitor should still find (no live page currently takes this shape) takes the normal nav entries plus `desktopOnly: true` on its `MegaItem` when the page needs a pointer and a wide viewport: that flag hides the link at and below the tablet step (`BREAKPOINT_TABLET_PX` in `src/tokens/breakpoints.ts`, the number's one home; design.md's Responsive → Breakpoints section owns what the step does) on the surfaces that render at every width (the footer and the command palette), and the drawer omits desktop-only pages outright (`MegaNav.tsx` filters `desktopOnly` items out of each section's `drawerItems`, so the flag alone is enough)
- **Mega panel** for sidebar-registered pages: automatic for membership. The Docs and Foundations panels derive their groups from `docsSidebarLinks` and `foundationsSidebarLinks` (the Docs panel's Learn group takes every docs page no other group or the showcase claims), and the mobile drawer derives from the panels, so never add a hand list of hrefs to a panel group. The page does need an icon: an entry in `DOC_ITEM_ICONS` or `FOUNDATION_ICONS` in `website/src/config/navigation.ts` (the docs table also feeds the command palette's rows). `scripts/validate-nav-panels.mjs` fails the build on a missing or orphaned icon
- **Sitemap** for sidebar-registered pages: automatic — it derives from the sidebar configs, so the entry above covers it
- **Breadcrumbs**: sub-pages of an existing section resolve automatically from the sidebar entry. Only if the page starts a *new* section: add a `breadcrumbSections` entry, and a new `NavSection` in `buildNavSections()` — with its `isActive` predicate, and `mega.groups` if the section should open a panel. A standalone immersive page that mounts `StageToolbar` (whose trail comes from `getBreadcrumbs`) resolves through neither path: add a literal branch for it in `getBreadcrumbs` in `website/src/config/navigation.ts` — `/playground` and `/graph` are the precedent — or the toolbar renders an empty trail and nothing fails the build
- **The site-chat corpus has a ceiling, and a visible page counts against it**: `scripts/generate-site-corpus.mjs` extracts every indexed page's prose automatically, so there is nothing to register — but a substantial page can push the corpus past `TOKEN_BUDGET` and fail the build with an over-budget error before any validator runs. That constant's own doc block owns the call between raising the budget and trimming a section; read it, and run `node scripts/generate-site-corpus.mjs --sizes` to see which section actually grew before deciding
- **The AI-summary panel**: every page with the shared chrome gets the chat FAB, and hovering it opens a per-page summary with prompt chips. Add the page's entry to `website/src/data/page-summaries.json` (`routes` map: a `title`, a super-concise `text`, and 1 chip for a simple page, 2 for a dense one — the validator's field grammar is authoritative: text length and full stop, every chip carrying `id`/`label`/`prompt`, labels inside the suggestion budget, no em dashes), plus a `starters` pool: more questions than the chat's welcome screen draws (the validator reads the number), each `{ id, label }`, written about **this** page so the chat opens on its subject rather than its section's, answerable from the page's own prose (read it back in the corpus), and naming roles rather than theme values (`validate-chat-starters.mjs` fails a colour family or the base typeface) — `scripts/validate-page-summaries.mjs` fails the build for a route without one, so this cannot be skipped silently. Component pages derive theirs from the component registry and need nothing here; a chromeless page has no FAB and is exempt automatically
- **A drag grip**: a page that mounts something a visitor can drag (a split pane, a compare handle, a pannable map) adds an entry to `scripts/gestures.json` with the setup that reaches the grip, so the gesture stress check drives it in `verify` and CI (CLAUDE.md's Gestures row owns the registry). No validator notices a missing entry
5. **Verify**: load the page in the browser and confirm the sidebar highlights it, the breadcrumb trail is correct, and both themes render properly.
new-templateBuilds a full-viewport template screen from the library alone, then registers it everywhere templates are tracked: the nav entry the index carousel and sitemap derive from, the chromeless list, and the chat-corpus exclusion. Owns the checklist so a screen never ships half-wired.
new-template.mdmd---
name: new-template
description: Build a new template screen — a complete product screen composed from the design system alone — and wire it into every surface that tracks templates. Use when asked to add, create, or build a template screen.
---
# new-template
Build a new template screen — a complete product screen composed from the design system alone — and register it everywhere the site tracks templates.
## When invoked
Use this skill when asked to add, create, or build a template screen — phrases like "add a [X] template", "build a template screen for [X]", "the templates need a [X]".
A template is a **website surface, not a library component**: it lives in `website/src`, imports the published package, and gets no `design.md` component spec, no Storybook story, and no registry entry in `src/components/registry.json`. If the work is really a new *component* the screen needs, do that first with the `new-component` skill.
## Instructions
### 1. Read the conventions before composing anything
- **design.md's Template screens section owns the family's composition rules** — read it in full; it exists so the next screen lands right without re-learning the review rounds that produced it.
- design.md's **Composition** section owns the page-assembly rules the family sits on (parent owns spacing, one level of chrome, a component that brings its own bordered chrome sits directly on the page).
- All demo data is **fictional** — an invented product with an invented name, never a real company's screen with the serial numbers filed off. content-design.md's template-screens register row owns the copy rules.
### 2. Read the exemplars
The live implementations under `website/src/components/templates/` are the source of truth for structure — pick the one nearest the new screen's shape and mirror it rather than improvising:
- A data view (table or board as the stage), or a dashboard of KPI tiles and charts over one — merchant sales, which is also the exemplar of a live assistant and of the entity switcher
- A timeline or scheduling stage — the roadmap planner or team calendar
- A stage behind a scrolling grid (a map under the first screen, no KPI band, a Describe interpreter that bills no model) — the analytics template
- An assistant home (the real chat as the page, a board of tiles under the composer) — the chat home
- An instrument with one subject in two projections (a map or globe stage, a stage-mounted toolbar, full-width content) — the relay console
- A phone-only app screen (platform chrome, drawn as a framed device on the dotted stage at wider widths) — the mobile dashboard
- A single centred form with no app shell — the sign-in screen
- A marketing page with no app shell (a hero, sections and a footer, a hamburger drawer at narrow widths and a bottom tab bar on a phone) — the landing page
Each implementation's doc comment records its own composition decisions; read the chosen exemplar's before writing.
### 3. Build the implementation
Create `website/src/components/templates/<Name>/` with `<Name>.tsx` + `<Name>.module.css`:
- Open the `.tsx` with a doc comment in the exemplars' shape: what the screen is, the fictional product, the composition calls made and which design.md rules they follow, and the corpus-exclusion pointer.
- **A screen with a sidebar lays out through `TemplateShell`** (`website/src/components/templates/TemplateShell/`): pass `sidebar` (the rail's own props, without layout, sub-nav mode, expanded state or footer), `chatOpen`, `frame`, `tabBar`, `assistant`, `contentClassName` and, for an instrument or board whose panes scroll themselves, `fill="viewport"`. The shell owns the rail and its footer switchers, the app shell mode the visitor picks, flush, floating or inset (stored by `website/src/lib/templateLayout.ts`; a template has no backdrop, which is a chat's alone), viewport sizing, the docked assistant's width reservation and the Mobile-preview swap; the template styles only its content column (its padding, gap and floating max width). The shell runs the rail in panel mode with the side panel closed, so give two or three nav items demo `children`. Wrap the top bar (search, then `AiButton` and the notifications `CircularButton`, both at the default size) in `StickyTopBar` from the shell's folder, with no `Divider` under it. design.md's App shells section owns the three modes and its Template screens section these conventions.
- **A screen without a sidebar sizes its shell from `--layout-viewport-height`** (`height:` or `min-height: calc(var(--layout-viewport-height, 100vh))`, whichever the screen's flow needs), never bare `100vh`: the templates index carousel renders pages in scaled same-origin iframes and pins that variable to give viewport-tall shells a fixed size.
- **The assistant panel**, three shapes, the mock the default: a screen that wants a mock chat uses the shared `TemplateAssistant` (`website/src/components/templates/TemplateAssistant/`), which owns the site chat's responsive modes (docked at the dock threshold, a modal overlay below it, a takeover at phone widths; design.md's Template screens rule 7). Inside `TemplateShell` the host passes `chatOpen` and the shell reserves the width, and the assistant reads the shell's layout (docked flat at the shell gap in the inset layout, with the site chat's widen grip); a screen outside it gates its own reservation on `@media (min-width: 1440px)`; a host that seats the panel inside a frame of its own passes `placement="contained"` and places it through `className` (the mobile dashboard hosts it as a full-screen sheet inside its phone; that prop is the assistant's own and unrelated to the site chat's Chat position menu, which a template does not offer — leave `placementEnabled` off on a hosted `ChatWidget`, design.md's ChatWidget spec owns the rule); and a screen whose subject **is** the chat hosts the package's real `ChatWidget` (`rift-ds/chat`) on the scripted demo transport (`createDemoTransport()` with `DEMO_RENDERERS` from `rift-ds/chat/demo`), inside its own `SiteChatProvider` (the chat home). Reach for the second only when the widget's own behaviour is the thing being shown — it brings the live component's state with it, so the screen has to own the open/closed handling too, and it leaves `context` unset so the staged assistant does not announce this site's page name. When the chat is the page itself rather than a panel beside one (the chat home), seat it as the whole viewport: `closeEnabled={false}` and `fullscreenEnabled={false}`, `setOpen(true)` on mount, `phone` and `compact` from `useTakeoverViewport()`, the built-in history rail with `threads` (seeded through the provider's `threads` option), and the screen's board in the `widgets` slot (design.md's ChatWidget spec owns the slot and the exception). The third shape, a live assistant over the screen's own data, passes a real `ChatWidget` as `TemplateAssistant`'s `chat` (design.md's Template screens rule 7, with merchant sales the reference); it bills a model, so reach for it only when asked. One that follows what the reader picks or proposes changes they confirm builds on the library's primitives rather than its own (`useSelectionFocus` with `describeSelection` for what a highlight sits in, `useGeneratedStarters`, `defineAction` from `rift-ds/chat/actions`, ActionCard and ActionReadout; design.md's ChatWidget patterns own the flow), and gets an eval beside `evals/merchant-chat`.
- **A sidebar shell previews as an app in a Mobile preview** (the carousel's Mobile mode and the playground's Mobile stage both set the flag; design.md's Template screens rule 8 owns it). Pass `frame` (the large-title bar's title and subtitle) and `tabBar` (an `AppFrameTabBar` from the same nav sections, with the assistant on its circle) to `TemplateShell`, which does the swap; inside the content, read `useAppFrame()` from `website/src/components/templates/AppFrame/` to skip the web top bar and the page-title block. Merchant sales is the reference wiring. A screen with no tab-bar shape on a real phone (a chat session, a sign-in) skips this.
- Responsive steps come from the breakpoint modules: the template family's `BREAKPOINT_TEMPLATE_COMPACT_PX` / `BREAKPOINT_TEMPLATE_NARROW_PX` in `website/src/config/breakpoints.ts`, the chat dock and takeover and the layout set in `src/tokens/breakpoints.ts`. A screen whose own grid breaks somewhere else says why with a `ds-allow(breakpoint)` directive above the query (`scripts/validate-breakpoints.mjs` enforces it)
- Every control is a library component, every value a semantic token — the standard component and token rules apply unchanged. The product's name wears the shared `ProductMark` (`website/src/components/templates/ProductMark/`), a publisher or identity provider the screen names takes its mark from `PublisherMarks` (`website/src/components/templates/PublisherMarks/`), and demo humans are Avatar initials; design.md's Template screens section owns all three.
- Dates and times in demo data are pinned strings formatted by hand, so the statically built HTML and the hydrating client can never disagree over a locale or a clock (the exemplars' convention).
### 4. Create the route
Create `website/src/app/templates/<slug>/` with two files, mirroring a live template's:
- **`page.tsx`** — a doc comment (why the route is chromeless and corpus-excluded, pointing at both lists) and a default export that renders the implementation. Nothing else.
- **`layout.tsx`** — `export const metadata = pageMetadata("/templates/<slug>", "<description>")` (import from `@/config/navigation`). The description is real page metadata, so it follows content-design.md like any shipped copy.
### 5. Register the screen — three lists and the playground stills, in one change (a fourth list when the screen has a grip)
- **`templatesSidebarLinks` in `website/src/config/navigation.ts`** — add `{ href, label, description }`, plus `mobileOnly: true` for a screen with no tablet or desktop layout (the carousel then keeps its slide in the phone frame at every device size; the field's doc on `NavLink` owns the contract), and `hideFromShowcase: true` only if the screen should stay out of the index carousel while keeping its page, sidebar entry and sitemap row: the index link stays first, then one entry per screen in the family's curated order. This is the one authoritative list of templates: the index carousel, the sidebar, the sitemap, llms.txt, the chat corpus's Templates list, and the playground's Templates rail all derive from it. **No validator holds it**, so a skipped entry fails silently — the screen simply never appears anywhere.
- **`CHROMELESS_ROUTES` in `website/src/config/chromeless.ts`** — the shared footer, chat panel, and palette would otherwise render inside the app shell being shown. Build-enforced indirectly: `validate-page-summaries.mjs` reads this list as its coverage exemption, so a template route left out fails the build demanding a page summary the screen should not have.
- **`EXCLUDED_ROUTES` in `scripts/generate-site-corpus.mjs`** — with a written reason in the existing entries' shape (fictional demo data; the screen's name and summary reach the corpus through the site map's Templates list, drawn from the sidebar entry). This one **is a gate**: `validate-chat-coverage.mjs` fails the build for an uncovered route.
- **The playground's template stills**, unless the screen is `mobileOnly`: run `node scripts/capture-template-thumbnails.mjs` and look at the two new pictures before committing them. The script serves the built site, and for a new template the website's `npm run build` cannot finish yet: its prebuild runs `validate-template-thumbnails`, which fails until the stills exist. Build with `npx next build` inside `website/` for the capture (the prebuild chain is what you are skipping, so run it afterwards). Passing a running dev server as the origin instead puts Next's dev indicator into every still. The script shoots every listed template, so restore the unchanged ones (`git checkout -- website/public/images/templates/`, which leaves the new, untracked pair alone) and commit only the stills that should change. This one **is a gate**: `validate-template-thumbnails.mjs` fails the build for a listed template with no still in either mode, but only for a missing file: a still also goes stale when an existing screen changes what it shows, so re-run the script then too (CLAUDE.md's Screenshot surfaces entry owns the rule).
- **`scripts/gestures.json`**, only when the screen carries a grip a visitor can drag (a resizable pane, a docked rail's widen grip): one entry per grip with the setup that reaches it, so the gesture stress check drives it in `verify` and CI (CLAUDE.md's Gestures row owns the registry). No validator notices a missing entry.
Nothing else needs registering, unless the screen has a live assistant, which also needs its own route behind the shared guardrails and a sentence on /privacy. Chromeless routes are automatically exempt from the AI-summary panel (`validate-page-summaries.mjs`) and the anchor rail; breadcrumbs and page title derive from the sidebar entry.
### 6. Verify
Load the route in the browser and confirm: the shell fills the viewport with no shared chrome inside it, both themes render, and the templates index carousel picked the screen up (it derives from the sidebar entry — an empty slide or a missing one means step 5 went wrong). For a `mobileOnly` screen, also check the route at a phone width (the app edge to edge) and at 600px or wider (the framed device), and that its carousel slide stays in the phone frame with the device toggle on Desktop and Tablet. For a sidebar shell, switch the carousel to Mobile and confirm the slide shows the large-title bar and the tab bar, and that the route itself at a phone width still shows its own narrow layout. Then run the corpus generator or `npm run validate-registry` to prove the exclusion entry satisfies the coverage gate.
new-validatorWalks a new validator or generator script through the ritual the existing scripts follow: the doc block that records what it guards and why it runs where it runs, the CRLF and path conventions that keep Windows checkouts green, and the regenerate-and-byte-compare pattern for generated surfaces. Ends with the wiring: the validate-registry chain, the website's subset, and the closing CLAUDE.md entry.
new-validator.mdmd---
name: new-validator
description: Author a new validator or generator script and chain it into the validate-registry build chain. Use when asked to add a validator, add a generator, build-enforce an invariant, or wire a new check into the build.
---
# new-validator
Author a new script under `scripts/` — a validator that fails the build on a broken invariant, or a generator that owns a derived surface — and wire it in so the invariant can never drift silently again.
## When invoked
Use this skill when asked to add a validator or generator, to build-enforce a rule that is currently only prose, or to give a new registry or generated surface its guard — phrases like "add a validator for X", "make the build catch this", "generate this file from the registry".
## Instructions
CLAUDE.md's **Registries** section is the philosophy: every displayed fact has one authoritative home, and a validator is how a home stays authoritative. Read the section first, then skim one reference of each shape before writing a line — `scripts/validate-doc-refs.mjs` for a standalone validator, the `scripts/generate-component-md.mjs` + `scripts/validate-component-md.mjs` pair for a generated surface, and `scripts/validate-rendered-spacing.mjs` for a built-HTML check.
1. **Decide the shape.** A *validator* checks an invariant and fails the build; a *generator* owns a derived file and gets a companion validator that holds disk to source. If the fact being guarded is countable or displayed, it probably wants a registry + generator + validator, not a lone check — CLAUDE.md's Registries intro owns that call. A generated JSON surface in a shape this repo defines also wants a hand-written JSON Schema beside it and a row in `BINDINGS` in `scripts/validate-json-schemas.mjs`, which proves every document against its schema on each build (the component schemas and `src/tokens/values.json` are the pattern); a surface in a standard format (the DTCG token files, the shadcn registry) is held to that format's own shape by its generator's validator instead.
2. **Decide where it runs.** Four slots exist, and the source of the data decides:
- **The `validate-registry` chain** (the entry in the root `package.json` is the authoritative list): for anything that reads source files, registries, or generated data. It runs before every build via `prebuild`/`prestorybook`/`prebuild-storybook`, so nothing here may depend on build output.
- **The post-website-build slot** in `verify` (root `package.json`) and CI's website job (`.github/workflows/ci.yml`): for checks that need the finished build. The tail of the `verify` entry is the authoritative list of residents; each one's doc block argues why source-level checking would over- or under-report, and a new arrival owes the same argument. The slot has two sub-shapes: validators that read the prerendered HTML from disk, and served-site checks that start a real server (and, for the smokes, a real browser) — the latter share `scripts/served-site.mjs` for the server lifecycle and the registry-derived route sample, so a new served-site check starts there rather than rolling its own.
- **The git-history slot** in `verify` and CI's library job, never in `validate-registry`: for a check whose baseline is an earlier revision, which needs the repository's tags. `scripts/validate-api-compat.mjs` is the one resident: Vercel's tag-less shallow clone runs the registry chain before every deploy, so a missing baseline there must not block the site, and the script warns rather than fails outside CI (where `fetch-depth: 0` makes a missing tag a real failure, so the gate cannot pass vacuously).
- **Run-on-demand, in no chain**: a script whose source is the network or a running server never joins the build — it is invoked by hand (or by a loop skill), commits its output, and records the exclusion with a written reason in its own doc block. `scripts/sync-worldmap-land.mjs` and `scripts/check-external-links.mjs` are the reference set. A generator in this class still gets a header in its output pointing back at the script, but no regenerate-and-byte-compare companion — CI cannot re-fetch the network, so step 7's determinism rules apply to chain generators only.
3. **Write the doc block.** Every script opens with `#!/usr/bin/env node` and a `/** ... */` header that states, in this order: the filename; what it guards and why the failure matters (numbered checks if there are several); and why it runs where it runs. The doc block is the script's authoritative record — sanctioned exceptions live in it (or in a named constant beside it) with a written reason a stranger could audit, the way `CONDENSED_ROUTES` in `validate-corpus-coverage.mjs` does it. Never enumerate facts another file owns; point at the owner.
4. **Follow the output conventions.** Collect failures into an `errors` array rather than exiting on the first; on failure print a `✗ <what> validation failed:` header, each error on its own line, and `process.exit(1)`; on success print one `✓` line with a count. Every error message names the fix — for a stale generated file, the exact regeneration command.
5. **Normalize CRLF.** Every text file the script reads for parsing, matching, or byte-comparing gets `.replace(/\r\n/g, '\n')` immediately after `readFileSync`, with the comment:
```js
// Normalize CRLF so Windows checkouts validate identically to CI.
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
```
This is the convention most often missed: a script that passes on macOS and CI but fails every Windows checkout (`core.autocrlf=true`) has been fixed repeatedly in this repo — `git log --grep=CRLF` finds the history. Normalize on the *read*, once, not scattered through the checks.
6. **Keep paths portable.** Derive the repo root from the script's own location — `join(dirname(fileURLToPath(import.meta.url)), '..')` — never from `process.cwd()` assumptions or a machine-local path. Build paths with `join()`, and before comparing a path to another string (a glob, a registry slug, a relative display path) normalize separators the way `scripts/component-docgen.mjs` does: `path.replaceAll('\\', '/')`. On Windows, `join()` hands back backslashes; a comparison that assumes `/` fails only there.
The same portability discipline covers child processes: a `node_modules/.bin` shim cannot be `spawnSync`ed on Windows, and `spawn('npm', …)` fails there without a shell. A script that shells out resolves the tool's real bin entry and runs it under `process.execPath` instead — `scripts/validate-package-publish.mjs` and `scripts/served-site.mjs` are the two references (the latter also owns the Windows `taskkill` stop).
7. **For a generator, use the regenerate-and-byte-compare pattern.** The generator exports its assemble function and `outputPath` (or `outputDir`), and guards its write behind `const isMain = process.argv[1] === fileURLToPath(import.meta.url)` so the validator can import it without side effects. It must be deterministic — no network, no timestamps, nothing not already derivable from the sources — or the byte-compare flaps. The companion validator imports the assemble function, regenerates in memory, and compares against the CRLF-normalized disk read; for a multi-file surface it also checks the reverse direction, so an orphan on disk fails too. `validate-site-corpus.mjs` (single file, plus content screens) and `validate-component-md.mjs` (folder, both directions) are the two references.
8. **Wire it in.**
- Add it to the `validate-registry` entry in the root `package.json` — generators run first, validators after, so keep a generator ahead of everything that imports it.
- If it is website-relevant, add it to the website's own chains in `website/package.json` (that file is authoritative for the subset — a library-only check stays out), split by kind: a generator goes in both `predev` and `prebuild`, a validator in `prebuild` only. `predev` exists to start dev from fresh data, and a validator there would fail `next dev` on exactly the stale state the generators are about to fix.
- A post-build check goes at the end of `verify` in the root `package.json` **and** into CI's website job in the same change — CLAUDE.md's CI & Local Verify section owns the keep-in-sync rule.
9. **Close the loop in the docs.** A script guarding a new countable collection gets a one-line row in CLAUDE.md's Registries table (registry, accessor, validator — follow the existing rows), with the mechanism detail in the script's own doc block rather than in CLAUDE.md; a script extending an existing surface amends that surface's row; a standalone invariant gets a sentence in the CLAUDE.md section that owns its subject. `scripts/validate-doc-refs.mjs` will hold every path and npm script the new prose names to reality.
10. **Verify.** Run the new script directly first (`node scripts/<name>.mjs`), break the invariant on purpose to see it fail with a useful message, restore, then `npm run validate-registry` end to end. A post-build check needs `npm run verify` instead, since nothing shorter builds the HTML it reads.
## Guardrails
- Never hand-edit a generated file to make a validator pass — regenerate it, and commit the regenerated content with the change (CI's drift guard diffs after the generators run)
- No counts, no hand-copied script lists, no machine-local paths in the doc block or the docs — point at the authoritative home
- A check that reads build output never goes in the `validate-registry` chain; it runs before any HTML exists
- A validator with no failure mode is decoration — if you cannot make it fail by breaking the invariant, it is not guarding it
visual-reviewOpens the site in a browser preview, drives each page through both light and dark mode at desktop and mobile widths, and screenshots them. Checks for invisible text, broken layouts, overflow, and stuck hover states, then reports findings or confirms all clear.
visual-review.mdmd---
name: visual-review
description: Start the website dev server and screenshot pages in both light and dark mode, at desktop and mobile widths, to catch visual issues. Use when asked to visually review changes, check light and dark mode, or screenshot pages.
---
# visual-review
Start the website dev server and screenshot pages in both light and dark mode, at desktop and mobile widths, to catch visual issues.
## When invoked
Use this skill when asked to visually review changes — phrases like "check how this looks", "review light and dark", "does this look right", "screenshot the page", "visual check".
This skill catches what is **broken** — clipped text, invisible elements, layout collapse. For whether something is **good** — craft, consistency, polish, taste — use the `design-qa` skill instead.
## Instructions
Use the browser/preview tools available in the current environment for every step below — this skill describes *what* to do; map it to whatever tools the harness currently provides. Never launch the dev server through a raw shell command.
1. **Determine which URLs to review.** If not specified, default to the page(s) most recently modified in the current conversation. Ask if unclear.
2. **Open the website's Next.js dev server** (the `website` configuration in `.claude/launch.json`; port 3000 by default, but the config sets `autoPort`, so read the URL the preview actually reports rather than assuming 3000) in the browser preview and wait for it to be ready.
3. **For each URL, check both themes at both viewports.** The rendered theme is the `data-theme` attribute on `<html>`. To switch: click the theme toggle in the top nav (`MegaNav`: in the bar's end cluster at every width from 720px up, as the three-icon strip while the full row fits and as one circle that steps System, Light, Dark once the bar condenses; in the drawer's footer, as the strip, only below 720px) — on the strip the explicit Light or Dark segment, not System; on the circle, press until its tooltip reads Light or Dark — or set both `data-theme` and `data-theme-setting` programmatically (the only route on the chromeless stage pages, which render no nav). While `data-theme-setting` is `system` (the default on a fresh profile), the theme follows the browser's colour scheme live, so forcing `data-theme` alone can be overridden mid-review by an OS theme change. Verify the attribute actually changed before screenshotting, then take a screenshot in each theme. Repeat at a mobile viewport (~375px wide — mobile is a first-class surface: type and spacing collapse at the token layer, and navigation moves into a drawer): screenshot both themes there too, and on at least one page open the drawer nav, expand a section, and screenshot it open. The site chat changes form with the viewport and the default desktop width shows the least interesting one: a full-viewport takeover with its own stacked welcome screen on phones, a modal overlay behind a scrim at mid widths, and a docked panel that insets the whole page beside it at the widest (design.md's ChatWidget spec owns the exact breakpoints, and the seats below). On at least one page, open the chat at the mobile width and at a desktop width wide enough to dock, and screenshot both — the docked form is the one that moves the page, so a layout that survives a mid width can still break there. At that dock-wide width, also use the header's Chat position menu to capture the other two seats: Dock left, where the page's inset (and the header inside it) mirrors to the other side, and Floating, a card with no scrim over a page at its full width. The background is a third axis: the header's background menu (between the theme dot and the toggle in the bar, in the drawer's footer below 720px) sets or removes `data-background` on `<html>`; for a change to a background surface, capture at least one page under each row of `SITE_BACKGROUNDS` (`website/src/lib/shaders/background-storage.ts`).
4. **Examine each screenshot for:**
- Text that is invisible or the same colour as its background
- Components that appear broken, overflow their container, or clip
- Spacing that looks inconsistent or misaligned compared to other pages
- Hover/focus states that appear stuck in an active state
- Images or assets that failed to load (broken image icons)
- Any layout that differs unexpectedly between light and dark
- At mobile width: horizontal overflow (a page that scrolls sideways), content clipped by the viewport, and drawer navigation that fails to open, scroll, or close
5. **Report findings** concisely:
- Format: `[URL] [dark|light] [desktop|mobile] — description of issue`
- If no issues found, say: `[URL] — looks correct in both themes at both widths`
6. **Stop the preview server** when all pages are reviewed, unless the session is still using it.
## Key context
- Theme state lives on `document.documentElement`: `data-theme-setting` is the visitor's choice (`system`, `light` or `dark`) and `data-theme` is what it resolves to (`light` or `dark`) — every style reads the latter. The choice persists via the localStorage key `theme` and both attributes are applied before first paint by an inline script in the root layout, which also keeps a `system` setting following the OS
- The theme preset is a second, independent attribute: `data-brand` on `<html>`, restored before first paint from the localStorage key `brand` (`BRAND_STORAGE_KEY` in `website/src/lib/theme/brand.ts`). A browser profile that once picked another look keeps it, so before capturing, confirm `data-brand` is the served theme (`SERVED_THEME_ID` in the same file) or the theme under review — a page judged in a stale preset is judged against the wrong palette. The header's theme switcher sets it, and so does the attribute route in step 3
- The background pick is a third persisted attribute: `data-background` on `<html>`, restored before first paint from the localStorage key `background` (`BACKGROUND_ATTR` and `BACKGROUND_STORAGE_KEY` in `website/src/lib/shaders/background-storage.ts`); absent means the field. A profile that once picked Horizon screenshots Horizon on every page, so confirm it is the default or the background under review before capturing. The header's background menu sets it
- The theme toggle is rendered by `MegaNav`, which pages mount themselves — the chromeless stage routes (`CHROMELESS_ROUTES` in `website/src/config/chromeless.ts`, `/graph` included) render no nav and so no toggle; switch themes there programmatically via the attribute route in step 3
- The sitemap footer (`SiteFooter`), the chat button/panel, and the site-wide command palette (`SitePalette`, opened from the header's search button or Cmd+K, so invisible in a static screenshot until triggered) are site chrome mounted once from the root layout, not per page — expect the first two in every screenshot's lower region (the chat as a floating button when closed; open, it is a takeover on phones, a modal overlay on mid widths, and a docked panel at the widest that pads the page to make room, on whichever edge the visitor chose; popped out from its Chat position menu it is a free card over a full-width page, which is the Floating seat and not a failed dock — see step 3). Hovering the closed chat button opens its per-page AI-summary panel (a frosted TLDR card with prompt chips, fed from the page-summaries registry) — expected chrome, not a stray overlay, so a pointer resting near the bottom-right corner can legitimately summon it mid-capture. Deliberately hover it on at least one page per theme and let the reveal settle (the think beat plus the typed reveal take a few seconds; the constants in `src/tokens/motion.ts` are the timings) before capturing, since no automated gate sees that panel render. The chat panel itself animates open and closed: a capture right after opening catches it low and part-transparent mid-entrance, and one right after a close catches the still-mounted dying panel — both are the animation, not a layout bug or a stuck overlay; let it settle before capturing. A change of seat glides the same way, so a panel caught between two edges is mid-move. The welcome starters and a fresh answer's follow-up chips also stage a shimmer-then-pop reveal (design.md's ChatWidget spec owns the choreography), so shimmer pills where chips belong are a loading state, not missing content. Three quick-reply states are by design too (design.md's ChatWidget spec, the Quick replies paragraph, owns them): a reply that asks shows a question card and no chips; a used chip row, or an answered card, is a record, the pick outlined and the rest faded and inert; and a set of more than one question holds the composer, dimmed under its own placeholder, until it is answered or skipped. Also by design, per the same spec: a reply that is only a card, with no prose; an entry button reporting the agent's state, with `done` or `error` for a beat as a reply ends; and a composer that is one row or two depending on what it holds. The playground's chat director stages each one (its Quick replies events), and its Flow lever shows the bottom-up thread, a second layout to capture. On the mobile pass, check the footer's collapse against design.md's Site footer pattern (it owns the column ladder): the brand block leaves the row and sits above the links at the same breakpoint the page's nav rail disappears at. The footer is identical on every page, so a difference between two pages is a finding; the brand block's width matching the rail is deliberate, not a coincidence to report
- The routes in `CHROMELESS_ROUTES` (`website/src/config/chromeless.ts`) deliberately render none of the shared chrome — footer, chat, and command palette — so their absence there is not a finding
- Nav items flagged `desktopOnly` in `website/src/config/navigation.ts` (the flags there are the authoritative list; the field can be set on any item, so check whether one carries it before expecting this) disappear at and below the tablet step (`BREAKPOINT_TABLET_PX` in `src/tokens/breakpoints.ts`, the number's one home) from every surface that renders at all widths — the footer's Design system column and the command palette — and never appear in the mobile drawer. A link present at desktop and gone on the mobile pass is that flag working, not a finding. The pages behind those flags need a pointer and a wide viewport, so a ~375px screenshot of the page itself proves nothing either way
- The `animate-in` class on page elements triggers CSS entry animations — these are normal on first load
- A chat welcome with a widget board enters in stages (composer, then greeting, then the tiles popping in one after another, after the hold `--ds-chat-widget-welcome-hold` sets; design.md's ChatWidget spec owns the choreography), so an early capture of the chat home template, the home page's chat frame or the playground's chat card shows a missing greeting or missing tiles. Let it settle before capturing
- The ambient background (`BlurBackground`) is layout-mounted chrome too, and it is the largest thing in every screenshot. Four of its behaviours produce **false findings** if you do not expect them:
- **What paints is the visitor's pick.** With the field picked (the default), a WebGL2 field normally and the CSS blobs underneath as the fallback, and which one you capture depends on the machine's GPU, so the same page can legitimately screenshot two different ways on two runs. With a scene picked (a row of `SITE_BACKGROUNDS`), the library's ShaderScene paints in the field's place on every page, low under the home hero and as the top half of the scene in the standard band, and the band stays bare while the scene's chunk loads. The doc block of `BlurBackground.tsx` owns all of it. A background that differs between runs is not a finding; a *broken-looking* one is. The band also sinks at 30% of the scroll on every page (the `bg-parallax` rule in `website/src/app/globals.css`), so a band offset after scrolling is not a finding either.
- **There is a moment with no background at all.** While the renderer resolves, the blobs are hidden and the canvas has not faded in. A screenshot caught in those first frames shows bare page floor. Let the page settle before capturing.
- **Most pages get a fixed-height band, not a full screen (the doc block of `website/src/components/BlurBackground/BlurBackground.tsx` owns the modes and the number) — and the immersive stages get no ambient background at all.** Pages rendering `FullBleedBackground` fill the viewport; pages rendering `HiddenBackground` (beside `DotBackground`, the dotted stage ground) hide the ambient layer entirely, by design — a dotted stage with no glow is that mode working, not a missing background. Grep `FullBleedBackground` and `HiddenBackground` under `website/src` for the current sets rather than trusting a list here. Judge each page against its own variant.
- **It is deliberately fainter in light mode.** The whole layer, either renderer, runs at reduced opacity over a light floor (the comment on the `html[data-theme="light"] .blur-container` rule in `website/src/app/globals.css` owns the value and the reason). A glow that is weaker in light than in dark is that rule working, not a theme-parity finding.
- To rule the background in or out of a finding, `?tune=1` on any page in dev opens its control panel, which reports the live renderer and lets you A/B the shader against the CSS blobs, with the field picked (pick Field from the header menu first; the tuner drives only the field)
design-qaRenders the target in both themes, magnifies its seams, corners, and states, and lines it up against its nearest rendered siblings. Judges the arrangement against the fundamental principles (proximity, alignment, repetition, contrast, spacing) and the parts against component-scale craft checks, fixes what has one right answer, and reports the taste calls that need a decision.
design-qa.mdmd---
name: design-qa
description: Rigorous visual QA and polish pass over a component, a page, or a sweep across several. Renders the target in both themes, magnifies the details, judges the craft against a durable rubric and the rendered siblings, fixes the defects, and reports the taste calls. Use when asked to design-QA something, polish a component or page, judge whether something looks good enough, or find visual inconsistencies across components or pages.
---
# design-qa
The taste gate. Find everything a designer who owns the system would notice and send back — especially the things nobody wants to spend time calling out — then fix what has one right answer and surface what doesn't.
This skill judges **rendered pixels**, not source code. Code review can confirm the tokens are right; this pass decides whether the thing is good.
## When invoked
Use this skill when asked to design-QA, polish, or judge something visual — phrases like "design QA [Name]", "polish the [Name] component", "is [Name] good enough", "look at these buttons", "review the [category] components together". It also runs as the mandatory quality gate inside the `new-component` skill (see the last section).
## Scope
Accept any of:
- **A component** → every one of its Storybook stories, plus its website showcase page if it has one (the page is where composition problems show)
- **A page** → a URL path on the website. Page reviews inherit the `visual-review` skill's viewport matrix — both themes at desktop *and* mobile widths — since an arrangement that only holds at one width isn't holding
- **A sweep** → several components or pages at once ("the form controls", "every page under /foundations"), each judged individually *and* against the others — cross-target inconsistency is the sweep's whole point. A sweep over a family starts from the `component-consistency-audit` skill's family table for it (the fill, border, radius, hover, selected and disabled each member takes, built from the stylesheets now), so the lineup is judged against a stated convention rather than against whichever sibling was captured first
If no target is given, default to what this session built or changed. Ask only when genuinely ambiguous.
## Ground rules — why this pass doesn't go stale
- The rubric below is principles, deliberately free of facts about real components. **Facts come fresh each run**: the spec is `design.md` read now, the component list is `src/components/registry.json` read now, and the standard for consistency is siblings **rendered** now — never a remembered lineup or a list written here.
- Judge the render first, the source second. A seam defect lives in pixels; the CSS that caused it comes after the finding, as the fix's address.
- Component names in this skill's examples are fictional by design — a factual claim about a real component inside an example rots silently.
## Evidence
Use the browser/preview tools the harness provides for every step below; never launch a dev server through a raw shell command.
1. **Render the real thing.** Components: the `storybook` configuration in `.claude/launch.json`. Pages: the `website` configuration (theme is `data-theme` on the root element, the theme preset is `data-brand` beside it and the background pick is `data-background`; the `visual-review` skill's step 3 and Key context document how to switch all three and which preset to confirm — verify the attribute actually changed before capturing — and what page chrome to expect).
2. **Capture the matrix.** Every variant and state, in light **and** dark. Then **magnify the details**: zoomed crops of every join, corner, divider, icon seat, and focus ring. Seam defects — a doubled border where two segments meet, a divider a pixel taller than its track, a radius that doesn't flow through a join — are invisible at 1x and glaring at 3x. If the harness offers a zoom or region capture, use it on every joint the component has; if not, scaling the render up (a transform on the story root works) is a fine substitute. One capture-environment trap: after a programmatic theme flip in a hidden browser pane, CSS transitions can freeze mid-flight (paused rendering never advances them), so a token can screenshot at neither theme's value and read as a broken palette. Before judging any colour, let the page settle in a fronted tab or force-finish the page's animations — and when a colour still looks wrong, confirm it with computed styles before writing the finding.
3. **Drive the states for real.** Hover, focus-visible (reached by keyboard, not click), active, disabled, loading, empty, error. Capture each. A state you didn't render is a state you didn't review. On a page review, the layout-mounted chat button's hover-summoned summary panel counts as one of the page's states — summon it and let the reveal settle. The chat panel and its suggestion chips animate on open, close, reveal, and a change of seat (design.md's ChatWidget spec owns the choreography), and so does a welcome with a widget board, whose composer, greeting and tiles enter in stages after a short hold: capture after they settle, and read a mid-flight frame — a part-transparent panel, shimmer pills where chips belong — as animation, not defect. A reply ending in a question card with no chips, a faded record row, and a dimmed composer while a question set is open are designed states, not defects (the same spec's Quick replies paragraph), as are a reply that is only a card, an entry button reporting the agent's state, and a composer that is one row or two depending on what it holds.
4. **Stress the content.** Longest plausible label, most items, zero items, a narrow container, wrapping text. Use story controls or a scratch story; don't ship the scratch.
5. **Line up the siblings.** Pick the two or three nearest relatives by role — same registry category, or same pattern family (everything pill-shaped, everything with a chevron, every card) — and capture the same crops of them. Consistency findings come from this lineup, not from memory. For a page: one or two established pages of the same type.
6. **Read the spec and the source.** The target's `design.md` component section, `design.md`'s **Composition** section — the authoritative page-level arrangement rules the principles below are the vocabulary for — the token rules, and the component or page source. After the visual pass, to name causes and to check behaviour pixels can't show.
## The eye — the design principles
These describe the arrangement, not the parts. The design system already governs the parts — the tokens, radii, type, and their validators decide what a component may be made of. A layout assembled one component at a time will satisfy every token and still be wrong, because these properties only exist between components. Evaluate them after assembly, looking at the whole. They are also the vocabulary findings are written in: "not good enough" only becomes fixable once the violated principle has a name.
### Proximity
Space encodes relationship, and it is read before anything else on the screen. Uniform spacing claims nothing is related to anything. The authoritative rule — and the rhythm ladder that makes it actionable — is `design.md`'s Composition section; this heading is the name the finding gets.
### Alignment
Every element belongs to a shared structure. Placement is never arbitrary and never local. An element positioned correctly relative to its neighbour but not to the composition is misaligned — and this is the most common failure when building a layout piece by piece.
### Repetition
A treatment used consistently becomes a signal. The same role gets the same treatment throughout, which is what makes deviation legible as emphasis rather than as inconsistency.
### Contrast
Differences must be unmistakable. Anything not the same must be clearly different, since near-sameness reads as error. Attention needs somewhere to land first.
### Spacing
Padding belongs to the component; margin belongs to the layout. The rule itself is `design.md`'s Composition rule 1 (parent owns spacing) — cite it there; the finding to watch for is a component asserting a relationship it cannot see by carrying its own outer margin.
### After assembly
Stop and look at the full composition before finishing:
- What structure do the elements share? If you cannot name it, there is no alignment.
- What does the spacing say about what belongs together? Is that true?
- What does the eye land on first? Is that correct?
- Which treatments repeat, and which are one-offs? Can each one-off be justified?
## The craft checks — the same principles at component scale
Inside and between a component's own parts, judged at magnification, per theme:
**Geometry and joins**
- **Optical beats mathematical.** Icons, chevrons, punctuation, and round shapes carry uneven visual weight, so a mathematically centred glyph often looks off-centre. Judge centring by eye at 3x, never by the box model.
- **Concentric corners.** A nested radius relates to its parent's through the padding between them; equal radii on nested boxes look wrong at the corner. (`design.md` records the system's worked example in the Composer geometry.)
- **Joins render once.** Where two segments share an edge — a split control, an attached input-and-button, a segmented anything — the shared border must not double, the group's outer radius must flow through as if it were one shape, and any divider spans exactly the height it should. The two halves must look designed together in *every* state: capture the compound in each state and ask whether the treatment of the inactive half was chosen or inherited.
- **Hairlines are crisp.** 1px lines land on the pixel grid; a soft, blurry border means a half-pixel offset somewhere.
- **A row lines up.** Controls that share a row (a field, a button, a picker, a segmented set) measure the same height at each size, `--control-height-default` and `-compact`; measure them side by side rather than trusting the token, since a border or a line height outside it shows here first.
- **A popup shows whole.** Open every menu, listbox, popover and tooltip inside a scrolling or clipping container and near each screen edge: it must escape the container, flip when its side has no room, stay a margin inside the screen, and follow its control on scroll (`src/stories/AnchoredPopups.stories.tsx` holds the clipping case; design.md's "Popups are placed by one behaviour" owns the rest).
**States as a family**
- **Every state designed, not derived.** A loading state is composed — spinner colour, size, and seat all look chosen — not "opacity plus a spinner". Disabled reads as one coherent treatment, not per-part fading.
- **States don't move geometry.** Hover, focus, and loading never shift layout, resize the control, or reflow the label. Compare each state crop pixel-for-pixel against the resting crop.
- **The family shares logic.** If hover deepens the fill on one variant, it deepens it on all. A state treatment that exists on one component of a pattern family and not its siblings is a Repetition finding, whichever side is right.
- **A gesture renders nothing until release.** Drag the grip, scrub the slider, move the card, and watch the subtree: during the gesture only the dragged node's style (or, for a pen stroke, its path's `d`) should change, and React should commit once, when the pointer lets go. React DevTools' render highlighting shows a per-frame commit as a subtree flashing with the pointer, and a drag that stutters while something beside it animates is the same finding seen at 1x. Per-move state over a re-animating sibling can reach React's nested-update limit and unmount the root, so this is a Defect, not polish, and the fix is the floating card's move in `src/chat/ChatLauncher.tsx` (the variables written per frame, one state commit on release; its widen grip is the pure-DOM case). A per-move update whose re-rendered subtree is genuinely small passes only with a comment beside the handler saying so; a fictional `GadgetDial` that re-renders nothing but its own needle is the shape that earns one.
**Theme parity**
- Dark is designed, not inverted. For each key crop, put light and dark side by side: fills, strokes, and elevation must make the same statement in both. A treatment that reads as a solid fill in light and dissolves into the background in dark is a Contrast finding. One asymmetry is by design: a tinted theme's light surfaces carry less of the tint than its dark ones (`LIGHT_SURFACE_TINT_RATIO` in `website/src/lib/theme/theme-overrides.ts`), so a floor that reads more neutral in light is not a finding.
**System fidelity**
- The right component is used — a page re-implementing an existing library pattern in local CSS is a Repetition finding even when it looks fine today.
- Icons match the type: an icon's stroke weight sits with the font weight beside it; a heavy icon next to light text reads as borrowed from another system.
- The action colour means action, status roles carry status, and visible copy follows `content-design.md`. Token *compliance* has its own skill (`token-audit`); here, judge what the token **choices** look like — a legal token in the wrong role is exactly the kind of thing this pass exists to catch.
**Consistency — the same thing drawn the same way**
- **One drawing per primitive.** A checkbox, radio, switch, chosen mark, close button or hidden label looks exactly like every other in the library, because it *is* the same one: the shared indicators, `ChosenMark`, `CircularButton` and `ds-visually-hidden` named in `design.md`'s Components intro. A control that reads a shade different from the standalone control beside it (a thinner ring, a glyph where the others have a box, a check in another colour) is a redrawn primitive, and the finding is a Defect whichever side looks better. `node scripts/validate-shared-primitives.mjs` catches the copies it can recognise; the eye catches the rest.
- **The surface is the family's.** Line the target up against its family (the rows in `design.md`'s "Surfaces by family"): a fill where the siblings are transparent, a radius step the family does not use, a hover or current-item treatment that differs, a disabled state that fades differently. A member outside its family's convention with no sentence in its spec is a Defect; a family with no convention yet is a Direction call, and the `component-consistency-audit` skill is where it gets decided and written down.
## Judging
The bar is a three-second glance from the designer who owns the system. The founding rule of this skill: **the findings nobody wants to spend time writing up are exactly the findings to write up.** "The buttons on the new component are not good enough" is a legitimate trigger; this pass exists to turn that sentence into named, located defects.
Every finding names the principle or craft check it violates, and each is classified:
- **Defect** — objectively wrong against the rubric, the spec, or the sibling lineup: a doubled seam, an off-grid hairline, a state that shifts layout, an inconsistent gap. One right answer. *Fix it.*
- **Polish** — a clearly better version is available, and the change is small and safe: an icon a shade too heavy, padding a step too tight, an abrupt transition where every sibling eases. *Fix it.*
- **Direction** — more than one defensible answer, or it changes the design: this variant shouldn't exist, this layout wants a rethink, this empty state needs different content. *Recommend one option; don't apply.*
## Polish loop
For every defect and polish finding:
1. Fix at the source — component CSS/TSX or the page module. The fix itself obeys all the library's token and motion rules (the `new-component` skill's `ComponentName.css` section is the reference).
2. Re-render and re-capture **the same crops**. Keep the before/after pairs.
3. Re-run the eye over the changed area — fixes cause regressions too.
4. Loop until a full pass yields no new defect or polish findings.
## Report
Lead with the verdict, evidence attached:
```
## Design QA: [target]
**Verdict:** [clean | N findings fixed, M decisions open | sent back — needs direction before polish helps]
### Fixed
- [what was wrong, one sentence] → [what changed] (before/after crops)
### Open decisions
- [the taste call] — recommendation: [one option and why]
### Checked and clean
[One short paragraph: which variants, states, themes, stress cases, and siblings
were actually reviewed — so "clean" has a defined coverage.]
```
Reference the exact element and file, e.g. "the divider in Gadget's split variant sits 1px proud of the fill (Gadget.css:47)" — the component in that example is fictional by design.
## Guardrails
- **Contrast findings:** the `color-contrast` axe rule is deliberately switched off — rule-wide, every pair — by a settled decision, and the comment beside the override in `.storybook/preview.ts` is its authoritative record (the shipped action pairings it discusses clear AA). The action fills are gated at token level regardless: `ACTION_PAIRINGS` in `scripts/validate-theme-presets.mjs` holds every action fill against what sits on it, in every state, theme and preset, so a contrast finding on one of those pairs is already enforced. Read the comment before raising any contrast finding, and never re-raise the decision itself as a finding without asking the owner first.
- **Page reviews** inherit the false-finding caveats in the `visual-review` skill's Key context — the ambient background's two renderers, the layout-mounted chrome, `desktopOnly` nav — read that section before judging a page screenshot.
- **A clean pass is a valid outcome.** Don't invent findings to justify the run; state what was covered and stop.
## Inside new-component
The `new-component` skill invokes this pass as soon as the component renders in Storybook, before registration. There it is a gate, not a report: loop until no defect or polish findings remain, fold any open direction calls into the hand-off summary, and treat "renders correctly" as necessary but nowhere near sufficient. Before registration `npm run storybook` cannot cold-start (its `prestorybook` hook runs the `validate-registry` chain, which fails on the unregistered folder) — use a running Storybook or the `storybook-raw` entry in `.claude/launch.json` (port 6016, no lifecycle hook), per new-component's step 4.
responsive-sweepLoads every page of the site at every breakpoint width, from a small phone to a wide desktop, and measures what a person would otherwise have to spot: content running off the screen, text cut off or spilling out of its box, and controls stacked on top of each other. Each finding comes with a marked screenshot, is judged by eye before it is called a defect, and is fixed where it starts: in the component when the component breaks, with a story at the width that broke it.
responsive-sweep.mdmd---
name: responsive-sweep
description: Sweep every page of the built website at every breakpoint width for layout breaks a person would otherwise have to find by hand. Content running off the screen, text cut off or spilling out of its box, and controls drawn on top of each other. Runs scripts/check-responsive-layout.mjs, judges each finding from its screenshot, fixes the real breaks at their source, and reports. Use when asked to sweep the site, check every page at every size, run the responsive sweep, or QA the layout before a release. Never commits, pushes, or deploys.
---
# responsive-sweep
The served-site checks in `verify` render at one desktop width, apart from the hydration smoke's phone pass, which catches only a page that scrolls sideways on its handful of routes, so an element that breaks on a phone or a tablet reaches no check. This skill closes that gap. `scripts/check-responsive-layout.mjs` loads every static route at every width step the breakpoint modules declare and measures the layout; this skill judges what it finds, fixes the real breaks, and reports. The script's doc block owns the routes, the width ladder, what each kind of finding means, its options, and the `ACCEPTED` list.
**Never commit, push, or deploy.** Fixes land in the working tree for the owner to review, unless `super-ship` invoked this skill, in which case its rules on committing apply.
## When invoked
Run when asked to "run the responsive sweep", "check every page at every size", or QA the layout before a release, and as a step of `super-ship`. For one page or component, `visual-review` is faster.
## What it can and cannot see
It sees geometry: where boxes and text sit relative to the viewport, their own boxes and each other. It does **not** see colour or contrast, dark-mode-only problems, alignment and spacing taste, states that need an interaction first (an open menu, an expanded drawer, a filled form), or dynamic routes. `visual-review` and `design-qa` own those. A clean sweep means no measurable layout break, not that every page looks right.
## Instructions
### 1. Build
The sweep reads `website/.next/`, so the build must be of the tree under review:
```bash
npm --prefix website run build
```
A build already made from this tree in the same session (for example by `verify`) can be reused. If another session is working in the same checkout, say so before building, since the build replaces its `.next`.
### 2. Sweep
Run it in the background; a full sweep takes several minutes. Keep the output outside the repo, in the session's scratchpad:
```bash
node scripts/check-responsive-layout.mjs --shots <scratchpad>/responsive-sweep --json <scratchpad>/responsive-sweep.json
```
`--routes` narrows to route prefixes and `--quick` uses a five-step ladder, both for re-checking a fix, never for the sweep of record. The output is grouped: one line per element and kind, with every route and width it shows at, so a break in shared chrome is one finding, not two hundred.
If the run reports `error` findings (a page that failed to load), stop and report them first: a page that does not load at all is worse than any layout break, and `smoke-hydration.mjs` is the place to look.
### 3. Judge every finding by eye
Open each finding's screenshot (the measured element is outlined in red) and give it one verdict. A finding is a lead until it has been looked at.
- **Defect**: a person on that screen would see something broken: content cut off by the screen edge, a word or label missing its end, text drawn over something else, a control they cannot reach or tell apart.
- **By design**: a deliberate crop, a decorative spill, a floating layer doing its job. The code says so, usually in a comment beside the rule; cite it.
- **Instrument error**: the measurement is wrong (the screenshot shows nothing amiss and nothing deliberate explains it). The fix is in the script, not an entry in `ACCEPTED`.
`truncated` findings end in an ellipsis or a line clamp, which is usually deliberate. Call one a defect only when the cut hides the meaning and nothing else on screen carries it, such as a column header whose last letters are the word, or when the box has room the text is not given.
With many findings (more than about fifteen groups), split the screenshots between up to four subagents, each handed this section and the script's doc block, and check every defect they call yourself before acting on it.
### 4. Fix at the source
- **Find where the break starts.** A component class (`ds-*`) breaking on several routes is the component's defect: fix it in `src/components/`, and add a story at the width that broke it (the story tests then hold it). A page or template class (a CSS module) is the page's.
- **Follow the system's rules while fixing**: tokens, logical properties, the breakpoint constants. CLAUDE.md's Token Architecture and Component Anatomy sections apply as they would to any change.
- **Prove each fix** by re-running the script on the affected routes with `--routes`, after rebuilding the website, and by opening the new screenshot.
Stop and ask rather than fix when the right answer is a design decision: a layout that needs a new arrangement at a width, not a repair, or a fix that would change a component's published behaviour.
### 5. Record what is by design
Every by-design verdict becomes an entry in `ACCEPTED` in the script, with its kind, the shortest label substring that matches it, any route narrowing, and the reason, citing where the code says so. **Propose the entries and wait for the owner's yes**: an accepted finding is never reported again, so a wrong entry hides a real break for good. Fix instrument errors in the script's `inspect` function, re-run, and say what changed.
### 6. Report
Repeat the full report in the final message; there is no report file.
- The sweep: routes, widths, and the findings by kind.
- **Fixed**: each defect, where it showed (routes and widths), the cause, the fix, and the story added.
- **Proposed acceptances**: each by-design finding with its reason, waiting for approval.
- **Left for the owner**: defects that need a design decision.
- **Instrument changes**, if any.
**"No layout breaks at any width" is a valid outcome.** Say so briefly, with the route and width counts, and stop.
## Guardrails
- Never widen `ACCEPTED`, raise a tolerance, or drop a width to make the sweep quieter without the owner's yes. Quieter is not the goal; true is.
- The width ladder comes from the breakpoint modules. A new step belongs in `src/tokens/breakpoints.ts` or `website/src/config/breakpoints.ts`, never as a literal in the script.
- A finding at the small-phone width counts. The site promises to work on a phone, and the narrowest phones are where most breaks start.
- Labs routes are fidelity benches. A break in a library component there is a library defect; a break in the bench's own chrome is low priority, and the report says which is which.
animation-jump-checkPlays a page in a headless browser and records where every visible element sits on every animation frame, then reports what a still can never show: an element that snaps instead of gliding, neighbours drifting out of step, an open popover losing its anchor. Each finding is a lead until someone has stepped through that moment and watched it, so the report separates real faults from motion that is meant to step.
animation-jump-check.mdmd---
name: animation-jump-check
description: Check a page's animations frame by frame for what a screenshot cannot show. Elements that snap instead of gliding, neighbours moving out of step, and open popovers that lose their anchor. Runs scripts/check-animation-jumps.mjs, judges each finding by watching the motion, and reports. Use when asked to check an animation for jumps, find a pop or snap in some motion, or run the animation jump check. Never fixes, commits, pushes, or deploys.
---
# animation-jump-check
`design-qa` judges animations after they settle, and the served-site checks do not look at motion, so a one-frame snap reaches no check. `scripts/check-animation-jumps.mjs` records every element's box on every frame of a page. This skill points it at the motion in question and judges what it finds. The script's doc block owns what each line means, what it skips, its options and its exit codes.
**Report only.** Do not fix, commit, push, or deploy unless separately asked.
## Instructions
### 1. Decide what to record
Name the route and what sets the motion off. An entrance plays on load; anything else needs `--click` or `--hover` to start it. Use `--select` for the part of the page that moves: a smaller recording stalls less and reports less noise. Set `--ms` long enough to cover the whole animation.
### 2. Run it
Prefer the production build, since a dev server adds its own stalls and reloads. With no `--origin`, the script boots the existing build, so confirm that build is of the tree under review. Keep output in the session's scratchpad.
```bash
node scripts/check-animation-jumps.mjs /some-route --select ".some-root" --click ".some-trigger" --json <scratchpad>/jumps.json
```
Exit 2 means the recording cannot be trusted, usually because too many frames stalled. Narrow `--select` and run again; never read an incomplete run as clean. Run the same recording twice before believing a single-frame finding, because headless timing varies.
### 3. Judge every finding by watching it
Step through the moment the line names, using the built-in browser or Playwright with the animation slowed down. Give each finding one verdict:
- **Defect**: a person would see it pop, or two things visibly part ways.
- **By design**: the motion is meant to step. Cite the code or design.md spec that says so.
- **Instrument error**: nothing visible happens, for example a change under something opaque, or a page layout shift unrelated to the animation.
`manual` lines are not findings. Look at them only when the swap is part of the motion in question.
### 4. Report
Repeat the full report in the final message; there is no report file.
- What was recorded: route, trigger, width, frames, and the share stalled.
- **Defects**: each with its time, the element, what is seen, and the likely cause in the source.
- **By design** and **instrument errors**: one line each.
- What it could not see: colour and opacity changes, easing that is smooth but wrong, and every state not triggered.
**"No jumps" is a valid outcome**, and it means no measurable snap in what was recorded, not that the motion is good.
## Guardrails
- Propose `data-snap` rather than adding it, and only for an element whose stepping is deliberate. It hides that element from the check for good.
- Never raise a threshold to make a run quieter without the owner's yes.
token-auditScans CSS files for hardcoded hex colours, raw rgb() values, pixel values, and transition timings that should reference design tokens, plus component TS/TSX files for colour-shaped literals. Flags near-twins of existing tokens as their own finding class. Reports file, line number, offending value, and recommended token replacement. Accepts a single component, all-components, or website as scope.
token-audit.mdmd---
name: token-audit
description: Scan CSS files (and component TS/TSX colour literals) for hardcoded values that should use design tokens, and report violations — including near-twins of existing tokens. Use when asked to check for hardcoded values, raw colours or pixel values, or audit token usage and design system compliance.
---
# token-audit
Scan CSS files for hardcoded values that should use design tokens, and report violations.
## When invoked
Use this skill when asked to check for hardcoded values, audit token usage, find raw colours or pixel values, or check design system compliance — phrases like "check for hardcoded values", "token audit", "are there any raw colours", "audit [component] CSS".
## What is already automated
`scripts/validate-token-usage.mjs` already fails the build on any `var(--…)` reference to a custom property nothing defines, so unresolvable-token references need no hand-check; this skill hunts for raw values that never reference a token at all. `scripts/validate-action-colour.mjs` fails it on a component that starts using `--color-action-primary-bg` without a sanctioned role, but its `SANCTIONED` table is per component, not per selector: a decorative use added inside a component already listed passes the build, so that residue is this skill's to catch. Two more categories are fully build-enforced and need no hand-check: media-query widths (`scripts/validate-breakpoints.mjs` holds every `@media` width, and every media-query string in TypeScript, to the breakpoint set) and z-index (`scripts/validate-stacking.mjs` requires a `--layer-*` token for any value of 10 or more). A value either one accepts under a `ds-allow(breakpoint)` or `ds-allow(stacking)` directive is sanctioned, not a finding. Over the library's own stylesheets (`src/components`, `src/chat`), `scripts/validate-raw-values.mjs` now fails the build on the plain strays too: literal colours, motion timings, font sizes, line heights, letter spacing, font weights, and off-scale padding, margin, gap and radius, unless a directive covers them. For library scope this skill's remaining job is judgement the script cannot make: near-twins, the right replacement token, a directive whose reason no longer holds, icon sizing done through `font-size`, and a row control's `height` or `min-height` written as a literal that matches a `--control-height-*` step (40px or 32px in the base theme), which the script does not check: a control that shares a row takes the token, so it moves with the density lever and lines up with its neighbours. Website CSS is outside the gate and still fully this skill's.
## Instructions
1. **Determine scope.** Accept one of:
- A specific component name (e.g. `Avatar`) → scans `src/components/Avatar/Avatar.css`
- `all-components` → scans all `src/components/**/*.css` and the chat widget's stylesheets, `src/chat/*.css`
- `website` → scans all `website/src/**/*.css` (which includes the `.module.css` files)
- A specific file path
**The site background is outside every CSS scope, and deliberately so.** Its blob colours are token *names* in `website/src/data/shader-background.json`, resolved at runtime and handed to the GPU — no CSS file names them, so a `website` scan will pass without ever looking at what actually picks the site-wide background palette. Do not hand-audit it: `scripts/validate-shader-background.mjs` already fails the build if any blob references a token that is not in the registry, which is a stronger guarantee than this skill can offer. The scene half of that palette lives beside it: with a scene picked from the header, the background's colours are the `--color-ambient-*` roles each scene lists in `src/components/ShaderScene/scenes.ts` (the `SITE_BACKGROUNDS` rows in `website/src/lib/shaders/background-storage.ts` carry only params), and `scripts/validate-site-backgrounds.mjs` holds every row's scene, params and the scenes' colour roles to what exists. Say so in the report rather than leaving a reader to assume the surface went unexamined.
**The chart series palette is the second out-of-CSS surface.** `src/components/Chart/palette.ts` emits `var(--color-chart-series-N, <light-theme hex>)` references that SVG paint resolves live — so charts follow a theme switch — and the literal hexes are cascade fallbacks, deliberate and not violations. Do not hand-audit those either: `scripts/validate-token-references.mjs` fails the build if a fallback stops matching what its token resolves to. Nor is the chart palette the only guarded mirror — the mirror checks in `scripts/validate-token-references.mjs` and `scripts/validate-theme-mirrors.mjs` are between them the authoritative list of out-of-CSS token surfaces (the hand mirrors split across the two scripts), and a new mirror lands in one of them, not here. Mention the shader background and those scripts' mirrors in the report the same way, so no reader assumes those surfaces went unexamined.
2. **Read the token files in `src/tokens/` first** to know what tokens are available and what raw values they map to — primitives (raw hex/px), the light *and* dark semantic files, typography (font size, weight, line-height), and motion (`tokens-motion.css` — durations and easings; its JS-side counterpart, the shared constants in `src/tokens/motion.ts`, is outside this skill's CSS scope). The fastest authoritative index of names is the **generated** `src/tokens/registry.json` — every semantic token with its category, machine-readable (never edit it — it regenerates from the CSS). It holds no values: for what each token resolves to per theme and breakpoint, read the generated `website/public/skill/rift-design-system/references/tokens.md`, which `scripts/token-values.mjs` resolves from the token CSS on every build, instead of following `var()` chains by hand.
3. **Scan each CSS file** in scope for violations:
**Flag as violations:**
- Hardcoded hex colours: `#rrggbb`, `#rgb`, `#rrggbbaa`
- Raw `rgb()` or `rgba()` calls that could map to a semantic colour token
- Pixel values for `padding`, `margin`, `gap`, `border-radius`, `font-size`, `line-height` that correspond to a known token (cross-reference the primitives file)
- Hardcoded font weights (e.g. `font-weight: 600`) where a typography token exists
- Icon sizing done wrong: `font-size` set directly on a Material Symbols icon, or raw pixel icon dimensions matching an `--icon-size-*` step — the fix is setting `--icon-size` to a step from the `--icon-size-*` scale (`src/tokens/registry.json` is the list; the icon font reads that one property for size, width, and height)
- Hardcoded `transition`/`animation` durations and easings (`0.2s`, `ease`, literal cubic-beziers) where a `--motion-duration-*`/`--motion-ease-*` token matches — component and website CSS is fully migrated, so any literal timing is a violation unless a directive sanctions it
**Do NOT flag:**
- Files within `src/tokens/` themselves (these define the tokens)
- `0px`, `0`, `100%`, `50%` — these are structural, not token-replaceable
- **Any value sanctioned by a `ds-allow` directive** — the one and only signal that an off-token value is deliberate. `/* ds-allow(<category>): <reason> */` inside a comment covers the declaration/rule/section it sits at; `/* ds-allow-file(<category>): <reason> */` in a file header covers the whole file for that category. Enumerate the current sanctions with `grep -rn "ds-allow" src website/src --include='*.css'` (the same scope `validate-css-directives.mjs` scans); the category set and grammar are build-enforced by `scripts/validate-css-directives.mjs`. This skill deliberately names no components: an off-token value with **no** directive is a violation, and the fix is either a token or a new directive at the site (plus a design.md note) — never an exception added to this skill
- `1px` border widths — acceptable
- Values inside `calc()` that are genuine arithmetic, not replaceable with a single token
- CSS variable declarations themselves (lines starting with `--`)
**Hunt near-twins, not just strays.** A raw value that is *almost* an existing token is a typo recorded as a decision, and the legality check alone never sees it — it can even sit under a `ds-allow`. Compare every collected raw value against the resolved values in the generated token reference (step 2) **and against the other raw values in scope**: a colour within a few points per channel of a token (including cross-notation twins — a hex, an `hsl()`, and an `rgb()` of the same colour are one value written three ways), or a spacing value one pixel off a neighbouring scale step. Report a near-twin as its own finding class, distinct from a plain stray, naming what it is a twin of — `path/to/GadgetTile.css:17 — #0E6D8E → near-twin of var(--color-action-primary-bg), probably a mistyped copy` (a fictional example by design — `GadgetTile` is not a real component). A twin of a token is repaired by pointing at the token; a twin pair of raw values collapses into whichever one is sanctioned.
**Component TS/TSX files are in scope for colour-shaped literals.** The CSS glob is not the whole surface: hex, `rgb()`/`rgba()`, and `hsl()` literals also live in component `.ts`/`.tsx` files (under `src/components/` and `src/chat/` alike), where no `ds-allow` mechanism exists. Scan them too, and judge each hit against the mirror checks in `scripts/validate-token-references.mjs` and `scripts/validate-theme-mirrors.mjs` (between them the authoritative list of sanctioned out-of-CSS token surfaces — the chart palette, every `getCSSVar` fallback, and the remaining hand mirrors split across the two). A literal outside those mirrors is a finding to judge, not skip — deliberately theme-frozen fixture data can be legitimate, but a near-twin of a token in it is still a typo.
4. **For each violation**, output:
- File path (relative to repo root)
- Line number
- The offending value
- Recommended token replacement (if a clear match exists in the token files)
Format (fictional values): `path/to/file.css:42 — #7A1F3D → var(--color-gadget-accent-bg)`
5. **Summarise** at the end:
- `X violation(s) found`
- `Y acceptable raw value(s) noted (documented exceptions)`
- If zero violations: "No token violations found. CSS is token-compliant."
token-renameCarries a token rename through the whole system in one pass: a codemod over every stylesheet and component, the guarded hand mirrors, the validator regexes that parse the old shape, the foundations pages, the Storybook token docs, and design.md. The mirror guards turn the sweep into a build-error checklist, so a consumer the codemod missed fails the build instead of shipping half-renamed.
token-rename.mdmd---
name: token-rename
description: Rename or renumber a token family across every consumer, mirror, and doc in one gated pass. Use when asked to rename tokens, renumber a scale, or change a token naming convention.
---
# token-rename
Rename a token family so every consumer, mirror, and doc moves in the same commit — the build, not memory, finds the stragglers.
## When invoked
Use this skill when a token family changes its names rather than its values — a renumbered scale, a renamed suffix grammar, a prefix change. For adding a token, use `new-token`; for changing what a token resolves to, no rename machinery is needed.
## The governing idea
A rename touches thousands of `var()` sites, but almost none of them need judgment — the work is one codemod plus a build-error-driven checklist. `scripts/validate-theme-mirrors.mjs` and `scripts/validate-token-references.mjs` hold every hand mirror of token data to the CSS, and `scripts/validate-token-usage.mjs` fails on any reference nothing defines, so after the mechanical sweep the chain enumerates exactly what remains. **Timing matters more than mechanics**: renames on the published token surface are breaking changes for consumers, so batch them and land them before a release, never dribbled across versions.
## Instructions
### 1. Write the mapping first
Old name to new name, one line per token, primitives and semantics both. The mapping is the review artifact — get it agreed before touching a file. If the rename introduces a new **prefix**, add it to `CATEGORY_PREFIXES` in `scripts/generate-token-registry.mjs` first (generation fails until the category has a home) and give the category its place wherever counts display.
### 2. Codemod the mechanical layer
A scripted replace over the token files and every consumer: CSS, TS/TSX and JSON under `src/**` and `website/src/**`, plus the token names in `scripts/generate-agent-skill.mjs`. Order the replacements longest-name-first so a shorter name never clobbers a longer one's substring, and match whole custom-property names (the name followed by a non-name character), never bare substrings. Run it, then `git diff --stat` — the shape of the diff should match the mapping's reach, and a file count far off the expectation means the pattern over- or under-matched.
### 3. Let the chain enumerate the rest
```bash
npm run validate-registry
```
Expect failures — they are the checklist, not a problem. The usual remainder, each named by its validator:
- **Hand mirrors** — the playground's ramp/step tables, `presets.ts` overrides, InspectMode's prefix strings (`validate-theme-mirrors.mjs` names each), and the action-family token names in `scripts/validate-theme-presets.mjs` (`ACTION_PAIRINGS`, the required action roles, any `SANCTIONED_AA_GAPS` keys), which that script fails on by name. Two fail at generation rather than in a validator: `LIGHT_SURFACE_STEPS` in `website/src/lib/theme/theme-overrides.ts` names neutral steps, and the tint function throws on a step the neutral table does not hold; and the consumer skills' prose in `scripts/generate-agent-skill.mjs` names tokens through a lookup that throws on a name the registry does not hold. The playground export's THEME.md and prompt templates in `website/src/lib/theme/theme-export.ts` name tokens too, and `scripts/validate-theme-export.mjs` fails on one that no longer exists.
- **Validator parsers** — a renumbering can break the regexes that parse the old shape (step patterns, label parsers). Fixing a parser to accept the new grammar is expected; weakening what it asserts is not.
- **design.md** — every token name it mentions is held to the registry, so stale prose fails by name, and a family size it states in words is held to the token files by `scripts/validate-design-counts.mjs`, so a renumber that adds or drops a step fails until the sentence moves too.
- **Docs and doc pages** — the foundations pages' swatch rows and `src/stories/Tokens.stories.tsx` carry names in data arrays the mirrors guard; page prose that *describes* the old grammar (a "sizes run xs to xl" sentence) is yours to catch by reading, since no validator parses prose meaning.
Repeat codemod-then-chain until green, then `npm run verify` — the story tests and built-HTML checks catch a renamed token that a runtime path assembles dynamically.
### 4. Land it whole
One category per commit, verify green between categories, and note the rename in the entry the release skill writes when the version ships — a renamed published token is exactly what a consumer's changelog exists for.
## Guardrails
- Never weaken a validator to get past a rename failure — fix the data or the parser's grammar, keeping what it asserts
- Never leave a category half-renamed across commits; a commit is a complete category or it is not pushed
- Old names never linger as aliases — the token files carry one name per token, and consumers get the rename through a release note, not a shim
content-auditScans shipped prose against content-design.md: banned words, em dashes, promotional register, first person where the system should be the subject, and rhythm problems no word list catches. Reports each finding with its location, the offending text, and a suggested rewrite. Accepts a page, a data file, or a whole surface as scope.
content-audit.mdmd---
name: content-audit
description: Audit prose against content-design.md for AI-writing tells, voice violations, and register mismatches. Use when asked to audit copy, check content quality, review prose for AI slop, or check writing against the content guide.
---
# content-audit
Audit prose against `content-design.md`, and report violations with suggested rewrites.
## When invoked
Use this skill when asked to audit copy, check prose quality, find AI-writing tells, or review text against the content guide — phrases like "content audit", "audit the copy on the homepage", "does this read like AI", "check this against content-design.md".
## Instructions
1. **Determine scope.** Accept one of:
- A specific file path (a page, a data file, a markdown doc)
- `release-log` → `website/src/data/release-log.json` (titles + story bodies, plus the predecessor versions' one-line summaries)
- `registry` → the `description` fields in `src/components/registry.json`, and the chat widget's `description` and per-subpath strings in `src/chat/registry.json` (rendered on its component page and served by MCP)
- `package-meta` → the npm package description: `PACKAGE_DESCRIPTION` in `scripts/package-manifest.mjs`, mirrored into the root `package.json` (renders on the npmjs.com package page — its register row calls it production copy at the README's bar; no validator judges its prose, and the `readme` scope never reaches it)
- `corpus-prose` → the hand-written connective paragraphs inside `scripts/generate-site-corpus.mjs` (the chat model can repeat any of them verbatim to a visitor — its register row holds them to the page-prose bar; the corpus validators screen for leaks, never for voice, so the judgement rules stay this scope's. Fix the generator's strings, never the generated file)
- `readme` → `README.md` prose plus `src/stories/Configure.mdx`. README's marked regions (the doc block of `scripts/generate-readme-content.mjs` owns the list) are **generated** by `scripts/generate-readme-content.mjs` (the component regions from `src/components/registry.json`, the rest from `scripts/brand.mjs`) — a rewrite inside them silently reverts on the next build, so fix the source instead (component copy through the `registry` scope); the `/blueprints` page copies under `website/public/` are generated too (synced from the root specs by `scripts/sync-blueprints.mjs` on every build), so edit the root file, never the copy
- `skills` → the `displayDescription` frontmatter strings and the `invoke` phrase lists across `.claude/skills/` (both render on /skills — the descriptions as card copy, the invoke phrases as visible chips — so both are published copy; skill instruction *bodies* are out of scope)
- `website` → user-visible strings in `website/src/app/**` page files; a page slug (e.g. `overview`) scopes to that page folder
- `chat` → the chat's shipped prose, in two layers. The package's (`src/chat/`, shipped to every consumer): the greeting fragments in `greeting.ts`, the default tagline, disclaimer, locked-model line, the composer's placeholder while a question set holds it (`questionsPlaceholder`) and the Chat position menu's label inline in `ChatWidget.tsx`, that menu's row labels in `placement.ts`, the placeholder replies in `useChat.ts`, the transport's failure lines in `transport.ts`, the package's fallback follow-up pool in `followups.ts`, the handlers' default notices, the follow-ups prompt, the `ask_user` tool's description and its error line, and the questions intro line in `server.ts`, the `ask_user` schema's field descriptions in `questions.ts` (read by the model, so the tool-copy register applies), QuestionCard's default labels in `src/components/QuestionCard/QuestionCard.tsx`, ActionCard's default labels in `src/components/ActionCard/ActionCard.tsx`, the starters prompt (`startersSystemPrompt` in `server.ts`), the model-facing lines `skippedText` and `confirmationLine` write in `actions.ts` and `selectionLines` writes in `selectionContext.ts` (the tool-copy register applies), the guardrail notices in `upstash.ts`, and the demo's staged copy — the turns, chip labels and question prompts and options in `demo-script.ts`, the fixtures in `demo-fixtures.ts`, the welcome fillings in `DemoWelcome.tsx`, the agent panes in `demo-agent-panes.tsx` and their empty-state lines in `demo-agent.tsx`, the artifact panel's empty state in `ChatArtifactPane.tsx` and the demo's two artifacts in `demo-artifacts.tsx` (the framed page's own copy included), and the scripted transport's own fallback replies in `sim.ts`. The site's (its configuration of the package): the component-page and fallback starter pools in `website/src/components/SiteChat/starters.ts` (each static page's own pool lives in `page-summaries.json`, the `page-summaries` scope) and the written follow-up fallbacks in `website/src/lib/chat-followups.ts` (chip length is build-covered by `scripts/validate-chat-starters.mjs`; register, voice and banned words stay this scope's), the site's tagline, disclaimer and placeholder in `website/src/components/SiteChat/useSiteWidgetProps.tsx`, the highlight notes in `website/src/components/SiteChat/siteFocus.tsx`, the merchant sales assistant's persona and tool copy in `website/src/app/api/merchant-chat/route.ts`, the model names and one-line descriptions in `website/src/lib/chat-model.ts` (rendered in the composer's picker; unscanned by any validator), the persona strings in `website/src/app/api/chat/persona.ts`, the tool descriptions, trace points and notice copy in `website/src/app/api/chat/route.ts`, the guardrail notice copy in `website/src/app/api/chat/guardrails.ts` (all visitor-visible; the guardrail notices' register row is content-design.md's — say what happened and what to do next, never blame the visitor), the event-rail copy in `website/src/app/playground/ChatDirector.tsx`, and the view modules under `website/src/app/playground/views/` (TypeView's tier notes and labels). The em-dash half is split: `scripts/validate-shipped-prose.mjs` build-scans the story and handler modules' string literals (`STORY_MODULES` is its list), so this scope's em-dash job there is done by the build — but the widget's inline strings and the persona stay unscanned, and every judgement-level rule (register, voice, banned words) stays this scope's across all of it
- `footer` → the sitemap footer's shipped copy: the column titles and the bottom row's copyright and author credit in `website/src/components/SiteFooter/SiteFooter.tsx`, and the link labels in `website/src/config/social.ts` (the credited name and its URL are `AUTHOR_NAME`/`AUTHOR_URL` in `scripts/brand.mjs` — audit the sentence around them, never the values) (visible on every non-chromeless page, but outside `app/**`, so the `website` scope misses them — the same gap the `chat` scope closes for the panel)
- `palette` → the command palette's shipped copy: the group labels, per-item descriptions, the ask-chat row's trailing chip label and the search placeholder in `website/src/components/SitePalette/` (reachable from every non-chromeless page, but outside `app/**`, so the `website` scope misses it — the same gap the `chat` and `footer` scopes close for theirs; no validator scans it). The ask-chat row's *label* is the visitor's own typed query — unauthored by design, never a finding — but its trailing chip ("Ask <ASSISTANT_NAME>" — `ASSISTANT_NAME` in `scripts/brand.mjs` owns the name) is authored copy in scope; the empty state is unreachable (the ask row matches every query), so there is none to audit
- `backgrounds` → the header's background menu copy: each row's `label` and `description` in `website/src/data/site-backgrounds.json`, rendered as the menu's title and subtitle on every non-chromeless page (a data file outside `app/**`, so the `website` scope misses it, the same gap the `nav` and `footer` scopes close for theirs; `scripts/validate-site-backgrounds.mjs` build-covers the em-dash ban and the lengths, and register, voice and banned words stay this scope's)
- `nav` → the navigation config's shipped copy: the section and link descriptions, group labels and the mega showcase card's overline, title and description in `website/src/config/navigation.ts` (rendered in the mega panel, the sidebars, the footer's derived columns and the home and DS-landing cards — outside `app/**`, so the `website` scope misses it, the same gap the `chat`, `footer` and `palette` scopes close for theirs; no validator scans it)
- `agent-skill` → the consumer agent skills' instructional prose, the manifest's one-line summaries and the agent starter's prose (`assembleAgentStarter()`, published as `website/public/AGENTS.md`), all hand-written inside `scripts/generate-agent-skill.mjs` (their register row is in the guide's table). The published folders under `website/public/skill/` (`AGENT_SKILLS` in `scripts/brand.mjs` owns the names) are generated and byte-compared, so fix the generator, never the files; `scripts/validate-agent-skill.mjs` build-covers the em-dash ban there, and register, voice and banned words stay this scope's. The reference tables (the component catalogue, the token reference) derive from registry data and are not prose to audit, and the sibling surface, the per-component `.md` pages, deliberately has no scope for the same reason
- `theme-export` → the playground theme export's shipped copy: the themed AGENTS.md's theme section and the THEME.md and agent prompt templates in `website/src/lib/theme/theme-export.ts` (the AGENTS.md's other sections are the agent starter, the `agent-skill` scope's), the From the source section's fit lines and traits (the DESIGN.md match report's row notes and the trait texts in `website/src/lib/theme/design-md.ts`, which ship in the export whenever the theme came from a file), the export dialog's description in `website/src/app/playground/ExportThemeDialog.tsx`, and the lever words in `LEVER_VOCABULARY` in `website/src/lib/theme/theme-overrides.ts`, which the rail prints beside each slider and THEME.md uses to describe the look (a visitor carries the exported files into their own repo, but all of it lives in `lib/` modules and a component file, so the `website` scope's page scan never reaches it, and /playground is chromeless and outside the corpus, so no corpus check does either). `scripts/validate-theme-export.mjs` build-covers the token names the templates use, the band ranges, the colours the prose names (held to the ones the exported CSS draws) and the From the source section over a fixture, `scripts/validate-design-md-import.mjs` holds every report note and trait line free of em dashes, and `scripts/validate-shipped-prose.mjs`'s module scan build-covers the em-dash ban in all three files; register, voice and banned words stay this scope's. Their register row is the guide's Playground theme export entry: imperative and addressed to the reader's coding agent in the templates, declarative in the dialog description, and one plain word per band in the vocabulary
- `cli` → the package init bin's terminal output: the usage block, error lines, the deprecation line and success lines in `src/cli/init.mjs` (prints in a consumer's terminal, so it is shipped copy; the em-dash half is build-covered through `validate-shipped-prose.mjs`'s module scan, and its register row in the guide holds errors to the Microcopy standard — what happened, then what to do next), plus the scaffold templates the `init` subcommands write into a consumer's project, every folder under `src/cli/templates/` (`init chat`'s, `init app`'s and `init block`'s; the block comments at the top of each file and the widget's placeholder title, tagline and disclaimer; read as instructions in a stranger's repository, so every path and env name is checked against the files that exist — `scripts/validate-chat-widget.mjs` holds the chat scaffold's file and env names to the docs page and every template's imports to the package, `scripts/validate-blocks.mjs` the blocks' files to their registry, nothing holds the comments — and no validator scans them for em dashes)
- `page-summaries` → the FAB panel's per-page TLDR copy: the `title`, `text`, chip labels and `starters` labels in `website/src/data/page-summaries.json` (`routes` and `essays` maps — the `essays` map is empty by design, since this site has no essays feature — rendered to every visitor on the chat FAB's summary panel, but a data file outside `app/**`, so the `website` scope misses it, the same gap the `footer` scope closes for its copy). `scripts/validate-page-summaries.mjs` build-covers length, the full stop and the em-dash ban; register, voice and banned words stay this scope's — its register row lives in the guide's table
- `loops` → the loops registry's shipped copy: the `description`, `cadence`, `trigger`, `stages` and `guardrails` strings in `website/src/data/loops.json` (rendered verbatim as the /loops cards and embedded in the chat corpus, but a data file outside `app/**`, so the `website` scope misses it — the same gap the `page-summaries` scope closes for its copy). `scripts/validate-loops.mjs` build-covers structure and `scripts/validate-shipped-prose.mjs` the em-dash ban; register, voice and banned words stay this scope's — its register row lives in the guide's table
- `schemas` → the hand-written prose in the meta-schemas at the top of `website/public/schema/` (`title`, `description`, `$comment`) and the `$description` strings `scripts/generate-token-values.mjs` writes into the DTCG files (content-design.md's Schema prose row owns the register)
- `mcp` → the agent-facing routes' shipped copy: the tool blurbs and example prompts in `website/src/lib/mcp-tools.ts`, the connect snippets in `website/src/lib/mcp-clients.ts`, the server instructions and browser landing page in `website/src/app/api/mcp/route.ts`, the shared tool implementations' error and hint strings in `website/src/lib/site-tools.ts` (read by agents over MCP and repeated by the chat model to visitors), the `CHAT_TOOLS` descriptions in `website/src/app/api/chat/route.ts` (read by the model choosing a tool), and the `/llms.txt` section intros in `website/src/app/llms.txt/route.ts` (route handlers and `lib/` modules, not page files, so the `website` scope misses all of them — `scripts/validate-mcp-tools.mjs` holds the roster to the registered tools but judges no prose; their register rows live in the guide's table)
- `stage` → the immersive stages' floating chrome: the toolbar labels and tooltips in `website/src/components/StageToolbar/`, the control-bar labels in `website/src/components/StageControlBar/`, inspect mode's labels and pin/release states in `website/src/components/InspectMode/`, the playground's theme-source import (the dropzone and source cards in `website/src/app/playground/` and the DESIGN.md match report's row notes and the trait list under it in `website/src/lib/theme/design-md.ts`, all four in `validate-shipped-prose.mjs`'s module scan), and the graph instrument's panel and caption copy in `website/src/components/SystemGraph/` — whose column labels ship from `scripts/generate-dependency-graph.mjs`, so fix the generator's strings, never `dependency-graph.generated.ts` (all rendered on the chromeless stage routes, so the `chat`/`footer`/`palette` scopes never reach them, and outside `app/**`, so the `website` scope misses them too — the same gap those scopes close for theirs; `scripts/validate-shipped-prose.mjs` scans the two SystemGraph modules for the em-dash ban, and otherwise no validator scans any of them). Their register row is the guide's Immersive stage copy entry, with the Microcopy section as the label-style standard
2. **Read `content-design.md` first — it is the only rule source.** The Words to Avoid and Patterns to Avoid tables, the Voice rules, the Register by Surface table, and the Microcopy section are the checklist. This skill deliberately maintains no word list and no pattern list of its own: when the guide changes, the audit changes with it. If a rule seems missing, the fix is an entry in `content-design.md` (per its Iteration Guide), never a rule added here.
3. **Scan the scoped prose** and classify every finding at one of three severities:
**Banned** (the guide allows no use in shipped copy):
- Hard-ban words and phrases from the Words to Avoid table
- Em dashes anywhere in shipped copy. `scripts/validate-shipped-prose.mjs` already fails the build on these, so a clean tree means the surfaces it reads are clear and you are checking the ones it cannot judge: the chat's persona and greeting strings, and any prose outside its scope (its doc block is authoritative). Report a hit there as Banned exactly as before
- Title Case in shipped headings, buttons, or labels
- First person in surfaces whose register says "None" (check the Register by Surface table for the scoped surface)
- Emoji, exclamation marks in UI copy, unsourced statistics
**Rationed** (legitimate in a narrow sense; flag for a density check):
- Words from the Rationed table — flag every use, note which look literal, and count per page
- American spellings in prose (colour/color and friends) — never flag code identifiers, token names, CSS properties, or file paths
**Judgment** (needs a reader, not a regex — quote the passage and say why):
- Rhythm uniformity: three or more similar-length sentences in a row
- Rule-of-three adjective stacks, copula avoidance, participial tails, negative parallelism, hedge stacking, elegant variation, bolded-label bullets, summary closers, throat-clearing openers
- Register mismatches: promotional tone in a neutral surface, a tagline restating its section name, an empty state describing absence instead of the next action
4. **Never flag:**
- `content-design.md` itself, and quoted examples anywhere (a rule must be able to name what it bans)
- Skill instruction bodies, `design.md`, `CLAUDE.md` — agent-facing references, out of scope by design (see the guide's Overview)
- Code, identifiers, token names, class names, and anything inside backticks or code fences
- Text authored by third parties (external-skill copies keep their upstream voice)
- The labs rebuilds under `website/src/app/labs/**` and the template screens under `website/src/components/templates/**` (served under `/templates/<slug>`, whether or not they began in labs) — their copy is fictional demo data staging a product screen, excluded from the chat corpus for the same reason, so register and voice rules do not apply to it. The `/templates` index page's own prose is ordinary page copy and stays in scope
5. **For each finding**, output:
- File path (repo-relative) and line number, or the entry label for JSON surfaces
- Severity, the offending text, and the guide rule it breaks
- A suggested rewrite that keeps the sentence's meaning and any links intact
Format: `website/src/app/example/page.tsx:42 — banned — "a seamless theming journey" → "theming by overriding one primitive"`
6. **Summarise** at the end:
- Counts per severity, then the strongest single finding
- If nothing is found: "No content violations found. Prose follows content-design.md."
- Run the guide's Self-Review Tests over the longest passage in scope and report the result, pass or fail
pre-deployRuns the same checks as CI before a push to Vercel: lint, the library type-check, the API compatibility gate against the last release, the publishable npm package build, every Storybook story as a render, interaction, *and* accessibility test (Vitest + headless Chromium + axe, with story play functions asserting behavior) plus the unit tests of the pure modules, the Storybook build, the website lint + build (Next.js), the validators that read the built HTML, and the served-site checks: a hydration smoke in a real browser, a pointer-flood stress of every drag grip, and a page-level axe pass in both themes. Knows the npm-workspace layout and watches for SSR-unsafe code, portal regressions, and static generation failures.
pre-deploy.mdmd---
name: pre-deploy
description: Run the full local verify (lint, the library build and the API compatibility gate, the package build with the publish lint, story tests, Storybook build, website lint + build, the built-HTML validators, and the served-site checks) and confirm the site is safe to push to Vercel (a push to main deploys the live site). Use when asked whether changes are ready to push, deploy, or ship, or for a pre-deploy check.
---
# pre-deploy
Run the full local verify and confirm the site is safe to push to Vercel. A push to `main` deploys the live site (`SITE_URL` in `scripts/brand.mjs` is the authority), so "safe to push" means safe to deploy — a green verify is the gate before that push.
## When invoked
Use this skill when asked to check if changes are ready to push, deploy, or ship — phrases like "is this ready to push?", "run the build", "pre-deploy check", "check before I push".
## Instructions
1. **Run the full verify** from the repo root:
```
npm run verify
```
This is the single source of truth for local checks and mirrors the CI jobs in `.github/workflows/ci.yml`, minus the CI-only checks CLAUDE.md's **CI & Local Verify** section records as deliberate exceptions (its list is authoritative) — so a green verify can still meet a red CI on news from outside, like a fresh advisory. It runs, in order: ESLint, the library type-check, the API compatibility gate (`validate-api-compat.mjs`: nothing the latest `v*` tag published may be removed or narrowed, so the next release stays a minor unless a break is deliberate), the publishable package build (`build:lib` — vite lib mode + d.ts into `dist/`), the story tests (every Storybook story rendered in headless Chromium, **with axe asserting WCAG 2.1 AA on each one** and any story `play` function asserting behavior — an accessibility violation or a failed interaction assertion fails the suite exactly like a render error) and the unit tests (the `unit` project in `vite.config.ts`, `src/**/*.test.ts` in Node), the Storybook build, the website lint, the website build, and then the checks that need the finished build rather than source — the tail of the `verify` entry in the root `package.json` is the authoritative list of these, same as the registry chain below. Some read the prerendered HTML (a space lost between JSX and render, say, or an href pointing at a route that no longer exists); the rest serve the build and load it in a real browser — hydration must succeed, every checked page must be visible with content, and it must stay visible when hydration is made to fail, because a build can be green while the rendered site is blank (the hydration smoke's doc block records the outage that proved it), every grip in `scripts/gestures.json` must survive a throttled pointer flood with no page error, no empty body and no runaway chain of React commits (the gesture check's doc block owns the budget and the drag that blanked the playground), and the served pages must pass axe in both themes at the same rule set Storybook enforces. That is why all of them follow the website build instead of riding the registry chain. (The library half gains one post-build check of its own: `validate-package-publish.mjs`, publint and arethetypeswrong over `dist/` after `build:lib`.) The registry validators run via the builds' `prebuild` hooks, so a registry-drift failure surfaces before the compiles even start. The `validate-registry` entry in the root `package.json` is the authoritative list of what runs; read the failing script's own doc block for what it guards, because the failures do not share a family resemblance — one means an unregistered component, another that a prose edit removed a fact the chat eval depends on (`validate-chat-coverage.mjs`), another that the ambient background references a colour token that no longer exists (`validate-shader-background.mjs`). This list used to be enumerated here and went stale twice; a pointer cannot. The prebuild also regenerates the derived surfaces owned by the `validate-registry` chain (the generator scripts at the front of its entry in the root `package.json` are the authoritative list) — if any come out modified, commit them with the work that changed their source.
Note: the repo is an npm workspace — one `npm install` at the root covers the website too, and the website resolves `rift-ds` through a symlink to the repo root. The website build is a plain `next build`; there is no separate install step inside `website/`.
2. **Check the output of each step for:**
**Lint failures:**
- Any ESLint `error` lines (warnings don't fail the run, but mention them)
**TypeScript errors:**
- Any `error TS` lines
- Type mismatches, missing props, invalid imports
**Next.js-specific issues:**
- `"use client"` missing on components that use browser APIs (`window`, `document`, `localStorage`, `useEffect`, `useState`, etc.)
- SSR-unsafe code running outside client guards — particularly watch `website/src/app/layout.tsx` (the inline `themeScript`)
- Portal/modal components that reference `document` at module or render scope — these have caused past static-build failures (AlertDialog and Toast both needed fixes; watch for regressions if portal-based components change)
- Pages that fail static generation (look for `Error occurred prerendering page`)
**General failures:**
- Any non-zero exit code
- `Build failed` or `Compiled with errors`
3. **Report result:**
If everything passes:
> Verify passed (lint + story tests + library, package, Storybook, and website builds, plus the API compatibility gate, the publish lint, the built-HTML validators, and the served-site checks). Safe to push.
**If the change touched component CSS (the chat widget's under `src/chat/` included), `src/tokens/`, or `.storybook/`, also dispatch the Chromatic workflow** (`gh workflow run chromatic.yml`) — once a Chromatic project is provisioned for this repo (none exists yet) — and check the diff before or right after pushing — visual regressions are the one thing `verify` cannot see, and Chromatic is deliberately not part of it because every run bills cloud snapshots against a monthly budget. Text-only, script-only, or website-prose changes don't need a run.
If any step fails, show:
- Which step failed (lint, component library, the API compatibility gate, story tests, Storybook, website lint, website build, or one of the checks that run after it — the built-HTML validators or the served-site checks; the tail of the `verify` entry in the root `package.json` is the authoritative list; a gesture failure is either a stale selector or setup, the drag that took nothing, or a real per-move render regression, the commit budget)
- If **story tests** failed, say whether it was a render error, an **a11y violation**, a **play-function assertion**, or a **unit assertion** (a `*.test.ts` in the `unit` project, fixed in the pure module it tests, not in a story) — they surface identically but are fixed differently. An axe failure names the rule (e.g. `button-name`, `nested-interactive`) and the offending markup; contrast is deliberately excluded from the gate, so a contrast complaint means someone re-enabled `color-contrast` in `.storybook/preview.ts`. A play-function failure (e.g. `expect(element).toHaveFocus()`) means a behavior regression — for an overlay, look at the shared hooks in `src/behaviors/` before the component itself
- The exact error message(s)
- File path and line number if available
- A brief diagnosis of likely cause
4. **Do not push** — this skill only checks and reports. Pushing is the owner's decision.
shipMakes finished work live on the deployed site. Surveys the tree so unrelated files never get swept into a commit, runs the full local verify (the single script mirroring CI) before anything is committed, merges branch work into main when needed, pushes, confirms the CI run goes green, then loads the deployed site in a real browser to prove it renders (a deploy is not done at HTTP 200), and reports exactly what deployed.
ship.mdmd---
name: ship
description: Make finished work live on the deployed site (SITE_URL in scripts/brand.mjs is the authority). Commit, run the full verify, merge branch work into main when needed, push, watch CI go green, then prove the deployed site actually renders with the live hydration smoke. Use when asked to ship it, make it live, push to main, or deploy this. If asked to "merge and push" (a retired skill name), confirm the intended end state — ship, checkpoint, or land — before acting.
---
# ship
Make finished work live on the live site (`SITE_URL` in `scripts/brand.mjs` is the authority for where that is) — builds green first, no unrelated files swept in, a clear report after. The end state is always the same: the work is on `main`, pushed, and deployed.
## When invoked
Use this skill when asked to make completed work live — phrases like "ship it", "make it live", "push to main", "deploy this". This skill always ends with a deploy; to save progress without deploying, that's `checkpoint` (or `park` to also return to main).
**If asked to "merge and push"**: that's the retired ambiguous skill name, and it now maps onto more than one verb. Confirm the intended end state before doing anything: `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).
## Instructions
0. **Check the branch**: `git branch --show-current`.
- **On `main`**: follow steps 1–9 directly. **A push to main deploys the live site** (`SITE_URL` in `scripts/brand.mjs` is the authority).
- **On any other branch**: the work rides the branch into `main`. Follow steps 1–4 on the branch (commit there), then merge in step 5. Never cherry-pick or copy files across branches to avoid a merge.
1. **Survey the tree before touching anything**: run `git status --short` and classify every entry:
- **In scope** — files created or modified as part of the work just completed in this session
- **Out of scope** — anything untracked or modified that predates the session, or that wasn't part of the requested work
**Never run `git add -A`, `git add .`, or `git add` on a directory** — always add explicit file paths. Out-of-scope files are excluded by default and named in the final report; if it's genuinely unclear whether something belongs, ask before including it.
2. **Run the full verify before committing**:
```bash
npm run verify # the single local mirror of CI — the script entry in the root package.json is the step list
```
This one script is the single source of truth for local checks and mirrors the CI jobs in `.github/workflows/ci.yml` — if CI gains a check (tests, a11y), it gets added to `verify`, never listed here separately, except the CI-only checks CLAUDE.md's **CI & Local Verify** section records as deliberate exceptions (its list is authoritative). The registry validators run automatically via the builds' `prebuild` hooks.
**A labs-only change** (only `website/src/app/labs/` and the few files a lab registers in; `scripts/labs-only-change.mjs` owns the allowlist) may run `npm run verify:labs` instead: CI's lighter labs path, which drops the story tests, the Storybook build, the package steps and the site-wide gesture and axe sweeps. It opens with the allowlist check and fails if anything outside labs changed, so it cannot stand in for a change that needs the full verify.
**Run it plainly and let its own exit status be the verdict. Never pipe verify through `tail`, `head`, or `grep`** — a pipeline reports the last command's exit code, not verify's, and that exact mistake masked two red builds on 2026-07-26. **If any step fails, stop** — fix the failure if it was caused by this session's work, otherwise report it. Never push red.
Note: the build regenerates the derived surfaces owned by the `validate-registry` chain — the generator scripts at the front of the `validate-registry` entry in the root `package.json` are the authoritative list of what gets rewritten. If a generated file changed after the builds, it changed because this session's work made it stale — treat it as in scope and commit it in the same push, either alongside the edits that caused it or as its own `chore(generated):` commit when the regeneration is large enough to bury the real change (both are sanctioned; the 0.19.0 and 0.20.0 component drops used the split). CI's drift guard is a `git diff --exit-code` step and an untracked-file check after the generators run in `.github/workflows/ci.yml`, so what it cares about is that no regeneration is left uncommitted, a newly generated file included, not which commit carries it.
3. **Delta-scoped prose check** — the step that keeps drift audits boring. For the identifiers this session's diff touched (component names, prop names, script names, moved/deleted paths), grep the prose surfaces — `.claude/skills/`, `README.md`, every root spec (the doc list in `scripts/validate-doc-refs.mjs` is the authoritative set; it includes tracked specs that are not published to /blueprints, which the `FILES` array in `scripts/sync-blueprints.mjs` deliberately omits), and the data registries whose text fields ship verbatim as prose (CLAUDE.md's **Registries** table names them; the component and widget `description` fields in `src/components/registry.json` and `src/chat/registry.json` are the ones most often missed, and they reach the MCP endpoint, the site chat's corpus, the consumer agent skill and page metadata word for word) — and judge whether any claim just became false (`README.md` ships in the npm tarball, so a false claim there reaches every consumer). Any prose the session wrote or rewrote also follows `content-design.md` (run its Self-Review Tests on anything longer than a sentence). Fix what did in the same push; `validate-doc-refs` catches dead references mechanically, but only a reader catches a sentence that is now wrong.
**When the diff touches a `*Props` or `*OwnProps` type** under `src/components/` or `src/chat/`, two more checks. Run the `api-consistency` skill scoped to those components (its scoped mode reports only on them and their family). Then sweep internal callers (`src/`, `website/src/`, the stories) for **every** deprecated spelling of that component's API, older aliases included, not only the one this session retired: a caller still on a spelling two renames old renders today and fails the day the alias goes.
4. **Group changes into logical commits** — one commit per concern, not one giant commit. Match the repo's conventional style (`feat(scope):`, `fix(scope):`, `chore(scope):`), with a 1–3 sentence body explaining the why. Check `git log --oneline -5` if unsure of the voice.
5. **Merge (branch case only)**: with the branch committed and verify green:
```bash
git checkout main
git pull --ff-only
git merge <branch>
```
A fast-forward or a merge commit are both fine. **If the merge conflicts, stop and report** — never resolve conflicts silently as part of a ship. Never force-push to make a merge "work".
6. **Push**: `git push` on `main`. Remember: **a push to main deploys the live site via Vercel** (`SITE_URL` in `scripts/brand.mjs` is the authority) — pushing is publishing.
If the pushed work changed component CSS (the chat widget's under `src/chat/` included), anything under `src/tokens/`, or `.storybook/`, offer to dispatch Chromatic (`gh workflow run chromatic.yml`) — once a Chromatic project is provisioned for this repo (none exists yet) — `verify` proves nothing about pixels, and this is the decision point pre-deploy's Chromatic rule exists for. It bills cloud snapshots, so it's an offer, not an automatic step.
Chromatic only snapshots Storybook, so it says nothing about the website. If the pushed work touched a site-wide background surface — the config (`website/src/data/shader-background.json`), the site's composition of it (`website/src/components/BlurBackground/`, and the `.blur-container` rules in `website/src/app/globals.css` that place, mask and fade it), the renderer itself (`src/components/ShaderField/`), the background pick that can put a scene in the field's place on every page (`SITE_BACKGROUNDS` in `website/src/lib/shaders/background-storage.ts`, its hooks in `background.ts` and `lazy-scene.ts`, the header's `website/src/components/BackgroundSwitcher/`), the scene renderer (`src/components/ShaderScene/`, in the library, so Chromatic sees it only as a story, never as a site background), the shaded-chat recipe (`website/src/components/StagedShader/ShadedChat.module.css`, which grounds the chat home template, the playground's chat card and the home reel's shaded mode at once), or the dotted ground (`website/src/components/DotBackground/`) under the immersive stages and inside the home page's chat frame — offer a `visual-review` pass instead (one page under each `SITE_BACKGROUNDS` row, plus `/templates/chat-home` and the playground's Chat view when the shaded-chat recipe changed): all but the last change the background on all of the site's pages at once, the last is the whole ground under the stage pages and the home page's chat frame (review `/` too), and no automated gate covers any of them. `verify` proves the config validated and the shader compiled, not that the result looks right. The renderer is the easiest to miss, because it lives in the library rather than the website and Chromatic's Storybook snapshots do not cover a full-viewport site background.
Same pattern for the chat: if the pushed work changed what the site chat answers from or how it answers — the corpus sources (page prose feeds `site-corpus.generated.ts` by construction), the persona or guardrail configuration in `website/src/app/api/chat/`, the handlers and guardrails the package ships in `src/chat/server.ts` and `src/chat/upstash.ts` (the site's routes are thin callers of them), the route itself, or the shared lookup implementations in `website/src/lib/site-tools.ts` (which also serve `/api/mcp`, so the same edit deserves an MCP spot-check) — offer to run the answer-quality eval (`npm run eval:chat`, ritual in `evals/chat/README.md`). `verify` proves the corpus regenerated, not that the answers stayed good, and the eval costs real API spend — an offer, not an automatic step.
7. **Confirm CI went green**: after the push, watch the GitHub Actions run to completion:
```bash
gh run watch $(gh run list --workflow=ci.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status
```
The `--workflow=ci.yml` filter is load-bearing: a push to main can trigger other workflows within the same second (CodeQL, if it is enabled for this repo — check rather than assume), and an unfiltered `--limit 1` can hand you one of those runs instead. `--exit-status` makes a red run exit non-zero rather than reporting and returning 0. CI runs in parallel with the Vercel deploy — it gates nothing, but a red run on main means something the local verify missed (or an environment difference) and must be investigated, not left as a red X.
**Branch case, after CI is green**: delete the merged branch — `git branch -d <branch>`, and `git push origin --delete <branch>` if it was pushed. Its commits are on `main`; the repo stays main-only by default. Name the deletion in the report.
8. **Prove the deployed site renders** — the step the 2026-09-06 outage was missing. A green verify, a green CI run, and an HTTP 200 all held that morning while every JS browser showed a blank page: the failure lived in Vercel's runtime rendering (the root route's ISR regeneration sees an internal pathname no local run can reproduce), so only the live site can prove itself. Read the URL from `scripts/brand.mjs` (the authority), never from memory. First confirm the new deployment is what's serving — the `data-dpl-id` in the homepage HTML changes with every deploy:
```bash
SITE_URL="$(node -e "import('./scripts/brand.mjs').then(b=>console.log(b.SITE_URL))")"
curl -s "$SITE_URL/" | grep -o 'data-dpl-id="[^"]*"'
```
If it hasn't changed from before the push, wait a moment and re-check — Vercel usually finishes before CI does. Then run the live hydration smoke:
```bash
node scripts/smoke-hydration.mjs "$(node -e "import('./scripts/brand.mjs').then(b=>console.log(b.SITE_URL))")"
```
The script's doc block owns what it asserts, and its passes are labelled in the output, so a red line names which one failed. **A red result means the deploy may have taken the site down — treat it as an active outage, not a report line**: diagnose immediately, and if the cause isn't quickly fixable, revert the deploy (`git revert` the pushed commits and push again) rather than leaving the site dark while investigating. Never skip this step because verify and CI were green — they were green during the outage too.
9. **Report** in the final message:
- Each pushed commit (hash + subject), confirmation `npm run verify` passed locally, the CI run result, and the live smoke result (step 8)
- Every file deliberately left out and why
- Anything the deploy will visibly change on the live site
- Branch case: the merge and the branch deletion
## Guardrails
- Never force-push, never rewrite pushed history
- Never commit `.env*` or anything credential-shaped — even if explicitly staged by mistake
- If there is nothing in scope to commit and nothing unmerged on the branch, say so and stop — don't invent a commit
- Shipping is the owner's call: only invoke this flow when asked to ship, and never chain into it automatically from other work
super-shipChains the drift audit, an API consistency audit and the responsive sweep into the ship flow for structural work. Runs the full drift audit, then the API audit over the components the work touched, fixes the broken and stale findings both surface, stops to ask if any audit section was skipped, sweeps every page at every breakpoint width and fixes the layout breaks it finds, then ships the combined result: full verify, merge into main, push, CI watched to green, and the live site proven rendering. Gap findings and new-validator recommendations are reported as follow-ups, never built mid-ship.
super-ship.mdmd---
name: super-ship
description: The bulletproof ship for structural work. Run the full drift audit and a scoped API consistency audit, fix the broken and stale findings, sweep every page at every breakpoint width for layout breaks and fix those, then ship the combined result live via the ship flow. Use when asked to super ship, audit and ship, or ship bulletproof, or when shipping a structural change where the docs and skills may have gone stale. For a small change, plain ship is enough.
---
# super-ship
Drift audit, API audit, fix, re-check, responsive sweep, ship — one invocation. A higher-order skill: it composes `drift-audit`, `api-consistency`, `responsive-sweep` and `ship` and owns only the rules of the composition. Everything about *how* to audit lives in `.claude/skills/drift-audit/SKILL.md` and `.claude/skills/api-consistency/SKILL.md`; everything about *how* to sweep lives in `.claude/skills/responsive-sweep/SKILL.md`; everything about *how* to ship lives in `.claude/skills/ship/SKILL.md`. Read all four and follow them; this file only says how they connect.
## When invoked
Use this skill when asked to "super ship", "audit and ship", or ship "bulletproof" — typically after structural or architectural work (a moved directory, a new build step, a renamed surface, a changed dependency model) where the repo's self-descriptions may have gone stale and a plain ship would deploy them stale.
**Not for small changes.** A colour tweak, a copy fix, a single-component change: that's plain `ship`. The audit sweeps every skill and doc, and running it for a one-line change is ceremony, not safety. If invoked on something clearly small, say so and offer plain `ship` instead.
**Invoking this skill is the ask to ship.** `ship`'s guardrail says never to chain into it automatically from other work, and `drift-audit`'s says the report is the deliverable and fixes wait for approval. This skill is the sanctioned exception to both: by invoking it, the owner has pre-approved fixing the drift the audit finds *and* deploying the result. The carve-outs below say where that pre-approval stops.
## Instructions
### 1. Audit
Run the full `drift-audit` flow per its SKILL.md — every section, not a lightened pass. The one behavioural change: at its final step, do not stop to ask whether to apply fixes. The full report still gets produced; it becomes part of the final report here.
If the audit finds `validate-registry` itself broken, stop the whole flow and report — its own rule, and nothing downstream is trustworthy until it's fixed.
**Coverage gate.** Read the coverage table at the end of the drift-audit report. If any section is marked skipped, or was narrowed to less than the change-type playbook calls for given the kinds of change in the session's diff, stop and ask before going further. The owner may accept a narrow audit for a small change; that is their call to make with the table in front of them, never the flow's to make silently. A narrowed audit that deployed is exactly how stale descriptions have shipped before.
### 1b. API audit
Run `api-consistency` in its scoped mode over every component the session's diff touched (any file under `src/components/` or `src/chat/`) plus each one's family. Widen to the whole library only when the change is library-wide (a token change, or a change to a convention every component follows), and say in the report that you did. If the diff touches no component, record that the API audit had nothing in scope and move on.
### 2. Fix
Apply the **Broken** and **Stale** findings in-session. All of `drift-audit`'s guardrails still hold while fixing: never edit a generated file to resolve a finding, never weaken a validator.
For **API findings**, the line is the published contract:
- **Fix in-session** the drift-like ones, which change no consumer's code: a stale registry description or design.md spec, an internal caller on a deprecated spelling, a showcase page or story demoing a deprecated name.
- **Stop and ask** on anything that changes the published contract: a rename, a new prop, a new deprecation. Each of those moves semver, and the version is the owner's call.
Two categories of finding are **reported, not acted on**:
- **Gaps** (missing skill coverage, a recommended new validator): these are new work, not drift repair. Building a validator mid-ship is scope creep; list them as follow-ups.
- **Judgement calls**: a finding that might be deliberate scope, or whose fix could reasonably go two ways, or that touches a decision recorded elsewhere (e.g. a settled decision with an authoritative comment). Stop and ask rather than resolving it silently to keep the pipeline moving. A super ship that pauses on a real question is working as designed.
### 3. Re-check
Re-run the audit's mechanical checks over whatever the fixes touched (at minimum `npm run validate-registry`), so a fix can't itself introduce a dangling reference, and re-run the scoped API audit over any component a fix changed. Then check the combined change against drift-audit's change-type playbook: every surface it names for the kinds of change present has moved. There's no need to repeat the full prose read.
### 3b. Responsive sweep
Run the `responsive-sweep` flow per its SKILL.md, against a website build of the combined tree (session work plus the audit fixes), so what is swept is what deploys. Every route, the full width ladder: never `--quick` or `--routes` for the sweep of record. Two composition rules:
- **Defects are fixed in-session**, under the same pre-approval as drift: a layout break with one right answer is a repair, not new work. A break whose fix is a design decision (a new arrangement at a width, a change to a component's published behaviour) is a judgement call: stop and ask.
- **Proposed acceptances stop the flow.** An entry in the script's `ACCEPTED` list hides a finding from every later run, so it waits for the owner's yes, like a judgement call. Ship once the owner has answered: each accepted entry is committed, each rejected one is fixed or left as a reported follow-up.
The sweep's fixes are their own concern for ship's commit rule: something like `fix: responsive breaks found by the pre-ship sweep`, with each break and its widths in the body.
### 4. Ship
Follow the `ship` flow per its SKILL.md, from its step 0, with everything that entails — tree survey, full verify, logical commits, merge if on a branch, push, CI watched to green, and the live render proven with the deployed-site smoke. Two composition rules on top:
- **Commit the drift fixes separately** from the session's own work — they are a different concern, and ship's one-commit-per-concern rule already implies it. Something like `docs: repair drift found by pre-ship audit` with the findings named in the body.
- **Ship the combined result or nothing.** Never push the session work while leaving audit or sweep fixes uncommitted, or the reverse — the whole point is that what deploys and what describes it move together. If verify goes red on an audit fix, that fix is in scope to repair, same as session work.
### 5. Report
One combined final message:
- The audit summary in `drift-audit`'s report format (broken / stale / gaps / verified counts), with its coverage table, and any narrowing the owner accepted at the coverage gate
- An API consistency section: the components in scope (and whether it was widened to the whole library, and why), the findings fixed, and the contract changes left for the owner
- A responsive sweep section in `responsive-sweep`'s report format: routes and widths swept, breaks fixed, acceptances the owner approved, and breaks left for a design decision
- Which findings were fixed and shipped, and which are left as follow-ups (gaps, judgement calls)
- Everything `ship`'s report requires: commits pushed, verify and CI results, files left out, what visibly changes on the live site
## Guardrails
- This file never restates a step from `drift-audit`, `responsive-sweep` or `ship` — a change to any of them flows through automatically. If a conflict appears between this file and one of them, theirs wins for their own steps; this file wins only on the composition rules above.
- The pre-approval covers fixing drift and deploying; it does not cover changing the published contract, shipping past a skipped audit section, resolving judgement calls, building new validators, or resolving merge conflicts — those stop and ask, per the rules above and ship's own guardrails.
- If the audit comes back completely clean and there is nothing to ship, say so and stop — a clean audit is a valid outcome, not a failure to find something.
checkpointSaves work in progress to a remote branch as a safety net, then stays on that branch so work continues. It never touches main and never deploys: if invoked on main it moves the work to a new branch first, because pushing main publishes the site. A quick lint runs before the push and failures are flagged without blocking the backup.
checkpoint.mdmd---
name: checkpoint
description: Save work in progress to a remote branch and keep working. Never touches main and never deploys; if invoked on main it moves the work to a new branch first. Use when asked to checkpoint, save progress, back this up, or push to the branch.
---
# checkpoint
Save the session's work-in-progress to a remote branch, then keep working on it. Nothing merges, nothing deploys, `main` is never touched. The end state: the work is safely on GitHub and the session stays on the branch.
## When invoked
Use this skill when asked to save unfinished work — phrases like "checkpoint", "save my progress", "back this up", "push to the branch". When the work is finished and should go live, that's `ship`; to save and *stop* working on it, that's `park`.
**If asked to "merge and push"**: that's the retired ambiguous skill name, and it now maps onto more than one verb. Confirm the intended end state before doing anything: `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).
## Instructions
1. **Check the branch**: `git branch --show-current`.
- **On a work branch**: commit and push there (steps 2–5).
- **On `main`**: move the work to a branch first — there is no "push to main but don't deploy", because a push to `main` deploys the live site (`SITE_URL` in `scripts/brand.mjs` is the authority). Create a branch named for the work, not the date: `git checkout -b wip/<short-topic>` (e.g. `wip/nav-search`, `wip/chart-tokens`). Uncommitted changes ride along automatically. Never commit directly to `main` from this skill.
2. **Survey the tree**: run `git status --short` and classify every entry as in scope (this session's work) or out of scope (predates the session or wasn't part of the requested work). **Never run `git add -A`, `git add .`, or `git add` on a directory** — always add explicit file paths. Out-of-scope files stay out and are named in the report.
3. **Quick check, not the full verify**: run `npm run lint`; if the session touched `website/`, also run `npm --prefix website run lint` (root ESLint ignores the website workspace, so the root lint alone carries no signal about website work). This is a backup, not a release — the full `verify` (several minutes) is `ship`'s job. **A lint failure does not block the push**: the whole point is that the work is saved even mid-mess. But flag any failure loudly in the report so it isn't a surprise at ship time.
4. **Commit in the repo's conventional style** (`feat(scope):`, `fix(scope):`, `chore(scope):`, with a short why in the body) — checkpoint commits eventually reach `main` through a merge, so they are real history, not throwaways. One commit is fine if the work is one concern; split if it's clearly several.
5. **Push the branch**: `git push -u origin <branch>` (same-named remote branch). Pushing a branch publishes nothing — no deploy, no site change.
6. **Stay on the branch** and report:
- The branch name and each commit (hash + subject)
- Lint status, including any failure being flagged rather than fixed
- Every file deliberately left out and why
- The closing line: work is backed up, session continues on the branch; say `ship` when it should go live, `park` to set it aside
## Guardrails
- Never touch `main`: no commits to it, no merges into it, no pushes of it
- Never force-push, never rewrite pushed history
- Never commit `.env*` or anything credential-shaped — even if explicitly staged by mistake
- If there is nothing in scope to commit, say so and stop — don't invent a commit
- Never chain into `ship` automatically — going live is always a separate, explicit ask
parkShelves an experiment: commits the session's work, pushes it to a remote branch, then returns to a clean main. Nothing deploys and nothing merges; the report names the branch so the work is easy to resume later.
park.mdmd---
name: park
description: Commit and push the session's work to a branch, then return to a clean main. Nothing merges and nothing deploys. Use when asked to park this, shelve this, or set an experiment aside for later.
---
# park
Save the session's work to a remote branch, then step off it and return to a clean `main`. Same safety net as `checkpoint`, different end state: the session ends back on `main`, with the experiment shelved under a named branch.
## When invoked
Use this skill when asked to set the current work aside — phrases like "park this", "shelve this", "set this aside". To save and *keep* working on the branch, that's `checkpoint`; to make the work live, that's `ship`.
**If asked to "merge and push"**: that's the retired ambiguous skill name, and it now maps onto more than one verb. Confirm the intended end state before doing anything: `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).
## Instructions
1. **Get the work onto a branch**: `git branch --show-current`.
- **On `main`**: create a branch named for the work, not the date — `git checkout -b wip/<short-topic>` (uncommitted changes ride along). Never commit to `main` from this skill.
- **On a work branch already**: stay on it.
2. **Survey the tree**: run `git status --short` and classify every entry as in scope (this session's work) or out of scope. **Never run `git add -A`, `git add .`, or `git add` on a directory** — always add explicit file paths. Note that out-of-scope untracked files are untouched by branch switches — they will still be sitting in the tree after the return to `main`; name them in the report.
3. **Quick check, not the full verify**: run `npm run lint`; if the session touched `website/`, also run `npm --prefix website run lint` (root ESLint ignores the website workspace). A failure does not block the park — a shelved experiment is allowed to be mid-mess — but it goes in the report so resuming starts with eyes open.
4. **Commit in the repo's conventional style** (`feat(scope):`, `fix(scope):`, with a short why) — a parked branch may later merge to `main` via `ship`, so its commits are real history.
5. **Push the branch**: `git push -u origin <branch>`. Pushing a branch publishes nothing.
6. **Return to main**: `git checkout main`, then confirm with `git status --short` that the tree is clean (out-of-scope untracked files excepted).
7. **Report**:
- The branch name — this is the resume handle; say it plainly ("to pick this back up, ask to resume `wip/<topic>`")
- Each commit (hash + subject), lint status, files left out and why
- Confirmation the session is back on a clean `main` and nothing deployed
## Guardrails
- Never touch `main`: no commits to it, no merges into it, no pushes of it
- Never force-push, never rewrite pushed history
- Never commit `.env*` or anything credential-shaped — even if explicitly staged by mistake
- If there is nothing in scope to commit, say so and stop — don't invent a branch
- Never delete the parked branch — it is the only copy of the shelved work
landResolves every piece of pending work in the repo in one pass. Sweeps worktrees, branches, uncommitted changes and stashes, reads each one to judge whether it is finished, superseded or abandoned, and proposes a disposition for all of it at once: land, keep, or delete. Approved work merges into a local main one branch at a time and the combined result runs the full verify; anything deleted is archived to a recoverable tag first, and nothing is ever pushed.
land.mdmd---
name: land
description: Triage every piece of pending work in the repo and resolve all of it in one pass. Sweeps worktrees, local and remote branches, the working tree, and stashes; assesses each one as worth landing, worth keeping, or junk; then merges what lands into a local main, archives and deletes the junk, and verifies the combined result. Never pushes and never deploys. Use when asked to land the work, combine parallel sessions, clean up branches and worktrees, or work out what is worth shipping.
---
# land
Resolve **all** the pending work in the repo in one pass. Some of it is finished and should ship, some is half-built and should wait, some is stale and should go. `land` finds every piece, forms a view on each, gets one decision from the owner, and leaves the repo in the state that decision implies.
The end state: a local, unpushed `main` carrying the work that was worth keeping and passing `npm run verify`, with everything discarded archived to a recoverable tag and every branch and worktree that no longer earns its place gone.
One of the shipping verbs (CLAUDE.md's Shipping vocabulary owns the roster). `ship` makes one line of work live, `checkpoint` saves it, `park` shelves it; `land` is the one that runs when several of them have piled up and it is no longer obvious what is worth shipping.
## When invoked
Use this skill when pending work has accumulated and needs sorting out — phrases like "land the work", "combine my branches", "clean up the branches", "what's worth shipping". It is the right skill whether the work arrived from parallel sessions, from experiments that were parked and forgotten, or from a working tree that drifted.
**If asked to "merge and push"**: that's the retired ambiguous skill name, and it now maps onto more than one verb. Confirm the intended end state before doing anything: `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).
**It never pushes and never deploys.** Landing is local, deliberately: a batch of accumulated work reaching the live site (`SITE_URL` in `scripts/brand.mjs` is the authority) is a decision, not a side effect of tidying up. When `main` is landed and green, `ship` takes it live.
## Instructions
### 1. Establish the base
```bash
git branch --show-current
git rev-parse main
git fetch --prune
```
**Record the `main` SHA before anything moves.** It is the unwind handle for step 8, the diff anchor for step 7, and belongs in the final report; nothing else can restore a half-landed `main`.
Then check divergence with `git rev-list --left-right --count main...origin/main`. If `main` is behind, `git pull --ff-only`. If it has genuinely diverged, stop and report: something pushed elsewhere, and reconciling that is its own decision.
The primary checkout should be on `main`. Uncommitted changes there are **not** a blocker — they are a candidate like any other, handled in step 2. Do not bounce out to `checkpoint`.
### 2. Sweep every source of pending work
Five places work hides. Sweep all of them; a session that ended may have left any combination.
```bash
git status --short # the working tree
git worktree list --porcelain # worktrees (exclude the primary checkout)
git worktree prune --dry-run -v # registrations whose directory is gone
git branch --no-merged main # local branches carrying work
git branch -r --no-merged origin/main # remote branches carrying work
git branch --merged main # merged leftovers: cleanup only, nothing to land
git stash list # forgotten stashes
git tag -l 'archive/*' # what past runs already archived
```
- **The working tree is a candidate.** Uncommitted changes usually contain more than one concern. Classify them into piles by what they belong to, not by file type, and treat each pile as its own candidate. This repo's generated files (see step 6) often span piles, because regenerating reflects everything in the tree at once.
- **Merged branches** carry nothing. They skip triage and go straight to disposal.
- **Stale worktree registrations** are normal, not a problem to investigate: agent worktrees auto-remove when they end unchanged.
- **Existing `archive/*` tags** are reported, not acted on. They are the recovery trail from earlier runs. Only propose pruning one when its work has demonstrably shipped by another route.
### 3. Read each candidate, then judge it
```bash
git log --oneline main..<branch> # what it carries
git log -1 --format='%ci (%cr)' <branch> # how old the tip is
git diff --stat main...<branch> # how wide it reaches
git -C <worktree-path> status --short # what is NOT in those commits
```
**Read the diff, never the branch name.** Agent branches are auto-named (`claude/musing-sanderson-5a94ee`) and say nothing about their contents. Even hand-named branches describe the intent at creation, not what survived.
Then test whether `main` has moved out from under it — the difference between *old* and *stale*:
```bash
git log --oneline main --not <branch> -- $(git diff --name-only main...<branch>)
```
Commits here are changes `main` made to the very files the candidate touches. A short list means the work still applies. A long list, or a rewrite of the same component, means the candidate is probably superseded and its conflicts are not worth resolving.
Form an actual view on each candidate, and say it plainly in step 5:
- **Finished** — coherent, complete, matches current conventions. A landing candidate.
- **Unfinished** — mid-build, or its own notes record it as still being tuned. Keep, do not land.
- **Superseded** — `main` solved this another way, or rewrote underneath it. Delete.
- **Abandoned** — old, narrow, and nothing since referenced it. Delete.
**The dirty-worktree rule.** Uncommitted changes in a worktree are not on its branch, so merging the branch silently drops them and removing the worktree destroys them. Treat any dirty worktree as a session possibly still in flight: name every uncommitted file, and never land or remove it on an assumption. Committing another session's half-finished work is not this skill's call.
Do not run `npm install` in a worktree, and do not lint one with no `node_modules`. A fresh worktree has none, installing root plus the website workspace in each is slow, and step 8's combined `npm run verify` carries the real signal.
### 4. Predict the collisions
For every landing candidate, before anything moves and without touching the working tree:
```bash
git merge-tree --write-tree --name-only main <branch>
```
Exit 0 is clean; exit 1 conflicts. On a conflict the first output line is the merged tree's object id, not a path — conflicted paths are the lines after it, up to the blank line. Read the exit status directly and **never pipe this through `head`**, for the same reason `verify` is never piped: the pipeline reports the wrong command's status.
Two honesties for the report: predictions are against **today's** `main`, so once one branch lands the rest are provisional and get re-predicted before each merge; and two branches that each merge cleanly can still conflict with each other.
### 5. Propose a disposition for everything, then confirm once
Present **one table covering every candidate**, with a proposed disposition and the reason for it:
| Candidate | Kind | Age | Carries | Conflicts | Proposed | Why |
|---|---|---|---|---|---|---|
Dispositions are exactly three:
- **Land** — merge into `main` in this run
- **Keep** — leave exactly as it is, changing nothing
- **Delete** — archive to a tag, then remove the branch and worktree
Follow the table with a short plain-English reading of anything non-obvious, especially every **Delete** proposal. A delete needs a stated reason, not just an age.
**Confirming at scale.** The candidate count is unbounded, and `AskUserQuestion` caps at four options, so never ask per candidate. Ask **one** question about the triage as a whole, with options along the lines of: accept as proposed; accept the landings but keep everything marked delete; land nothing and only dispose; stop and change nothing. The owner names exceptions in free text ("delete these 4", "keep cosmic-wind"). Apply any exceptions, re-present the amended table in two or three lines, and proceed without asking a second full question.
The default is that **nothing lands and nothing is deleted until it is named**. Silence is not approval.
If predicted conflicts make the order matter, recommend one: fewest conflicts first, so the hardest merge happens against the most complete `main` and gets resolved once.
**Mixed candidates.** When a branch is partly worth landing, offer to land a subset by `git cherry-pick <sha>` rather than forcing all-or-nothing, and archive the full branch before deleting the remainder. This is the one place cherry-picking is sanctioned; `ship` bans it because there it means dodging a merge, which is a different act from deliberately selecting commits.
### 6. Land what lands
One branch at a time, each as its own merge:
```bash
git merge <branch>
```
**Never octopus-merge** (`git merge a b c`): it refuses outright on any conflict and leaves history that cannot be bisected. Never rebase another session's branch. Never force anything.
Uncommitted piles that were approved for landing get committed on `main` in the repo's conventional style (`feat(scope):`, `fix(scope):`, `chore(scope):`), one commit per concern, with a 1–3 sentence body explaining the why. **Never `git add -A`, `git add .`, or `git add` on a directory** — always explicit paths. When piles must be committed separately but share generated output, stash the other pile by pathspec, regenerate, commit, restore, regenerate, commit — so each commit carries a generated state that matches its own sources.
**Conflict policy** — three cases resolve mechanically, one never does:
- **Generated surfaces** (everything the generator scripts at the front of the `validate-registry` entry rewrite — the `*.generated.ts` files under `website/src/data/` and `website/src/config/`, the barrels, the token registry and values (`src/tokens/registry.json`, `src/tokens/values.json`), the marked regions of `README.md`, the generated trees under `website/public` — the blueprint copies, the per-component `components/*.md` pages, the shadcn registry under `r/`, the component schemas and token files under `schema/`, the consumer agent skills under `skill/` — their folders are named by `AGENT_SKILLS` in `scripts/brand.mjs` — and the agent starter, `AGENTS.md`): never hand-merge. Take either side to get a resolvable tree, run `npm run validate-registry` to rebuild them from the merged sources, and commit the result. The generator scripts at the front of the `validate-registry` entry in the root `package.json` are the authoritative list of what gets rewritten. A hand-merged generated file is wrong even when it looks right, and CI's drift guard catches it later at a worse moment.
- **Registry entries that are lists** (the Registries table in `CLAUDE.md` is the authoritative roster; the list-shaped ones are those holding an array or keyed map of entries — as opposed to the generated surfaces above and the single-tuned-state case below; a keyed map has no ordering, so the ordering half below does not apply to it): two branches each adding an entry is a textual conflict, not a semantic one. Keep both, then restore the ordering the registry requires (components alphabetical by `name`; the release log newest first; skills and loops in their curated order). Dropping one side is a silent feature loss no validator can see — or, for a registry held to its parent in both directions (the page summaries are the live example), a build failure on whichever branch's entry was dropped.
- **Registries that are a single tuned state**, not a list — `website/src/data/shader-background.json` is the current example: keeping both sides is meaningless and actively breaks things. Its `blobs` array is a fixed-size set the shader's `BLOB_COUNT` must match, and its `params` are one coherent look, so a merged pair fails `validate-shader-background.mjs` or produces a design nobody chose. Treat two sessions retuning the background as a semantic conflict and use the stop-and-ask rule below. `website/src/data/site-backgrounds.json` straddles the two: its rows are a list (two sessions each adding a background merge like any list), but each row's `home` and `page` tuning is a single tuned state, so two sessions retuning one row is this case too.
- **Version bumps**: keep the single higher version and make the version's three homes agree (`CLAUDE.md`'s Release section owns which they are; refresh the lockfile with `npm install --package-lock-only`). `scripts/validate-package-exports.mjs` fails the build when they disagree.
- **Source, CSS, and prose conflicts**: stop and ask. A semantic conflict between two sessions' intentions is not resolvable from inside a batch cleanup.
### 7. Check the prose the landed work invalidated
Landed work can make a sentence elsewhere in the repo false, and no validator can see it. This runs before the verify so its fixes are covered by the same green run.
`land` has an exact anchor for the scope, which `ship`'s equivalent check does not — it works from "this session's diff", while here everything landed in this run is one range:
```bash
git diff --name-only <pre-land-sha>..main
```
From that diff, take the identifiers it touched — component and prop names, script names, token names, moved or deleted paths, and any rule the work made stricter — and grep the prose surfaces for them: `.claude/skills/`, `README.md`, and every root spec (the doc list in `scripts/validate-doc-refs.mjs` is the authoritative set). Then judge whether any claim just became false.
Two classes matter most, because both actively mislead:
- **An instruction that now contradicts the build.** The worked example: a page moved from a curated subset to full coverage with a validator enforcing it, while `CLAUDE.md` still told the next agent that skipping an entry was fine. Following the doc would have produced a build failure the doc called acceptable. Whenever landed work makes a rule *stricter*, the instructions that describe the old latitude are the first place to look.
- **A false claim in `README.md`**, which ships inside the npm tarball and so reaches every consumer.
Fix what drifted, in this run, as its own `docs(...)` commit. Any prose the landing itself wrote follows `content-design.md`. `scripts/validate-doc-refs.mjs` catches dead references mechanically, but only a reader catches a sentence that is now wrong.
### 8. Verify the combined result
```bash
npm run verify
```
Run it plainly and let its exit status be the verdict. **Never pipe it through `tail`, `head`, or `grep`** — a pipeline reports the last command's exit code, not verify's. This is the step the skill is built around: each candidate may have been green alone, and only the combined result is what would ship. Run it even when nothing merged, if anything at all was committed to `main`.
**If verify fails**: nothing is pushed, so nothing is broken in public. Identify the merge or commit that introduced it, report it, and offer to unwind to the SHA from step 1 with `git reset --hard <pre-land-sha>`. That is destructive, so it is offered and confirmed, never automatic. **Dispose of nothing while verify is red** — an unwound merge needs its branch and worktree to still exist.
### 9. Dispose of the junk
**Archive before deleting. Always, without being asked.**
```bash
git tag archive/<short-name> <branch>
```
A tag keeps the commit reachable permanently, costs nothing, never appears in `git branch`, and is not pushed unless someone asks. It is what makes deleting unmerged work a reversible act, and it is the difference between a cleanup and a loss. Name tags for the work, not the branch (`archive/cosmic-wind`, not `archive/claude-musing-sanderson`). Do this for every candidate marked **Delete**, including remote-only ones, before a single deletion runs.
Then:
```bash
git worktree remove <worktree-path>
git branch -d <branch> # merged branches
git branch -D <branch> # unmerged, only once archived and approved
git push origin --delete <branch>
git worktree prune
```
- `git worktree remove` **never** takes `--force`. If it refuses, the worktree is dirty: return to step 3's rule and ask. Note that the permission classifier may block a force-removal anyway, so a dirty worktree that must go is finished by the owner in their own terminal, not retried here.
- `git branch -D` is sanctioned **only** for a candidate that was archived in this step and explicitly approved for deletion. Everywhere else, `-d`, and its refusal is the safety net.
- Deleting a remote branch is the one outward-facing action in this skill. It removes work from GitHub, so it runs only against an approved, archived candidate, and every deletion is named in the report.
For every candidate marked **Keep**: change nothing. No commits, no deletions, no tidying of stray files. Name each in the report with its branch and worktree path so it stays a resume handle.
### 10. Report
- **Landed**: each branch or pile, its merge or commit, and a one-line summary of what it carried
- **Kept**: each one, why, and its resume handle
- **Deleted**: each one, its `archive/*` tag, and the recovery line — `git checkout -b <name> archive/<tag>`
- Conflicts resolved, how, and which were regeneration rather than a judgement call
- Prose the landed work invalidated, and the fix — or explicitly that nothing drifted
- The `npm run verify` result and the pre-land SHA as the unwind handle
- Branches and worktrees removed, local and remote
- Closing line: `main` carries unpushed commits and nothing deployed. Say `ship` to take it live, or leave it local.
## Guardrails
- **Never push work, ever** — not `main`, not a branch. The one outward-facing action is step 9's deletion of an already-archived remote branch. This skill ends local. Deploying is `ship`, always a separate ask
- **Never delete anything that was not archived first.** No `-D`, no remote deletion, no worktree removal without a tag already written
- Never remove a worktree with uncommitted changes, and never reach for `--force`, `git reset --hard`, or `git checkout -f` inside a worktree this session did not create. Those changes are the only copy, and another session may still be writing them
- Never land, commit, or delete anything not explicitly approved, and never read "clean up" as blanket permission to delete
- Never commit another session's uncommitted work to make a merge clean. Report it and stop
- Never hand-merge a generated file; never drop one side of a registry conflict; never resolve a source conflict silently
- Never commit `.env*` or anything credential-shaped, however it arrived in a merge
- If there are no candidates, say so and stop. A clean sweep is a valid outcome
releaseCuts an npm release of the component library: bumps the single source-of-truth version, runs the Release workflow in dry-run to prove a real consumer can install and build the tarball, publishes with signed provenance, then tags and writes the GitHub Release against the exact commit that shipped. Knows the two things that bite on release day: a version number can never be reused, and the registry lags a green publish by minutes.
release.mdmd---
name: release
description: Cut a new npm release of rift-ds. Bump the version, dry-run, publish via the Release workflow, then tag the published commit. Use when asked to cut a release, publish a new version, or ship the package to npm.
---
# release
Cut a new npm release of `rift-ds` — bump, dry-run, publish, tag.
## When invoked
Use this skill when asked to cut a release, publish a new version, or ship the package to npm — phrases like "cut a release", "publish the next version", "ship the package".
**This is the one workflow in this repo where a mistake is permanent.** npm never lets a version number be reused, even after unpublishing, so a botched publish burns that version forever. Read the guardrails before starting.
## Instructions
### 1. Decide the version
Read `PACKAGE_VERSION` in `scripts/package-manifest.mjs` — that constant is the *authoritative* version; two other files mirror it (see step 3) and `validate-package-exports.mjs` fails the build when they disagree (the root package.json version is a hand-maintained mirror; `dist/package.json` is generated from the manifest). Then pick the next version from what actually changed since the last release:
- **patch** — bug fixes, internal refactors, docs
- **minor** — new components, new exports, new tokens (additive)
- **major** — a renamed/removed prop, export, or token; anything a consumer must edit code for
Check what shipped since the last tag to justify the choice, and confirm it with the owner before bumping:
```bash
git log $(git describe --tags --abbrev=0)..HEAD --oneline
```
`node scripts/validate-api-compat.mjs` answers half of this mechanically (the published schema files and meta-schema properties under `website/public/schema/` included): it compares the working tree's props, export names, subpaths, deep-import files and bin with the last tag, and fails on anything removed or narrowed. A clean run means nothing published has gone, which rules out a major for shape reasons (a moved default is behaviour, and still needs a reader). A deliberate break ships only as a major, with a `SANCTIONED_BREAKS` row in that script naming the version and the reason. The one other kind of row is a correction to the baseline's own record, where the published types never changed and only the generated API misreported them (its reason starts "not a break" and says so); that rides in a minor. Either way, after the tag in step 7 the row goes stale and the next run fails until it is deleted.
Be deliberate about breaking changes: components are exported both from the barrel and from `./components/*` deep paths, so a renamed component folder breaks consumers even if the barrel still exports the old name. Every other subpath in `SUBPATHS` in `scripts/package-manifest.mjs` (the chat widget's, `./format`, the published behaviours and the token modules) is a public path on the same terms, and the scaffold templates under `src/cli/templates/` (one folder per `init` subcommand: `chat`, `app` and `blocks`) ride in the tarball, so a renamed export they import breaks files the bin has already written into consumer apps.
### 2. Pre-flight
- **Working tree must be clean and pushed.** The workflow builds from the repo, not your disk — anything uncommitted will not be in the release. `git status --short` and `git log origin/main..HEAD` should both be empty.
- **CI on `main` must be green.** A release from a red main ships known-broken code.
- **Confirm the Trusted Publishing registration on npmjs.com still covers THIS repo** before a real publish. It is keyed to repo + workflow filename, and was registered for `robritacca-dotcom/rift-ds` + `release.yml` on 2026-09-26. Renaming either breaks publishing until the registration is updated, and the owner owns anything that changes on npmjs.com.
- Run `npm run verify` if anything at all is uncommitted or you haven't verified since the last change.
### 3. Bump and commit
**Three files carry the version — edit the first two, regenerate the third:**
1. `PACKAGE_VERSION` in `scripts/package-manifest.mjs` — the source of truth for what ships.
2. `"version"` in the root `package.json` — **must be kept in sync by hand.** No generator writes it. The root package.json stays `private: true` forever; only the generated `dist/package.json` is published, but the parity check still gates every build.
3. `package-lock.json` — npm records the version there too. Refresh it with `npm install --package-lock-only` and commit it with the bump; a stale lockfile dirties every later plain `npm install` (the 0.3.0 bump shipped without this and broke the worktree-based skills' cleanup).
`scripts/validate-package-exports.mjs` fails the build when any of the three disagree.
**Per-version facts (the version, its date, what shipped) have one home: the release log** (`website/src/data/release-log.json`, written in step 8). Never restate them in prose anywhere else, a doc or a workflow comment: nothing validates a hand copy, so it drifts the first release it is forgotten.
Then:
```bash
npm run validate-registry
```
This re-runs the three-way version parity check and regenerates the derived surfaces — and several generators stamp `PACKAGE_VERSION` into their output, so the bump rewrites every per-component markdown page under `website/public/components/`, every component schema and the index under `website/public/schema/` (their `version` field) and every DTCG token file there (its `$extensions` block), the consumer agent skills' folders under `website/public/skill/` (`AGENT_SKILLS` in `scripts/brand.mjs` owns the names), the agent starter (`website/public/AGENTS.md` and its module, `website/src/data/agent-starter.generated.ts`) and the site corpus that carries the starter's text. Whatever the regeneration touches is the list. The diff is large by design. Commit all of it together as the bump commit (`chore(release): <version>`) and push — a bump pushed without the restamped files fails CI's drift guard (that exact split produced the 0.15.0 cleanup commit), and the commit you push here is the commit that will be published and tagged.
### 4. Dry run — never skip this
```bash
gh workflow run release.yml -f dry_run=true
```
Watch it to completion — but confirm you are watching the run you just dispatched, not the previous one. Registration lags the dispatch by a few seconds, so `--limit 1` immediately after `gh workflow run` can return the prior run (at step 5 that prior run is the green dry run, and watching it reads as a successful publish):
```bash
sleep 10
gh run list --workflow=release.yml --limit 3 --json databaseId,createdAt,displayTitle # newest first — check createdAt is after your dispatch
gh run watch <the-new-databaseId> --exit-status
```
The dry run does everything except upload: builds `dist/`, packs the tarball, installs it into two scratch Vite + React apps — one that **builds with none of the optional peers installed** (the regression this catches: a peer leaking into the main barrel), and one that installs every peer, runs the packed bin's `init chat`, `init app` and `init block` for every block against an app-router layout, and type-checks and builds the scaffolds — then prints the publish preview. It needs no npm token, so it is free to run as often as you like. Read the preview's file count and package size and sanity-check them against the previous release; a sudden jump means something got swept into the tarball. Expect `LICENSE`, `README.md`, the init bin under `bin/` (named by `BIN_NAME` in `scripts/brand.mjs`, origin-stamped and executable) and its scaffold templates (every folder of `src/cli/templates/`, one per `init` subcommand) under `bin/templates/` alongside the build output; anything else new is not deliberate.
### 5. Publish
```bash
gh workflow run release.yml -f dry_run=false
```
Watch it the same way (the same watch-the-right-run caution applies, and matters more here). The publish step runs `npm publish --access public --provenance --loglevel verbose` from `dist/` — the verbose flag is deliberate, because its log lines are the only visible evidence of the OIDC token exchange the auth guardrails below tell you to look for — and signs a provenance attestation tying the tarball to this repo, commit, and workflow run.
### 6. Verify — and do not panic at a 404
**The registry lags a successful publish by several minutes.** A `404` from `npm view` right after a green workflow is propagation, not failure. Confirm the workflow's publish step actually ran (`gh run view <id> --json jobs`) and look for `+ rift-ds@<version>` in its log — if that line is there, it published. **Never re-run the workflow on a 404**; the version is already consumed and the rerun will fail with `EPUBLISHCONFLICT`.
Once it propagates, confirm the registry serves the new version:
```bash
npm view rift-ds version --prefer-online
```
The real consumer-shaped check already ran before publish: `scripts/smoke-consumer.mjs` packs the tarball into two scratch Vite apps and builds them: one with no optional peer installed, importing the barrel, and one with every optional peer that also runs the packed bin's `init chat`, `init app` and `init block` for every block, and builds the scaffolds (bare `node` can't import the barrel because components import their own CSS; that needs a bundler). To repeat it against the *published* artifact rather than the local tarball, scaffold a scratch Vite app, `npm install rift-ds@<version>`, import the barrel plus `tokens/tokens.css`, and run its build; for the chat, install every optional peer in a scratch Next.js app router project, run `npx rift-ds@<version> init chat` there, and build it; in the same project, run `npx rift-ds@<version> init app`, add the lines it prints to the root layout (importing `tokens/base.css` among them), and build again. The smoke consumer is a Vite app with `next/link` stubbed, so this is `init app`'s only check against a real Next.js app.
### 7. Tag the published commit
Tag the commit that was **published**, not necessarily current HEAD — the provenance attestation names that commit, so the tag, attestation, and tarball should all agree:
```bash
git tag -a v<version> <published-sha> -m "v<version> — <one-line summary>"
git push origin v<version>
gh release create v<version> --verify-tag --title "v<version> — <short title>" --notes-file <notes>
```
Tagging moves the compatibility gate's baseline, so any `SANCTIONED_BREAKS` row this release carried now matches nothing. Delete it in the next commit.
Write the notes for a consumer, not a maintainer: what's new, anything breaking with the migration step spelled out, and the install snippet. The previous release is the format reference; the prose follows `content-design.md`.
### 8. Append the release-log entry
The `/releases` page is 1:1 with npm by rule: every publish gets exactly one entry in `website/src/data/release-log.json`, written now, as part of this ritual — never later, never speculatively. Add the new entry at the TOP of `releases` (newest first) with the published `version`, the publish `date`, a plain descriptive `title`, and `body` paragraphs written for a consumer (the same story as the GitHub release notes, condensed — `content-design.md`'s release-log register row owns the voice). `scripts/validate-release-log.mjs` holds the structure. The site corpus carries the log, so run `npm run validate-registry` after adding the entry and commit the regenerated corpus with it; commit both with the version-bump follow-ups so the site and the registry never tell different stories.
### 9. Report
Version published, the npm URL, the tagged commit, the release URL, and anything a consumer must do to upgrade.
## Guardrails
- **Never** publish from a local machine (`npm publish` by hand) — releases go through the workflow so every release is provenance-signed and smoke-tested
- **Never** re-run the publish workflow after a successful publish, even if the registry 404s
- The version's three homes must agree — step 3 above is the procedure and `CLAUDE.md`'s Release section owns the fact. Never bump `package.json` alone — the manifest is what ships
- Never publish from a dirty tree, an unpushed commit, or a red CI
- **Auth is Trusted Publishing (OIDC) — there is no npm token to expire or rotate.** The one exception is a package's first-ever publish: a trusted publisher can only be attached to a package that already exists, so a brand-new name must be created with the workflow's `bootstrap_token` input (off by default, `NPM_TOKEN` secret, revoked immediately after). npm's tell for that case is specific — `404 ... OIDC token exchange error - package not found` from the exchange endpoint. Otherwise, if the publish step fails to authenticate, the cause is one of: `actions/setup-node` was given a **`registry-url:`** (see below); the `id-token: write` permission was dropped from `release.yml`; the workflow file was **renamed or moved** (the trusted-publisher registration on npmjs.com is keyed to the filename `release.yml`); the registration on npmjs.com names a different repository — it is keyed to repo + workflow filename, so check the pre-flight above for what is registered today; the npm CLI on the runner is older than 11.5.1; or the registration itself was removed. The owner owns anything that has to change on npmjs.com.
- **A `404` on `PUT` during publish is an AUTH failure, not a missing package.** npm returns 404 instead of 403 so it doesn't leak whether a package exists — the message even says "or you do not have permission to access it". Do not go hunting for a missing package; check the auth path.
- **Never add `registry-url:` to `actions/setup-node` in this workflow.** It writes an `.npmrc` with `_authToken=${NODE_AUTH_TOKEN}` *and* exports a placeholder `NODE_AUTH_TOKEN`, so npm thinks it is already authenticated, skips the OIDC exchange entirely, and gets rejected. The tell in the log: no mention of `oidc`/`trusted` anywhere, and `NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXX` in a step's env block. Provenance signing still succeeds in this state — that is GitHub→Sigstore and proves nothing about npm accepting the token.
- **A dry run never authenticates**, so it cannot prove OIDC is working — it exercises the build and the tarball, nothing else. After any change to `release.yml`'s auth, permissions, or filename, the only real proof is a genuine publish. Treat that as a reason to make the *next* release a small patch, not a reason to skip the dry run.
- The npm README is the package README — if the release changes install or usage, fix `README.md` in the same release, since it ships inside the tarball. Fix `src/stories/Configure.mdx` too: it carries the same install snippet on the Storybook landing page, deploys separately, and is where people evaluating a component arrive. `generate-readme-content.mjs` enforces that both mention the package name, but it cannot tell whether the *snippet* is still correct. The README banner image (`.github/readme-banner.jpg`) has no generator: it is a screenshot of the site hero and states its headline and subtitle in pixels. If either moved since the last re-shoot, run `node scripts/capture-readme-banner.mjs` and commit the image in the same release
release-postTurns a shipped release into its X announcement: a bento-grid release card drawn in the system's own dark theme, every colour and type value lifted from the live token CSS rather than remembered, rendered headlessly to a paste-ready PNG at twice the post size. The copy comes from the tag and the commits, not from memory, and holds to the content guide: sentence case, no emoji, neutral, and inside the character limit.
release-post.mdmd---
name: release-post
description: Produce the X announcement for an npm release of rift-ds — a bento release card rendered to a paste-ready PNG, plus post copy in the project's voice. Use when asked for a release post, a release image, or announcement copy for a version.
---
# release-post
Produce the X announcement for a release: a bento release card as a paste-ready PNG, plus post copy.
## When invoked
Use this skill when asked for a release announcement, a release image or card, or post copy for a version — phrases like "image for an X post about the release", "announce the release", "release card".
Two principles govern everything below: **facts come from the repo, never from memory** (the tag, the commits, the token CSS), and **the card is drawn in the system's own visual language** — it should look like a screen from the site, not a generic promo graphic.
## Instructions
### 1. Gather the release facts
Work from the tag, not recollection:
```bash
git log --oneline $(git describe --tags --abbrev=0 v<version>^)..v<version>
git show v<version> --no-patch --format='%s%n%b'
```
(`git describe` on the version tag's parent resolves to the previous release's tag, so the range is exactly this release.) **The repo's first tag** (`v1.0.0`) has no earlier tag to anchor the range — take it from the full history instead (`git log --oneline v1.0.0`).
The tag message names the headline features; the commit list fills in the rest. The version's entry in the release log (`website/src/data/release-log.json`, written at publish time by the `release` skill) is the summary of record — the card and copy must agree with it. Pick the strongest feature as the hero and a handful of others as supporting tiles; a release with more features than tiles drops features rather than shrinking them.
### 2. Lift the real design values
Read the token files before drawing — never write a colour or type value from memory:
- `src/tokens/tokens-primitives.css` — the hex values behind the semantic roles
- `src/tokens/tokens-dark.css` — which primitive each dark-theme role resolves to (the card is dark-theme; it reads best in a feed)
- `src/tokens/tokens-typography.css` — the display sizes, weights and letter-spacing
**This repo has no earlier cards to match, and the site does not serve the base theme.** Resolve every role through the served theme: `SERVED_THEME_ID` in `website/src/lib/theme/brand.ts` names it, and the root layout renders it as `data-brand` on `<html>`. Its generated stylesheet in `src/tokens/presets/` overrides the base theme's tokens (composed by `presetOverrides` in `website/src/lib/theme/presets.ts`), so read the base token files and then that sheet, never the base files alone. The sheet has a light block and a dark block, and a tinted preset's neutral primitives differ between them, so read the block for the theme the card is drawn in.
The values to resolve: the page and container greys, the container border, the text ramp, the dark-theme action colour and its ink as the served theme resolves them, and — if a tile shows status colours — the dark-theme status *icon* hues. Radii follow the system's rules: pill for buttons and chips, the card radius for tiles, the composer radius if a tile draws the composer. `design.md` owns those; check it when unsure rather than guessing.
### 3. Draw the card
Author a plain HTML file in the scratchpad (never in the repo tree) at **1200×675** — X's landscape card ratio. The established layout (this repo has no earlier cards of its own to match; the layout below is the record):
- **Header row**: the package name as an uppercase overline in the action colour; the version large in the light display weight with its tracking; the release date beside it; an `npm i` pill and the site's host (from `SITE_URL` in `scripts/brand.mjs`) on the right.
- **Bento grid** below: four columns by two rows. The hero feature takes a 2×2 tile with a title, one sentence, a few pill chips, and a small abstract drawing of the feature (panels, glyphs, a miniature control — drawn with divs and inline stroke SVG, never emoji or screenshots). Each remaining feature gets a 1×1 tile: bold title, a tiny visual or code chip, one caption sentence.
- A faint radial glow or two behind everything, in the ambient colours (`--color-ambient-*` as the served theme resolves them) — the site's ambient background in still form. Subtle; the ground stays near-black.
The faces the served theme declares load from Google Fonts via a `<link>` in the head. All copy on the card follows `content-design.md`: sentence case, neutral, no emoji, no em dashes, one idea per line. Tile captions are one short sentence each.
### 4. Render to PNG
Render headlessly with the Playwright already in the repo's dependencies — no browser pane, no manual export. ESM ignores `NODE_PATH` on every platform, so import Playwright by an absolute `file://` URL into its package inside the repo's `node_modules` (derive the repo root with `git rev-parse --show-toplevel`). On a Windows checkout only, also strip the leading slash a `file://` pathname puts before the drive letter; on macOS or Linux that slash is the path's root and must stay.
The render script: chromium headless, viewport 1200×675 with `deviceScaleFactor: 2`, `goto` the card file with `waitUntil: 'networkidle'`, then `await page.evaluate(() => document.fonts.ready)` plus a short settle before screenshotting the card element — without the fonts wait, the PNG ships in the fallback face. The result is 2400×1350: crisp at twice the post size.
**Look at the PNG before handing it over.** Read the image and judge it like a design-QA pass: nothing clipped, the hero drawing balanced in its tile (the first render of the first card left it sunk in a corner), captions unwrapped where they should be, glyph colours right. Fix and re-render until it holds up.
### 5. Write the copy
Post copy is shipped prose in spirit — `content-design.md` governs it. Additionally:
- **Fit the standard character limit** (280). Count it. Offer one primary version that fits, and optionally a longer alternate.
- Lead with the package and version, or with the hero feature — either works; hype never does.
- Name real things: components, exports, subpaths. "FilterMenu joins the shared overlay behavior layer" beats "big improvements to overlays" (FilterMenu is fictional — pull the real names and counts from the release's own diff and registries, never from this file).
- No emoji, no exclamation marks, no hashtag stuffing.
The copy and the card must tell the same story: the hero feature on the card is the hero feature in the copy.
### 6. Deliver
Send the PNG with SendUserFile so it lands as a file the owner can copy straight into the post, and put the copy in the reply as a quoted block. If the owner wants to hand-tweak the card, the `design` canvas flow (publish the card as an editable artifact) is the follow-up to offer — not the default, since the ask is a paste-ready image.
## Guardrails
- Never restate release facts from memory — the tag, the commits, and the version's release-log entry are the sources, and they must agree with what the card claims
- Never write a hex value or font size the token CSS didn't supply this session; the card is a picture of the system, so a drifted colour is a wrong picture
- Working files live in the scratchpad, never the repo tree; nothing this skill produces is committed
- The card carries no screenshots and no third-party marks — abstract drawings only
- Posting is the owner's move: this skill ends at a PNG and copy, never at anything published
drift-auditSweeps every place the repo describes itself (skills, CLAUDE.md, design.md, the README that ships to npm, and the website's own explanations of how it is built) and flags anything that no longer matches reality. Executes the commands and recipes the docs prescribe rather than just reading them, so a skill that would break on the next run is caught before someone runs it. Reports findings grouped by severity.
drift-audit.mdmd---
name: drift-audit
description: Comprehensive self-consistency audit after a structural or architectural change. Verifies every skill, doc, and website surface still describes the repo as it actually is. Use after big changes, or when asked whether the docs and skills are up to date.
---
# drift-audit
Verify that every self-description in the repo — skills, docs, website prose — still matches how the repo actually works.
## When invoked
Use this skill after any structural or architectural change (a new build step, a moved directory, a changed dependency model, a new published surface), or when asked whether the docs and skills are still accurate — phrases like "run a drift audit", "are the docs up to date", "check for gaps".
## The governing idea
Build validators already catch everything *mechanically checkable*. This audit exists for the layer beneath them: **prose that asserts something about the system, and instructions that only fail when someone follows them.** A skill telling you to run a deleted npm script passes every validator and every build — it fails silently, months later, for whoever runs it.
**Do not trust this file's own description of the architecture.** It deliberately contains no *inventory* facts — no counts, paths, component lists, or versions, only the command entry points needed to derive them — because inventory facts would rot too. Derive the current shape from the sources of truth (package.json scripts, registries, the validator chain, the exports map) every time.
**Verify by executing, not by reading.** The most valuable findings come from actually running what a doc prescribes. Reading a worktree recipe looks fine; running it surfaces that the bundler now rejects it.
## Instructions
### 1. Establish the current reality first
Before judging any prose, build an accurate picture of what is true *right now*:
```bash
npm run validate-registry # what the automated chain enforces, and what it prints
# The chain is not the whole automated layer: the `verify` entry in the root
# package.json ends with the validators that need built HTML and so run after
# the website build (in `verify` and CI), outside this chain.
node -e "console.log(Object.keys(require('./package.json').scripts).join('\n'))"
node -e "console.log(JSON.stringify(require('./package.json').exports, null, 2))"
git log --oneline -20
```
(Heads-up: `validate-registry` **writes** — its leading generator scripts regenerate the derived surfaces; the script entry in the root `package.json` is the authoritative list of what. Check `git status` before and after, so regenerated output isn't mistaken for a finding.)
Read the root `package.json` (scripts, dependency model, workspaces), the validator scripts named in `validate-registry`, and the recent commits. The recent commits tell you *what kind* of drift to hunt for — a dependency-model change implicates install instructions everywhere; a renamed route implicates nav, sitemap, and cross-links.
If `validate-registry` fails, stop and report that first — the automated layer is broken and everything downstream is unreliable.
### 2. Mechanical cross-checks
These are checkable by grep and should be exhaustive. For each, the question is "does the thing this text references still exist?"
- **Every command referenced in prose exists.** Collect `npm run <script>` mentions across `*.md`, `.claude/skills/**`, and `website/src/**`, and diff against the real script list. A referenced-but-missing script is a broken instruction. **Check every workspace's scripts, not just the root** — a mention may be workspace-scoped (`--workspace <name>`, or preceded by a `cd`), which a naive grep reports as missing when it is perfectly valid.
- **Every file path referenced in prose exists.** Extract path-looking strings from README.md, every root spec (the doc list in `scripts/validate-doc-refs.mjs` is the authoritative set — it includes tracked specs that are not published to /blueprints; `FILES` in `scripts/sync-blueprints.mjs` is only the published subset), and every SKILL.md, and test each one. Moved or deleted files leave dangling references. Expect noise and filter it before reporting: bare filenames used conversationally (`globals.css`), scaffolding placeholders (`ComponentName.tsx`, `my-component/page.tsx`), date placeholders (`YYYY-MM-DD.md`), and shorthand for a pair (`tokens-light/dark.css`) are all fine. Only a path that *claims* to point at something real and doesn't is a finding.
- **Every import specifier in docs matches the real exports map.** Any `import … from "…"` in documentation or example code should resolve against the package's current `exports` (or be an obvious third-party import). Renamed aliases and scopes hide here.
- **Internal links resolve.** Route strings in website prose (`/foundations/...`, `/playground`) should correspond to real app directories, and the nav config should agree.
- **Counts come from registries, never literals.** Grep displayed numbers near countable nouns; each should be an imported constant.
- **Retired wording has left the generated surfaces.** After a vocabulary or API change (a renamed prop, a value moved to another prop, a deprecated spelling), grep the generated outputs that carry prose to strangers for the retired word: `website/src/data/component-api.generated.ts`, `website/src/data/site-corpus.generated.ts`, `website/public/skill/`, `website/public/components/` and `website/public/schema/components/`. They regenerate on every build, so a hit means a **source** still says it (a registry description, a prop's JSDoc, a design.md spec, page prose). Trace it back and fix the source; never edit the generated file.
- **Config still applies where it is declared.** A restructure can leave a config block sitting somewhere the tool no longer reads, and nothing warns you — it just silently stops taking effect. Check that declared intent matches installed reality: dependency `overrides`/`resolutions` (npm honours these **only** in the workspace root), engine constraints, lint and TS config inheritance, and bundler aliases. For dependency pins specifically, compare the declared range against what is actually installed (`npm ls <pkg>`) and run `npm audit` — pins are usually security fixes, so one that stops applying is a silent regression, not a style issue.
### 3. Prose surfaces — read against reality
For each surface, the test is: *if a stranger followed this exactly, would it work, and would what they believe afterwards be true?*
- **README.md** — highest stakes: it ships inside the npm tarball, so its install and usage instructions reach every consumer. Verify the install command, import examples, customization recipes, and local-dev steps against the real package.
- **Data registries that ship as prose** — the registry fields that reach readers verbatim: component and widget descriptions, page summaries, loops, release stories (CLAUDE.md's **Registries** table is the authoritative list of registries; read it to find which carry prose). They are read against reality like README, not merely validated for shape: a description can pass every structural check while naming a prop value the contract retired, and it ships word for word through the MCP endpoint, the site chat's corpus and the consumer agent skill.
- **CLAUDE.md** — the project's operating manual: structure diagram, quick start, command list, registries/generated surfaces, architecture invariants, infrastructure facts. Every generated surface must be listed with its generator, and every registry with its accessor and validator; the detail behind each lives in the script's doc block, which the audit reads against the code.
- **design.md** — design language claims and per-component specs. Check that stated invariants are still enforced and that specs match the components.
- **content-design.md** — the content style guide. Check that its Register by Surface table still lists every prose surface that exists, that its pointers at skill-owned standards still land, and that no rule in it duplicates one CLAUDE.md owns (fact-architecture rules live in CLAUDE.md, style rules here — a rule restated in both is drift).
- **Website self-descriptions** — any page that explains how the system is built (the overview/pipeline, get-started and docs pages, foundations pages). These are public claims; treat inaccuracy as a bug.
- **Other root specs** — any tracked root spec beyond the three above, published or not (the doc list in `scripts/validate-doc-refs.mjs` is the authoritative set; `FILES` in `scripts/sync-blueprints.mjs` lists only the ones published to /blueprints). Each makes claims about code it describes; spot-check its heavily-referenced facts the same way, and check that its published-vs-repo-only status is stated where readers would assume otherwise.
- **Blueprint copies** — if the repo publishes copies of its own docs, confirm they are generated rather than hand-maintained, and that they regenerated.
- **Statistical drift — the code outvotes the docs.** An agent (or a new contributor) learns the system from what is most *common* in the codebase, not from what the prose asserts: the most-used stray pattern outvotes the documented one, because prevalence reads as canon. For a few core conventions the docs state — tokens over raw values, the canonical component over ad-hoc rewrites, the published import paths, the prop vocabulary (a census of internal callers passing a deprecated `variant` spelling against those passing the documented `variant` and `tone` pair is the worked example) — census actual usage and check that the dominant pattern is the sanctioned one. A convention can be drift even when every individual instance is legal, if an unsanctioned variant outnumbers the documented form; and when the census is mechanically repeatable, the fix to recommend is a validator (see Guardrails), not a docs edit. The same lens covers adoption: a surface the repo publishes but nothing consumes — a component no page or story renders, an export subpath nothing imports — is a public claim the numbers contradict; judge whether it is merely early or actually dead, and say which.
### Change-type playbook
The recent commits say what kind of change landed; each kind has surfaces that must have moved with it. Check them all for the kinds present:
- **Public API change** (a prop renamed, added, deprecated, or a value moved to another prop): the component's published schema (`website/public/schema/components/<slug>.json`, regenerated; a removed file or meta-schema property is check 6 of `scripts/validate-api-compat.mjs`), the registry description, the design.md spec section, the showcase page and the stories, internal callers of **every** deprecated spelling (older aliases included, not only the newest), the components that compose it, and the generated outputs (the retired-wording check in section 2). Run the `api-consistency` skill scoped to the component and its family.
- **Token change**: the homes the `new-token` skill lists (or `token-rename` for a rename), the foundations page captions, and the theme presets.
- **Route change**: navigation config, cross-links in page prose, the sitemap, llms.txt, page summaries, and `EXCLUDED_ROUTES` or other route lists in the corpus generator.
### 4. Skills self-audit — the highest-yield section
Read **every** `SKILL.md`, not just the ones that seem related. For each, ask:
- Does every command it prescribes still exist and still do what it claims?
- Does every path it references still exist?
- Does it describe a workflow that a tooling change has since broken? **Where a skill scripts a multi-step recipe (worktrees, builds, deploys), actually execute it in a throwaway location and confirm it completes.** Clean up afterwards.
- Does it tell the reader to hand-edit something that has since become generated?
- Does it describe one-time setup that is now complete, or a future state that has since arrived?
- Does its section/category list omit anything added since it was written?
- Does it duplicate a fact that lives in a registry — or in a doc that owns it (design.md, CLAUDE.md, content-design.md) — instead of pointing at that home?
Then check for **missing coverage**: is there now a repeated, consequential workflow with no skill? Recent commits are the evidence — a ritual performed manually twice is a skill-shaped hole, especially where mistakes are expensive or irreversible.
### 5. Consumer and privacy surfaces
- **What ships externally.** If the repo publishes a package, inspect the built artifact — its manifest, its file list, its size, and the docs inside it. Personal data hides in doc comments that become type declarations.
- **No secrets or personal data in public surfaces.** Sweep tracked files and the built artifact for credential-shaped strings, private emails, tokens, keys, and absolute local paths. Distinguish deliberately-public identifiers (an analytics measurement ID visible in page source by design) from genuine leaks, and say which is which rather than crying wolf.
### 6. Report
Group by severity, most actionable first. Every finding needs a file path (with line number where it applies), what it currently says, why that is wrong now, and the fix.
```
## Drift Audit
### Broken — following this would fail
- path/to/SKILL.md:42 — prescribes `npm run <deleted-script>`; replaced by X in <commit>
### Stale — inaccurate, would mislead
- CLAUDE.md:88 — structure diagram still lists a deleted directory
### Gaps — missing coverage
- No skill covers <repeated consequential workflow>
### Verified accurate
- <surfaces checked and found correct — say so explicitly, so the reader knows the scope>
### Summary
X broken · Y stale · Z gaps · N surfaces verified
```
State plainly what you **executed** versus what you only **read** — an unverified pass is weaker evidence, and the reader deserves to know which they are getting. The report always ends with a coverage table, one row per section 1 to 5 (and the playbook), marked **executed**, **read** or **skipped**, with the reason for anything short of executed:
```
### Coverage
| Section | Status | Note |
|---|---|---|
| 1. Current reality | executed | |
| 2. Mechanical cross-checks | executed | |
| Change-type playbook | executed | public API change |
| 3. Prose surfaces | read | |
| 4. Skills self-audit | read | recipes not re-run, no tooling change |
| 5. Consumer and privacy | skipped | no package change since the last audit |
```
A narrowed audit is a legitimate choice for a small change; an unlabelled one is not, because whoever acts on the report reads silence as coverage.
Then ask whether to apply the fixes. Do not fix silently as you go: the report is the deliverable, and some findings are judgement calls (a "gap" may be deliberate scope).
## Guardrails
- Never edit generated files to resolve a finding — fix the generator or its source, then regenerate
- Never weaken a validator to make a finding disappear
- If a finding is mechanically checkable and keeps recurring, the real fix is a **new validator in the `validate-registry` chain**, not a docs edit — recommend that explicitly (this repo's convention: anything countable or checkable gets build-enforced so it can never drift again)
- Report honestly when a surface was skipped or a check was inconclusive; silence reads as "verified"
component-doc-pageCreates a full-quality documentation page for a design system component on the website. Reads the component's props to generate a variant showcase grid (the Button page is the benchmark), writes all three page files, and adds the component's preview entry (the one hand-maintained surface). The index card, sidebar, sitemap and breadcrumbs derive from the component registry, so there is no navigation to wire.
component-doc-page.mdmd---
name: component-doc-page
description: Create a full-quality documentation page for a design system component on the website. Use when asked to document a component on the website, add a component docs page, or create the website page for a component.
---
# component-doc-page
Create a full-quality documentation page for a design system component on the website.
## When invoked
Use this skill when asked to document a component on the website, add a component page, or create docs for a component — phrases like "document [X] on the website", "add a docs page for [X]", "create the website page for [X]".
This is a more thorough, component-specific version of `new-page`. The Button page is the quality benchmark for static variant grids; for components whose value is interaction or streaming state (the `ai` category), the chat-message page (`website/src/app/components/chat-message/page.tsx`) is the exemplar — a `"use client"` page with small stateful demos instead of a grid.
## Instructions
1. **Gather requirements** if not already provided:
- Component name (PascalCase)
- Figma node URL (optional — ask the owner, or omit if unknown)
- Storybook path (optional — format: `/?path=/docs/components-<slug>--docs`)
2. **Read the source component** `src/components/ComponentName/ComponentName.tsx`:
- Extract all props from the TypeScript interface (the entry in `website/src/data/component-api.generated.ts` has them pre-parsed with types, defaults and descriptions — the source is still worth reading for behaviour)
- Identify all variant enumerations (e.g. `variant`, `size`, `status` props with union types)
- Understand the component's states (default, hover, active, disabled, loading, etc.)
- Note the BEM class names used for each variant/state
3. **Read the gold-standard reference:**
- `website/src/app/components/button/page.tsx` — study the variant showcase grid structure (rows = states, columns = variants), the `pageHeader` block, `introSection`, and how `PageLinks` is used
- `website/src/app/components/button/page.module.css` — CSS module structure
4. **Create `website/src/app/components/<component-slug>/page.tsx`:**
- Mirror the Button page's layout shell exactly — same components, same nesting, same class names — and render `<ComponentsSidebar />` with no props: it resolves the active entry from the pathname and its groups from the registry. Don't improvise structure. Mirror the *structure*, not deprecated APIs: if an existing page still uses a prop marked `@deprecated` in the component source (the `priority` → `variant` rename is the precedent), write the current prop name.
- Invariants the exemplar can't teach:
- `subDisplay` is a *tagline* for the component (e.g. Button's "The main action element") — not the word "Components"; the breadcrumb already shows the section
- `introBody` is a clear 1–2 sentence description of the component's purpose, inferred from its props and JSDoc if available
- All copy on the page (tagline, intro, section labels) follows `content-design.md` — neutral, sentence case, no em dashes
- Import the component through the package, never a relative path into `src/`: `import { X } from "rift-ds/components/X/X"` (recharts-backed charts come from `rift-ds/charts`) — the website is an npm-workspace consumer of the published package's exports
- Include `PageLinks` with whichever Figma/Storybook URLs were provided
- End the page with `<ComponentInstallStrip slug="<component-slug>" />` after the last section — the per-page install commands, rendered at the page floor across the content column's full width. `scripts/validate-website-surfaces.mjs` requires the mount with the page's own slug and fails the build without it
- **Variant showcase grid**: render the component in every meaningful combination of its variants and states. For components with discrete variants × states (like Button), render a proper grid. For simpler components, render one example per meaningful state/variant.
- If the component has a `tone` prop beside `variant` (emphasis × colour, per CLAUDE.md's Component Anatomy), the grid's columns are the emphasis × tone cells the component actually draws, built from one cell list the way the Button page does. Skip combinations the component renders identically rather than showing a duplicate, and never demo a deprecated single-axis value.
- If a demo renders h2 headings of its own (a heading-bearing component like SectionTitle, or Prose sample content), wrap that demo container in `data-anchor-ignore` — the site-wide floating anchor rail reads every page's h2s, and demo headings are the demo's, not sections of the page. The discovery rules live in the doc block of `website/src/components/FloatingAnchorNav/SiteAnchorRail.tsx`.
5. **Create `website/src/app/components/<component-slug>/page.module.css`:**
- Standard layout classes: `dsLayout`, `dsContent`, `pageHeader`, `pageTitle`, `subDisplay`, `introSection`, `introBody`
- Any additional classes needed for the variant showcase grid
- Page assembly follows design.md's **Composition** section (rhythm ladder, spacing ownership, one level of chrome, dividers last) — the exemplar shows the pattern, the spec owns the rules
- **Prose is never width-capped.** No `max-width` on `introBody`, section body text, or any paragraph class — page text fills the content column, exactly as the Button page does. A `max-width` is only legitimate on a *demo container* (a box that holds a rendered component, e.g. a drawer or form-control mount) where the component itself needs a bounded stage. If you find yourself capping a paragraph "for readability", don't — the column width is the layout's decision, not the page's. `scripts/validate-page-prose-width.mjs` fails the build on a page rule that sets paragraph type and a `max-width`; text that belongs to a rendered demo is sanctioned in its table.
- CSS custom properties only
- Mobile type and section rhythm collapse at the **token layer**, system-wide (`design.md`'s responsive spec owns the breakpoint) — do not add per-page `@media` overrides for tokenized values; when the showcase grid genuinely needs a breakpoint, use a width from `src/tokens/breakpoints.ts` (a content-driven one outside it needs a `ds-allow(breakpoint)` directive above the query; `scripts/validate-breakpoints.mjs` enforces it)
6. **Create `website/src/app/components/<component-slug>/layout.tsx`** — it is exactly this, with no description of its own:
```tsx
import { componentPageMetadata } from "@/config/navigation";
export const metadata = componentPageMetadata("<component-slug>");
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
```
Both the title and the description resolve from the component's entry in `src/components/registry.json` (or, for the chat widget, `src/chat/registry.json`; `website/src/data/component-pages.ts` merges the two) — that is the only place the one-line description lives, so do **not** pass one here. `scripts/validate-page-titles.mjs` requires this exact call and fails the build otherwise; a slug with no registry entry fails the website build with a message naming the fix.
7. **Do not touch `website/src/config/navigation.ts`.** `componentsSidebarLinks` is **derived** from the registry — there is no list to add to. Registering the component in `src/components/registry.json` (with its `label`, `slug` and `description`; the chat widget's entry lives in `src/chat/registry.json` and is merged in through `website/src/data/component-pages.ts`) is what puts it in the sidebar, and the sitemap, mega-nav and breadcrumbs follow from there. If the component is not yet registered, do that first; the `new-component` skill covers the entry's shape.
8. **Add a preview entry to `website/src/components/ComponentPreviews/ComponentPreviews.tsx`** — the one surface still hand-maintained, because each entry holds a bespoke preview:
- Add a `"component-slug": () => (...)` entry to the `previews` map — use the real component (imported from `rift-ds`) where it reads well at miniature size, as most entries do, or a small inline-styled mockup where it doesn't (see the accordion entry). An icon in a mockup sets `--icon-size` inline (`style={{ ["--icon-size" as string]: "14px" }}`), never `fontSize`: the icon class sizes its box from that property, so a bare font size leaves a small glyph in a default-size box and the tile sits off-centre. The index's category section renders the entry through `ComponentCardGrid`, so there is no card to place by hand and no order to maintain.
9. **Sitemap is automatic** — `website/src/app/sitemap.ts` derives its routes from the shared sidebar configs, which for components derive from the registry. Do not edit `sitemap.ts` by hand. `scripts/validate-website-surfaces.mjs` build-enforces the showcase page, its `ComponentInstallStrip` mount (step 4), the step-8 preview entry (in both directions — an entry whose key is not a registered slug fails too), and the `design.md` spec section; the sidebar entry and its ordering are no longer checked because they can no longer drift. If the component has no `design.md` spec section yet, add one before building — see the `new-component` skill's spec step for the expected shape.
accessibility-auditAudits a component or page against WCAG 2.1 AA criteria. Checks semantic HTML, ARIA usage, keyboard navigation, focus styles, and colour contrast via both source code analysis and live screenshots. Reports file and line-level findings with WCAG criterion and severity.
accessibility-audit.mdmd---
name: accessibility-audit
description: Audit a component or page for accessibility violations against WCAG 2.1 AA criteria. Use when asked for an accessibility audit, a11y check, WCAG compliance check, or "is X accessible".
---
# accessibility-audit
Audit a component or page for accessibility violations against WCAG 2.1 AA criteria.
## When invoked
Use this skill when asked to check accessibility, run an a11y audit, or find WCAG issues — phrases like "accessibility audit", "a11y check on [component/page]", "check WCAG compliance", "is [X] accessible".
## What is already automated — read this before auditing anything
**Axe runs on every Storybook story and fails the build.** `.storybook/preview.ts` sets `a11y.test: 'error'`, so `npm run test` (and therefore CI and `npm run verify`) already enforces WCAG 2.1 AA across the whole library — and the same run executes any story `play` function, so behavior a story asserts (focus, dismissal, keyboard) is enforced too. Start by running it:
```bash
npm run test
```
If that is green, every violation axe can detect is already absent — and re-checking icon-only button names, label association, `role="dialog"` naming or ARIA parent/child relationships by hand is duplicated effort.
**This skill exists for the three things that gate does not cover:**
1. **Colour contrast — excluded from the axe gate entirely.** `color-contrast` is switched off in `.storybook/preview.ts` by a settled decision of the owner's. **Read that override's comment first**: it is the authoritative record of why, and the single place the details belong. Since the 2026-08-20 split of the action colour by mode the shipped pairs the rule used to fail all clear AA; the rule stays off because the action colour is a token consumers re-point to their own palettes, so the gate would be judging a value the system does not own. One slice of contrast is build-enforced at token level instead: `scripts/validate-theme-presets.mjs` holds every action fill against the label or icon components draw on it — resting, hover and pressed, in both themes, for the shipped tokens and every preset — and its `ACTION_PAIRINGS` table is the authoritative list of what it covers. Every other rendered pair (text and surfaces, status, charts, alpha composites over real content) is the manual auditor's job, and **the single highest-value thing to audit**: nothing else checks it. Never propose re-enabling the rule without asking the owner first.
2. **What axe cannot see.** Axe catches roughly a third of WCAG issues. It cannot tell whether alt text is *meaningful*, whether focus order makes sense, or whether a helper message *should* have been associated with its control. (Behavior a story's `play` function asserts *is* covered — `Dialog.stories.tsx` proves the modal focus trap, restore, and stacked Escape in CI — so the manual job is the overlays with no play coverage yet.) (Two such bugs once shipped undetected until a manual survey found them: Dropdown announced neither its helper text nor its error state. Both were since fixed by moving Dropdown onto `Field`, but it took the manual survey, not axe, to find them.)
3. **Website pages beyond the automated sample.** A page-level axe pass (`scripts/validate-website-a11y.mjs`, in `verify` and CI) now runs against the served site in both themes with the same rule set — but only over the route sample in `scripts/served-site.mjs`, at one desktop viewport. Full-site sweeps, mobile viewports, and any page outside that sample remain this skill's job.
Report a finding as **already-enforced** if `npm run test` or the page-level axe pass would have caught it; that tells the reader the gate is working rather than implying a gap.
## Instructions
1. **Determine scope.** Accept one of:
- A component name (e.g. `Dropdown`) → audits `src/components/Dropdown/Dropdown.tsx` and its CSS (the chat widget's own components are the `.tsx` modules in `src/chat/`; read the folder rather than a list. Its question flow moves focus deliberately: into the first option when a set of questions holds the composer, and back to the composer once the set is answered or skipped; check both by keyboard)
- A website page URL (e.g. `/components/button`) → audits the live rendered page
- `all-components` → audits all components in `src/components/` and the chat widget in `src/chat/`
2. **Read the source files.** For each component in scope, read the `.tsx` and `.css` files before taking screenshots.
3. **Structural audit (from source code).** Most items here are already enforced by axe — spend your effort on the ones marked **[manual]**, which it cannot evaluate:
**Semantic HTML & ARIA:**
- Interactive elements use correct roles (`button`, `link`, `checkbox`, etc.) — never a `<div onClick>` without `role` and `tabIndex`
- Icon-only `<button>` elements have `aria-label` describing their action
- **[manual]** `<img>` elements have *meaningful* `alt` text — axe only checks that the attribute exists; decorative images use `alt=""`
- Form inputs are associated with `<label>` via `htmlFor`/`id`, or have `aria-label`
- Modals and dialogs use `role="dialog"` and `aria-modal="true"`, with `aria-labelledby` pointing to the title
- Lists use `<ul>`/`<ol>` + `<li>`, not `<div>` stacks
- Heading hierarchy is logical — no h3 before h2, no skipped levels
**Keyboard Navigation:**
- **[manual]** All interactive elements are reachable by Tab, in an order that makes sense — axe cannot judge order
- Custom interactive components handle `onKeyDown` for Enter/Space (buttons) and arrow keys (any component with roving or list focus — radio groups, segmented controls, listboxes, tablists)
- **[manual where no `play` function covers it]** Modal/dialog *actually* traps focus while open and restores it to the trigger on close — axe sees the attributes, not the behaviour. The modal overlays share one behavior implementation (design.md's Components intro owns the contract), so a real trap/restore defect there is a finding against all of them, not one. The site chat is modal only in some of its forms, and its floating card's move and size grips are pointer-only by a recorded decision: design.md's ChatWidget spec says which forms trap focus and why the grips are not a finding
- Escape key closes dismissible overlays (any floating panel — tooltips, popovers, menus, dialogs, pickers), and **only the topmost**: a popover, menu or visible tooltip open inside a dialog closes before the dialog does, and a press on a dialog's backdrop with a menu open closes only the menu. Every overlay dismisses through the shared layer stack (`scripts/validate-overlay-listeners.mjs` keeps it so), and `Dialog.stories.tsx`'s nested-overlay stories assert the contract; a component handling Escape in its own keydown handler must mark it handled with `preventDefault()`, so check that on anything new
- A literal English accessible name the consumer cannot replace is a finding: it cannot be localised. `scripts/validate-accessible-names.mjs` catches literal `aria-label`, `aria-roledescription` and `*Label` attributes, and `scripts/validate-visible-text.mjs` literal JSX text; **[manual]** check English built in string literals and arrays (a weekday list, a `${n} results` template), which neither can see
**Focus Styles:**
- Every interactive element has a `:focus-visible` rule in its CSS
- Focus ring uses the action colour token (`--color-action-primary-bg`) — design.md's action-colour rules are the authoritative list of sanctioned uses. Flag any `outline: none` without a visible replacement
**Motion** (axe evaluates none of this):
- **[manual]** Anything that animates for more than five seconds, or loops indefinitely, can be paused, stopped, or hidden (WCAG 2.2.2). CSS motion satisfies this through the reduced-motion block in `tokens-motion.css`, which carries two mechanisms and whose own header comment owns the contract: token collapse for token-consuming CSS, and a universal `*` guard that also covers hardcoded and `ds-allow(motion)`-sanctioned literal timings (an infinite loop on an off-scale duration is still guarded). Read it there rather than assuming which half applies
- **[manual]** Animation driven from JavaScript is **outside that guard** — a `requestAnimationFrame` loop cannot be seen by CSS, so each one has to check `prefers-reduced-motion` itself. Enumerate the JS-side checks with a grep for `prefers-reduced-motion` across `src/components/**/*.tsx`, `src/chat/**/*.{ts,tsx}` (the streaming reveal's check is in a `.ts` file) and `website/src` — the grep is the inventory, not any list written here (the site's ambient background, which renders a single static frame under the preference, is one illustration; more exist and new ones keep arriving) — and confirm each still honours the preference rather than assuming the token layer covers it. That grep has one known blind spot: the recharts-backed charts animate their marks in from JavaScript but never contain the string — their guard is recharts' own `isAnimationActive: 'auto'` default, and design.md's chart-set spec owns the contract (a literal `true` would override the guard). Run a second grep for `isAnimationActive` across `src/components/` — the one expected hit is the shared entrance hook in `src/components/Chart/useChartAnimation.ts`, which keeps recharts' `'auto'` guard while an entrance is due and sets `false` once it has played (design.md's chart-set spec sanctions it); a literal `true`, or a chart setting the flag without the hook, is a finding to check against that spec
- **[manual]** Motion triggered by interaction (parallax, cursor-reactive effects) is disabled under reduced motion, or is not essential (WCAG 2.3.3)
4. **Visual audit (from screenshots).** Start the preview server and screenshot the target in both light and dark mode (follow the `visual-review` skill pattern). Check:
- **Colour contrast (the priority — axe does not check it):** compute the ratio for every foreground/background pair actually rendered, not just body text. Flag anything below 4.5:1 for normal text or 3:1 for large text and UI components (WCAG 1.4.3 / 1.4.11). Note which token is used. The `color-contrast` axe rule is off for every pair (see item 1 under "What is already automated"); the action-fill pairings are the one slice the build gates, so cross-check a failing action pair against `ACTION_PAIRINGS` in `scripts/validate-theme-presets.mjs` — a pair it lists is already enforced (a hit there means the render is not using the token the table names, unless the cell is pinned in `SANCTIONED_AA_GAPS` in the same script, which is an owner-accepted gap to note rather than raise), and a label or icon on an action fill that it does not list is a gap in the table worth reporting.
- **Text sizing:** No text visually below ~12px (WCAG 1.4.4)
- **Focus visibility:** Confirm focus rings are clearly visible in both light and dark themes
- **Forced colors (WCAG 1.4.11 under high contrast):** render the target with forced colors on (Playwright's `forcedColors: 'active'`, or the browser's emulation) and check that every state carried by a fill (checked, selected, on, a count) still stands apart from the page, that a floating panel keeps an edge once its shadow is dropped, and that focus rings still show. `src/tokens/tokens-forced-colors.css` re-points the state-bearing roles at system colours (design.md's Forced colors section owns which); the page-level check covers only the table bench (`FORCED_CHECKS` in `scripts/validate-website-a11y.mjs`), so anything else is this audit's to judge. A state that vanishes is a finding against the token layer, not the component, unless the component draws the state outside the tokens.
Stop the server when done.
5. **For each issue, report:**
```
src/components/FilterMenu/FilterMenu.tsx:84 — WCAG 4.1.2 Name, Role, Value [Critical]
Trigger button has no accessible name. Icon-only button needs aria-label="Open filters".
```
(The example is fictional by design — `FilterMenu` is not a real component; it illustrates the report shape only.)
Severity:
- **Critical** — blocks keyboard or screen reader users entirely
- **Moderate** — degrades experience significantly
- **Minor** — best practice violation, low direct impact
6. **Summarise:**
- `X critical · Y moderate · Z minor`
- If clean: "No accessibility violations found. Component meets WCAG 2.1 AA."
api-consistencyReads component Props interfaces, across the library or scoped to one component and its family, and flags inconsistencies: mixed boolean naming (disabled vs isDisabled), mismatched size enums, emphasis and colour mixed in one prop, missing standard props, structural mismatches within families, and registry descriptions or specs that name a prop the contract no longer carries. Produces a grouped findings report prioritised by breaking impact.
api-consistency.mdmd---
name: api-consistency
description: Review component prop interfaces across the design system, or scoped to one component or family, for naming inconsistencies, missing standard props, mixed colour vocabulary, contract violations, and descriptions that disagree with the contract. Use when asked to review component APIs, audit prop naming consistency, check a component's API, or check TypeScript interfaces for API inconsistencies.
---
# api-consistency
Review component prop interfaces across the design system for naming inconsistencies, missing standard props, and pattern violations.
## When invoked
Use this skill when asked to review component APIs, check prop naming consistency, or audit TypeScript interfaces — phrases like "review component APIs", "prop consistency audit", "are our component props consistent", "check for API inconsistencies", "check this component's API".
It also runs **scoped** inside other flows: `new-component` runs it on a new component before registering, and `ship` and `super-ship` run it on the components a diff touched. When run scoped inside another flow, report only findings about the components in scope (the touched components and their family). Library-wide findings the owner has already reviewed and accepted are not news to that flow, and re-raising them there buries the finding the flow exists to catch.
## Instructions
1. **Determine scope.** Accept one of:
- **Scoped** (the cheap mode other skills call): one component, or a family. A family is the component's shared folder (the registry entry's `folder`) or its `category` in `src/components/registry.json`, plus any component that composes it or that it composes. Every check below runs, but only over these components, and the family is the comparison set for the family checks.
- A list of specific components (e.g. `Button, CircularButton, ButtonGroup`) → compare those
- `all` → scan all components in `src/components/`, plus the chat widget's documented exports in `src/chat/` (they ride the same generated API with `importPath` `rift-ds/chat`)
- A category description (e.g. "all button-like components", "all form inputs") → infer the relevant components
2. **Read every component's TypeScript interface.** Start from `website/src/data/component-api.generated.ts` for the prop inventory — it holds every own prop with type, default, requiredness and description, parsed exactly as the published `.d.ts` documents them. Then read the `.tsx` files in scope for what the generated file does not capture:
- The structural contract (the ref, `{...rest}` placement, the own-props split, `'use client'`)
- Default values (from destructuring defaults in the function signature)
3. **First, check conformance to the published contract.** This is the highest-value part of the review and takes precedence over style preferences below. The contract is defined in the `new-component` skill and summarised in CLAUDE.md's **Component Anatomy** — read one of them rather than trusting this list, which exists to tell you *what to look for*, not to restate the rules:
- **The ref, the rest spread and the native props.** `node scripts/validate-component-contract.mjs` build-enforces a ref (forwardRef with a `displayName`, or React 19's plain `ref` prop), props extending the native element's type, and the ref and `{...rest}` on one node with the root class after the spread; the props-contract story (`src/stories/Contract.stories.tsx`) proves at runtime that both reach the same element. Run the script rather than re-reading for these. What it cannot judge, and you still check: whether the node it found is the *primary* one (the panel for a portal component, the control for a form field), and whether own handlers the component sets on that node compose with the consumer's (`onMouseEnter` from rest must still fire) instead of overwriting them.
- **`'use client'` present when the component is interactive, and absent when it is purely presentational.** A needless directive silently costs consumers Server Component rendering — flag both directions.
- **Native event signatures keep the standard names.** `onChange` must be a `ChangeEventHandler`, never `(value) => void`. The convenience callback is named for the value's shape: `onValueChange` (string/number), `onCheckedChange` (boolean), `onValuesChange` (array) — and both fire.
- **The controlled/uncontrolled pair convention.** State a consumer can own comes as `x` / `defaultX` / `onXChange`, and works with `x` omitted. A controlled-only prop with no `default*` partner, or a selection named `activeTab`/`onTabChange` where the convention says `value`/`onValueChange`, is a finding, unless the component's design.md spec says it is fully controlled by design.
- **English text is a prop, announced or visible.** A literal English `aria-label` (or visually hidden text) the component writes with no `*Label` prop to replace it is a finding: a consumer cannot localise it. The same goes for visible words. `validate-accessible-names.mjs` and `validate-visible-text.mjs` catch the literal attributes and JSX text; what they cannot see, and you check, is English inside string literals and arrays: a weekday or month list, a pluralised count built in a template (`${n} results`), a status default.
- **No Figma variant properties in the code API.** A `state` enum mixing `hover`/`active` (CSS pseudo-classes the browser owns) with `disabled` (real semantics) has two sources of truth. Prefer `variant` over `priority`/`kind`, and `disabled` as a real boolean.
- **Labelled form controls compose inside `Field`** rather than re-implementing label/helper/required/ARIA wiring.
- **Deprecations, not removals** — an old prop should still work and carry `@deprecated` naming its replacement.
Where a prop deliberately shadows a native attribute with different meaning (`size` vs the native character-width attribute, `title` vs the native tooltip), that is acceptable **only if the collision is documented in the prop's JSDoc**. Flag undocumented collisions.
4. **Then check these style-level inconsistencies:**
**Boolean prop naming:**
- Should follow `is*`/`has*` convention OR plain adjective — not both (e.g. `isDisabled` on one component, `disabled` on another doing the same thing)
- Flag: mixed usage within the same component family
The machine-held half of this vocabulary is `website/public/schema/component.schema.json`: its `allOf` rules are the list of what it locks, every generated component schema is proven against it on each build, and CLAUDE.md's **Schemas are generated twins** bullet owns the rule that a new sanctioned tone changes that file and `SANCTIONED_TONES` together. A finding that changes a locked vocabulary is also an edit there. The decisions in HISTORY.md's 2026-11-01 schemas entry are accepted findings: do not re-raise them.
**Event handler naming:**
- Must be `on*` (e.g. `onClick`, `onChange`, `onDismiss`)
- Flag: `handleClick`, `clickHandler`, `onClickHandler`, or similar
**Content prop naming:**
- `label` for display text, `children` for slot content
- Flag: `text`, `title`, `copy`, `content` used interchangeably across components for the same purpose
**Size enum values:**
- Should use a consistent vocabulary across components
- Flag: `"sm"/"md"/"lg"` on one component and `"small"/"medium"/"large"` on another, or `"compact"/"default"` on one and `"small"/"medium"` on another
**Colour vocabulary:**
- **Emphasis and colour are separate axes.** `variant` holds emphasis (`primary`/`secondary`/`tertiary`); `tone` holds colour (`default`/`neutral`/`destructive`). Flag a `variant` union that mixes the two without the colour values marked as legacy aliases beside a `tone` prop (CLAUDE.md's Component Anatomy states the rule; design.md's Components intro owns the details; `scripts/validate-prop-vocabulary.mjs` enforces the mechanical half). Its `SANCTIONED_TONES`, `SANCTIONED_FORM_VARIANTS`, `SANCTIONED_SIZES` and `LEGACY_VALUES` tables are the record of the deliberate exceptions (a `tone` in another vocabulary, a `variant` that picks a form rather than an emphasis, a size scale of its own, a labelled legacy alias), so read them before flagging any of these
- **One status set.** A status-bearing component carries the five status roles on `variant`, the same five every other status component uses. Flag a sixth role, a missing one, or a synonym (`success` for `positive`, `danger` for `error`)
- **One word for danger: `destructive`.** Flag `danger`, `negative` and other strays naming the same thing, unless the value is a labelled legacy alias in `LEGACY_VALUES`
- **Data entries take a boolean, components take a tone.** An item inside a data array (a menu item, a command row) marks danger with `destructive?: boolean`; a component marks it with `tone`. Flag the two crossed
**Popup side vocabulary:**
- The components that open an anchored panel name its preferred side with `position` (Tooltip, Popover, HoverCard), `placement` (ModelPicker, ThemePicker, AiButton's `summaryPlacement` and `requestPlacement`) or `align` for the edge (DropdownMenu, Popover, SplitButton, ThemePicker). Those are settled and never renamed (the API only grows); flag a new component that introduces a fourth spelling, or uses one of these words for something else
**Missing standard props on interactive components:**
- All components rendering clickable/interactive elements should have `className?: string`
- All components with visual disabled states should have `disabled?: boolean`
- All form-like components should have `id?: string`
- A form control's `name` must reach `FormData`, whatever element it renders: on the native input, or through a hidden input when the control is a `div` or `button` with an ARIA role (design.md, "A control reaches a form through `name`"). Flag a `name` that a form never reads, including one spread onto a `button` or a `div`. The controls design.md lists as taking no `name` are not missing one.
**Family consistency:**
- Components in the same family (e.g. Button / CircularButton / ButtonGroup) should share `size` enum values
- If one component accepts `iconLeft`/`iconRight`, siblings in the same family should follow the same pattern
- Default values: if `size` defaults to `"default"` on a Gadget, it should not default to `"medium"` on a related Sprocket
5. **Then check the contract agrees with its description.** For each component in scope, read its entry in `website/src/data/component-api.generated.ts` (exactly what `/api/mcp`'s `get_component` serves, so this is the contract strangers' agents see), then read the two prose descriptions of it against that entry:
- its registry `description` (`src/components/registry.json`, or `src/chat/registry.json` for the widget), which also ships through the site chat's corpus, the consumer agent skill and the page metadata
- its `###` spec section in `design.md`
Flag any prop or value a description names that the contract does not carry, or carries only as `@deprecated` (a description still saying "neutral and destructive variants" after those moved to `tone` is the classic case). A design.md spec is not a props list and is not expected to name every prop: the prop JSDoc is the one home of the full contract, and the generated component API, the per-component `.md` pages and the MCP endpoint all serve it, so restating it in design.md would be a second copy to drift. Flag a spec that omits a prop only when the prop changes what the component looks like or how it behaves (a variant, a size, a mode) and a reader of the spec would design wrongly without it. The fix goes in the source of the description, never in a generated file.
6. **Output a grouped findings report.** The component names in this example are **fictional by design** — findings about real components go stale the moment someone fixes them, so this block only demonstrates the format:
```
## API Consistency Report
### Contract violations (highest impact)
- Gadget — takes no ref; a consumer cannot reach its node
- Sprocket — props do not extend ComponentPropsWithoutRef<'span'>; data-* unreachable
- MetricPod — has 'use client' but no hooks or handlers; blocks Server Component rendering
### Boolean prop naming
- Gadget: uses `disabled` (plain adjective)
- Doodad: uses `isDisabled` (is* prefix)
→ Standardise to `disabled` across all interactive components
### Size enum values
- Gadget: "compact" | "default" | "large"
- Whatsit: "small" | "medium" | "large"
→ Standardise to the enum of the most-used component in the family
### Colour vocabulary
- Whatsit: `variant` holds "primary" | "danger"; danger is a colour, and a stray word for destructive
→ Move it to `tone="destructive"`, keep `variant="danger"` as a deprecated alias
### Description disagrees with contract
- Doodad — registry description names a "ghost variant"; the contract has no such value
### Missing className prop
- Doodad — no className passthrough
- Gadget — no className passthrough
### Summary
X naming inconsistencies · Y missing props · Z structural mismatches
```
7. **Prioritise fixes** by impact:
- **High:** Renames that would require consuming code changes — flag these clearly so the owner can decide whether to batch into a breaking release
- **Medium:** Missing props that are commonly needed by consumers
- **Low:** Style preferences with no breaking impact
component-consistency-auditSweeps every component for the same thing done more than one way: a checkbox, radio, switch or chosen mark drawn by hand instead of the shared indicator, a visually-hidden recipe or close button copied rather than composed, and a surface family (option rows, cards, overlay rows, navigation rows) whose members split on fill, border, radius, hover, selected or disabled. Runs the shared-primitives validator for the mechanical half, builds the family tables fresh from the stylesheets for the rest, and lands the fixes on a local branch for approval.
component-consistency-audit.mdmd---
name: component-consistency-audit
description: Library-wide audit of how components treat the same thing. Finds controls, marks and surfaces drawn more than one way, components that redraw a shared primitive instead of composing it, and surface families that split on fill, hover, radius, selected or disabled, then fixes the defects for approval. Use when asked to audit consistency, check whether components look and feel alike, find redrawn controls or duplicated primitives, or review surface treatments across the library.
---
# component-consistency-audit
The library's answer to drift between components. Every validator in the build holds one component to the system; this audit holds the components to each other: the same role gets the same treatment, and the small things every component needs are drawn once and composed everywhere else.
It exists because the drift it looks for is invisible from inside any one component. On 2026-10-23 four controls on the playground looked wrong next to each other, and the sweep that followed found the same pattern across the library: three components drawing their own checkbox or radio, a switch copied by hand, a "chosen" check styled seven times in three colours, twenty-one stylesheets each carrying the visually-hidden recipe, three self-drawn close buttons, and seven surface families each split on fill, hover, radius or selected colour. Every one of those had passed every build check.
## When invoked
Use this skill when asked to audit or check consistency across components — phrases like "audit component consistency", "consistency audit", "are the components consistent", "find redrawn controls", "check the surface families", "why do these look different".
It also runs **scoped** inside other flows: `new-component` runs it on a new component and its family before registering, and `design-qa`'s sweep mode borrows its family tables for the lineup. Scoped, report only findings about the components in scope and their family.
## Scope
Accept any of:
- **`all`** (the default when nothing is named) → every component under `src/components/` plus the chat widget's modules in `src/chat/`.
- **A family** → a registry `category`, a shared folder, or a pattern family named in words ("the option rows", "everything with a chosen mark", "the overlay list rows"). The family is judged against itself.
- **A component** → that component plus its family, judged against the family.
## Ground rules — why this audit does not go stale
- The checks below are principles. **Facts come fresh each run**: the component list is `src/components/registry.json` read now, the one drawing of each primitive is `design.md`'s Components intro ("Shared primitives, never redrawn" and "Surfaces by family") read now, and the family tables are built from the stylesheets now. Never judge against a remembered lineup or a list written here.
- Every component name in this skill's examples is fictional by design. A factual claim about a real component inside an example rots silently.
- The validator is the floor, not the audit. `node scripts/validate-shared-primitives.mjs` fails a second drawing of the things it can recognise; what it cannot judge (a legal token in the wrong family, a hover that disagrees with its siblings, a radius chosen per component) is this skill's work.
## Instructions
### 1. Run the mechanical half
`node scripts/validate-shared-primitives.mjs`. A failure is a finding already located; fix it first. Read its doc block for what it covers and its `SANCTIONED` table for the deliberate departures, so a departure the owner has already recorded is not re-raised.
### 2. Inventory the controls and marks
For every component in scope, find each place it shows a choice state and classify the drawing:
- **A form control's indicator** (a box, a circle, a track, a tick inside one): must be `CheckboxIndicator`, `RadioIndicator` or `SwitchIndicator`, rendered by the host, with the host's root carrying the role, the state and the `ds-control-host` class. A glyph, an SVG, a `<div>` with a border radius, or the indicator's class names written by hand are all findings.
- **A chosen mark** (the trailing check on a selected list or menu row): must be `ChosenMark`, rendered only on the selected row. A per-component check span, a different colour or size, or a leading check standing in for a checkbox on a multi-select row are findings.
- **A mutually exclusive set of pills**: must be `SegmentedControl` (or the Inspector family's panel-scale one). A hand-built `role="radiogroup"` of buttons is a finding.
- **A status or decorative icon that merely looks like a control** (a completed step's check, a plan item's circle): leave it, and say so in the report, so the next run does not re-examine it.
Grep is enough to locate the candidates: `aria-checked`, `aria-selected`, `role="switch"`, `role="checkbox"`, `role="radio"`, the Material ligatures `check`, `check_box*`, `radio_button_*`, and selectors ending in `__check`, `__indicator`, `__dot`, `__thumb`, `__track`. The audit is the reading, not the grep.
### 3. Build the surface family tables
For every stylesheet in scope, record the root or row rule that is a bordered or filled surface: background token, border token, radius token, hover background, selected or active background, and the disabled rule. Group the rows into families by role, not by name, and for each family state the dominant treatment and list the outliers. The families the library has today, and the convention each one holds, are in `design.md`'s "Surfaces by family"; a new family is a new paragraph there, not a private rule in one stylesheet. The shape of the table:
| Family | Fill | Border | Radius | Hover | Selected | Disabled |
|---|---|---|---|---|---|---|
| (one row per family, the convention) | | | | | | |
Flag:
- A member whose resting fill is `none` or `transparent` while its siblings are filled, or the reverse.
- A family using more than one radius step for the same role.
- A hover, selected or current treatment that differs from the family's (a current navigation row on the hover token, so hover and current read the same, is the classic case).
- A disabled state that is not `opacity: 0.4` with `cursor: not-allowed`, or is missing where the component takes `disabled`.
- A raw value where the family uses a token (`1px` for `--border-025`).
- A row control (a field, a button, a picker, a segmented set, a filter chip) whose height is not `--control-height-default` / `--control-height-compact` at its two sizes, or is padding-derived rather than set: it drifts the moment its border, line height or the density lever moves, and the row stops lining up. design.md's Control Height Scale is the roster.
A deliberate departure needs a sentence in the component's `design.md` spec saying so; one without is a finding whichever way it resolves.
### 4. Check the micro-primitives
The things every component needs and must not redraw. For each, the one home is named in `design.md`'s Components intro; confirm every use composes it:
- Visually hidden text → `ds-visually-hidden` from `src/behaviors/visually-hidden.css`.
- A header's close button → a compact tertiary `CircularButton`.
- The check and minus glyphs → `CheckIcon` and `MinusIcon`, exported beside Checkbox.
- A focus ring on a control → the 2px action ring on the indicator; a host that is itself the target rings its own edge.
- A label-and-description text column, a helper line, a disabled treatment → the same tokens and the same structure as the nearest sibling (Checkbox and RadioButton are the pair to line up).
A second copy with no reason beside it is a finding. A copy with a reason that still holds is recorded in the validator's `SANCTIONED` table, if the validator can see it, or in the component's spec, if it cannot.
### 5. Line up the pixels
For every family with a finding, render the members side by side in both themes (the `design-qa` skill's Evidence section owns the method) and confirm the finding in pixels before writing it: a token difference that renders identically in both themes is still drift, but it is a Cleanup, not a Defect.
### 6. Judge and fix
Every finding names the primitive or the family rule it breaks, and each is classified:
- **Defect** — a redrawn primitive, or a member outside its family's convention with no recorded reason. One right answer: compose the primitive, or take the family's tokens. *Fix it.*
- **Cleanup** — a duplicate that renders the same today (a copied recipe, a raw value equal to the token). *Fix it.*
- **Direction** — the family itself has no convention yet, or two defensible ones. *Recommend one, write it into `design.md`'s "Surfaces by family" only once the owner picks, and do not restyle members until then.*
Fixes land at the source and obey every library rule (`new-component`'s CSS section is the reference). A new primitive is exported beside the control that owns it (the docgen rule: below the component, every prop documented), its states keyed on its own modifier classes, and its consumers moved onto it in the same change. Then: update the touched components' `design.md` specs, extend the validator's `SANCTIONED` table for any departure that survives, run `npm run validate-registry` and `npm run test`, and leave the work on a local branch for approval. **Never push, merge or deploy** from this skill.
## Report
```
## Component consistency audit: [scope]
**Verdict:** [clean | N defects fixed, M cleanups fixed, K decisions open]
### Redrawn primitives
- [component]: [what it drew] → [what it composes now]
### Surface families
| Family | Convention | Outliers found | Resolution |
### Micro-primitives
- [what was copied, where] → [the one home it now uses]
### Open decisions
- [the family or treatment with no convention] — recommendation: [one option and why]
### Checked and clean
[Which components, families and themes were actually reviewed, so "clean" has a defined coverage.]
```
## Guardrails
- A clean pass is a valid outcome. State the coverage and stop.
- The action colour's roles are design.md's colour-role list, and `scripts/validate-action-colour.mjs`'s `SANCTIONED` table records who may use it; a chosen mark in the action colour is sanctioned there, and a menu row's fill is not. Do not move a use between the two without the owner.
- Contrast is settled: read the comment beside the `color-contrast` override in `.storybook/preview.ts` before raising anything about it.
- A deliberate departure the owner recorded (a sentence in a spec, a row in a sanction table) is not a finding. Re-raise it only with the owner's say-so.
seo-auditSweeps the technical SEO surface of the site: page titles and descriptions, canonical URLs, the sitemap and robots rules, social preview tags, and structured data. It checks the HTML the server actually sends rather than trusting the source, fixes what it finds on a branch for approval, and reports a clean pass when there is nothing worth changing.
seo-audit.mdmd---
name: seo-audit
description: "Behind-the-scenes SEO sweep of the website: page metadata, canonicals, sitemap, robots, social preview tags, structured data. Verifies the rendered HTML, fixes technical issues on a local branch, and a clean pass is a valid outcome. Use when asked to run the SEO audit or check the site's SEO. Never pushes, merges, or deploys."
---
# seo-audit
A recurring optimizer sweep over the website's technical SEO: each run inspects everything a crawler or link unfurler sees, fixes what is safely fixable on a local branch, and reports for approval. **A run that finds nothing to fix is a valid outcome** — say so briefly and stop; never invent a change to have something to ship.
## When invoked
Run when asked to "run the SEO audit" (`/seo-audit`).
## Scope guardrails (read first)
- **Behind the scenes only.** Head metadata, crawl/index surfaces, link unfurl tags, structured data, redirects, internal-link integrity. Nothing a sighted visitor sees changes: no layout, no CSS, no component structure, no visible copy. (Meta titles and descriptions are in scope — they render in search results, not on the page.)
- **Crawl-policy changes are report-only.** Anything that changes what gets indexed or where the canonical site lives (robots rules, `noindex`, canonical host, redirect policy) gets proposed in the report, not implemented — unless it is an outright bug, like a page accidentally marked `noindex`.
- **Local branch only.** Never push, merge, or deploy; never touch the user's working tree. Do the work in a temporary git worktree on a fresh branch named `seo/YYYY-MM-DD-<slug>`, and remove the worktree when done (the branch survives). Skip the worktree entirely on a clean pass.
- **No hardcoded facts.** New metadata prose follows `content-design.md`; anything countable derives from a registry (see CLAUDE.md — never write a component count into a meta description).
## The sweep
### 1. Inventory the surfaces
The crawl/index surface lives in `website/src/app/`: the root `layout.tsx` (site-wide metadata and `metadataBase`), `sitemap.ts`, `robots.ts`, `manifest.ts`, `opengraph-image.tsx`, `icon.tsx` and `apple-icon.tsx`, and the `llms.txt` route — plus the structured data the layout injects, built in `website/src/lib/structuredData.ts`. Per-page metadata comes from each page's `layout.tsx`; component pages derive theirs from the registry via `componentPageMetadata`. Read these fresh each run — the list above says where to look, not what is there.
### 2. Verify the rendered output, not the source
Build the site (`npm run build` in `website/`), then serve it via the `website-prod` entry in `.claude/launch.json` — it wraps `npm run start` and takes any assigned port, so it never collides with a running dev server; read the port from what it reports. Then fetch the served HTML — plain HTTP requests are enough; no browser needed. Sample every section of the site (at least one page per top-level route group, plus the home page and one component page), and fetch the sitemap, robots, and manifest routes directly. For each sampled page check the `<head>`:
- Title present, unique across pages, and following the site's title template.
- Meta description present, sensible length (~70–160 characters), and specific to the page. Judge component pages in the rendered head, never in the registry: `searchDescription()` in `website/src/config/navigation.ts` owns the floor and pads a short registry description at the metadata layer, so a terse registry line is deliberate.
- Canonical URL correct — one canonical host, no duplicate-content splits.
- Open Graph and Twitter card tags complete enough for a clean unfurl (title, description, image, type, url).
- No accidental `noindex`/`nofollow`; viewport and charset present.
- Structured data (JSON-LD) valid where present; note opportunities where a page type clearly warrants it.
### 3. Cross-check the crawl graph
- Every public route appears in the sitemap, and every sitemap URL returns 200 from the running server. Registry-driven collections (components from the two registries merged in `website/src/data/component-pages.ts`, template screens from `templatesSidebarLinks` in `website/src/config/navigation.ts`) must be complete in it — if one is missing, the fix belongs in how `sitemap.ts` derives the list, not in a hand-added entry. Nav-linked does not imply sitemap-listed: a deliberately noindex page may sit in the nav (its own metadata comment is the record of that decision), and it stays out of the sitemap by design.
- Robots rules and the sitemap agree (nothing disallowed that the sitemap advertises).
- Internal links resolve: no anchors pointing at routes that 404. (Largely already automated: `scripts/validate-internal-links.mjs` enforces this on every build for prerendered hrefs — spend the crawl on what it cannot see: external links, redirect chains, and the live HTTP status of anything client-rendered.)
- The 404 page itself returns HTTP 404, not 200.
### 4. Fix, verify, hand off
Apply the mechanical, clearly-safe fixes in one coherent batch on the branch; leave judgment calls (crawl policy, new structured-data strategy, description rewrites that change meaning) as proposals in the report. Do not build in the worktree: a fresh one has no `node_modules` of its own, so the prebuild chain fails wherever module lookup cannot climb to the main checkout's install (the corpus generator needs `typescript`), and a worktree that happens to sit inside the checkout only borrows that install, and installing there is what `land` forbids. Commit the batch on the branch and remove the worktree (the branch survives); the build check happens when the branch is landed, whose combined `npm run verify` builds it. Metadata edits are page prose, so that build regenerates the site chat's corpus (`website/src/data/site-corpus.generated.ts`), and the regenerated file is committed with the landing.
Where a finding is deliberately not a bug — a page intentionally out of the sitemap, an intentionally bare head — record why in a short comment at the site of the decision, so future runs read the reason instead of re-flagging it.
### 5. Report
Repeat the full report in the final message (no report file — the in-place comments from step 4 are the durable record). Plain English, findings grouped as **fixed on the branch** (with before → after), **proposed** (needs a decision), and **checked clean** (what was verified and passed). End with the branch name if one exists, a note that the build check is deferred to landing (step 4 does not build in the worktree), and the reminder that nothing is pushed or deployed: merging the branch (or saying `ship`) approves it; deleting it rejects it.
chat-qualityReads the site chat's own report card: every thumbs verdict a visitor left, joined back to the logged exchange it rates. Disliked answers become golden-set regression cases, the answer-quality eval runs against the updated set, and the fixes land on a branch with a plain-English report for approval. One of the loops described on the Loops page.
chat-quality.mdmd---
name: chat-quality
description: "Biweekly loop that turns real chat feedback into eval coverage. Read the thumbs verdicts from the live Redis, join them to the logged exchanges, grow the golden set from actual failures, run the answer-quality eval, and land the fixes on a local branch for approval. Use when asked to run the chat quality loop. Never pushes, merges, or deploys."
---
# chat-quality
Biweekly loop closing the site chat's feedback cycle. The widget's thumbs verdicts land in Redis and, until this loop, nothing ever read them. Each run joins the verdicts to the exchange log, turns real failures into golden-set regression cases (the standing rule in `evals/chat/README.md`), runs the eval, and hands the fixes over on a local branch. **Never push, merge, or deploy — the user approves every change.**
## When invoked
Run when asked to "run the chat quality loop" (`/chat-quality`), or by a scheduled task once one is created.
## Scope guardrails (read first)
- **The KV store is live.** An Upstash Redis has been connected to the site since 2026-09-26, so the loop has real verdicts to read. The stop-and-ask rule in step 1 still stands as the safe failure mode if the credentials are ever missing.
- **Read-only on the live Redis.** `SCAN`, `GET`, `LRANGE`, `LLEN` against `chat:*` keys only — never `SET`, `DEL`, `EXPIRE`, or anything that writes. The logs are production evidence with a 30-day TTL; this loop observes them.
- **Visitor privacy.** Logged questions are visitors' own words and may carry personal details. They may appear verbatim in the local report, never in the committed diff: a golden-set case gets a paraphrase that preserves the failure, not the visitor's sentence. Visitor hashes never leave the report.
- **One eval run per loop.** `npm run eval:chat` spends real API budget (golden set × 3 repeats). Run it once, after the golden-set changes, not iteratively.
- **Fixes stay inside the chat's answer pipeline**: `evals/chat/golden-set.json`, corpus sources (page prose, `corpus-facts()` blocks, the generator's exclusions), the persona in `website/src/app/api/chat/persona.ts`, and the chat's lookup-tool layer — the `CHAT_TOOLS` definitions in `website/src/app/api/chat/route.ts` and their shared implementations in `website/src/lib/site-tools.ts` (a disliked prop or token answer can be a tool-description or lookup bug, and `site-tools.ts` also serves `/api/mcp`, so a fix there changes both surfaces). The engine is the package's, so a cause can also sit there: the follow-ups prompt, the handlers' notices and the `ask_user` guidance (`DEFAULT_ASK_USER_DESCRIPTION`, the authority for SPEC rules A1 and A2: a reply that asks when it should answer, or answers when it should ask) in `src/chat/server.ts`, the guardrail notices in `src/chat/upstash.ts`, the fallback follow-up pool in `src/chat/followups.ts` (and the site's own in `website/src/lib/chat-followups.ts`). Those are in scope, but a package edit changes every consumer's chat, not only this site's — say so in the finding, and prefer the site-side fix when one exists (for the asking guidance that is `askUser: { description }` in `website/src/app/api/chat/route.ts`). No UI, no components, no guardrail-cap changes (spend caps are the user's call — propose, don't edit).
- **Local branch only.** Never push or touch the user's working tree — do the work in a temporary git worktree on a fresh branch named `chat/YYYY-MM-DD-<slug>`, and remove the worktree when done (the branch survives). Skip the worktree entirely when there is nothing to encode.
## The loop
### 0. Close the previous loop
Read the newest report in `evals/chat/loop-reports/` (git-ignored, local-only). Note whether its branch was merged; don't re-propose a case a previous run already encoded. First run ever: say so and move on.
### 1. Pull the verdicts and the log
Credentials are `KV_REST_API_URL` / `KV_REST_API_TOKEN` in `website/.env.local`. They are often **commented out** there so local dev fails open — read the values from the file either way; never uncomment them or export them into a dev server. If the file has no values at all, stop and ask the user for read access; there is no loop without the data.
Query the Upstash REST API directly (plain `curl`): `SCAN` for `chat:feedback:*` and `GET` each verdict; `LRANGE` the day lists for the window since the last report (the key builders are owned by `src/chat/upstash.ts` and the entry shape by `ExchangeLog` in `src/chat/server.ts` — the site's `website/src/app/api/chat/guardrails.ts` only configures them — so read them there rather than trusting a remembered shape). Join verdicts to log lines by exchange `id`.
### 2. Triage
Every **down** verdict is a golden-set candidate: read the question, the answer, and the page it was asked from. The exchange's `path` field holds the request's whole context as the route checked it, so for the site chat it is a JSON string of the page and, when the visitor had highlighted something, the highlight with what it sits in (`website/src/lib/site-chat-context.ts`); a bare path is an older entry. A down verdict on a highlighted question is often about the highlight, so read it with the answer. Also skim the unrated exchanges for silent failures — invented paths, guardrail notices where a real answer was possible, questions the corpus plainly couldn't answer. The log does not record a question set the reply offered: an answer tapped on a question card arrives as the next exchange, its "question" being the plain-text answer ("Next.js", or one prompt per line for a set), so read those against the exchange before them rather than as visitor questions. For an asking failure, the cheapest assertion is the `[question]` line `evals/chat/transform-response.js` appends (`contains` when the reply should ask, `not-contains` when it should not). **An empty or all-up window is a valid outcome**: write the short report and stop. Don't manufacture cases from answers that were actually fine.
### 3. Encode the failures
Apply the standing rule from `evals/chat/README.md`: each real failure becomes a golden-set case **before** it is fixed — paraphrased question, its `requiredFacts`, and the cheapest assertion that would have caught it, with the case's `description` citing the rule ids it covers per `evals/chat/SPEC.md` (a failure no rule covers means the spec gains the rule in the same change). If a required fact is missing from the corpus, that's the actual bug: fix the source (page prose or a `corpus-facts()` block), and `scripts/validate-chat-coverage.mjs` will hold the new case to the regenerated corpus. The one exception: a failure whose facts live in the generated prop or token data is answered by the chat's lookup tools, not the corpus — its case is marked `source: tools` and carries empty `requiredFacts` (the standing-rule section in `evals/chat/README.md` and rule T4 in `evals/chat/SPEC.md` own the convention); never fix one by stuffing a prop fact into page prose.
### 4. Run the eval
Follow `evals/chat/README.md` exactly — it owns the procedure (the `website-eval` launch entry / `npm run dev:eval -w website`, the port-3000 gotcha, `npm run eval:chat`) and how to read the transcripts (seat by seat; the aggregate score is noise). The question being answered: do the new cases fail before the fix and pass after, and did nothing that previously passed regress?
### 5. Fix on a branch
Work in the temporary worktree on branch `chat/YYYY-MM-DD-<slug>`. One coherent batch: the new golden-set cases plus the corpus/persona fixes they demanded. A persona or tool-definition edit updates its matching rule row in `evals/chat/SPEC.md` in the same commit, and a new rule ships with either a tripwire or an explicit unenforced entry there. Do not build in the worktree: a fresh one has no `node_modules` of its own, so the prebuild chain fails wherever module lookup cannot climb to the main checkout's install (the corpus generator needs `typescript`), and a worktree that happens to sit inside the checkout only borrows that install, and installing there is what `land` forbids. The build check happens when the branch is landed, whose combined `npm run verify` builds it; when page prose changed, the corpus that build regenerates is committed with the landing.
### 6. Report and hand off
Save to `evals/chat/loop-reports/YYYY-MM-DD.md` **and** repeat in full in the final message: the window, verdict counts (up/down/total exchanges), each disliked answer and the diagnosis, what was encoded, the eval result, and the branch name. Plain English. End with the approval step: merging the branch (or saying `ship`) approves it; deleting it rejects it. Nothing is pushed or deployed.
link-rotCollects every external link the built site renders, from docs references to footer profiles, and checks each one still resolves. The build already proves internal links can never break; the outside world offers no such guarantee. Failures are verified by hand before anything is called dead, because bot-blockers fake them, and real rot is fixed on a branch for approval. One of the loops described on the Loops page.
link-rot.mdmd---
name: link-rot
description: "Monthly loop that checks every external link the built site renders still resolves. Build the site, probe the external hrefs with scripts/check-external-links.mjs, verify every failure by hand, and fix genuinely dead links on a local branch for approval. Use when asked to run the link rot loop or check the external links. Never pushes, merges, or deploys."
---
# link-rot
Monthly loop over the one class of link the build cannot guard: external ones. `scripts/validate-internal-links.mjs` makes a broken internal href impossible; a moved profile, a deleted article, or a dead product page outside the site rots silently. Each run probes every external href in the prerendered HTML, verifies the failures by hand, and fixes real rot on a local branch. **Never push, merge, or deploy — the user approves every change.**
## When invoked
Run when asked to "run the link rot loop" (`/link-rot`), or by a scheduled task once one is created.
## Scope guardrails (read first)
- **A probe failure is a lead, not a verdict.** Plenty of hosts answer scripts with 403/429/999 while serving browsers fine. Never flag, fix, or remove a link the loop hasn't confirmed dead in a real browser.
- **Link surgery only.** Swap an href for its new home, or remove a dead anchor with the minimal wording change that removal forces. Rewrites beyond that, and any dead link whose replacement needs a judgment call (which article now says this? does the paragraph still hold without it?), go in the report as proposals.
- **Local branch only.** Never push or touch the user's working tree — do the work in a temporary git worktree on a fresh branch named `links/YYYY-MM-DD`, and remove the worktree when done (the branch survives). Skip the worktree entirely on a clean pass.
## The loop
### 1. Build and probe
```bash
npm --prefix website run build
node scripts/check-external-links.mjs
```
The script owns extraction and probing — which HTML it reads, timeouts, the HEAD-then-GET fallback, and the `KNOWN_BLOCKERS` list of hosts that reject scripts on principle (its doc block also owns why it is deliberately not part of `verify`). A same-day build's output can be reused if it is fresh.
### 2. Verify every failure by hand
For each reported failure: retry with `curl` and a browser user agent, then load it in the Browser pane. Three outcomes:
- **Alive in a browser** → a bot-blocker, not rot. Add the host to `KNOWN_BLOCKERS` in `scripts/check-external-links.mjs` with a written reason, so future runs list it for manual checking instead of re-flagging it.
- **Redirects permanently to a real new home** → fixable; point the href at the destination.
- **Actually dead** → fixable or proposable per the scope guardrails.
Also spot-check the links the script listed under known blockers — they are probed by nobody, so this manual pass is their only coverage.
### 3. Fix on a branch
Work in the temporary worktree on branch `links/YYYY-MM-DD`. Do not build in the worktree: a fresh one has no `node_modules` of its own, so the prebuild chain fails wherever module lookup cannot climb to the main checkout's install (the corpus generator needs `typescript`), and a worktree that happens to sit inside the checkout only borrows that install, and installing there is what `land` forbids. The build check happens when the branch is landed, whose combined `npm run verify` builds it; if anchor text changed, the corpus that build regenerates is committed with the landing.
### 4. Report and hand off
Repeat the full report in the final message (the in-repo `KNOWN_BLOCKERS` reasons are the durable record; no report file). Group findings as **fixed on the branch** (old href → new href, with the pages that rendered it), **proposed** (dead, but the replacement is a judgment call), and **false alarms recorded** (hosts added to the blocker list). **"Every link resolves" is a valid outcome** — say so briefly and stop. End with the branch name if one exists, a note that the build check is deferred to landing (the fix step does not build in the worktree), and the approval step: merging the branch (or saying `ship`) approves it; deleting it rejects it.