Skills
Reusable AI instructions, tuned for this project
These 22 skill files live in .claude/skills/ (most in this repo; only ga-report lives in my personal skills folder) 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 on their own schedule: 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 (colour and spacing live in `tokens-light.css`, typography and motion in their own files; 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. Three 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 (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. The criterion is the recharts dependency, not the category: a dependency-free chart is an ordinary own-folder component (the registry's `charts` entries **without** a `folder` field are the current list — don't restate it here). 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 (`website/src/data/component-api.generated.ts`) — so a prop description is public copy strangers' agents 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. 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: var(--icon-size-sm|md|lg|xl)` on the icon element — never `font-size` or raw pixel dimensions on an icon (the scale's values live in the token files)
- 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; no component CSS in the library contains a `data-theme` selector)
### 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`)
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. 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. `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. **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**. Run `npm run validate-registry` to confirm — 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; never hand-edit either barrel), and the component prop API (`website/src/data/component-api.generated.ts`). **Commit every file those generators rewrote alongside the registration** — 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 `@robr0/design-system` 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 Rob: "Should I add the website documentation page now? (invokes the `component-doc-page` skill)" — and whoever does that work must complete every registration above.
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. Spacing, radius, border, shadow, 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-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.
## 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 (foundations, the docs cluster, work; writing is fed dynamically from Substack; standalone pages like `/playground` and `/contact` 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
- **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 at 768px system-wide) — do not add per-page `@media` overrides for tokenized values; when a page genuinely needs a breakpoint, use the canonical set in `design.md`'s responsive 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`, `/contact` — 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 — `/canvas` is crawler-hidden but nav-linked while it is alpha; `/covers` is both. Either way the page records why — in its `layout.tsx`, or in the `page.tsx` itself when the page needs no layout of its own (`/covers` is the precedent for the simpler shape). A full-viewport or immersive page that should also render none of the shared chrome (the layout-mounted footer and chat panel) 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 450px band that fades into the page floor. 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
- 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; work is curated editorially, strongest first and roughly newest first; components are derived from the registry — see the exception in step 1)
- **Work pages are a second exception**: a `/work/<slug>` page must also be registered in `website/src/data/case-studies.json` (near the top if it is the newest or the strongest — `/work` and the home page both derive from that order), with every field `scripts/validate-case-studies.mjs` requires (its `REQUIRED` list is authoritative — read it rather than trusting any copy) and the logo/cover assets in `website/public`. The validator fails the build for an unregistered case-study folder, so this is a gate, not a convention. A study with a vector cover has a second gate: `scripts/validate-cover-rasters.mjs` holds `website/src/data/cover-renders.json`, the drawing map in `website/src/components/covers/case-study-covers.tsx`, and the rendered webp files under `website/public/covers/rendered` to each other in both directions — register the cover in all three (shoot the rasters with `npm run covers:render` against a running dev server) or the build fails
- **Blueprint pages are a third 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 extend `dsActiveMatchers`/`dsMegaItems` only if it belongs under the Design system umbrella. 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`). 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 (the `/canvas` 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 959px 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 if it lives under the Design system umbrella, extend `dsActiveMatchers` (and `dsMegaItems` if it should appear in the mega menu)
- **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 canvas board** (only if the page starts a new *top-level* IA section): add the section to `siteSections()` in `website/src/app/canvas/ia.ts` — the board shows one landing page per section by design (its doc block explains why), so sub-pages, component pages, and blueprint pages need nothing here. A deliberately hidden section (noindex, absent from all navigation — see the hidden-page step above) stays *off* the board: the board is the public IA, and `/labs` is the precedent. No validator holds this list, so it is the one registration that fails silently when skipped
5. **Verify**: load the page in the browser and confirm the sidebar highlights it, the breadcrumb trail is correct, and both themes render properly.
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. 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: below 720px it is a full-viewport takeover with its own stacked welcome screen, between 720 and 1439px a floating modal panel, and from 1440px a docked panel that insets the whole page beside it. On at least one page, open the chat at the mobile width and at a width of 1440px or more and screenshot both — the docked form is the one that moves the page, so a layout that survives 1280px 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` (top-right of every page)
- The sitemap footer (`SiteFooter`) and the chat button/panel are site chrome mounted once from the root layout, not per page — expect both 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 from 1440px that pads the page to make room — see step 3). On the mobile pass, check the footer's collapse: a brand block beside four link columns at desktop, then 3 columns and 2 as the width drops, with the brand block leaving the row and sitting above the links at the same 960px 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 neither the footer nor the chat — 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 959px 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 450px band, not a full screen.** Only pages rendering `FullBleedBackground` fill the viewport; grep `FullBleedBackground` under `website/src/app` for the current set 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.
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:** one axe rule is deliberately switched off by a settled decision, and the comment beside the `color-contrast` override in `.storybook/preview.ts` is its authoritative record. Read it before raising any contrast finding on what it covers, and never re-raise that decision as a finding without asking Rob 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.
token-auditScans CSS files for hardcoded hex colours, raw rgb() values, pixel values, and transition timings that should reference design tokens. 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 for hardcoded values that should use design tokens, and report violations. 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. Mention both surfaces in the report the same way.
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 `--icon-size: var(--icon-size-sm|md|lg|xl)` (the icon font reads that one property for size, width, and height; the scale's values live in the token files)
- 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 `--`)
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."
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)
- `site-updates` → `website/src/data/site-updates.json` (titles + story bodies)
- `registry` → the `description` fields in `src/components/registry.json`
- `case-studies` → the `title` and `dek` fields in `website/src/data/case-studies.json` (shipped copy on /work and the home page)
- `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 across `.claude/skills/` (they render on /skills, so they 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. `about`) scopes to that page folder
- `chat` → the site chat's shipped prose: the greeting, the starter-suggestion copy, the welcome tagline and the disclaimer line in `website/src/components/SiteChat/` (the last two are inline literals in `SiteChat.tsx`, not data files, and no validator scans them), the persona and easter-egg strings in `website/src/app/api/chat/`, and the playground sim's scripted story — the turns and chip labels in `website/src/lib/chat-sim.ts` and the event-rail copy in `website/src/app/playground/ChatDirector.tsx` (all visitor-visible but not "page files", so the `website` scope misses them; 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 the two story modules' string literals, 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
- `covers` → the alt text in the two cover registries, `website/src/data/cover-renders.json` (the case-study covers) and `website/src/data/essay-covers.json` (the essay illustrations). Alt text is read aloud and indexed, so it is shipped copy, and its rule in the guide is its own: describe what the picture shows, never restate the title. Neither file is a page, so the `website` scope misses both
- `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)
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/**` — their copy is fictional demo data reproducing a reference product, excluded from the chat corpus for the same reason, so register and voice rules do not apply to it
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 three 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 *and* accessibility test (Vitest + headless Chromium + axe), the Storybook build, the website lint + build (Next.js), and the two validators that read the built HTML. 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 build, story tests, Storybook build, website lint + build, and the built-HTML validators) and confirm the site is safe to push to Vercel. 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.
## 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`. 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** — an accessibility violation fails the suite exactly like a render error), the Storybook build, the website lint, the website build, and then the two validators that read the prerendered HTML rather than source — `validate-rendered-spacing.mjs` (a space lost between JSX and render) and `validate-corpus-coverage.mjs` (page prose that never reached the chat corpus) — which is why they follow the website build instead of riding the registry chain. 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 `@robr0/design-system` 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 built-HTML validators). Safe to push.
**If the change touched component CSS, `src/tokens/`, or `.storybook/`, also dispatch the Chromatic workflow** (`gh workflow run chromatic.yml`) 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 built-HTML validators that run after it)
- If **story tests** failed, say whether it was a render error or an **a11y violation** — 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`
- 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 Rob's decision.
shipMakes finished work live on robertritacca.com. 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, and reports exactly what deployed.
ship.mdmd---
name: ship
description: Make finished work live on robertritacca.com. Commit, run the full verify, merge branch work into main when needed, push, and watch CI go green. 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 robertritacca.com — 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–7 directly. **A push to main deploys robertritacca.com.**
- **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. 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 alongside the edits that caused it (CI's drift guard is a `git diff --exit-code` step after the generators run in `.github/workflows/ci.yml`, so an uncommitted regeneration fails the build).
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 robertritacca.com via Vercel** — 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`) — `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 the ambient background — the config (`website/src/data/shader-background.json`), the site's composition of it (`website/src/components/BlurBackground/`), or the renderer itself (`src/components/ShaderField/`) — offer a `visual-review` pass instead: any of the three changes the background on all of the site's pages at once, and no automated gate covers them. `verify` proves the config validated and the shader compiled, not that the result looks right. The renderer is the easiest of the three 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/`, or the route itself — 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 also triggers CodeQL within the same second, and an unfiltered `--limit 1` can hand you that run 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. **Report** in the final message:
- Each pushed commit (hash + subject), confirmation `npm run verify` passed locally, and the CI run result
- 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 `ga-analysis/output/`, `ga-analysis/service-account.json`, `.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 Rob's call: only invoke this flow when asked to ship, and never chain into it automatically from other work
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` publishes robertritacca.com. 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 `ga-analysis/output/`, `ga-analysis/service-account.json`, `.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 `ga-analysis/output/`, `ga-analysis/service-account.json`, `.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 Rob, 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.
Fourth verb in the shipping vocabulary. `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 robertritacca.com 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. Rob 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 blueprint copies under `website/public`): 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** (most of the Registries table in `CLAUDE.md`: the component and skills registries, `site-updates.json`, `case-studies.json`, `essays.json`, and the two cover registries `cover-renders.json` and `essay-covers.json`): 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`; skills and case studies in their curated order). Dropping one side is a silent feature loss no validator can see — or, for the cover registries, a build failure on whichever branch's entry was dropped, since both are held to their parents in both directions.
- **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** (`PACKAGE_VERSION` in `scripts/package-manifest.mjs`, the root `package.json` version, `package-lock.json`): keep the single higher version, make all three agree, refresh 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 Rob in his 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 `ga-analysis/output/`, `ga-analysis/service-account.json`, `.env*`, or anything credential-shaped, however it arrived in a merge
- If there are no candidates, say so and stop. A clean sweep is a valid outcome
releaseCuts an npm release of the component library: bumps the single source-of-truth version, runs the Release workflow in dry-run to prove a real consumer can install and build the tarball, publishes with signed provenance, then tags and writes the GitHub Release against the exact commit that shipped. Knows the two things that bite on release day: a version number can never be reused, and the registry lags a green publish by minutes.
release.mdmd---
name: release
description: Cut a new npm release of @robr0/design-system. 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 `@robr0/design-system` — 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 Rob before bumping:
```bash
git log $(git describe --tags --abbrev=0)..HEAD --oneline
```
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.
- 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 (no generated surface embeds the version literal itself, but the corpus mirrors any prose the bump edited, including that CLAUDE.md sentence). Commit the bump on its own (`chore(release): <version>`) and push — 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` and `README.md` 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 `+ @robr0/design-system@<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 @robr0/design-system 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 @robr0/design-system@<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. 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 lives in **three** places and only one is authoritative: bump `PACKAGE_VERSION` in `scripts/package-manifest.mjs`, mirror it into the root `package.json` (nothing generates it), and refresh `package-lock.json` with `npm install --package-lock-only`. 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 npm CLI on the runner is older than 11.5.1; or the registration itself was removed. Rob 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
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.
### 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, instead of pointing at the registry?
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 components-index card. The 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 Rob, 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 "@robr0/design-system/components/X/X"` (recharts-backed charts come from `@robr0/design-system/charts`) — the website is an npm-workspace consumer of the published package's exports
- Include `PageLinks` with whichever Figma/Storybook URLs were provided
- **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.
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** (768px, system-wide) — do not add per-page `@media` overrides for tokenized values; when the showcase grid genuinely needs a breakpoint, use the canonical set in `design.md`'s responsive 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 `@robr0/design-system`) 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, 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.
heuristic-analysisEvaluates a page or component against Nielsen's 10 Usability Heuristics. Takes screenshots in light and dark mode, reads the source code, then produces a structured findings table with severity ratings (Pass / Minor / Moderate / Critical) and specific fix suggestions.
heuristic-analysis.mdmd---
name: heuristic-analysis
description: Evaluate a page or component against Nielsen's 10 Usability Heuristics and produce a structured findings report. Use when asked for a heuristic analysis, UX review, or usability check.
---
# heuristic-analysis
Evaluate a page or component against Nielsen's 10 Usability Heuristics and produce a structured findings report.
## When invoked
Use this skill when asked to run a UX or usability review — phrases like "heuristic analysis of [page]", "UX review of [page]", "usability check on [component]", "how does [X] score on usability".
## Instructions
1. **Determine scope.** Accept one of:
- A website URL path (e.g. `/components/button`) → review the live page
- A component name (e.g. `AlertDialog`) → review the component source and its rendered output
2. **Gather visual evidence.** Start the preview server and screenshot the target in both light and dark mode (follow the `visual-review` skill pattern). Stop the server when done.
3. **Read the source code** for the page or component to understand the full implementation, not just what's visible in screenshots.
4. **Evaluate against each of Nielsen's 10 Heuristics.** For each, assign a severity:
- ✅ **Pass** — fully satisfied, no issues
- ⚠️ **Minor** — small gap, low user impact
- 🔶 **Moderate** — noticeable issue, degrades experience
- 🔴 **Critical** — breaks usability, must fix
**The 10 Heuristics:**
1. **Visibility of system status** — Does the UI communicate what's happening? (loading states, active states, progress indicators, feedback on interaction)
2. **Match between system and real world** — Do labels, icons, and concepts match the user's mental model? (plain language, familiar metaphors, no jargon)
3. **User control and freedom** — Can users undo, cancel, go back, or exit? (close buttons, undo actions, Escape key support on overlays)
4. **Consistency and standards** — Are patterns applied uniformly? (same component behaves the same way everywhere, no contradictory conventions)
5. **Error prevention** — Does the UI prevent mistakes before they happen? (confirmation dialogs for destructive actions, disabled states, validation hints before submission)
6. **Recognition rather than recall** — Are options visible rather than requiring memory? (labels on icon-only buttons, visible choices, no hidden commands)
7. **Flexibility and efficiency of use** — Can experienced users work faster? (keyboard shortcuts, compact modes, sensible defaults)
8. **Aesthetic and minimalist design** — Is every element necessary? (no redundant labels, no visual noise, appropriate information density)
9. **Help users recognise, diagnose, and recover from errors** — Are error messages plain, specific, and constructive? (not just "Something went wrong")
10. **Help and documentation** — Are tooltips, placeholder text, or inline guidance provided where genuinely needed?
5. **Produce a structured report:**
```
## Heuristic Analysis: [Page/Component Name]
| # | Heuristic | Severity | Finding |
|---|-----------|----------|---------|
| 1 | Visibility of system status | ✅ Pass | — |
| 2 | Match with real world | 🔶 Moderate | Submit button gives no feedback after click — add loading state |
...
### Findings requiring action
[Only Minor/Moderate/Critical items, each with a specific fix suggestion]
### Summary
X critical · Y moderate · Z minor · W passing
```
6. **Be specific.** Reference the exact element, prop, or file where possible. A finding like "the Dismiss button in Gadget has no visible focus ring (Gadget.css:47)" is more useful than "focus styles are missing". (The component in that example is fictional by design — a factual claim about a real component inside an example rots silently.)
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. 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 — partly excluded from the automated gate.** `color-contrast` is switched off in `.storybook/preview.ts` by a settled decision of Rob's. **Read that override's comment first**: it is the authoritative record of which pairs it covers and why, and it is the single place those details belong. What it names is out of scope — not a finding, not something to propose restyling, and not a reason to re-enable the rule. Skip them silently rather than restating them in a report. **Contrast everywhere else is the single highest-value thing to audit manually** — nothing else checks it.
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, whether a Dialog *actually* traps focus, or whether a helper message *should* have been associated with its control. (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. **Anything outside the story suite** — the Next.js website pages, which axe never runs against.
Report a finding as **already-enforced** if `npm run test` 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]** Modal/dialog *actually* traps focus while open and restores it to the trigger on close — axe sees the attributes, not the behaviour
- 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-token motion satisfies this through the `prefers-reduced-motion` guard in `tokens-motion.css`, which collapses every duration
- **[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
- **[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. The pairs named in `.storybook/preview.ts`'s rule override are out of scope: skip them silently rather than listing them, and do not name them in the report.
- **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".
```
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 Button, it should not default to `"medium"` on a related component
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 Rob 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
growth-loopRuns one analytics-driven copy experiment end to end: pulls GA4 data, filters bot noise, forms a falsifiable hypothesis about the words on a page, implements the change on a branch in a temporary worktree, verifies the build, and writes a problem / hypothesis / solution report for approval. Runs itself every Monday, as one of the loops described on the Loops page.
growth-loop.mdmd---
name: growth-loop
description: Weekly GA-driven copy experiment loop for www.robertritacca.com. Analyze last month's GA data, find one copy problem, implement the fix on a local branch, and write a report for approval. Use when asked to run the growth loop. Never pushes, merges, or deploys.
---
# growth-loop
Weekly GA-driven copy experiment loop for www.robertritacca.com (this repo's `website/` deploys there via Vercel). Each run: analyze last month's GA data, find ONE copy problem, form a hypothesis, implement the fix on a local branch, and write a clear report for the user to approve. **Never push, merge, or deploy — the user approves every change.**
## When invoked
Run when asked to "run the growth loop" (`/growth-loop`) or by the `growth-loop-weekly` scheduled task.
## Scope guardrails (read first)
- **Copy only.** Headlines, body text, CTA/link labels, button text, page `metadata` titles/descriptions — all inside `website/src`. No CSS, no layout, no component structure, no new components, no dependencies.
- **One focused change per run.** One page, or one copy element (e.g. the same CTA wording) across a few pages. A reviewer should be able to read the diff in under two minutes.
- **Local branch only.** Never `git push`, never merge, never touch the user's checked-out branch or working tree (use a worktree — see step 4).
- Never read into version control or modify `ga-analysis/service-account.json` or `ga-analysis/output/`.
## The loop
### 0. Close the previous loop
Read the newest report in `ga-analysis/loop-reports/` (git-ignored, local-only). If a previous experiment was approved/merged, check whether its metric moved in this run's data and record the verdict (improved / no change / worse / too early to tell) in this run's report. If the previous branch was never merged, note that instead and don't count it as tested. Don't re-run a hypothesis a previous report already tested unless the report says the change was never merged.
### 1. Pull the data
```bash
cd "$(git rev-parse --show-toplevel)/ga-analysis" && ./.venv/bin/python pull_ga.py --days 28
```
Output lands in `ga-analysis/output/all.json`. If the venv is missing: `python3 -m venv .venv && ./.venv/bin/pip install -q -r requirements.txt`. FutureWarnings are harmless.
### 2. Analyze — with the ga-report skill's judgment calls
Apply every gotcha from the `ga-report` skill (`~/.claude/skills/ga-report/SKILL.md` — installed only on Rob's Mac, like the GA venv and credentials; this loop runs there, not on the Windows machine). That skill owns the bot-traffic list, the pagePath-vs-pageTitle rule, and the traffic-mix baselines — read it fresh each run rather than trusting a remembered copy, and subtract the bots it names before drawing conclusions.
Look for **copy-shaped problems**, e.g.: a high-traffic landing page with weak engagement or dwell; strong entry pages that don't lead anywhere (missing/weak CTA copy); case studies with good dwell but low reach (weak titles/descriptions); a mismatch between what a traffic source promises and what the page's headline says.
### 3. Pick ONE problem and write the hypothesis
The hypothesis must be falsifiable and name its metric:
> If we [specific copy change], then [specific metric for a specific page/segment] should [direction] over the next few weeks, because [reasoning grounded in the data].
If the data doesn't support a confident copy hypothesis this week, **say so and stop** — a no-op run with a short "nothing worth changing" report is a valid outcome. Don't invent a change to have something to ship.
### 4. Implement on a branch (via worktree)
Work in a temporary worktree so the user's working tree is untouched:
```bash
REPO=$(git rev-parse --show-toplevel)
WT=$REPO/../.growth-loop-worktree
BRANCH=growth/$(date +%F)-<short-slug>
git -C "$REPO" worktree add "$WT" -b "$BRANCH" main
```
Make the copy edits in `$WT/website/src/...` — new copy follows `content-design.md` (voice, register, banned words) — then verify the build (the repo is an npm workspace — one install at the worktree root wires everything, including the `@robr0/design-system` link back to the worktree's own `src/`; it's seconds thanks to the npm cache. Do **not** symlink `node_modules` from the main checkout — Turbopack rejects symlinks that point outside the project root):
```bash
cd "$WT" && npm install --no-fund --no-audit
cd "$WT/website" && npm run build
```
If the build fails because of your edit, fix it. Then commit in the worktree (conventional message, e.g. `experiment(growth): reword /work CTA — hypothesis in loop report 2026-07-20`). Commit scope: the website `prebuild` regenerates tracked files, and they stay out of the commit **unless your edit is what changed them** — with one standing exception that always qualifies: the site chat's corpus (`website/src/data/site-corpus.generated.ts`) is built from page prose, so a copy edit changes it by construction. Commit the regenerated corpus alongside your copy edits every time (a branch without it fails CI's drift guard, and `git worktree remove` refuses a dirty worktree); leave the other regenerated files (`website/src/data/skills-content.generated.ts`, `website/public/*.md`) out unless they actually changed. Then clean up:
```bash
rm -rf "$WT/node_modules" "$WT/website/node_modules"
git -C $REPO worktree remove "$WT"
```
The branch survives worktree removal and is ready for the user to review.
### 5. Write the report
Save to `ga-analysis/loop-reports/YYYY-MM-DD.md` **and** repeat it in full in the final message to the user. Plain English — the user is a designer, no analytics jargon. Format:
```markdown
# Growth loop — YYYY-MM-DD
## Last week's experiment
[Verdict on the previous change, or "none / not merged".]
## The problem
[What the data shows, with the actual numbers, after bot filtering.]
## The hypothesis
If we ..., then ... should ..., because ...
## The change (branch: growth/YYYY-MM-DD-slug)
[File(s) touched. Before → after for every copy string changed.]
## How we'll know
[Which metric to look at next run, and roughly what movement would count as a win.]
```
### 6. Hand off for approval
End by telling the user: the branch name, that the build passed, and that nothing is pushed or deployed. To approve they merge the branch (or say `ship` on it); to reject they delete the branch. That's the whole approval step.
site-updatesKeeps the Project journal timeline evergreen: reads every commit since the last curated bookmark, clusters them into themes, and writes one short entry per theme (what was built and when, in plain English a non-technical reader can follow), extending an existing arc when the work continues one. Runs biweekly on a schedule, builds on a branch in a temporary worktree, and hands the new entry over for approval. Nothing is pushed without a human merge.
site-updates.mdmd---
name: site-updates
description: Biweekly loop that keeps the /project-journal build-progression timeline current. Read the git history since the last curated commit, consolidate it into concise thematic entries (what shipped and when, never commit digests), update the data file on a local branch, and report for approval. Use when asked to update the project journal or run the site updates loop. Never pushes, merges, or deploys.
---
# site-updates
Biweekly curation loop for the `/project-journal` page (Project journal) — the evergreen timeline of the build's progression. Each run reads the commits since the last curated bookmark, consolidates them into at most a couple of concise thematic entries, updates `website/src/data/site-updates.json` on a local branch, and writes a report for the user to approve. **Never push, merge, or deploy — the user approves every change.**
## When invoked
Run when asked to "update the project journal" (`/site-updates`) or by the `site-updates-biweekly` scheduled task.
## Editorial standard (the whole point — read first)
Entries are **concise thematic records, not commit digests and not essays**. The journal exists to be skimmed; an entry a reader has to wade through defeats it.
- **One entry = one theme**, consolidating however many commits belong to it, even non-contiguous ones. A CI workflow commit plus its docs commits is ONE entry, not four bullets.
- **Titles are plain and descriptive, written for a non-technical reader.** State what happened ("The design system publishes to npm"), never wordplay or a metaphor that needs the body to decode.
- **Bodies are one short paragraph** — two only for the period's single largest theme. Lead with *what* shipped and *when*. A clause of context is fine; paragraphs of rationale, architecture tours, and justification are not. What and when over why.
- **Stay high level.** Name the thing and its visible effect; leave the mechanism to the code and the specs. No commit hashes, no conventional-commit prefixes, no "various fixes" — the validator rejects hash-like strings in bodies.
- **Continue arcs instead of fragmenting them.** If the period's work extends a theme an existing entry already tells (more components in a family, round two of a security scrub), extend that entry's body or write an explicit follow-on that references the arc — don't add a disconnected fragment.
- **A reader who has never seen the repo** should be able to skim the timeline top to bottom and follow the build. Spell out names; write like the existing entries.
- **Omit themeless chores.** Typo fixes, dependency bumps, tiny tweaks with no story simply don't appear. The page shows the *largest* updates, not all of them.
- Point-in-time numbers inside a dated entry ("434 at the time") are fine; never write a *live* count that will drift — live counts belong to registries (see `CLAUDE.md`).
- **Sentence-level style follows `content-design.md`** — this section owns the entry *shape* (one theme, concise, neutral, what-and-when); the content guide owns voice, banned words, and rhythm. Run its Self-Review Tests on each drafted entry.
## The loop
### 1. Gate — is there enough to say?
First, run `node scripts/sync-essays.mjs` to pull any newly published essay into `website/src/data/essays.json`, so the regenerated chat corpus can discuss it (the script's own doc block owns the details). If it changes the file, re-run the sync inside step 3's worktree and include the change in the same commit as the rest of the loop's changes.
**A newly synced essay does not build on its own.** Every essay needs its illustrated cover pair (an entry in `website/src/data/essay-covers.json` plus the light and dark files under `website/public/covers/writing/`), and `scripts/validate-essay-covers.mjs` fails the website build until both exist; the sync script's doc block says the same. This loop cannot draw a cover, so when the sync brings in an essay with no pair: leave the synced `essays.json` change out of the loop's commit (`git checkout` it back in the worktree), finish the rest of the loop, and say in the report that a new essay is waiting on its cover before it can be synced. Never try to satisfy the validator with a placeholder pair.
Then read `asOf` from `website/src/data/site-updates.json`, and:
```bash
git log --format='%ad %s' --date=short <asOf.commit>..HEAD
```
If it has been fewer than ~12 days since `asOf.date`, or the new commits are only themeless chores, **stop** and tell the user "nothing worth recording yet" with a one-line summary of what was skipped. A no-op run is a valid outcome — don't invent an entry to have something to ship.
### 2. Curate
Cluster the new commits into themes and draft the entry (usually one, at most two) per the editorial standard. For context on what a commit actually was, read the touched files or `git show --stat` — the story should describe the change's substance, not its message. Check the existing entries first so a continuing arc extends rather than duplicates.
### 3. Implement on a branch (via worktree)
Work in a temporary worktree so the user's working tree is untouched:
```bash
REPO=$(git rev-parse --show-toplevel)
WT=$REPO/../.site-updates-worktree
BRANCH=site-updates/$(date +%F)
git -C "$REPO" worktree add "$WT" -b "$BRANCH" main
```
In `$WT/website/src/data/site-updates.json`: prepend the new entry (or extend an arc), and set `asOf` to the current `main` HEAD sha + today's date. Validate and build (the repo is an npm workspace — one install at the worktree root wires everything, including the `@robr0/design-system` link back to the worktree's own `src/`; it's seconds thanks to the npm cache. Do **not** symlink `node_modules` from the main checkout — Turbopack rejects symlinks that point outside the project root):
```bash
node $WT/scripts/validate-site-updates.mjs
cd "$WT" && npm install --no-fund --no-audit
cd "$WT/website" && npm run build
```
Fix anything your edit broke, then commit in the worktree (e.g. `content(site-updates): add "<entry title>" entry through YYYY-MM-DD`). The build regenerates tracked files, and your edit always changes one of them: the site chat's corpus (`website/src/data/site-corpus.generated.ts`) embeds the journal entries, so a new entry regenerates it by construction. Commit the regenerated corpus (and any other tracked file the build regenerated because of your edit) in the same commit — a branch without it fails CI's drift guard, and `git worktree remove` refuses a worktree with modified tracked files. Then clean up:
```bash
rm -rf "$WT/node_modules" "$WT/website/node_modules" # npm nests version-conflicting deps under website/, so both trees exist
git -C $REPO worktree remove "$WT"
```
The branch survives worktree removal and is ready for review.
### 4. Hand off for approval
The final message to the user IS the report — plain English:
- The full text of the new/extended entry (so approval needs no file-opening).
- Which commits it consolidates (date range and count, not a hash list).
- The branch name, that the validator and website build passed, and that nothing is pushed or deployed.
- To approve: merge the branch (or say `ship` on it). To reject: delete the branch.
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, in the same spirit as the growth loop: 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. Follow the temporary-worktree recipe in `.claude/skills/growth-loop/SKILL.md` (step 4), with branch name `seo/YYYY-MM-DD-<slug>`. 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 (e.g. article markup on writing pages).
### 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, case studies, writing) 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.
- 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 (see the commit-scope rule in the growth-loop recipe), 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.
ga-reportPulls GA4 data for this site and analyzes it in plain English — with the judgment calls baked in: which traffic is bots, why page titles fragment, and which numbers are normal for a portfolio rather than problems. Lives in my personal skills folder rather than the repo, because it encodes analytics context instead of codebase conventions.
ga-report.mdmd---
name: ga-report
description: Pull Google Analytics (GA4) data for robertritacca.com and analyze it in plain English. Use when the user asks for a GA report, site traffic analysis, analytics summary, "how's the site doing", visitor/pageview breakdown, or to refresh GA numbers.
---
# GA report
Pulls GA4 data for **www.robertritacca.com** (property `•••••••••`) via the
`ga-analysis/pull_ga.py` script, then analyzes it in plain, non-jargon English.
## Where things live
- Script + venv: `ga-analysis/` at the repo root (`git rev-parse --show-toplevel`)
- Runner: `./.venv/bin/python pull_ga.py`
- Output: `ga-analysis/output/all.json` (+ one CSV per report). Git-ignored.
- Credentials: `ga-analysis/service-account.json` (git-ignored; already set up).
## Steps
1. **Pick a window.** If the user gave one (e.g. "last month", "this week", "90 days"),
use it. Otherwise default to `--days 30`. The script accepts `--days N` or
`--start YYYY-MM-DD --end YYYY-MM-DD`.
2. **Run the pull** from the ga-analysis folder:
```bash
cd "$(git rev-parse --show-toplevel)/ga-analysis" && ./.venv/bin/python pull_ga.py --days 30
```
If the venv is missing, create it first:
```bash
python3 -m venv .venv && ./.venv/bin/pip install -q -r requirements.txt
```
The Python-version FutureWarnings are harmless — ignore them.
3. **Read the data.** Load `ga-analysis/output/all.json`. It's large; if it exceeds
the read cap, use a small Python snippet with the venv interpreter to compute
aggregates instead of reading the whole file. Reports included: `daily_trend`,
`channels`, `top_pages`, `countries`, `devices`, `sources`, `landing_pages`, `events`.
4. **Analyze in plain English.** Lead with what matters, not raw dumps. Cover:
- Headline: total users, % new, sessions, pageviews, pages/session, engaged %.
- Traffic sources (channels + the `sources` detail — call out LinkedIn / Medium /
Reddit / Google / any AI-assistant referral by name).
- Best content (top_pages) with dwell time — case studies are the important ones;
component gallery pages naturally have short dwell, don't flag that as a problem.
- Geography + device split.
## Judgment calls / gotchas (apply these every time)
- **Bot / junk traffic:** flag any country or source with high user count but very low
engagement rate (historically **Singapore ~4%** engaged = bots). Spam referrers seen:
`ddvvff.org`, `snucm.com`. Subtract these when stating the "real" audience size.
- **Page-title fragmentation:** the same URL (e.g. `/` or `/work`) appears under several
different `pageTitle` values because of past SEO/metadata edits. Sum by `pagePath`,
don't treat each title as a separate page.
- **Direct is usually high (~75%)** for this portfolio — that's dark social (LinkedIn app,
DMs, résumé links), not an error.
- **Mobile engagement** tends to run lower than desktop here — worth mentioning if the gap
is large.
## Output
A tight written summary (the user is a designer, not an analyst — no acronym soup).
Offer, but don't auto-run: charting it as a visual, or filtering bots in GA.
Never commit `output/` or `service-account.json`.