Skills
Reusable AI instructions, tuned for this project
These 27 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]".
## 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, forwardRef, 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)
- `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)
- `.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 consumer. 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/` — the registry entries with `folder: "Chart"` are the authoritative membership list), 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 — CLAUDE.md's registry row 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. 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
**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.
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.
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. **`React.forwardRef`** onto the primary DOM node, with `ComponentName.displayName = 'ComponentName'` after it. 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 *first* so the component's own attributes win. 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.
7. **Discard props that would be invalid on the rendered node** rather than spreading them — e.g. `name` on a `<div role="radio">`. Destructure with a `_` prefix (`name: _name`) and document why in the JSDoc; the ESLint config allows `^_`.
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. **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.
### File 2: `ComponentName.css`
- CSS custom properties exclusively — **no hardcoded hex colours**, no raw `rgb()`/`rgba()`
- 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`
- **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
- 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>`
- `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.
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`), the shadcn registry item (`website/public/r/<slug>.json`, plus its `registry.json` index), the consumer agent skill's catalogue (the brand-named folder under `website/public/skill/` — `SKILL_NAME` in `scripts/brand.mjs` owns the name), 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). **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, 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. If the component needs a new runtime dependency, stop and ask: the package's only runtime deps are the react peer and the optional recharts peer, 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.
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: `tokens-light.css` alone. Typography: `tokens-typography.css`. Motion: `tokens-motion.css`. 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.
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`. Other categories go on their matching foundations page (CLAUDE.md's checklist maps category → page).
- The matching Storybook doc: `src/stories/Tokens.stories.tsx` for colour, status, chart, elevation, spacing and motion; `src/stories/Typography.stories.tsx` for a type style (its Body and All Styles stories list every tier).
- A sentence in `design.md` recording the role and per-theme values, in the section that owns the token's subject.
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` and re-checks every home), then `npm run build` for the type-check. Displayed counts update themselves — never hardcode one.
## Guardrails
- Never hand-edit `src/tokens/registry.json` — it regenerates 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 WCAG AA check on the action pairing 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.
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/` and the playground's live preview both compile from the same `presetOverrides` composer, so the preview and the shipped CSS cannot disagree.
## 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.
- **Shape** (`radiusScale`, `pill`): the corner language.
- **The four feel levers** (`density`, `typeScale`, `motionScale`, `elevation`): 100/100/100/default is a legitimate position, but state it deliberately.
- **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 (the serif labels carry their parenthetical).
- **`accents`**: the ambient sextet. These drive the background blobs and chart series 2–7 together, 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.
- **`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, re-pitched display weights for a heavier face) — 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).
Existing entries in the file are the reference implementations; read two before writing one.
### 2. Decide where it appears
Add the id to `THEME_SELECTOR_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. Every theme-picking surface walks this list — the home page's dot row, 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.
### 3. Generate, gate, verify
```bash
node scripts/generate-preset-stylesheets.mjs
node scripts/validate-preset-stylesheets.mjs
node scripts/validate-theme-presets.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, requires the action family and all six accents, and checks the resolved action bg/text pairing at **WCAG AA 4.5:1 in both themes**. A failing pairing means the key needs to move (deepen or lighten it, 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 CLAUDE.md's Fonts entry owns the contract. The stylesheet generator fails naming the script otherwise, and the downloaded woff2s commit with the preset.
Then `npm run verify` — the mirror guards and the site build exercise everything the three 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. 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-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`, 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 the canonical set in that same spec
### 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 (CLAUDE.md's BlurBackground entry 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 (CLAUDE.md's BlurBackground entry 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, but wants a `DOC_ICONS` entry in the same file or its row falls back to the generic icon; 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 below 960px — the mobile breakpoint, whose single authoritative home is design.md's Responsive → Breakpoints section — on the surfaces that render at every width (footer, DS landing hero, home DS card), and the mobile drawer omits desktop-only pages outright (its hand-built tree in `MegaNav.tsx` — leave a comment at the omission)
- **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) — `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
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) — the sales pipeline
- A timeline or scheduling stage — the roadmap planner or team calendar
- An analytics shell — the marketing dashboard
- A conversation-centred screen — the agent workbench
- An instrument with one subject in two projections (a map or globe stage, a stage-mounted toolbar, full-width content) — the relay console
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.
- **Size the 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 and the canvas board render pages in scaled same-origin iframes and pin that variable to give viewport-tall shells a fixed size.
- **The assistant panel**, three shapes: a screen that wants a docked mock chat uses the shared `TemplateAssistant` (`website/src/components/templates/TemplateAssistant/`); a screen that is *itself* a chat surface builds its conversation pane inline (the agent workbench); and a screen whose subject **is** the chat hosts the real `SiteChat` on the simulated transport, inside its own `SiteChatProvider` (the payroll console). Reach for the third 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 should pass `contextLabel={null}` so the staged assistant does not announce this site's page name.
- Every control is a library component, every value a semantic token — the standard component and token rules apply unchanged. Demo humans render through Avatar's initials fallback, never portrait imagery.
- 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, in one change
- **`templatesSidebarLinks` in `website/src/config/navigation.ts`** — add `{ href, label, description }`: 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, and llms.txt 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 template's facts live on the /templates index, which is covered). This one **is a gate**: `validate-chat-coverage.mjs` fails the build for an uncovered route.
Nothing else needs registering. 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; the canvas board shows only section landing pages, so an individual template never joins it.
### 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). 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.
2. **Decide where it runs.** Three 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.
- **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 row in CLAUDE.md's Registries table (registry, count export, validator — follow the existing rows); 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`, top-right) — the explicit Light or Dark segment, not System — 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 has three geometries and the default desktop width shows the least interesting one: a full-viewport takeover with its own stacked welcome screen on phones, a floating modal panel at mid widths, and a docked panel that insets the whole page beside it at the widest (design.md's SiteChat panel spec owns the exact breakpoints). 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.
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 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 panel on mid widths, and a docked panel at the widest that pads the page to make room — 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. The welcome starters and a fresh answer's follow-up chips also stage a shimmer-then-pop reveal (design.md's Site chat pattern owns the choreography), so shimmer pills where chips belong are a loading state, not missing content. 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) disappear below 960px from every surface that renders at all widths — the footer's Design system column, the DS landing hero, the home DS card — 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
- The ambient background (`BlurBackground`) is layout-mounted chrome too, and it is the largest thing in every screenshot. Three of its behaviours produce **false findings** if you do not expect them:
- **It has two renderers.** A WebGL2 field normally, the CSS blobs underneath as the fallback. Which one you capture depends on the machine's GPU, so the same page can legitimately screenshot two different ways on two runs. A background that differs between runs is not a finding; a *broken-looking* one is.
- **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 (CLAUDE.md's BlurBackground entry 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/app` for the current sets rather than trusting a list here. Judge each page against its own variant.
- 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
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
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 `visual-review` skill's step 3 and Key context document how to switch it — 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, and reveal (design.md's Site chat pattern owns the choreography): capture after they settle, and read a mid-flight frame — a part-transparent panel, shimmer pills where chips belong — as animation, not defect.
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.
**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.
**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.
**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.
## 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). Read it 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.
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.
## 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`
- `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. 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 is the **generated** `src/tokens/registry.json` — every semantic token with its category and per-theme values, machine-readable; read it instead of parsing the CSS by hand (never edit it — it regenerates from the CSS).
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 token registry's resolved values **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, 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: `path/to/file.css:42 — #0E6E8F → var(--color-action-primary-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: `src/**` and `website/src/**` CSS and TSX. 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).
- **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.
- **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)
- `registry` → the `description` fields in `src/components/registry.json`
- `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 (`component-count`, `component-list`, `npm-badge`) are **generated** from `src/components/registry.json` — a rewrite inside them silently reverts on the next build, so fix that copy through the `registry` scope instead; 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 site chat's shipped prose: the greeting in `website/src/components/SiteChat/greeting.ts`, the starter pools in `website/src/components/SiteChat/starters.ts` 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 welcome tagline, the disclaimer line and the locked-model line inline in `SiteChat.tsx`, 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 and easter-egg strings in `website/src/app/api/chat/`, the tool trace points and notice strings in `website/src/app/api/chat/route.ts`, the guardrail notices in `website/src/app/api/chat/guardrails.ts` (all visitor-visible; the em-dash half is build-covered through `validate-shipped-prose.mjs`'s module scan, and the guardrail notices' register row is content-design.md's — say what happened and what to do next, never blame the visitor), and the playground's staged copy — the turns and chip labels in `website/src/lib/chat-sim.ts`, the event-rail copy in `website/src/app/playground/ChatDirector.tsx`, and the view modules under `website/src/app/playground/views/` (ChatView's staged history, TypeView's tier notes and labels — all visitor-visible but living in string literals or non-page modules the page scan's prose extraction cannot see; the chips' register row lives in the guide's Register by Surface table). The em-dash half is split: `scripts/validate-shipped-prose.mjs` build-scans those story modules' string literals (`STORY_MODULES` is its list), so this scope's em-dash job there is done by the build — but the SiteChat and api/chat strings 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 copyright in `website/src/components/SiteFooter/SiteFooter.tsx`, and the link labels in `website/src/config/social.ts` (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
- `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 skill's instructional prose, hand-written inside `scripts/generate-agent-skill.mjs` (its register row is in the guide's table). The published pair under the brand-named folder in `website/public/skill/` (`SKILL_NAME` in `scripts/brand.mjs` owns the name) is generated and byte-compared, so fix the generator, never the files; its sibling surface, the per-component `.md` pages, deliberately has no scope — every sentence there derives from registry data
- `cli` → the package init bin's terminal output: the usage block, error lines 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)
- `page-summaries` → the FAB panel's per-page TLDR copy: the `title`, `text` and chip 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
- `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/`, 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 publishable npm package build, every Storybook story as a render, interaction, *and* accessibility test (Vitest + headless Chromium + axe, with story play functions asserting behavior), 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 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, library and package builds 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 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), 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 and every checked page must be visible with content, because a build can be green while the rendered site is blank (the hydration smoke's doc block records the outage that proved it), 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 publish lint, the built-HTML validators, and the served-site checks). Safe to push.
**If the change touched component CSS, `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, 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)
- If **story tests** failed, say whether it was a render error, an **a11y violation**, or a **play-function assertion** — 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.
**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 after the generators run in `.github/workflows/ci.yml`, so what it cares about is that no regeneration is left uncommitted, 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`, and 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 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.
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, 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/`), the renderer itself (`src/components/ShaderField/`), or the immersive stages' ground (`website/src/components/DotBackground/`) — offer a `visual-review` pass instead: the first three change the background on all of the site's pages at once, the fourth is the whole ground under the stage pages, 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 guardrails in `website/src/app/api/chat/`, 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 (hydration succeeded, the theme guard's ready mark landed, the page is visible with content, at desktop and phone viewports). **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 into the ship flow for structural work. Runs the full audit first, fixes the broken and stale findings it surfaces, 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, fix the broken and stale findings, 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, fix, ship — one invocation. A higher-order skill: it composes `drift-audit` and `ship` and owns only the rules of the composition. Everything about *how* to audit lives in `.claude/skills/drift-audit/SKILL.md`; everything about *how* to ship lives in `.claude/skills/ship/SKILL.md`. Read both 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.
### 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.
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. There's no need to repeat the full prose read.
### 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 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 what was executed versus only read
- 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` or `ship` — a change to either 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 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 corpus, skills content and component-API files under `website/src/data/`, the barrels, the token registry, 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/`, and the consumer agent skill under `skill/` — its folder is named by `SKILL_NAME` in `scripts/brand.mjs`): 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.
- **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, ever** — not `main`, not a 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
first-deployThe one-time path from a green private repo to a live public product: the Vercel projects, the placeholder URL becoming real, the parked uptime cron waking up, the npm Trusted Publishing registration this repo does not yet have, the first publish, and the wiring the chat and analytics wait on. Ordered so nothing ships pointing at a thing that does not exist yet.
first-deploy.mdmd---
name: first-deploy
description: The one-time go-live checklist — everything between this repo building green and the site, package, and monitoring actually existing in public. Use when asked to go live, deploy for the first time, or set up the public phase.
---
# first-deploy
The one-time sequence from a repo that builds green to a site, package, and monitoring that exist in public. Once every box here is ticked, this skill is history — the `ship` and `release` skills own everything recurring.
## When invoked
Use this skill when asked to go live, do the first deploy, or start the public phase — the site is already live on Vercel, so what remains here is the rest of the public phase (npm, GA, chat wiring, repo visibility).
## The governing idea
Order is the whole content of this checklist: the init bin stamps `SITE_URL` at package build time, the install surfaces are validator-held to the real package, and the smoke tests need a production to smoke — so each step below unlocks the ones after it, and doing one early ships a pointer at nothing. **Steps marked (owner) happen in dashboards only the owner can reach**; stop and hand over rather than working around them.
## Instructions
### 0. Decide the identity first
The package rename is the gate: publishing under a name that is about to change burns version numbers on a placeholder. The brand name landed as Rift DS, but `PACKAGE_NAME` deliberately stayed `rift-ds`. Re-confirm that decision with the owner before the first publish, since it is far cheaper to change now than after a version exists. If they want it renamed, run rename day first — the `PACKAGE_NAME` doc block in `scripts/brand.mjs` owns that recipe.
### 1. The site exists — DONE
The Vercel project serves `SITE_URL`, Storybook serves `STORYBOOK_URL` (both in `scripts/brand.mjs`), and the hydration smoke proved the first deploy.
### 2. Monitoring wakes up — DONE
The cron in `.github/workflows/uptime.yml` runs on schedule against production, and the "inoperable until first deploy" notes in `ship` and `pre-deploy` are retired.
### 3. The package exists
- **(owner)** Register Trusted Publishing on npmjs.com for THIS repo and the workflow filename `release.yml` verbatim — the current registration names the predecessor repo, and a dry run never authenticates, so nothing but a real publish proves the path.
- Cut the first release with the `release` skill (it already carries the first-release branches: no tags to describe, the whole history is the changelog, and the release log's first entry lands with it).
- Prove the consumer path end to end: `npx <package> init` against the live site must install the agent skill and print the MCP connect line.
### 4. The optional wiring, each its own decision (owner)
- **Chat**: an Upstash/Redis store and the env vars the route reads; until then the widget stays in its switched-off state by design.
- **Analytics**: a GA4 property; setting `GA_ID` in brand.mjs turns the snippet on, and the privacy page's analytics section must be updated in the same change — it currently states analytics is off.
- **Chromatic**: a project and its token secret; until then the dispatch-only workflow stays unused.
- **Repo visibility**: flip public when the owner says so — SECURITY.md's advisory link assumes it eventually is.
- **README banner URL**: before the first npm publish, switch the README's banner `src` from the relative `.github/readme-banner.jpg` to the absolute raw.githubusercontent.com URL (built from the repo URL in brand.mjs) — the README ships inside the npm tarball, and npmjs.com cannot resolve a relative image path. Relative is deliberate until then: it renders on the private repo, where an absolute raw URL would not.
### 5. Close the loop
Grep the repo for "until the first deploy" and "does not exist yet" phrasings and retire each one that stopped being true, then update this skill's own registry entry: once everything above is done, `first-deploy` moves to `unlisted` history or is deleted — a completed one-time setup left looking current is exactly the drift the audits exist to catch.
## Guardrails
- Never work around a (owner) step with credentials or dashboard access you were not explicitly given
- Never publish before the identity decision in step 0 is made on purpose
- A 404 minutes after a green first publish is registry propagation — never re-run the workflow on it
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
```
**First release from this repo**: the history is fresh and carries no tags yet, so `git describe --tags` fails — compare against the full history instead (`git log --oneline`) and treat everything in it as what ships.
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.
### 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 covers THIS repo** before a real publish. The registration is keyed to repo + workflow filename, and it currently names a different repository — a publish from here fails auth until it is updated. 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.
**Two hand-maintained release-history mentions ride along with the bump** — nothing generates or validates either, so this step is the only thing keeping them true: the release-history sentence in `CLAUDE.md` (CI & Local Verify section) and the history comment at the top of `.github/workflows/release.yml`. Add the new version, date, and a short what-shipped clause to both in the bump commit.
Then:
```bash
npm run validate-registry
```
This re-runs the three-way version parity check and regenerates the derived surfaces — and two generated trees stamp `PACKAGE_VERSION` into their output, so the bump rewrites every per-component markdown page under `website/public/components/` and the consumer agent skill's brand-named folder under `website/public/skill/` (`SKILL_NAME` in `scripts/brand.mjs` owns the name). 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 a scratch Vite + React app and **builds that app without recharts installed** (the optional-peer path — the regression this catches), 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` and the init bin under `bin/` (named by `BIN_NAME` in `scripts/brand.mjs`, origin-stamped and executable) 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 a scratch Vite app and builds it (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.
### 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>
```
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; commit it 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.** 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, and this repo is not yet registered (see the pre-flight check); 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
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 0.x", "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 <previous-tag>^)..v<version>
git show v<version> --no-patch --format='%s%n%b'
```
**First release from this repo**: the history is fresh and carries no earlier tags, so there is no `<previous-tag>` to anchor the range — take it from the full history instead (`git log --oneline v<version>`).
The tag message names the headline features; the commit list fills in the rest. The release-history sentence in `CLAUDE.md` (CI & Local Verify section) is the one-line 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's shipped default theme is the mono preset** (`data-brand="mono"` is served). Resolve every role through that preset — its generated stylesheet in `src/tokens/presets/` overrides the base tokens (composed by `presetOverrides` in `website/src/lib/theme/presets.ts`) — rather than assuming the base dark-teal look.
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 shipped preset 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 teal; 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 accent-coloured radial glow or two behind everything (the accent as the shipped preset resolves it) — the site's ambient background in still form. Subtle; the ground stays near-black.
Nunito Sans loads 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. Two Windows-checkout gotchas from the first run: ESM ignores `NODE_PATH`, 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`); and strip the leading slash a `file://` pathname puts before the drive letter.
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 `CLAUDE.md`'s release-history sentence 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.
- **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.
- **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 markers and its generator.
- **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 — 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.
### 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.
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 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.
- 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 the canonical set in that same spec
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` — 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`) 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). 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 automated 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 accessible-teal split 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. That makes contrast — every pair, everywhere — 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
- A website page URL (e.g. `/components/button`) → audits the live rendered page
- `all-components` → audits all components in `src/components/`
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
- Escape key closes dismissible overlays (any floating panel — tooltips, popovers, menus, dialogs, pickers)
**Focus Styles:**
- Every interactive element has a `:focus-visible` rule in its CSS
- Focus ring uses the teal action token (`--color-action-primary-bg`) — design.md's teal 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` 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/` — a clean result means the default guard holds everywhere; any explicit override 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 — nothing automated covers this):** 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. Nothing is pre-exempt: the `color-contrast` axe rule is off for every pair (see Key context point 1), so every rendered pair is this audit's job; the shipped action pairings' AA clearance is recorded in that override's comment, so cross-check against it before flagging one of them.
- **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
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 all component Props interfaces and flags inconsistencies across the library: mixed boolean naming (disabled vs isDisabled), mismatched size enums, missing standard props (className, disabled), and structural mismatches within component families. Produces a grouped findings report prioritised by breaking impact.
api-consistency.mdmd---
name: api-consistency
description: Review component prop interfaces across the design system for naming inconsistencies, missing standard props, and pattern violations. Use when asked to review component APIs, audit prop naming consistency, 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".
## Instructions
1. **Determine scope.** Accept one of:
- A list of specific components (e.g. `Button, CircularButton, ButtonGroup`) → compare those
- `all` → scan all components in `src/components/`
- 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 (`forwardRef`, `{...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:
- **`forwardRef` onto the primary DOM node**, plus a matching `displayName`. A component that cannot take a ref cannot be focused, measured, or registered by a form library.
- **`{...rest}` spread onto that same node**, placed first so the component's own attributes win.
- **Props extend the native element's type** — `Omit<React.ComponentPropsWithoutRef<'el'>, keyof OwnProps>`. Without it, `data-*`, `aria-*`, `autoComplete` and friends are unreachable.
- **`'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.
- **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
**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
**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`
- `name` only belongs on a component that renders a **native** form control. Several controls here render a `div` with an ARIA role (Checkbox, RadioButton, Dropdown), where `name` cannot participate in form submission — on those it is a documented no-op, not a missing prop. Flag an *undocumented* `name`, not its absence.
**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. **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 — no forwardRef; a consumer cannot take a ref
- 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
### Missing className prop
- Doodad — no className passthrough
- Gadget — no className passthrough
### Summary
X naming inconsistencies · Y missing props · Z structural mismatches
```
6. **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
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` (its `sameAs` derives from `website/src/config/social.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.
- 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 registry, 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. Verify the website build passes in the worktree before committing. Metadata edits are page prose, so the build regenerates the site chat's corpus (`website/src/data/site-corpus.generated.ts`) — commit the regenerated file in the same batch, then remove the worktree (the branch survives).
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, confirmation the build passed, 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)
- **Inoperable until a KV store exists.** There is no live Redis to read yet; the stop-and-ask rule in step 1 already halts the loop safely when no credentials exist.
- **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). 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 (key format and entry shape are owned by `website/src/app/api/chat/guardrails.ts` — read `logKey`/`ExchangeLog` 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. 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. **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. Verify the website build in the worktree; commit the regenerated corpus in the same batch when page prose changed.
### 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`. Verify the website build in the worktree; if anchor text changed, the regenerated corpus rides along in the same commit.
### 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, confirmation the build passed, and the approval step: merging the branch (or saying `ship`) approves it; deleting it rejects it.