agents/openai.yaml
interface:
display_name: Storybook CSF3 — Stories
short_description: "Write a CSF3 story for ONE React component, covering only its materially-different states (no Cartesian), with a factory when 3+ stories share a shape. Use for 'write a story for X', 'document this component', 'add a Storybook story'."
default_prompt: |
Write a CSF3 story for ONE component covering exactly its materially-different states (refuse Cartesian). Read .storybook/component-states.json (run scripts/extract-states.sh if missing) for the minimum set; factory when 3+ stories share a shape (scripts/extract-prop-shapes.sh + scaffold-factory.sh). Load references/with-mcp.md OR without-mcp.md (mutually exclusive). Gate with scripts/validate-stories.sh before done.
policy:
allow_implicit_invocation: true
CONTEXT.md
# CONTEXT — shared vocabulary for the storybook-workbench bundle
Every skill in this bundle assumes the terms below. Read this once; skills reference it
instead of re-explaining. (Pattern borrowed from mattpocock/skills `CONTEXT.md`.)
## The pipeline in one line
**Setup -> Build -> Ship**, with two gates (Lint per-cycle, Audit periodic) and a Navigate verb
(`sb-hub`) that names the next step throughout. Only Setup -> Build -> Ship are sequential;
everything else is on-demand. The default audit order is setup, inventory, health, flows, stories.
| Macro | What | Skill |
|---|---|---|
| **Setup** | install Storybook if missing, discover what's real vs slop, detect the design system, map navigation | `sb-setup`, `sb-inventory`, `sb-health`, `sb-flows` |
| **Build** | author stories / app-maps / comparisons | `sb-stories`, `sb-wrappers` |
| **Ship** | graduate an Explore experiment to production | `sb-ship` |
| **Audit** | drift survey + decision board (periodic gate) | `sb-audit` |
| **Navigate** | inspect state, name the single next step | `sb-hub` (onboarding check + orchestrator + navigator) |
## Core terms
- **real · dead · slop** — a *real* component is exported under `src/components/` AND imported
from outside its own file (it's used). A *dead* component is defined-but-never-imported — this
is the per-component term; the inventory labels them "Dead components" and counts them (`29 dead`).
*Slop* is the aggregate: the share of the app that's dead/unused junk, surfaced as the slop
**rate** (`dead ÷ total`, e.g. `8% slop`) — the ~30% a vibe-coded app ships. So "real vs slop"
is the headline framing; **dead** is the precise label for one unused component. Ground truth
comes from `inventory-project.sh`, never from `CLAUDE.md`/`AGENTS.md` (those drift, lie, or are absent).
- **vendor** — shadcn-style installed primitives under `components/ui/`. They are app code
but NOT the user's authored components, so they are reported in their own `vendor` bucket
and excluded from the real/dead *domain-component* headline. (This is the fix for "the
inventory showed me 40 shadcn components I didn't write.")
- **kind** — every discovered file is bucketed: `component` · `page` · `app` · `vendor`
(shadcn `ui/`) · `module` (types/helpers/hooks/utils/lib/api/services — real code but not UI
components) · `scaffold` (SB init tutorial under `src/stories/`) · `support` (test/factory/mock).
Only `component`+`page`+`app` count toward the real/dead headline and the "most imported"
list — so `vendor`, `module`, `scaffold`, `support` never pollute the view of *your components
used in prod* (a `types.ts` is imported everywhere and would otherwise top the list).
- **Build outputs** (not four equal "modes") — `sb-stories` writes a **Component** story ⓢ, the
production output, one per materially-different state. `sb-wrappers` scaffolds Storybook-only
**views**: A/B Compare (`ABCanvas`), state grids (`StateGrid`/`StateMatrix`), role canvas,
token/health/inventory canvases, the decisions board, and the maps. `sb-flows` produces the
**Flow** view — the whole-app map (`AppFlowGraph`) plus per-flow journey maps (`JourneyGraph`):
connections, not just screens. Separately, **Explore** is a sandboxed iteration track
(`sb-explore`) that lives OUTSIDE `src/components/`; `sb-ship` graduates an Explore experiment
into a Component.
- **ledger** — `.storybook/audit/{findings,extraction-plan,status}.md`. Append-only memory
you steer by editing; the hub navigator honors your edits. Commit it or a `git clean`
loses it.
## STORAGE MAP — where everything lands (answers "where is it stored?")
**One place. `.storybook/` is the single home** for everything the bundle writes, so a client /
vibe-coded repo stays clean and one `rm -rf .storybook` removes the entire audit. **Never scatter
outputs across the repo.** The *only* thing whose location is a real choice is **where the stories
go** — decided once in `sb-setup` (see STORIES LOCATION below), recorded in `status.md`, honored by
every skill.
| What | Path | Commit? |
|---|---|---|
| Discovery ground truth | `.storybook/project-inventory.json`, `flows.json`, `component-states.json`, `prop-shapes.json`, `component-usage.json` (real prop/value usage at call sites) | yes |
| Design-system health | `.storybook/design-system-health.json` | yes |
| Findings ledger | `.storybook/audit/{findings,extraction-plan,status}.md` | yes |
| Decision ledger (pruned) | `.storybook/audit/decisions.md` | yes |
| Scaffolded wrappers | `.storybook/wrappers/*.tsx` (+ `icons.tsx`, `index.ts`) | yes |
| Factories | `.storybook/factories.ts` | yes |
| **Stories** (the one choice) | **isolated:** `.storybook/stories/**/*.stories.tsx` · **co-located:** `src/**/<Name>.stories.tsx` | yes |
| **Agent run artifacts** | `.context/storybook-workbench/<skill>/<run_id>/*.json` | **no** (gitignored — ephemeral) |
Nothing the bundle produces lands anywhere else. If you can't find an output, it is under `.storybook/`.
**Who refreshes what (keep rendered data fresh).** Some JSONs are **rendered** in Storybook (an autodocs
embed or a wrapper reads them) — those must refresh together; others are **authoring inputs** the agent
reads once to write code, regenerated on demand.
| JSON | Producer script | Rendered by | Refreshed by |
|---|---|---|---|
| `project-inventory.json` (incl. `tokens.map`) | `inventory-project.sh` | ProjectInventory · UsageSection (Colors/Typography/Scales/Semantic) · TokenUsageGrid | **`refresh-usage.sh`** |
| `component-usage.json` | `extract-component-usage.sh` | StateGrid/StateMatrix `usage=` · UsageSection (component Docs) | **`refresh-usage.sh`** |
| `flows.json` | `extract-flows.sh` | AppFlowGraph · JourneyGraph | **`refresh-usage.sh`** |
| `design-system-health.json` | `validate-design-system.sh` (sb-health) | UsageSection (Health) · DesignSystemHealth · TokenMatrix | **`refresh-usage.sh`** |
| `component-states.json` · `prop-shapes.json` · `runtime.json` | extract-states · extract-prop-shapes · discover-runtime | — (authoring inputs) | regenerated on demand by sb-stories / sb-setup |
| `index.json` (Storybook's OWN report) | `storybook index` (CLI, no server/build) | — (reconciled into `project-inventory.json.storyCoverage`) | run by `inventory-project.sh` / `refresh-usage.sh` |
**Story coverage is authoritative, not a guess.** When Storybook is installed, `inventory-project.sh`
runs `storybook index` and reconciles `index.json` (the stories Storybook actually registers) into
`storyCoverage` (`source: "storybook-index"`, `withRegisteredStory`, `needsStory`) — far better than the
basename-glob heuristic (`source: "heuristic"`, the fallback when Storybook isn't installed). Cross-agent:
plain CLI, no dev server, no MCP. MCP is the *authoring* accelerator; `index.json` is the *tracking* source.
`refresh-usage.sh` (+ `--docs`) is the one command that re-runs all four **rendered** extractors; `sb-audit`
runs it each pass, and it belongs in CI before `storybook build`. The autodocs import the JSON, so a rebuild
reflects reality with no hand-editing.
### STORIES LOCATION — ask once, recommend, record (the "don't scatter stories" rule)
A demo finding: writing `Foo.stories.tsx` next to every component scatters new files through a
client's `src/` — a mess in a repo you don't own. So **`sb-setup` must ASK the user where stories
live** (via `AskUserQuestion`, or numbered list where no blocking tool exists) and **recommend** based
on intent:
- **Isolated — THE DEFAULT** (this is an audit tool; assume a repo you don't own) — stories live under
`.storybook/stories/`, mirroring the component tree (`.storybook/stories/components/CourseCard.stories.tsx`).
`src/` is never touched; the whole audit is one removable folder. `sb-setup` sets `main.ts`
`stories: ['./stories/**/*.stories.@(tsx|ts)']` (relative to `.storybook/`). Stories import
components via the project's `@/` alias, not deep relative paths.
- **Co-located — opt in only** (Storybook's general convention, for a **project you own long-term** and
want stories to move with components on refactor) — `src/components/<X>/<X>.stories.tsx`.
- **A custom folder** — the user can name any single folder; it's globbed into `main.ts` and treated
as the one place. Still one location, never a mix.
Always **ask** (isolated is the recommended/first option and the fallback). Record the choice in
`.storybook/audit/status.md` as `storiesLocation: isolated|colocated|<path>` so `sb-stories` and the
hub honor it without re-asking. **The ask is enforced at two points** so it can't be skipped: `sb-setup`
asks during install, and if Storybook already existed (so `sb-setup` was skipped), the **first
`sb-stories` refuses to write until it asks** and records the choice. Never co-locate or guess silently.
## What loads when (the load map — answers "is this 200k tokens?")
- **Eager:** only the one skill's `SKILL.md` you triggered (each is ~60–110 lines).
- **Lazy:** a skill's local `references/*.md` plus the few shared references load *only when the
skill body says to* — never all at once. Discovery scripts write JSON the agent reads instead of
grepping source.
- **Never auto-loaded:** `CHANGELOG.md`, other skills' bodies, the wrapper `.tsx` source.
You install the bundle; you pay context only for the verb you run.
## Resume protocol (answers "we stopped mid-session with half-baked files")
Discovery scripts write JSON **atomically** (temp file → move), so a partial run leaves no
half-JSON. Story/audit work records progress in `.storybook/audit/status.md`. On re-entry the
hub (`/sb-hub` / `$sb-hub what's next`) reads `status.md` + checks which discovery
JSONs exist, and resumes from the first incomplete step — it never assumes a file half-written
in a prior session is complete. Rule for every skill: **finish the artifact you started or
mark it `incomplete` in `status.md` before stopping.**
## §wrapper-view-design — the ONE visual language for wrappers
Wrappers are Storybook-only React components scaffolded into `.storybook/wrappers/`. They share
one visual language so they don't look like a pile of different widgets:
1. **No emoji. Ever.** Use the icon set in `wrappers/icons.tsx` (`<Icon.palette/>`,
`<Icon.warning/>`, `<Icon.check/>`, …). Dependency-free inline SVG: 24×24 viewBox,
`currentColor` stroke, 1.6 width — icons inherit text color and size.
2. **Injectable.** Map-style wrappers (`AppFlowGraph`, `JourneyGraph`) accept an `icons` prop
so a project can pass its own lucide/Phosphor/custom set and match the app's language.
`mergeIcons(overrides)` merges over the defaults.
3. **Themed via the app's tokens (CSS vars, not a JS palette).** Wrappers color themselves with the
host app's CSS custom properties and a fallback — `var(--color-foreground, <fallback>)`,
`var(--color-surface, …)`, `var(--color-border-subtle, …)`, and the semantic
`--color-success/warning/error[-surface|-text]` roles. One `.dark` class flip re-skins every
wrapper light↔dark, and the fallback keeps it rendering standalone. This is the single source of
color. Keep *data* colors (token swatches, categorical legend hues, shader output) as-is; only
chrome reads from vars.
4. **`icons.tsx` always travels.** `scaffold-wrapper.sh` force-copies it next to any wrapper, so a
wrapper copied alone never loses its icons.
5. **Status fields hold a `WrapperIcon` component, not a glyph string** — e.g. severity →
`{ error: Icon.x, warning: Icon.warning, info: Icon.info }`, rendered via an aliased
`const SevIcon = style.icon; <SevIcon size={14} />`.
6. **Plain text where a swatch already carries the cue** (e.g. TokensCanvas section headings) —
no decorative glyph prefix.
To add an icon: add one inline-SVG entry to `Icon` in `icons.tsx` (keep the `// emoji → name`
comment so the mapping stays legible), then reference `<Icon.newName/>`.
## Cross-agent rules (answers "slash commands don't work in Cursor/Codex")
All three agents read the same `SKILL.md` (Agent Skills open standard); each skill also ships an
`agents/openai.yaml` for Codex's richer surface. What differs is *invocation UX*, not availability:
- **Claude Code** (`~/.claude/skills/`): each skill is a slash command — `/sb-inventory`, `/sb-hub`.
- **Codex** (`~/.codex/skills/`): custom slash commands are NOT read. Invoke by name —
`$sb-hub <phase>` (e.g. `$sb-hub what's next`) or `/skills` → pick the skill.
The `openai.yaml` `default_prompt` routes the phase.
- **Cursor / cursor-agent** (`~/.cursor/skills/`): reads `SKILL.md` like the others — trigger by
describing the task so the `description` matches, or by name. Custom `/sb-*` shortcuts do NOT fire
(Cursor has rules + skills, not Claude-style slash commands), but the skills themselves work.
- Any skill body that says "use the Agent tool" must branch per platform: Claude `Agent`
(model `sonnet`), Codex `gpt-5.x-mini` equivalent; if no override mechanism, omit the model
and inherit the default rather than fail the dispatch.
### Tool portability — how the SAME tools hold across agents
There is **no cross-agent tool-name registry**, and `allowed-tools` is *experimental* in the open
standard ("support may vary between agents"). So portability is two parts, not the field:
1. **`allowed-tools` = a Claude-only pre-approval** (lists run without a prompt). We keep it to the
**6 universal primitives** (`Bash Read Glob Grep Write Edit`) — present natively in every agent, so
it's safe where read and harmless where ignored (Codex/Cursor apply their *own* permission models:
Codex sandbox + approval, Cursor settings). `test-tool-portability.sh` fails on any non-universal tool.
2. **`compatibility` = the portable runtime contract** — every skill declares what must exist
(`bash, python3, node, git`). This is the field any agent reads to know the skill's real needs; the
gate requires it and `skills-ref validate` (the official validator) confirms each skill against the
standard. There is no install-time *translation* — primitives are universal; install just drops
`SKILL.md` and each agent applies its model. If a skill ever needs a **non-portable** capability,
document the per-platform branch in that skill body and add an eval before shipping it.
references/anti-patterns.md
# Storybook CSF3 — Anti-patterns
Universal anti-patterns to refuse when writing or reviewing stories. Most are well-known; this file's value is the **MCP-catches-vs-skill-must-enforce split** plus the designer-grade items at the bottom that MCP and most checklists miss entirely.
## Legend
- 🛡️ **MCP catches** — if `@storybook/addon-mcp` is wired, the injected instructions or `run-story-tests` will surface this automatically
- 📕 **Skill catches** — you must enforce this; MCP won't help
Out of 28 items below, **MCP automatically catches 5** (~18%); the rest are judgment, project-level decisions, or designer/prototype concerns — that's the value-add of this skill regardless of MCP.
## Code-level (one-liners — these are textbook)
1. 📕 **CSF2 (`storiesOf`, `.story` properties)** — convert via `npx storybook@latest migrate csf-2-to-3`. Refuse new ones.
2. 📕 **Imports from `@storybook/addon-essentials` / `@storybook/blocks`** — both empty in SB10. Use `@storybook/addon-docs/blocks`, `storybook/test`, `@storybook/react-vite`.
3. 🛡️📕 **Inline mock data inside `render`** instead of `args` — breaks Controls panel.
4. 📕 **All-props-as-args dumps** — only meaningful visual props in `args`; refs and internal callbacks clutter Controls.
5. 🛡️ **Hallucinated props** — always read the component's TypeScript interface (or call `get-documentation`) before writing args.
6. 🛡️📕 **Arbitrary Tailwind/inline-style values in component source** — clean up the component (replace `bg-[#3b82f6]` with `bg-primary`); the story is a symptom.
7. 📕 **Repeating the same `parameters.msw.handlers` per story** — lift shared handlers to `preview.tsx`; only override per-story for story-specific responses.
8. 📕 **Mocking at the wrong level** — auth/session/theme/locale → `preview.tsx`; API responses for *this* story's data → `parameters.msw.handlers`; component-internal `useState` defaults → story `args`.
9. 📕 **Stories tightly coupled to real app routing** — mock the router via decorator (`MemoryRouter`, `next-router-mock`, `parameters.router`); never import the real one.
10. 🛡️ **Missing `tags: ['autodocs']`** at meta level — no Docs page, no autodocs aggregation.
11. 📕 **Interactive component without a `play` function** — at least one story per interactive component needs a play that exercises the interaction; otherwise visual regression catches nothing functional.
12. 🛡️ **`getBy*` inside `play` for async assertions** — use `findBy*` (retries) or wrap in `waitFor`.
13. 📕 **Replicating the component's internal `useState` in the story** — the story becomes a fork of the component. Drive via `play` clicks, or use `useArgs` for two-way Controls sync (see `templates/controlled-component-story.tsx`).
## Workflow-level
14. 📕 **Co-locating `*.stories.*` in a published npm bundle** — exclude `src/stories/**` (or `*.stories.*`) from the build output.
15. 📕 **Writing stories before foundation phase** — theme/router/auth/queryclient/portal-root decorators must exist in `preview.tsx` first, or stories fail with cryptic provider errors.
16. 📕 **Extracting too many components in one session** — Storybook's official agentic-setup caps at 10 per session; Backend.AI's case study landed on 5–8 as the comfort zone. More than that compresses context and starts batching errors.
17. 📕 **Mega-stories with knobs for every prop** — write one story per behaviorally-distinct state. Visual regression needs named stories, not "AllVariants."
18. 📕 **Imposing a universal tag (e.g. blanket `'ai-generated'`)** — propose a taxonomy from project signals; don't stamp one tag on every story. A `'needs-work'` flag on genuinely-unreviewed output is fine because it *distinguishes*; `'ai-generated'` on 100% of a single-author repo distinguishes nothing → drop it. See item 32 (tag-as-noise) for the rule.
## Designer-grade — MCP won't catch these, and most agent checklists miss them
These are the ones worth slowing down for.
### 19. 📕 `disabled` as a pseudo-class toggle (the designer trip-up)
```ts
// ✗ Trying to surface :disabled via pseudo-states addon — won't work
parameters: { pseudo: { disabled: true } }
// ✓ disabled is an HTML attribute / prop
args: { disabled: true }
```
Designers reach for the pseudo-states toolbar expecting it to control `disabled`. The pseudo-states toolbar only covers CSS pseudo-classes: `:hover`, `:focus`, `:focus-visible`, `:active`. `disabled` is structural, not visual state.
### 20. 📕 Hardcoding title taxonomy before reading existing `preview.ts`
Before picking `Components/Form/Button`, read `.storybook/preview.ts` for `parameters.options.storySort.order`. If the project already uses `UI/Forms/Button` or `Atoms/Button`, match it. Inconsistent prefixes fragment the sidebar and lose designer trust immediately.
### 21. 📕 Missing state coverage on interactive primitives
The minimum coverage for the components designers actually inspect:
- **Button:** Default · Hover · Focus · Focus-visible · Active · Disabled · Loading · Destructive
- **Input:** Default · Hover · Focus · Filled · Error (with message) · Disabled · Read-only · Required
- **Modal:** Closed · Open-default · Open-scrollable · Loading · Destructive-confirmation
- **Nav item:** Default · Hover · Active/Current · Collapsed
Less coverage = silent regressions when a designer adjusts one state.
### 22. 📕 No Figma link in `parameters.design`
```ts
parameters: {
design: { type: 'figma', url: 'https://www.figma.com/file/.../?node-id=...' },
}
```
Without a Figma link (or Storybook Connect), designers reviewing the story can't side-by-side compare to the source.
### 23. 📕 No token reference for color/spacing values
If a story has `style={{ marginTop: 13 }}` or `style={{ color: '#111827' }}`, that magic number isn't traceable to a design token. Either use the token class (`mt-3`, `text-foreground`) or the token CSS variable (`var(--spacing-3)`, `var(--color-foreground)`).
### 24. 📕 Stale stories silently consuming old token values
When a designer updates a token, Storybook needs a rebuild for the change to take effect. Stories don't update automatically. Document this expectation — designers reviewing visual changes need to know "rebuild after token edit."
### 25. 📕 No do/don't blocks in the component's MDX docs
This is `storybook-doc-blocks` skill territory but flag during story review: components shipped without designer-authored do/don't blocks ship without intent documentation. Stories show *what it does*, do/don't blocks show *when to reach for it*.
## Determinism & prototype hygiene — observed in practice
These three recur in real story-first prototyping and aren't in most checklists.
### 26. 📕 Nondeterministic state inside a story or preview component
```ts
// ✗ Every snapshot differs — visual regression is now useless
const now = new Date(); // also Date.now(), new Date().toISOString()
const featured = items[Math.floor(Math.random() * items.length)];
const collapsed = localStorage.getItem('sidebar') === '1';
useEffect(() => { const t = setTimeout(...) }, []);
// ✓ Accept the value as a prop with a STATIC default
function Preview({ now = '2026-01-15T10:00:00Z', collapsed = false, featured = items[0] }) { … }
```
A story is a fixture, not a runtime. `new Date()` / `Date.now()`, `Math.random()`, `localStorage` / `sessionStorage`, `setTimeout` / `setInterval` all make the render differ run-to-run, which silently breaks Chromatic/visual regression and makes "looks the same" unprovable. **Do:** lift the nondeterministic value to a prop with a fixed default (static ISO date strings, a seeded pick, a `collapsed` boolean). **Don't:** read wall-clock, storage, or randomness from inside the story body or its preview component.
> **The other half — when the *component* (not the story) reads the clock or storage.** If a real component reads `Date.now()` for a relative "added 2 days ago" label, or reads `localStorage.theme` on mount, you can't lift that to a prop without forking it (anti-pattern 27). Instead pin the source **globally in `.storybook/preview.tsx` `beforeEach`** — `MockDate.set('2024-04-10T12:00:00Z')` (return `() => MockDate.reset()`) and `localStorage.setItem('theme','dark')` — seeding **only** the state the app actually reads. That's how `storybook ai setup` makes a relative-date or theme-aware `play` assert literal text (`"Added 2 days ago"`, `aria-pressed`) deterministically. Wiring lives in `references/install-wizard.md`.
### 27. 📕 Hand-rolling a component the app already has (prototype drift)
When a story-first prototype needs a sub-component the real app already ships (a sidebar, a card, a nav), **import the real one** — don't reimplement a local look-alike. A local `RealisticFoo` that duplicates a real `<Foo>` drifts: the prototype keeps stale styling the real component has since fixed, and a reviewer signs off on a frame that doesn't match production.
- ✓ **Do:** `import { Sidebar } from '@/components/...'` and feed it mock data, even inside an Explore/prototype story.
- ✓ **Do:** only inline a local variant when the real component *doesn't exist yet* — and mark it `// TODO: replace with <RealComponent> once it exists`.
- ✗ **Don't:** keep a story-first prototype *alive* after its component graduates to production. Two live copies = two sources of truth that drift. Preserve the prototype as decision history (`archived` tag), point new work at the production component — never maintain both.
### 28. 📕 Deriving a story's share URL/slug from the filename or component name
The Storybook URL slug comes from the **`title:`** field, not the file name or component name. Guessing it from the filename produces 404 share links sent to stakeholders.
```
slug = kebab(title.replaceAll('/', '-')) + '--' + kebab(exportName)
// title: 'Components/Marketing/Hero', export const LongBio → components-marketing-hero--long-bio
```
**Do:** read `title:` and the export name to build the slug. **Don't:** assume `Hero.stories.tsx` → `hero--…`; the title may namespace it as `components-marketing-hero--…`.
## Flow / page / audit-level — the connection half (v1.13)
### 29. 📕 Auditing only page bodies — missing the persistent nav
An "audit all connections" that sweeps page bodies + the components they render but never the **layout chrome** (sidebar / header / footer) is incomplete — that chrome links from *every* screen and is invisible to a page-body sweep. This was the field's #1 miss. `extract-flows.sh` emits `navSources[]` and prints a sweep reminder; act on it. Enumerate every **source of navigation** (page links · server redirects · modal triggers · layout chrome · card/widget deep-links) — see `references/flow-capture.md` Step 1.
### 30. 📕 Flow/page story rendered at component (narrow, centered) width
Pages and flows are not components. `layout: 'centered'` in the narrow canvas misrepresents how the app ships and is unreadable. Use `layout: 'fullscreen'` + a desktop viewport, and offer a mobile view. (`flow-capture.md` Step 6.)
### 31. 📕 Interactive flow story that never reaches its documented states
If the journey documents `modal-open → filled → confirmation`, the interactive `play` must actually drive through to `confirmation`. A flow story that stops at `loading` and never advances is incomplete — and reviewers sign off on a state the flow never demonstrates. Document each state as its own full-width story; make the interactive story reach all of them. (`flow-capture.md` Step 7.)
### 32. 📕 A tag that lands on >~80% of stories (tag-as-noise)
The skill should **propose** a tag taxonomy from project signals, never **impose** a universal tag. Blanket-applying `ai-generated` to ~100% of stories filters nothing — it's pure noise (a stable, done story should carry *zero* custom tags). For single-author vibe-code, drop `ai-generated` entirely; for "what changed", use Storybook's built-in **git New/Modified** filters, not a custom tag. Record the chosen vocabulary in `.storybook/audit/tag-system.md`. *(Supersedes the older "always tag ai-generated" guidance in item 18.)*
## Story-as-proof — adopted from `npx storybook ai setup` (v2.1)
These two come straight from Storybook's own emitted setup prompt (the live `Prompts/` catalog). They're the difference between stories that *render* and stories that *prove the preview is wired*.
### 33. 📕 No `CssCheck` — no proof the shared preview actually loaded the app's CSS
`toBeVisible()` passes on a completely unstyled component. So a suite where every `play` only asserts visibility has **no evidence** the global stylesheet reached the Storybook iframe — and "the #1 silent failure" (preview missing the CSS import) renders every story unstyled while every test stays green.
The fix is one dedicated proof story, project-wide:
```tsx
// exactly ONE story across the whole project — typically on Button
export const CssCheck: Story = {
play: async ({ canvas }) => {
const button = canvas.getByRole('button', { name: /add to cart/i })
const bg = getComputedStyle(button).backgroundColor
// --accent resolves to #aa3bff (light) / #c084fc (dark); either proves CSS loaded
await expect(['rgb(170, 59, 255)', 'rgb(192, 132, 252)']).toContain(bg)
},
}
```
- ✓ **Do:** write **exactly one** `CssCheck` asserting a concrete `getComputedStyle` value that resolves a real design token. If `index.css` failed to load, the token is unset and this story — and only this story — goes red, pinpointing the wiring fault.
- ✗ **Don't:** sprinkle `getComputedStyle` probes across many stories (redundant, brittle to token changes) — or omit it entirely and trust `toBeVisible`. `validate-stories.sh` tallies `getComputedStyle` stories across a multi-file scan and warns on 0 or >1.
### 34. 📕 A `play` that proves nothing the render already showed (no-op play)
A `play` whose entire body is `getByRole('button').toBeVisible()` (or `toBeInTheDocument`) adds a green check that asserts what the render already guarantees. It inflates the suite, slows headless runs, and creates false confidence. A `play` earns its place **only** when it asserts one of: an **interaction** (click/type → state change), **async data** (`findBy*` after MSW resolves), a **portal** (querying `canvasElement.ownerDocument.body`), a **CSS-driven state** (computed style — but see #33, exactly one), or **accessibility** (focus order, `aria-pressed` flip).
- ✓ **Do:** leave variant-only stories (Primary/Secondary/Danger) with **no** `play` — the render *is* the test. Reserve `play` for the states that need driving or awaiting.
- ✗ **Don't:** add a `play` to every story for symmetry. `validate-stories.sh` check 13 warns when a `play` body shows no interaction / async / portal / computed-style signal. (Inverse of #11: #11 says an interactive component needs *at least one* real play; #34 says don't pad the rest with no-ops.)
## Verification record
Trimmed 2026-05-27 from 312 → ~120 lines. Cut verbose code blocks for textbook items (1–13); kept the MCP/skill legend, the workflow items, and the designer-grade items that are this skill's actual differentiator.
references/composition-patterns.md
# Composition Patterns — beyond one-story-per-state
The unique value here: **five composition patterns observed in 191 production stories that AI agents reliably reinvent each time.** They aren't anti-patterns — they're the patterns that make Storybook a stakeholder-reviewable surface for design exploration, not just a per-component catalog.
**v1.7 update:** these patterns now have backing wrappers in `.storybook/wrappers/` (see `references/wrapper-library.md`). The pattern descriptions below are still the conceptual reference; the wrappers are the runnable implementation. Scaffold via `${CLAUDE_PLUGIN_ROOT}/scripts/scaffold-wrapper.sh --tier 1` to get `<ABCanvas>`, `<StateGrid>`, `<StateMatrix>`, etc.
| Pattern | Wrapper to use (v1.7) |
|---|---|
| A/B Comparison | `<ABCanvas>` |
| Role Comparison | `<StateGrid>` with `role` as the varied prop — only after an audit shows role-gated UI |
| Status Grid | `<StateGrid>` |
| Page Composition | `<StorySet ids={[...]} layout="strip">` or hand-rolled |
| Configurable Scrubbable | `<ShaderCanvas>` for visual experiments (opt-in 3D tier) |
The patterns:
1. **A/B Comparison** — two design directions side-by-side
2. **Role Comparison** — same component rendered for each user role (Teacher · Student · Admin)
3. **Status Grid** — every state of a component on one canvas
4. **Page Composition** — assemble a full page from real components inside a single story
5. **Configurable Scrubbable Prototype** — typed-args extension exposing stakeholder-tunable Controls
Each pattern below: when-to-use → naming convention → minimal snippet → anti-pattern that breaks it.
## When to load this reference
- The user says "let's compare two designs" / "side-by-side"
- The user says "show me this from every role" / "all permissions"
- The user wants every state of a component on one screen ("all variants", "status grid")
- The user wants to build/preview a full page assembled from existing components
- The user wants stakeholder-tunable prototypes ("let the PM change the trigger threshold and watch it scroll")
- Reviewing existing stories — these patterns commonly show up and aren't anti-patterns despite looking like mega-stories
## Pattern 1 — A/B Comparison
The story renders two complete design directions side-by-side with labeled headers. Used during design exploration (not after a direction is chosen).
**When to use**
- Stakeholder review of two candidate flows / heroes / forms
- Iteration loop between designer and PM before committing
- Visual diff that shipping side-by-side makes obvious
**Naming convention**
- Story name: `Comparison_<Topic>` or `<Topic>: A vs B`
- Story tag: `'comparison'` (Galleries can aggregate all comparisons)
- Title typically lives under `Public Pages/<Section>/` or `Labs/Comparisons/`
**Skeleton**
```tsx
export const Comparison_HeroVariants: Story = {
name: 'Comparison: Current vs Iteration',
parameters: { layout: 'fullscreen' },
tags: ['comparison'],
render: () => (
<div className="grid grid-cols-1 gap-8 p-8 lg:grid-cols-2">
<div>
<h2 className="bg-surface-100 mb-4 px-4 py-2 text-lg font-bold">Current (Button-driven)</h2>
<CurrentHero />
</div>
<div>
<h2 className="bg-surface-100 mb-4 px-4 py-2 text-lg font-bold">Iteration (Inline input)</h2>
<IterationHero />
</div>
</div>
),
};
```
**What this is NOT**
- Not a way to ship both designs to production — pick one, deprecate the other
- Not a mega-story (the components are real, just rendered twice)
## Pattern 2 — Role Comparison
Same component rendered for each user role on one canvas, so designers can see how the UI changes by audience.
**When to use**
- Permission-sensitive UI (Teacher dashboard vs Student dashboard)
- Multi-tenant or multi-role components (Live Session card seen by Teacher vs Student)
- Catching role-specific drift (Teacher action missing for Admin)
**Naming convention**
- Story name: `RoleComparison_<Topic>` or `<Topic>: Teacher vs Student`
- Story tag: `'role-comparison'`
- Often paired with the audience tag layer (`'platform'`, `'public'`)
**Skeleton**
```tsx
export const RoleComparisonLive: Story = {
name: 'Live Session: Teacher vs Student vs Admin',
parameters: { layout: 'padded' },
tags: ['role-comparison', 'platform'],
render: () => (
<div className="space-y-8">
{(['teacher', 'student', 'admin'] as const).map(role => (
<section key={role}>
<h3 className="text-foreground mb-2 text-sm font-semibold uppercase tracking-wide">{role}</h3>
<LiveSessionCard role={role} session={mockSession} />
</section>
))}
</div>
),
};
```
**What this is NOT**
- Not a replacement for per-role stories — the role-comparison story complements `LiveSessionCard_Teacher`, `LiveSessionCard_Student` (those are visual-regression anchors)
- Not where you cover role-specific empty/error states — those still need their own stories
## Pattern 3 — Status Grid
Every state of a component on one canvas. Distinct from per-state stories: those are visual-regression anchors; the status grid is the *designer's overview*.
**When to use**
- Status badges (4 statuses → render all 4)
- Empty/Loading/Default/Error/Success on one canvas
- "Show me every state" requests
**Naming convention**
- Story name: `StatusGrid` or `AllStates` or `<Topic>: All States`
- Story tag: `'status-grid'`
**Skeleton**
```tsx
const STATUSES = ['draft', 'pending', 'live', 'past'] as const;
export const StatusGrid: Story = {
name: 'All Statuses',
parameters: { layout: 'padded' },
tags: ['status-grid'],
render: () => (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{STATUSES.map(status => (
<div key={status}>
<p className="text-muted-foreground mb-2 text-xs font-medium uppercase">{status}</p>
<CourseCard course={createMockCourse({ state: status })} />
</div>
))}
</div>
),
};
```
**What this is NOT**
- Not a mega-story — only one prop varies, semantically meaningful, deterministic factory inputs
- Not the only state coverage — per-state stories still exist for visual regression
## Pattern 4 — Page Composition
Assemble a full page from real components inside a single story. Goes beyond per-component catalog: stakeholders see the page they'll ship.
**When to use**
- Landing page review (hero + features + CTA in one canvas)
- Multi-step flow review (4 modals in sequence)
- Designer/PM stakeholder approval of a whole page
**Naming convention**
- Story name: `Page_<Name>` or `<Section>: Full Flow`
- Story tag: `'page-composition'`
- Title under `Pages/<audience>/<Name>` or `Public Pages/<Section>/`
- `parameters.layout: 'fullscreen'` (always — partial-width breaks the composition)
**Skeleton**
```tsx
export const Page_LandingFlow: Story = {
name: 'Landing Flow (Complete)',
parameters: {
layout: 'fullscreen',
docs: {
description: {
story: `
## Landing flow — Jobs to be Done
1. **Hero**: "When I land here, I want to immediately understand what this is."
2. **Course grid**: "When I browse, I want to see what's available."
3. **CTA**: "When I'm interested, I want to sign up without friction."
`,
},
},
},
tags: ['page-composition', 'public'],
render: () => (
<div>
<Hero />
<section className="bg-surface-50 py-16">
<h2 className="mb-8 text-center text-3xl">Courses</h2>
<CourseGrid courses={sampleCourses} />
</section>
<section className="py-24">
<EarlyAccessCTA />
</section>
</div>
),
};
```
**What this is NOT**
- Not a Next.js/Inertia/Rails-rendered page — the story renders the *composition*, the routing layer is mocked or omitted
- Not a substitute for component-level coverage — per-component stories still exist
- Not the place for app-specific data fetching — use factories from `references/factory-patterns.md`
**Key value-add** — the JTBD prose inside `parameters.docs.description.story` (Jobs to be Done framing). production teams use this consistently in composition stories; it's the seam that makes the story stakeholder-readable, not just designer-readable. The skill recommends this as a section header inside any Page Composition story.
## Pattern 5 — Configurable Scrubbable Prototype
A story with a typed args extension that exposes prototype-tunable parameters as Controls. The PM/designer drags a slider and the prototype updates live.
**When to use**
- Motion experiments (tune duration/delay/easing live)
- Threshold/trigger tuning (scroll trigger %, debounce delay)
- A/B threshold candidates the PM wants to feel before picking
**Naming convention**
- Story name: `Configurable_<Behavior>` or `<Topic>: Tunable`
- Story tag: `'scrubbable'`
- Always Labs-scoped — these are exploration, not production
**Skeleton**
```tsx
interface TunableScrollArgs {
triggerOffset: number;
durationMs: number;
easing: 'linear' | 'ease-out' | 'ease-in-out';
}
export const Configurable_ScrollTrigger: StoryObj<TunableScrollArgs> = {
name: 'Scroll Trigger (Tunable)',
argTypes: {
triggerOffset: { control: { type: 'range', min: 0, max: 100, step: 5 }, description: 'When (% scrolled) to trigger' },
durationMs: { control: { type: 'range', min: 100, max: 2000, step: 50 }, description: 'Animation duration in ms' },
easing: { control: { type: 'select' }, options: ['linear', 'ease-out', 'ease-in-out'] },
},
args: { triggerOffset: 40, durationMs: 600, easing: 'ease-out' },
tags: ['scrubbable', 'labs', '!autodocs', '!test'],
render: (args) => <ScrollPrototype {...args} />,
};
```
The typed `StoryObj<TunableScrollArgs>` is the key — it's *not* `StoryObj<typeof meta>` here, because the args shape extends beyond the component's own props. SB10 supports this and it gives the Controls panel exactly the levers the PM needs.
**What this is NOT**
- Not a way to ship a configurable component — Controls are an exploration UI, not a runtime API
- Not where you assert behavior — `play` functions still belong on the per-state stories
## Choosing between patterns
| User says | Pattern |
|---|---|
| "compare two designs" / "side-by-side" | A/B Comparison |
| "show me from every role" / "all permissions" | Role Comparison |
| "show all states" / "every variant" | Status Grid |
| "full page" / "whole flow" / "everything together" | Page Composition |
| "let me tune" / "scrubbable" / "play with the timing" | Configurable Scrubbable Prototype |
When patterns overlap (full-flow Page Composition that's also an A/B), prefer Page Composition with `tags: ['page-composition', 'comparison']`. Tag composition is the point.
## Anti-patterns specific to composition
1. **Composition as the only coverage.** A `Page_LandingFlow` story doesn't replace per-component coverage — Hero, CourseGrid, CTA each need their own state coverage.
2. **Composition with fake components.** If `Page_LandingFlow` reaches for a `<MockHero />` instead of the real `<Hero />`, you've built a Figma replica, not a Storybook composition. Use real components.
3. **Cartesian explosion inside a Status Grid.** A 4-status × 5-size × 3-theme grid is 60 cells of noise. Pick one axis per grid; if two matter, split into two grids.
4. **No JTBD prose on Page Composition.** Without the framing, stakeholders don't know what they're reviewing. Always include `parameters.docs.description.story`.
5. **`Configurable_*` without `tags: ['labs']`.** Scrubbable prototypes belong in Labs — they pollute production docs otherwise.
## Verification record
Patterns derived from production survey (191 stories):
- A/B Comparison: 8 instances (e.g., `CurrentFlow_Complete` vs `IterationFlow_Complete` in `ConversionFlows.stories.tsx`, `Comparison: Teacher vs Student` in `DashboardSidebar.stories.tsx`)
- Role Comparison: 4 instances (`RoleComparisonLive`, `RoleComparisonNext` in `LiveSessionCard.stories.tsx`)
- Status Grid: 12 instances (`StatusComparison`, `AllStatesView`, `Sizes`, `StatusBadgeVariants`)
- Page Composition: 6 instances (`Page` stories in `pages/public/courses/`, `ConversionFlows`)
- Configurable Scrubbable Prototype: 3 instances (`Configurable_Scroll_Trigger` in `FloatingWidget.stories.tsx`, motion experiments in `CourseCardAnimations.stories.tsx`)
references/directory-structure.md
# Directory Structure — Designing YOUR Title Convention
Storybook's `title` field builds the sidebar hierarchy: `title: "Group/Sub/Component"` creates `Group ▶ Sub ▶ Component` in the sidebar. The convention is *project-level* — pick once, document it in `.storybook/preview.ts`, and enforce consistency.
This file is NOT a map of any specific project. It's a guide to *designing* the convention that fits yours, with three worked example taxonomies.
## Step 1 — Read what's already there
Before designing a convention, read `.storybook/preview.ts` for the existing one:
```ts
// .storybook/preview.ts
const preview: Preview = {
parameters: {
options: {
storySort: {
order: ['Foundations', 'Components', 'Pages', 'Flows', 'Labs'],
},
},
},
};
```
If `storySort` is set, match it. If not, you're designing fresh.
## Step 2 — Pick a taxonomy shape
Three convergent shapes — pick the one that fits the project type.
### Taxonomy A — Design system / component library
For projects shipping a reusable component library:
```
Foundations/
├── Colors (MDX page — <ColorPalette>)
├── Typography (MDX page — <Typeset>)
├── Spacing (MDX page)
└── Icons (MDX page — <IconGallery>)
Components/
├── Form/
│ ├── Button
│ ├── Input
│ ├── Select
│ └── Checkbox
├── Display/
│ ├── Card
│ ├── Badge
│ └── Avatar
├── Feedback/
│ ├── Toast
│ ├── Modal
│ └── Spinner
└── Navigation/
├── Tabs
└── Breadcrumb
Pages/ (full-screen previews + composed page layouts)
├── Public/
└── App/
Flows/ (the journey layer — App Map + user journeys, from sb-flows)
├── App Map (AppFlowGraph: routes · edges · navSources)
└── <Journey> (JourneyGraph: numbered states · desktop + mobile)
Labs/ (work-in-progress, hidden from autodocs)
└── ExperimentalThing
```
> Reusable compositions (FormGroup = Label+Input+Error, CardWithActions) are
> component-level — they live under `Components/<Domain>/<Name>`, not a separate
> root. The layer model is **Foundations → Components → Pages → Flows → Labs**:
> one screens layer (Pages) and one journey layer (Flows).
**When to pick this:** publishing to npm, multiple downstream consumers, atomic-design vocabulary fits.
### Taxonomy B — SaaS app
For projects building application UI (not a library):
```
UI/ (primitives — buttons, inputs, icons)
├── Buttons
├── Inputs
└── Icons
Features/ (composed by domain)
├── Auth/
│ ├── LoginForm
│ └── SignupForm
├── Billing/
│ ├── PaymentMethod
│ └── InvoiceList
└── Settings/
├── ProfileForm
└── NotificationPreferences
Pages/ (whole-screen previews)
├── Public/
│ ├── Landing
│ ├── Pricing
│ └── About
└── App/
├── Dashboard
├── ProjectList
└── ProjectDetail
Labs/ (sandbox)
```
**When to pick this:** internal app, single consumer, features mapped to product surface.
### Taxonomy C — Marketing site
For projects building landing pages / promotional content:
```
Foundations/
├── Colors
├── Typography
└── Spacing
Sections/ (one per type of page section)
├── Hero
├── Features
├── Pricing
├── Testimonials
├── FAQ
└── CTA
Blocks/ (reusable across sections)
├── PrimaryButton
├── Card
└── Logo
Pages/ (assembled — composed of sections)
├── Landing
├── Pricing
├── Solutions
└── About
```
**When to pick this:** primarily marketing, section-based composition, fewer atomic primitives.
## Step 3 — Document the choice in `preview.ts`
```ts
// .storybook/preview.ts
import type { Preview } from '@storybook/react-vite';
const preview: Preview = {
parameters: {
options: {
storySort: {
order: [
'Foundations',
['Colors', 'Typography', 'Spacing', 'Icons'], // explicit sub-order
'Components',
['Form', 'Display', 'Feedback', 'Navigation'],
'Flows',
'Pages',
['Public', 'App'],
'Labs',
],
method: 'alphabetical', // within each group
},
},
},
};
```
The bracketed sub-arrays control sub-ordering. Without them, sub-groups are alphabetical by default.
### Overview / spec / hub stories sort to the TOP — never nested below their content
An **Overview**, **Spec**, or feature-**hub** story is an *entry point* — the thing a reviewer opens first
to get oriented and reach the rest. It must sit at the **top of its root**, not buried as one alphabetical
leaf among the components it summarizes. Two cases:
- **A root-level hub** (e.g. `Figma Inventory`, a per-delivery overview) → list its root **first** in
`order`, before `Foundations`/`Components`/`Pages`:
```ts
order: ['Figma Inventory', 'Foundations', 'Components', 'Pages', 'Flows', 'Labs']
```
- **A per-feature overview** inside a group (`Detections/Overview`, `Hunts/Spec`) → put `Overview`/`Spec`
**first** in that group's sub-order so it precedes the feature's components:
```ts
order: ['Detections', ['Overview', 'Spec', '*'], 'Components', …] // '*' = everything else, alphabetical
```
Why this is a rule and not a preference: a feature delivered from Figma (sb-figma) or a multi-screen flow
scatters into many stories; if the overview sorts alphabetically it lands in the *middle* of its own
children and reads as just another leaf — the reviewer can't find "start here". Pin it to the top. The
`'*'` sentinel lets you order only the entry points and leave the rest alphabetical.
## Step 4 — Apply consistently
Every new `.stories.tsx` file should:
```ts
const meta = {
title: 'Components/Form/Button', // ← match the convention exactly
component: Button,
} satisfies Meta<typeof Button>;
```
Don't mix `'Form/Button'` (missing top-level) with `'Components/Form/Button'`. Don't mix `'components/form/Button'` (lowercase) with `'Components/Form/Button'`. Stick to one casing pattern.
## File-location options (independent of title)
The title convention is independent of where the `.stories.tsx` file lives on disk. **But the on-disk
location is NOT free choice — it's the `storiesLocation` decision** (CONTEXT.md § STORIES LOCATION),
asked once in `sb-setup` / first `sb-stories` and recorded in `.storybook/audit/status.md`. For an audit
the default is **isolated `.storybook/stories/`** (keeps `src/` clean); the patterns below apply only
when the user opted into **co-located** placement for a project they own. Don't pick a pattern here that
contradicts the recorded decision.
### Pattern (a) — Flat demo dir
```
src/stories/
├── Button.tsx
├── Button.stories.tsx
├── button.css
├── Input.tsx
├── Input.stories.tsx
└── input.css
```
Matches the Storybook CLI scaffold. Easy to browse as a catalog. Components and stories evolve together.
### Pattern (b) — Colocated with components
```
src/components/Button/
├── Button.tsx
├── Button.stories.tsx
├── Button.test.tsx
├── button.css
└── index.ts
src/components/Input/
├── Input.tsx
├── Input.stories.tsx
├── input.css
└── index.ts
```
Production design systems prefer this — refactoring a component touches one folder.
### Picking between them
| Want | Pick |
|---|---|
| Quickly scan all stories | (a) Flat — directory listing IS the catalog |
| Refactor a single component cleanly | (b) Colocated — one folder per component |
| Strict separation between source and demos | (a) Flat — demos in `src/stories/`, real code in `src/components/` |
| Publishable component library | (b) Colocated with `*.stories.*` excluded from the package build |
**Don't mix both.** Pick project-wide, document in the project's CONTRIBUTING.md or `.storybook/README.md`.
## Naming conventions inside the title
- **PascalCase for components:** `Button`, not `button` or `BUTTON`
- **Plural sub-groups for collections:** `Buttons`, `Inputs`, `Icons` (not `Button`, `Input` — those clash with component names)
- **Domain-led subgroups** instead of pattern-led when possible: `Components/Auth/LoginForm` beats `Components/Forms/Login` (auth context > form pattern)
- **`Labs/*` or `WIP/*`** for work-in-progress that shouldn't appear in autodocs. Pair with `tags: ['!autodocs']`
## When to skip the taxonomy entirely
For very small projects (< 20 components total), a flat structure is fine:
```ts
title: 'Button';
title: 'Input';
title: 'Modal';
```
Once you cross 20 components, group. Once you cross 50, sub-group.
## Anti-patterns
1. **Mixing conventions mid-project** — e.g., some stories use `Components/X`, others use `UI/X` for the same kind of thing
2. **Project-specific names without translation guidance** — e.g., a previous project's `Public Pages/...` taxonomy carried into a new project without renaming
3. **Title taxonomy that doesn't match `storySort`** — sidebar sorts alphabetically when `storySort` is missing, producing chaos
4. **Deep nesting (5+ levels)** — `Components/Form/Inputs/Text/Default/Sized/Small` is too much. Cap at 3.
references/extraction-workflow.md
# Extraction Workflow — Vibe-Coded App to Storybook
Layer 2 of the skill. Load this reference when the user wants to **capture an existing app's components, screens, and flows in Storybook** rather than write stories from scratch.
**When to load:**
- User says "extract Storybook from this app", "build a Storybook out of this", "capture the state of this app", "make stories for what we already have"
- User has a working React app (vibe-coded with Lovable/Bolt/v0/Cursor/Claude, or existing production code) and wants to systematize it
- SKILL.md Layer 2 hand-off
**Prerequisites:**
- Storybook installed (Layer 1 — install-wizard.md must have run first, OR Storybook already present)
- Project decorators wired (theme/router/queryclient available in preview.tsx)
## Phase 0 — Ground-truth inventory (v1.8.1+, MANDATORY)
Before manually scanning, run the **four-script discovery chain** to capture every value an authoring decision will depend on. Each script writes a structured JSON the agent reads instead of grepping ad-hoc. Replaces trusting `CLAUDE.md` / `AGENTS.md` — those drift, lie, or don't exist in vibe-coded repos.
```bash
# 1. Stack + design system + real vs dead components
~/agent-skills/plugins/storybook-workbench/skills/sb-inventory/scripts/inventory-project.sh
# 2. Routes + flows + overlays (Phase 3 ground truth)
~/agent-skills/plugins/storybook-workbench/skills/sb-flows/scripts/extract-flows.sh
# 3. State branches per component (Phase 1 minimum-story driver)
~/agent-skills/plugins/storybook-workbench/skills/sb-stories/scripts/extract-states.sh
# 4. Shared prop-shape clusters (Phase 2 factory threshold)
~/agent-skills/plugins/storybook-workbench/skills/sb-stories/scripts/extract-prop-shapes.sh
```
Four files land under `.storybook/`. Read them in order; each later phase reads the earlier JSONs.
| Script | Output | What it answers |
|---|---|---|
| `inventory-project.sh` | `project-inventory.json` | Stack, dominant design system, real vs dead components, tokens, orphan stories |
| `extract-flows.sh` | `flows.json` | Routes (5 flavors), ad-hoc page switchers, wizards/step machines, modal/dialog overlays, per-screen state recommendations |
| `extract-states.sh` | `component-states.json` | Per-component state branches detected (loading/error/empty/disabled/open/success/skeleton/variants), minimum story count, tier inference (primitive/composite/container) |
| `extract-prop-shapes.sh` | `prop-shapes.json` | Type definitions clustered by usage count, factory candidates (≥3 component files), single-use shapes (inline mocks) |
`inventory-project.sh` ground-truth output:
The script writes `.storybook/project-inventory.json` with:
| Section | What's in it | Why it matters |
|---|---|---|
| `libraries.*` | React/Vite/Tailwind v4/v3/shadcn/Radix/Base UI/R3F booleans | Tells you the stack without asking |
| `designSystem.dominant` | Exactly one of `tailwind-v4`, `shadcn`, `dtcg`, `css-vars`, `none` | The opinion. Use this everywhere. Mixed sources flagged separately. |
| `designSystem.mixed` | `true` if multiple sources have >5 tokens each | Signal of transition / inconsistency — investigate before authoring |
| `components.real[]` | Files imported FROM OUTSIDE their own file (sorted by importer count) | **Priority for stories** — these are the production surface |
| `components.dead[]` | Files defined but never imported elsewhere | **Likely AI slop** — don't write stories for these; flag for deletion |
| `tokens.orphan[]` | Declared `--foo` never referenced (`var()` or as scale prefix) | Soft signal — Tailwind v4 internal vars are noisy here |
| `orphanStories.items[]` | Stories importing files that don't exist | Hard signal — refactors left these behind; safe to delete |
**Use it like this in extraction:**
```bash
# 1. Discover
$ ~/agent-skills/plugins/storybook-workbench/skills/sb-inventory/scripts/inventory-project.sh
✓ Wrote .storybook/project-inventory.json
━━ Project inventory summary ━━
Stack: React=✓ Vite=✓ Tailwind v4=✓ shadcn=✗
Design sys: dominant=tailwind-v4 (TW4:349 shadcn:0 DTCG:0 CSS-vars:0)
Components: 196 real / 40 dead (slop) / 248 total
Tokens: 62 used / 345 orphan / 407 declared
Stories: 63 orphan stories (import missing components)
# 2. Open Foundations/Inventory in Storybook (after scaffolding ProjectInventory wrapper)
# 3. Author stories ONLY for components in components.real[]
# 4. Optionally delete orphan stories + dead components in a separate cleanup PR
```
Smoke-tested on a real 248-component production codebase: correctly identified Tailwind v4 as dominant (zero false positives), flagged 16% slop rate (40/248), surfaced 63 orphan stories.
## Phase 1 — Scan and classify
Goal: enumerate every real component, classify by tier, derive minimum story count from detected state branches.
**The chain reads `.storybook/component-states.json` (from Phase 0)** — the JSON already lists every state branch the component handles. The tier field is set: `primitive` (≤2 states), `composite` (3-4), `container` (5+).
```bash
# Top priority targets — components needing ≥4 stories (containers/composites)
python3 -c "
import json
d = json.load(open('.storybook/component-states.json'))
for t in d['priorityTargets'][:10]:
print(f'{t[\"file\"]}: {t[\"minimumStories\"]} stories — {\",\".join(t[\"states\"])}')
"
# Component density per dir — where's the most concentration?
python3 -c "
import json, collections
d = json.load(open('.storybook/project-inventory.json'))
by_dir = collections.Counter('/'.join(c['file'].split('/')[:-1]) for c in d['components']['real'])
for k, v in by_dir.most_common(10): print(f'{v:3d} {k}')
"
# How many already have stories?
find . -name "*.stories.tsx" -not -path "*/node_modules/*" | wc -l
```
**Authoring rule** — for each component, the `states` array from `component-states.json` is the canonical minimum story list. Default → loading → error → empty → disabled → success → open. **Don't author more states than the JSON suggests** (refuse Cartesian); **don't author fewer** (those are the real branches in the source).
Tier classification (already in JSON) drives extraction order:
| Tier | Heuristic | Extract first | Stories needed |
|---|---|---|---|
| **Tier 1 — Primitive** | Props-only, no `useState`/`useEffect`/`useQuery`/data hooks, JSX output is mostly leaf elements (`<button>`, `<input>`, `<div>`) | Yes — easiest, biggest coverage win | Default + meaningful states (per SKILL.md Step 2 checklists) |
| **Tier 2 — Composite** | Uses `useState`, child components from primitive layer, no data fetching | Second pass | Default + state combinations |
| **Tier 3 — Container / Page** | Data fetching (`useQuery`, `useSWR`, server actions), routing context, multiple stateful children | Last — needs foundation phase | Default + Loading + Error + Empty + state-driven variants |
**Storybook's official agentic-setup workflow caps at 10 components per session.** Backend.AI's real case study converged on **5–8 per session**. Stop when:
- Context utilization hits ~70%
- Next component requires understanding >3 new modules not already read
Save the ranked candidate list to `.storybook/extraction-plan.md` for future sessions to resume.
## Phase 2 — Identify shared data shapes (factory candidates)
**The chain reads `.storybook/prop-shapes.json` (from Phase 0).** It already clusters shapes by usage count.
```bash
# Factory candidates — types used in ≥3 component files. Run scaffold-factory.sh on each.
python3 -c "
import json
d = json.load(open('.storybook/prop-shapes.json'))
for c in d['factoryCandidates']:
print(f'{c[\"type\"]:24s} {c[\"componentFileUsages\"]} files {c[\"declaredIn\"][0][\"file\"] if c[\"declaredIn\"] else \"\"}')
"
```
**Decision rule (locked in `extract-prop-shapes.sh` at `--threshold 3`):**
| Shape appears in | Action | Why |
|---|---|---|
| **≥3 component files** | Run `scaffold-factory.sh <Type> <import-path>` — extract to `src/stories/factories/<name>.ts` using `makeFactory<T>` pattern | Diff hygiene + deterministic mock data across stories |
| **1-2 component files** | Inline minimal mock data in story `args` | Pre-factoring single-use shapes is premature abstraction |
See `references/factory-patterns.md` for the `makeFactory<T>` pattern. Don't override the threshold without a reason; `≥3` is the verified line where shared factories pay rent.
## Phase 3 — Map screens and flows
Goal: every distinct screen becomes a Page story; multi-step flows become MDX docs that link the page stories. **All four flow surfaces — routes, ad-hoc switchers, wizards, overlays — are already in `flows.json` from Phase 0.**
```bash
# Routes — every flavor, per-screen state recommendations baked in
python3 -c "
import json
d = json.load(open('.storybook/flows.json'))
print(f'Dominant router: {d[\"dominantRouter\"]}')
for r in d['perScreenRecommendations']:
print(f' {r.get(\"path\", \"?\"):24s} → {\",\".join(r[\"recommendedStates\"])}')
"
# Ad-hoc useState page switchers (App.tsx-style)
python3 -c "import json; print('\n'.join(json.load(open('.storybook/flows.json'))['adhocSwitchers']))"
# Wizards / step machines (useState<number> + setStep)
python3 -c "import json; print('\n'.join(json.load(open('.storybook/flows.json'))['wizards']))"
# Modal/Dialog/Sheet overlays — each is a flow with open/closed + inner loading/error
python3 -c "
import json
for o in json.load(open('.storybook/flows.json'))['overlays']:
# malformed grep lines land as {raw: ...}; skip them
if 'file' not in o: continue
print(f' {o.get(\"component\", \"?\"):12s} {o[\"file\"]}:{o.get(\"line\", \"?\")}')
"
```
The five route flavors detected by `extract-flows.sh`:
| Flavor | Detection pattern | JSON key |
|---|---|---|
| **react-router** | `<Route path=...>` declarations (single-line + Prettier multi-line) | `routes.reactRouter[]` |
| **nextjs-pages** | files under `pages/` (excl. `pages/api/`) | `routes.nextjsPages[]` |
| **nextjs-app** | `app/**/page.tsx` files | `routes.nextjsApp[]` |
| **tanstack** | files under `routes/` (file-based) | `routes.tanstack[]` |
| **adhoc-state** | `const [page, setPage] = useState` in App.tsx | `adhocSwitchers[]` |
**Migration-in-progress signal.** When `routerTies[]` in `flows.json` is non-empty, the project has 2+ router flavors with significant route counts — investigate before wiring a router decorator. Example: `dominantRouter: "react-router"` + `routerTies: ["nextjs-app"]` means a legacy react-router app is being migrated to Next.js App Router. Decide which decorator to wire (MemoryRouter vs Next router mock); don't silently pick the dominant one.
**Per-screen output:** one story per behaviorally-distinct state of the page. `flows.json` already proposes the right state set per path pattern — auth, list, detail, form, dashboard — based on path-keyword matching. Use them as the starting list; override only when source state branches diverge (`component-states.json` is the source of truth for those).
| Page type | Stories to capture |
|---|---|
| **Auth (login, signup, password reset)** | Empty form · Filled · Submitting · Validation error · Server error · Success / redirect (mocked) |
| **List view (users, posts, items)** | Default · Empty · Loading · Error · One-item · Many-items (test pagination/virtualization) · Filtered |
| **Detail view** | Default · Loading · Not found · Permission denied · Long content · Stale (cache miss) |
| **Form (settings, profile)** | Empty · Pre-filled · Dirty · Submitting · Per-field validation error · Submit success |
| **Dashboard / landing** | First-visit · Returning user · Empty (no data yet) · Loading skeleton · Partial data |
**Flow capture (the piece most teams miss):**
A flow is a multi-step user journey across screens — onboarding, checkout, password reset, multi-step form, etc. Document it as:
1. **One story per step** with title `Pages/{Flow}/{NN-StepName}`:
- `Pages/Onboarding/01-Welcome`
- `Pages/Onboarding/02-Profile`
- `Pages/Onboarding/03-Preferences`
- `Pages/Onboarding/04-Done`
2. **One MDX docs page** linking them: `stories/docs/Flows/Onboarding.mdx` with do/don't blocks, success criteria, drop-off concerns
3. **Optional `play` function** that drives a story through several steps to validate the transitions
Naming convention: `NN-` prefix for ordering, lowercase kebab for clarity in URLs.
**Common gotcha — unexported sub-components.** Vibe-coded multi-step flows often have step components defined as local consts inside the parent file, NOT exported. Example:
```tsx
// src/pages/Onboarding.tsx
function StepWelcome(...) { /* ... */ } // ← not exported
function StepProfile(...) { /* ... */ } // ← not exported
export function Onboarding() { /* renders one of them based on state */ }
```
Three ways to handle this — pick based on team appetite:
| Approach | When to use | Tradeoff |
|---|---|---|
| **(a) Refactor to export** each step component, then write per-step stories | When the team is committed to systematic UI documentation and OK touching code | Cleanest, but extraction is supposed to be non-mutating |
| **(b) Parent-only `Flow` story with `play` advancing state** through each step | When you don't want to refactor — write one story whose `play` clicks "next" through every step, capturing screenshots at each transition | Single story, less granular regression coverage |
| **(c) Inline duplicates of step components** in story file | When the team will refactor later but you want full per-step stories now | Temporary; flagged with `'needs-cleanup'` tag |
Default: (b) for first-pass extraction, switch to (a) when the team commits to the design system. (c) is debt.
**Ad-hoc routing — useState page switchers.** Vibe-coded apps often have `const [page, setPage] = useState('dashboard')` in App.tsx instead of a real router. `extract-flows.sh` flags these as a flat list in `adhocSwitchers[]` (count in `adhocSwitcherCount`). Treatment:
- Page detection: read `App.tsx` for the page-switcher state machine; each page value = one page story
- No router decorator needed; pages render directly
- Story title: `Pages/{PageName}` (no router-namespaced path)
- This is correct extraction behavior — document the ad-hoc reality; refactoring to a real router is its own task post-extraction
**Overlay flows — modal/dialog/sheet.** Overlays (`<Dialog open=…>`, `<Modal open=…>`, `<Sheet open=…>`, `<Drawer open=…>`, `<Popover open=…>`, `<AlertDialog open=…>`) are flows too — they have an open/closed state and usually an internal loading/error state when they fetch on open. `extract-flows.sh` flags them as a flat list in `overlays[]` (count in `overlayCount`).
Minimum overlay coverage:
| Story state | What it represents |
|---|---|
| `Closed` | `open: false` baseline (rare — usually skip unless toggle matters) |
| `Open` | `open: true`, default content rendered |
| `OpenLoading` | `open: true`, content fetch in-flight (loading skeleton) |
| `OpenError` | `open: true`, content fetch failed |
| `OpenEmpty` | `open: true`, content fetch succeeded but result was empty |
**`open` is an arg, not `parameters.pseudo`.** This is the most-violated pattern for overlays. `parameters.pseudo` is for `:hover` / `:focus` / `:disabled` CSS pseudo-classes — none of which model "the dialog is open." Use `args.open: true` so the Controls panel can toggle it and `useArgs` can sync internal close handlers (see `templates/controlled-component-story.tsx`).
## Phase 4 — Detect anti-patterns to flag (don't fix during extraction)
Capture the current state honestly. Don't refactor while extracting — that's a separate pass. But DO flag:
```bash
# Hardcoded hex colors — covers both Tailwind arbitrary form AND inline style={{}}.
# Lovable / Bolt / v0 / Claude / Cursor generated apps overwhelmingly use inline style={{}}
# for colors, NOT arbitrary-Tailwind form. Both patterns must be scanned.
grep -rE "(bg-\[#[0-9a-fA-F]{3,8}\]|text-\[#[0-9a-fA-F]{3,8}\])" src/ --include="*.tsx" | head -20 # Tailwind arbitrary form
grep -rE "style=\{\{[^}]*['\"#]?#[0-9a-fA-F]{3,8}" src/ --include="*.tsx" | head -20 # inline style={{}} form
grep -rE "(backgroundColor|color|borderColor|fill|stroke):\s*['\"]#[0-9a-fA-F]" src/ --include="*.tsx" | head -20 # object-property form
# Magic-number spacing (Tailwind arbitrary + inline pixel literals)
grep -rE "(mt|mb|ml|mr|p|m|gap)-\[[0-9]+px\]" src/ --include="*.tsx" | head -10
grep -rE "(padding|margin|gap):\s*['\"]?[0-9]+px" src/ --include="*.tsx" | head -10
# Inline mock data that should be a factory
grep -rE "\{\s*id:\s*['\"]\w+['\"]," src/ --include="*.tsx" | head -10
```
**Why three grep patterns for color?** AI-generated apps use inline `style={{ backgroundColor: '#xxx' }}` in ~90% of cases. Only sanitized / cleaned-up projects use the Tailwind `bg-[#xxx]` arbitrary form. Skip the inline-style grep and you'll undercount color debt by 10×.
Tag flagged stories with `['ai-generated', 'needs-cleanup']`. They render correctly but represent debt:
- Hardcoded values → run `/ds-token-extract` or `/ds-audit` after extraction completes
- Inline mocks → factory candidates from Phase 2
The extraction phase **documents reality, doesn't change it**. The cleanup is its own pass — invokes `/ds-audit` + `/ds-token-extract` + `/ds-component-extract` once the snapshot is captured.
## Phase 5 — Write extraction stories
For each candidate from Phase 1, write the story file following SKILL.md Step 5:
- Primitives → follow the import / `fn()` / `satisfies Meta<typeof X>` patterns in `references/without-mcp.md` §1-3; one named story per behaviorally-distinct state (per SKILL.md Step 2 checklists)
- Controlled components → start from `templates/controlled-component-story.tsx` (the only template that survives — the `useArgs` + render sync pattern is non-obvious)
- Pages → set `parameters.layout: 'fullscreen'`, mock data via factories (Phase 2), avoid importing real router/auth/query hooks; create a `<Name>Preview` wrapper component if the real page calls hooks
**Tag every extraction story with `['ai-generated']`** until a human reviews it. Add `'needs-work'` if you weren't sure about something (missing state coverage, ambiguous prop semantics).
Commit per component or per small batch (3-5 components) for clean diff history.
## Phase 6 — Snapshot the current state
After extraction, the Storybook is a **snapshot of what the app looks like today**. Lock it as the baseline:
```bash
# Visual regression baseline via Chromatic
npx chromatic --project-token=<token> --auto-accept-changes
# Or Lost Pixel
npx lostpixel
```
This is the moment that turns "messy vibe-coded app" into "this is where we are." Every future refactor compares against this snapshot. Regressions are caught; intentional changes are reviewed.
## Phase 7 — Hand off
After extraction completes, route to the right next-step skill:
| Goal | Next skill |
|---|---|
| Clean up the design tokens / hardcoded values | `/ds-audit` then `/ds-token-extract` |
| Refactor the components into a design system | `/ds-component-extract` |
| Add visual regression to CI | `/ds-test-setup` + `/ds-ci-gates` |
| Write designer-authored MDX docs per component | (future) `storybook-doc-blocks` — for now, use Storybook's `<Canvas>`/`<Controls>`/`<ColorPalette>` blocks manually |
| Iterate on individual components | This skill, Layer 3 (authoring) |
## Anti-patterns specific to extraction
1. **Refactoring while extracting** — don't. Extraction documents the state; refactoring is a separate concern. Mix them and you lose the "this is where we are" snapshot.
2. **Extracting >10 components in one session** — context will compress, errors batch, attribution becomes ambiguous. Stop at 5-8 done or 10-12 stubbed.
3. **Skipping the flow capture** — a Storybook of 80 components with no flow stories captures the parts but not the product. Even 3 flow MDX pages dramatically improve designer review value.
4. **Not tagging `['ai-generated']`** — extraction stories that merge silently become indistinguishable from human-reviewed stories. Tag aggressively; strip tags only after human review.
5. **Inventing structure** — if `.storybook/preview.ts` already has a `storySort`, match it. If it doesn't and the team has a strong opinion, use the taxonomy interview in `references/directory-structure.md`. Don't impose a different convention mid-extraction.
6. **Over-mocking** — extraction is "what does the app currently look like." Mock data should be representative, not pristine. If the real app has 1500-character article titles, capture that as a story; don't sanitize.
## Sandbox-verifiable scenarios
To validate the extraction workflow end-to-end:
1. Scaffold a fixture vibe-coded app (Vite + React + shadcn with 8-10 inline-styled components and 3-4 pages)
2. Run this workflow against it
3. Verify: extraction-plan.md is generated, 5-8 stories written, 1-2 factories extracted, 1 MDX flow doc created
4. Capture findings → `runs/<date>-extraction-pilot/REPORT.md` → `/kb ingest` → vault
See `docs/publishing/sandbox-pattern.md` for the methodology.
## Verification record
The component-discovery + classification heuristics derive from:
- Storybook's official agentic-setup workflow (9 phases, 10-component ceiling)
- Backend.AI 50-component migration case study (Feb 2026)
- Red Hat behavioral-verification engine pattern (April 2026)
Phase 3 flow capture is **not yet verified live** — it's the next sandbox pilot target.
references/factory-patterns.md
# Factory Patterns — Mock Data Close to Real Data
The textbook factory shapes (`makeFactory<T>`, `fishery`, `@faker-js/faker`) are well-documented in their own libraries. This file covers the **Storybook-specific decisions and constraints** that AI agents miss, plus the **data-layer adapter patterns** that make Storybook mocks behave like real data (TanStack Query / Inertia / Apollo / SWR / RTK Query).
The goal: **mocks should be one type import + one MSW handler away from being the real API.** When prototypes in Labs/ look and behave like production, designers can trust what they're reviewing.
## The framework-agnostic factory shape (production pattern)
A real production codebase ships a 527-line `.storybook/factories.ts` that demonstrates the framework-agnostic shape every project should converge on. Pure TypeScript, no React imports, re-usable in tests:
```ts
// .storybook/factories.ts (production pattern, abridged)
import type { Course, Category, AuthorProfile } from '@/types';
export function createMockCategory(overrides: Partial<Category> = {}): Category {
return {
id: 1,
name: 'Philosophy',
icon: 'book-open',
...overrides,
};
}
export function createMockInstructor(overrides: Partial<AuthorProfile> = {}): AuthorProfile {
return {
id: 1,
name: 'Dr. Sarah Mitchell',
bio: 'Expert in ancient philosophy with 20 years of teaching experience.',
avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200',
// ... every required field of AuthorProfile
...overrides,
};
}
export function createMockCourse(overrides: Partial<Course> = {}): Course {
return {
id: 1,
title: 'Introduction to Ancient Greek Philosophy',
state: 'published',
category: createMockCategory(), // ← compose
instructor: createMockInstructor(), // ← compose
starts_at: '2026-03-15T18:00:00Z', // ← static, deterministic
...overrides,
};
}
```
Properties of this shape:
- **No framework imports** — no React, no Vue. Plain TS. Test code can import the same factories.
- **`createMockX` naming** — consistent verb prefix; greppable.
- **`Partial<X>` overrides** — full type-checking, can override any field.
- **Composes** — `createMockCourse` calls `createMockCategory`. Single source of truth per shape.
- **Static defaults** — no `Math.random`, no `Date.now`, no unseeded `faker`. Visual regression doesn't churn.
### Scaffolding command — `scripts/scaffold-factory.sh`
The skill ships a generator that creates the stub for you:
```bash
~/agent-skills/plugins/storybook-workbench/skills/sb-stories/scripts/scaffold-factory.sh User '@/types/user'
```
What it does:
- Finds the factories file (`.storybook/factories.ts` → `src/stories/factories/index.ts` → `src/stories/factories.ts`)
- Creates it if missing, with the header comment + ruleset
- Adds `import type { User } from '@/types/user'` if not already present
- Appends a `createMockUser(overrides: Partial<User> = {}): User` stub with a TODO
- Refuses to overwrite if `createMockUser` already exists
The stub is deliberately incomplete — `tsc` will fail until the agent fills the required fields from the imported type. That's intentional: it forces engagement with the production type.
```bash
# After running:
$ scaffold-factory.sh User '@/types/user'
✓ Appended createMockUser stub to .storybook/factories.ts
Next: open the file, fill in the TODO with deterministic defaults from @/types/user.
Then: tsc --noEmit will fail until every required field is set — that's intentional.
```
### When to scaffold (the only decision that matters, recapped)
| Situation | Pattern |
|---|---|
| 1–2 components use a shape, never deeply nested | **Inline** minimal mock in `args` |
| 3+ components share a shape | `scaffold-factory.sh <Type>` then fill the stub |
| Multiple stories need *related* entities (User + Order, list + detail) | `@mswjs/data` shared mock DB on top of these factories |
| Need realistic strings (names, emails, addresses) | `faker` **with `faker.seed(1)`** at the top of the factories module |
## When to factor (the only decision that matters)
| Situation | Pattern |
|---|---|
| 1–2 components use a shape, never deeply nested | **Inline** minimal mock in `args` |
| 3+ components share a shape | Extract a factory to `src/stories/factories/<name>.ts` |
| Multiple stories need *related* entities (User + Order, list + detail) | `@mswjs/data` shared mock DB |
| Need realistic strings (names, emails, addresses) | `faker` **with `faker.seed(1)`** at the top of the factories module |
Don't pre-factor. Wait for the duplication to actually exist.
## The four rules that matter for Storybook
### Rule 1 — Defaults must be deterministic
A factory's default output (no overrides) must be byte-identical across runs. Non-obvious because most factory tutorials use `Math.random()` / `Date.now()` / `faker` without a seed — all three break visual regression.
```ts
// ✗ Every render produces different output → VRT churn
{ id: Math.random().toString(), createdAt: new Date().toISOString() }
// ✓ Deterministic
{ id: `user-${++counter}`, createdAt: '2026-01-01T00:00:00.000Z' }
```
Faker is fine **only** with a seed set once at the top of the factories module:
```ts
import { faker } from '@faker-js/faker';
faker.seed(1); // ← before any factory uses faker
```
### Rule 2 — Use the project's real types, not story-only shapes
The whole point of "mocks close to real data" is that the factory returns the production type:
```ts
// ✓ Import the project's actual type — same shape the API returns,
// same shape the components consume in production
import type { User } from '@/types/user';
export function makeUser(overrides: Partial<User> = {}): User {
return {
id: `user-${++counter}`,
email: 'user@example.test',
name: 'Test User',
role: 'member',
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
} satisfies User;
}
```
When the production type changes, TypeScript fails the factory — you find out before stories drift. **Story-only `interface MockX {}` is acceptable only when the shape is genuinely Storybook-internal** (e.g., a wrapper type that doesn't exist in production).
### Rule 3 — Factories are story/test code, never production
Two consequences:
- Put them under `src/stories/factories/` (or `.storybook/factories.ts` for a smaller project — pick one)
- Never import from `@/server/db`, `@/lib/api`, or any live data source — factories return literal objects only
The bundler must exclude these paths from production output. Vite skips `*.stories.*` by default; factories under `src/stories/factories/` ride along unless your build is unusual.
### Rule 4 — Compose factories for nested types
When the production type has nested entities, factories should call each other instead of duplicating:
```ts
import { makeAuthor } from './author';
import { makeCategory } from './category';
export function makeCourse(overrides: Partial<Course> = {}): Course {
return {
id: `course-${++counter}`,
title: 'Default Course Title',
state: 'published',
instructor: makeAuthor(), // ← compose, don't inline
category: makeCategory(), // ← same
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
} satisfies Course;
}
```
This way, when `Author` gets a new required field, `makeAuthor` is the only file to change — `makeCourse` and every other consumer keep working.
## Named pre-built instances for common variants
For the variants you reach for repeatedly (admin user, draft article, paid order), export named instances alongside the factory:
```ts
// src/stories/factories/user.ts
export function makeUser(overrides: Partial<User> = {}): User { /* ... */ }
// Common variants — every story file imports these instead of re-deriving
export const mockUser = makeUser();
export const mockAdmin = makeUser({ role: 'admin', name: 'Admin User' });
export const mockBanned = makeUser({ status: 'banned' });
export const mockGuest = makeUser({ role: 'guest', email: undefined });
```
Usage:
```ts
import { mockUser, mockAdmin } from '@/stories/factories/user';
export const RegularView: Story = { args: { currentUser: mockUser } };
export const AdminView: Story = { args: { currentUser: mockAdmin } };
```
Diffs across stories become meaningful — you see "this story uses the admin variant" without scanning args.
## Data-layer adapters — making mocks behave like the real data layer
This is the biggest gap between "mock data in args" and "mocks close to real data." Production apps don't pass data via props — they pull it from a data layer (server-state cache, page props, GraphQL client, etc.). Storybook needs to mock that layer for prototypes to behave like the real thing.
The pattern is the same across libraries: **a decorator (or `.storybook/mocks/<layer>.tsx`) intercepts the data-layer hooks and returns factory output.** Concrete adapters:
### TanStack Query (`@tanstack/react-query`)
```tsx
// .storybook/preview.tsx (decorator)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const decorators = [
(Story) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return <QueryClientProvider client={queryClient}><Story /></QueryClientProvider>;
},
];
```
Then mock the network layer with MSW so `useQuery` keys resolve to factory data:
```ts
// per-story
parameters: {
msw: {
handlers: [
http.get('/api/users/:id', () => HttpResponse.json(makeUser({ role: 'admin' }))),
],
},
}
```
This is the cleanest pattern — production code calls `useQuery(['user', id])` unchanged; Storybook intercepts the HTTP call with MSW.
### Inertia.js (`@inertiajs/react`)
Inertia injects shared props via `usePage()`. Mocking it requires a stand-in that returns the same shape:
```tsx
// .storybook/mocks/inertia-react.tsx
// Stub usePage, useForm, router, Link, Head, etc. with calls that return factory data.
// Pattern: define a React context providing the "page props" and a stub router with no-op methods.
export function StorybookInertiaProvider({ children, page }: { children: React.ReactNode; page: PageProps }) {
return <InertiaPageContext.Provider value={page}>{children}</InertiaPageContext.Provider>;
}
// Stub the @inertiajs/react module so production hooks resolve to factory data
// (in main.ts: viteFinal aliases '@inertiajs/react' → '.storybook/mocks/inertia-react'
// for the Storybook build only; production build keeps the real import)
```
Then per-story:
```ts
parameters: {
inertia: {
page: { props: { auth: { user: mockUser }, flash: {}, errors: {} } },
},
}
```
The production codebase ships a full Inertia mock (`@inertiajs/react`: `usePage`, `useForm`, `router`, `Link`, `Head`, `usePoll`, `usePrefetch`, `Deferred`, `WhenVisible`, `InfiniteScroll`, `Form`, `useRemember`); see its `.storybook/mocks/inertia-react.tsx` for the exact API. For other projects: this scope is what to aim for.
### Apollo Client (GraphQL)
Use `MockedProvider` from `@apollo/client/testing`:
```tsx
import { MockedProvider } from '@apollo/client/testing';
const decorators = [
(Story, { parameters }) => (
<MockedProvider mocks={parameters.apollo?.mocks ?? []}>
<Story />
</MockedProvider>
),
];
// Per-story
parameters: {
apollo: {
mocks: [{
request: { query: GET_USER, variables: { id: '1' } },
result: { data: { user: makeUser() } },
}],
},
}
```
### SWR (`swr`)
SWR's `SWRConfig` accepts a `fetcher` you can override:
```tsx
const decorators = [
(Story, { parameters }) => (
<SWRConfig value={{ fetcher: parameters.swr?.fetcher ?? defaultFetcher, provider: () => new Map() }}>
<Story />
</SWRConfig>
),
];
// Per-story — return factory data based on the key
parameters: {
swr: {
fetcher: (key) => key === '/api/me' ? makeUser({ role: 'admin' }) : null,
},
}
```
Or, like TanStack Query, pair SWR with MSW and don't override the fetcher.
### RTK Query (`@reduxjs/toolkit/query`)
Same MSW pattern as TanStack Query. Wrap the story in a `<Provider store={createMockStore()}>` decorator and let MSW intercept the HTTP layer.
### Choosing your adapter
If you already use MSW (or can adopt it), **MSW is the universal answer.** It intercepts at the network layer, so the data-layer hooks (`useQuery`, `useSWR`, `fetch`) work unchanged. The only data layers MSW can't reach are framework-private (Inertia shared props, Next.js Server Components, Apollo's in-memory cache) — for those, build a stub provider.
## `@mswjs/data` — the one non-obvious cross-entity pattern
When stories need *related* entities (list view + detail view fetching from the "same DB"), `@mswjs/data` gives you a shared dataset across stories without each one redefining mocks:
```ts
// src/stories/factories/db.ts
import { factory, primaryKey } from '@mswjs/data';
export const db = factory({
user: { id: primaryKey(String), name: String, email: String, createdAt: String },
order: { id: primaryKey(String), userId: String, amount: Number, status: () => 'pending' as 'pending' | 'paid' | 'shipped' },
});
export function seedDb() {
db.user.create({ id: 'u1', name: 'Alice', email: 'alice@example.test' });
db.order.create({ id: 'o1', userId: 'u1', amount: 100, status: 'paid' });
}
```
```ts
// .storybook/preview.tsx
beforeAll(() => seedDb());
export const parameters = {
msw: { handlers: [...db.user.toHandlers('rest'), ...db.order.toHandlers('rest')] },
};
```
Different stories now see the same data without each one re-seeding.
## File layout
```
src/stories/factories/
├── seed.ts (single faker.seed call — imported first)
├── db.ts (@mswjs/data factory + seedDb)
├── user.ts (makeUser, mockUser, mockAdmin, ...)
├── order.ts (makeOrder, mockPendingOrder, ...)
├── article.ts (project domain types)
└── index.ts (re-export everything for one-line story imports)
```
Stories import once: `import { mockUser, mockOrder } from '@/stories/factories'`.
For very small projects, a single `.storybook/factories.ts` is fine — split into the directory above when it crosses ~200 lines.
## Three mock layers — what most projects underuse
Production projects don't just need a data factory. The pattern that emerges in real Storybook setups (verified against a production app's 191 stories) is **three distinct mock layers**, each serving a different need:
| Layer | Purpose | Lives at | Example |
|---|---|---|---|
| **Data factories** | Type-safe entity constructors (User, Order, Course) — return literal objects matching production types | `src/stories/factories/` or `.storybook/factories.ts` | `makeUser(overrides)`, `mockAdmin` |
| **Content fixtures** | Static content that's verbose but not entity-like — long copy, FAQ items, marketing strings, lorem-ipsum-equivalent for the project's voice | `.storybook/mocks/<topic>.tsx` or `stories/**/_fixtures.tsx` | `faq-items.tsx` (e.g., production codebase: 7 FAQ entries with realistic Q+A pairs) |
| **Third-party-SDK stubs** | Stand-ins for SDKs that can't run in Storybook (analytics, framework data-layer hooks, error tracking, payment processors) | `.storybook/mocks/<sdk>.tsx` + `viteFinal` alias | `plausible-tracker.ts` (analytics no-op), `inertia-react.tsx` (data layer) |
Each layer has different rules:
- **Data factories** must be type-safe (`Partial<T>` overrides, return `T`). Already covered above.
- **Content fixtures** must be stable across runs (no timestamps, no random IDs) and *realistic* (not "Lorem ipsum" — write copy that mirrors the production voice so design review reflects real reading).
- **Third-party-SDK stubs** must match the SDK's *public* TypeScript surface so production code compiles unchanged; internals can be no-op or `console.log`.
## The underscore-prefix pattern — non-stories under `stories/`
Production codebases need helper files that live next to stories (shared fixtures, page-chrome wrappers, static previews) but **must NOT be discovered by Storybook's glob** as story files. The convention:
```
stories/
├── pages/public/courses/
│ ├── CoursesBrowseCompact.stories.tsx
│ ├── CoursesBrowseSidebar.stories.tsx
│ └── _fixtures.tsx ← shared CATEGORIES, SAMPLE_COURSES, page chrome
├── pages/public/login/
│ ├── SignIn.stories.tsx
│ └── _static-previews.tsx ← unmockable-state preview components
```
The leading `_` matches a project convention (Vite + Storybook respect it implicitly when stories glob is `*.stories.tsx`). The Storybook stories glob looks like:
```ts
// .storybook/main.ts
stories: ['../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)']
```
— underscore files don't match `*.stories.tsx`, so Storybook ignores them.
**Two production uses (both verified in the production codebase):**
### Use 1 — `_fixtures.tsx` for shared mocks across variants
When two or more `.stories.tsx` files for the same page need the same fixtures (a homepage in Compact and Sidebar variants both need the same SAMPLE_COURSES + CATEGORIES), put them in a sibling `_fixtures.tsx`:
```tsx
// stories/pages/public/courses/_fixtures.tsx
import { createMockCategory, createMockCourse } from '../../../../.storybook/factories';
export const CATEGORIES = [
createMockCategory({ id: 1, name: 'Philosophy', icon: 'lightbulb' }),
createMockCategory({ id: 2, name: 'Art History', icon: 'palette' }),
// ...
];
export const SAMPLE_COURSES = [
createMockCourse({ id: 1, title: '...', category: CATEGORIES[0] }),
// ...
];
```
Each `.stories.tsx` variant imports from `./_fixtures`:
```tsx
import { CATEGORIES, SAMPLE_COURSES } from './_fixtures';
export const Default: Story = { args: { courses: SAMPLE_COURSES, categories: CATEGORIES } };
```
**Why this beats `.storybook/factories.ts`:** factories return entities by type; fixtures return *the exact composition this page needs* (e.g., "courses sorted by relevance, three categories selected, two enrollments shown"). Page-shaped, not type-shaped.
### Use 2 — `_static-previews.tsx` for unmockable states
Some states are hard or impossible to seed via the live mock layer:
- **In-flight states** — the form is mid-submission, processing indicator showing, before any callback fires
- **Server-validation errors mid-flight** — the moment the server returned an error but the UI hasn't reset
- **Transitional states** — the moment between two animations
- **Removed-on-success states** — UI feedback that's already been dismissed by the time live state machines settle
For these, build a **static preview component** that **mirrors the production primitives** but fakes the state plumbing:
```tsx
// stories/pages/public/login/_static-previews.tsx
/**
* Static previews for sign-in flow states the live mocked components can't easily seed
* (in-flight processing, server validation errors, removed-on-success feedback).
*
* Each preview composes the exact same primitives (`Button`, `Input`, `GoogleButton`,
* `Modal.*`, `Mail` icon) used by the production `SignInForm` / `SignInModal`, so design
* review reflects real visuals — only the state plumbing is faked.
*/
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// ... import the SAME primitives production uses
export type SignInBodyVariant = 'idle' | 'sending' | 'error';
export function SignInBody({ variant }: { variant: SignInBodyVariant }) {
const errorText = variant === 'error' ? 'Please enter a valid email.' : undefined;
const seededValue = variant === 'error' ? 'not-an-email' : '';
const processing = variant === 'sending';
return (
<form onSubmit={(e) => e.preventDefault()}>
<Input label="Email" value={seededValue} error={errorText} onChange={() => {}} />
<Button type="submit" disabled={processing}>
{processing ? 'Signing in...' : 'Sign in'}
</Button>
</form>
);
}
```
Then the story file uses these static preview components for the hard-to-mock states:
```tsx
// stories/pages/public/login/SignIn.stories.tsx
import { SignInBody } from './_static-previews';
export const Sending: Story = {
render: () => <SignInBody variant="sending" />,
};
export const ValidationError: Story = {
render: () => <SignInBody variant="error" />,
};
```
**The rule:** "mirror production primitives, fake only the state plumbing." If you replicate the look but use *different* primitives, the static preview drifts away from production visuals — which defeats the purpose.
## Per-story context-override provider — generalized from Inertia stub
A pattern that emerges when a project uses any context-injected data layer (Inertia.js `usePage`, React Router's location, custom session contexts): you want one story to render logged-in, another logged-out, another as admin — without writing a custom decorator per story.
The solution is a **two-layer provider** wrapped at the preview level + overridable per story via context:
```tsx
// .storybook/mocks/<context-name>.tsx
const DefaultContext = { user: defaultMockUser, url: '/', /* ... */ };
const OverrideContext = React.createContext<Partial<typeof DefaultContext>>({});
export function StorybookOverrideProvider({
user,
url,
children,
}: {
user?: MockUser | null;
url?: string;
children: React.ReactNode;
}) {
return (
<OverrideContext.Provider value={{ user, url }}>
{children}
</OverrideContext.Provider>
);
}
// The hook the production code calls — production sees the real implementation;
// Storybook sees this stub via viteFinal alias (see install-wizard.md).
export function useSessionContext() {
const override = React.useContext(OverrideContext);
return {
user: override.user === undefined ? DefaultContext.user : override.user,
url: override.url ?? DefaultContext.url,
};
}
```
Per-story usage:
```tsx
import { StorybookOverrideProvider } from '../../../.storybook/mocks/session';
export const TeacherView: Story = {
decorators: [
(Story) => (
<StorybookOverrideProvider user={makeTeacher()}>
<Story />
</StorybookOverrideProvider>
),
],
};
export const LoggedOut: Story = {
decorators: [
(Story) => (
<StorybookOverrideProvider user={null}>
<Story />
</StorybookOverrideProvider>
),
],
};
```
**Why this is non-obvious:** the textbook decorator pattern wraps the same provider with hard-coded values, requiring a separate decorator per story variant. The context-override-provider pattern lets each story declare ONLY the override it needs, falling through to defaults for everything else. Production code reads `useSessionContext()` unchanged.
## Realistic-timing mocks
When prototyping a form / submit / save / load flow, the **timing** matters as much as the data. A submit that completes in 0ms looks broken; one with realistic ~800-1500ms latency demonstrates the loading state designers need to review.
Two patterns:
### Pattern A — MSW handler with `delay()`
```tsx
parameters: {
msw: {
handlers: [
http.post('/auth/login', async () => {
await delay(800); // ← visible loading state
return HttpResponse.json({ user: makeUser() });
}),
],
},
}
```
### Pattern B — In-mock `setTimeout` (when the mock is a SDK stub, not HTTP)
For SDK stubs like `useForm` (Inertia) that don't go through HTTP, bake the delay into the mock:
```tsx
// .storybook/mocks/inertia-react.tsx — useForm stub
const submit = useCallback((method, url, options) => {
setProcessing(true);
setTimeout(() => {
setProcessing(false);
options?.onSuccess?.();
options?.onFinish?.();
}, 1000); // ← 1s simulated round-trip
}, [data]);
```
Stories that demo flows (`onSuccess` triggers a redirect, cooldown, post-submit transition) need this delay or the flow is invisible.
**Set delays to match typical production latency** (300-1500ms for HTTP, 200-500ms for SDK methods). Faster than that = invisible loading; slower = stories feel sluggish.
## Router-as-console-logger — making navigation demoable
The classic problem: a story has a button labeled "Go to dashboard." In the real app, this routes; in Storybook with a no-op router, clicking it does nothing — which looks broken and breaks designer trust.
Pattern: stub the router methods to **log what would happen** instead of being silent no-ops:
```tsx
// .storybook/mocks/<router>.tsx
export const router = {
visit: (url, options) => {
console.log('[Storybook] router.visit:', url, options);
},
replace: (options) => {
console.log('[Storybook] router.replace:', options);
},
get: (url, data, options) => {
console.log('[Storybook] router.get:', url, data, options);
},
// ... post, put, patch, delete, on, off, ...
};
```
The DevTools console becomes the "what would have happened" view. Designers reviewing a flow can confirm "yes, clicking sign-in should navigate to /dashboard" without leaving Storybook.
Same applies to analytics SDKs (Plausible, Mixpanel, Amplitude) — stub each method to log the event name + payload. The story remains visually correct, and the would-be analytics calls are inspectable.
## Anti-patterns specific to story factories
1. **Random data in factory defaults** — see Rule 1
2. **Faker without `faker.seed(1)`** — strings change every run
3. **Factory return type is `any` or `Partial<X>`** — lose the production-type guard; type drift goes undetected
4. **Factories that import the real database / API** — see Rule 3
5. **Importing factories into production component code** — bundler must exclude `src/stories/**`
6. **Per-story `parameters.msw.handlers` duplicating shared handlers** — lift to `preview.tsx` defaults
7. **Defining the same factory in two story files** — extract once the third caller appears; don't wait longer
8. **Hardcoded mock data inline when 3+ stories share a shape** — same rule from the other direction
## Verification record
Rewritten 2026-05-27 — absorbed production lessons (composition, named instances, project-type imports) into project-agnostic guidance; added data-layer adapter patterns for TanStack Query / Inertia / Apollo / SWR / RTK Query. Cut textbook `makeFactory`/`fishery`/`faker` boilerplate that AI knows from training.
references/test-wiring.md
# Headless test wiring — make stories an agent-runnable gate
Load this reference when the task is "make the stories testable by an agent / in CI", "run the
stories headless", "get a machine-readable a11y list", or after authoring flow/interaction
stories that carry a `play`. The Lint gate (`validate-stories.sh`) checks story *shape*; this
wires the *runtime* — interaction + a11y — into two CLI commands an agent can run and parse.
> **Why this earns its keep (field-verified).** Two commands caught real bugs a static check
> never would: `test:storybook` surfaced a headless render crash (`createElement … data:image/svg+xml
> … not a valid name`) and a mislabeled button; `test:storybook:a11y` produced a concrete,
> actionable violation list. The MCP `run-story-tests` tool is great in-session, but the **CLI**
> wiring is what gives an agent a pass/fail it can act on in CI or a fresh shell.
## What this is (and what it is NOT)
- **IS:** Storybook's Vitest browser-mode runner — every story runs as a test (smoke + any `play`),
with `addon-a11y` able to fail on violations. One config, two npm scripts.
- **IS NOT:** a replacement for Chromatic/visual regression (that's `ds-test-setup`) or for the
Lint gate. It's the *interaction + a11y* layer between them.
## Bridge first — confirm the current setup against Storybook docs
Vitest/runner versions move fast. **Before writing config, fetch the live setup** rather than
trusting this file's snapshot (MCP `get-documentation` for "test runner" / "vitest addon", or
WebFetch `storybook.js.org/docs/writing-tests`). Storybook's `init` already adds `addon-vitest` on
10.4 — check whether the project is already wired before adding anything:
```bash
grep -q '@storybook/addon-vitest' package.json && echo "VITEST_ADDON_PRESENT" || echo "ADD_IT"
test -f vitest.config.ts -o -f vitest.workspace.ts && echo "VITEST_CONFIG_PRESENT" || echo "NO_VITEST_CONFIG"
```
## The wiring (verify shape against docs before pasting)
`vitest.config.ts` — the `storybookTest` plugin + a Playwright-backed browser provider:
```ts
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
export default defineConfig({
plugins: [storybookTest({ configDir: '.storybook' })],
test: {
browser: {
enabled: true,
provider: 'playwright', // `npx playwright install` once
headless: true,
instances: [{ browser: 'chromium' }],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
});
```
`.storybook/vitest.setup.ts` — apply project annotations + gate a11y by env (see toggle below):
```ts
import { beforeAll } from 'vitest';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as preview from './preview';
const project = setProjectAnnotations([preview.default]);
beforeAll(project.beforeAll);
```
`package.json` scripts — the two agent-runnable commands:
```jsonc
{
"scripts": {
// interaction + smoke: every story renders, every play runs
"test:storybook": "vitest --project=storybook --run",
// a11y as failures: flip addon-a11y from 'todo' to 'error' for this run
"test:storybook:a11y": "STORYBOOK_A11Y=error vitest --project=storybook --run"
}
}
```
## The verify loop — one batch, then iterate (adopted from `storybook ai setup` Step 7)
When you've just authored a batch of stories, **how** you run them the first time matters. Storybook's own setup prompt encodes a tag-based honesty protocol — adopt it verbatim:
1. **First run is the whole batch, together.** `npx vitest --project storybook run` over *all* new stories at once — **no single-file runs before the batch**. Per-file runs hide cross-story interference (a leaked portal root, an unreset `MockDate`, a shared MSW handler) that only surfaces when stories run in one session. The batch is the real signal.
2. **Tag every freshly-authored file `['ai-generated', 'needs-work']` up front.** `needs-work` is a claim of *unverified*, not *broken*.
3. **Strip `'needs-work'` only from files vitest confirms green** in that batch run. A file that passed earned it; a file that didn't keeps the tag.
4. **Cap retries at ~5 per failing file.** If a story still fails after ~5 focused fix attempts, **leave `'needs-work'` on it** and surface it to the human — don't loop forever or quietly delete the story. The lingering tag is the honest hand-off: it tells the user exactly which stories to look at.
> This is the inverse of tag-as-noise (`anti-patterns.md` #32): `needs-work` is legitimate *because it's transient and targeted* — it names unverified work and gets removed the moment a story goes green. `ai-generated` on 100% of a stable suite is the noise; `needs-work` on the 2 files that still fail is signal.
The `'todo' ↔ 'error'` a11y split below composes with this: run `test:storybook` for the interaction batch, then `test:storybook:a11y` for the accessibility pass.
## The a11y `'todo' ↔ 'error'` toggle
`addon-a11y` has three test modes: `'off'` (skip), `'todo'` (report, don't fail CI), `'error'`
(fail CI on any violation). Default to **`'todo'`** so a backlog doesn't gridlock CI; let the
`test:storybook:a11y` script opt into `'error'`. Read the env in `preview.ts`:
```ts
// .storybook/preview.ts
const a11yMode = process.env.STORYBOOK_A11Y === 'error' ? 'error' : 'todo';
export default {
parameters: { a11y: { test: a11yMode } },
// ...
};
```
This is the agent loop: run `test:storybook:a11y`, read the violation list, log each to the ledger
(many are **log-only** — see below), fix the ones in scope, re-run.
## `play` = demo vs test (keep them separate)
`play` is for **assertions**, not for browsing. Conflating them produces a confusing auto-running
demo. Split per flow (full pattern in `references/flow-capture.md`):
- **`Walk-through`** — no `play`. For humans clicking through. Tag `['flow']`.
- **`Flow test`** — the `play` that drives + asserts. Hidden from the sidebar via the lifecycle
tags so it still runs headless but doesn't clutter browsing: `!dev` (hide in dev sidebar),
`!test` only if you want it skipped (you don't — for the test story, keep it test-eligible),
`!autodocs`. The `!manifest` tag drops a story from the static manifest entirely.
This separation came from Storybook's official AI doc — bridge to it (`storybook.js.org/docs/writing-tests`)
rather than hardcoding tag semantics that may shift.
## Log-only findings
Headless runs surface real production issues (unassociated `<label>`s, sub-AA contrast, render
crashes). Most are **not** in scope for a story-authoring pass — the point of storying them is to
**reflect prod as it actually is**. Log each to `.storybook/audit/findings.md` under a `LOG-ONLY`
marker and keep moving; don't fix code mid-authoring unless asked. (See SKILL.md "log-only finding".)
## When to hand off
- Visual regression / Chromatic snapshots → `ds-test-setup` (this reference stops at interaction + a11y).
- Detailed axe rule config beyond addon-a11y defaults → `.storybook/preview.ts` `a11y.config`.
references/validate-workflow.md
# Validate Workflow — runnable check before a story ships
The recurring question this reference answers: **"is this story actually conformant, or did the agent just declare done?"** Anti-patterns are prose; this is the runbook that turns them into a deterministic pass/fail plus a sub-agent prompt for the judgment-needed checks.
Two paths, used together:
- **Bash validator (`scripts/validate-stories.sh`)** — deterministic checks for CI and for the agent to call before declaring a story done. Returns `PASS`/`FAIL` per check, exits non-zero if anything failed.
- **Sub-agent review prompt** — for judgment-needed checks the validator can't make (weak coverage, missing JTBD prose, mega-story smell, designer-grade concerns). Dispatched as a single Agent call returning a fixed-shape report.
## When to load this reference
- The user said "validate my story" / "review my story" / "is this story good"
- The agent has just written a story file and is about to declare done
- CI is being wired (the bash script is the entrypoint)
- A skill-internal pre-merge gate (the validator runs over the diff)
## What the validator actually checks
The 13 checks below (plus a project-level CssCheck tally) are the ones that catch the highest-leverage anti-patterns + the SB10 import gaps verified in earlier runs. Each is binary (PASS/FAIL/WARN); judgment is reserved for the sub-agent path.
### Group A — SB10 imports + API (4 checks, deterministic)
| # | Check | Pattern |
|---|---|---|
| 1 | Imports `@storybook/react-vite`, not `@storybook/react` | `grep -E "^import .* from ['\"]@storybook/react['\"]"` → must be empty |
| 2 | Imports `storybook/test`, not `@storybook/test` | `grep -E "from ['\"]@storybook/test['\"]"` → must be empty |
| 3 | Uses `satisfies Meta<typeof X>`, not `const meta: Meta<typeof X> =` (type annotation) | `grep -E "^const meta(:|\s*=).*Meta<.*>\s*=\s*\{" \| grep -v "satisfies"` → must be empty (the annotation form widens type and breaks `args` inference in `play`) |
| 4 | `useArgs` is from `storybook/preview-api`, not from `react` | If file uses `useArgs`, must `grep "from ['\"]storybook/preview-api['\"]"` |
### Group B — Anti-pattern grep (4 checks, deterministic)
| # | Check | Pattern |
|---|---|---|
| 5 | No CSF2 (`storiesOf(`, `.story` properties) | `grep -E "storiesOf\(\|\.story\s*=\s*\{"` → must be empty |
| 6 | No dead SB10 imports (`@storybook/addon-essentials`, `@storybook/blocks`) | `grep -E "from ['\"](@storybook/addon-essentials\|@storybook/blocks)['\"]"` → must be empty |
| 7 | No inline hex in story `render` body | `grep -E "render:.*\{[^}]*#[0-9a-fA-F]{3,8}"` → must be empty (multi-line check via `pcregrep -M` if available; see script) |
| 8 | No `disabled: true` inside `parameters.pseudo` | `grep -E "pseudo:\s*\{[^}]*disabled"` → must be empty (`disabled` is structural, see anti-pattern 19) |
### Group C — Coverage signal (2 checks, deterministic)
| # | Check | Pattern |
|---|---|---|
| 9 | `parameters.layout` is set on meta | `grep -E "layout:\s*['\"](centered\|fullscreen\|padded)['\"]"` → must match at least once |
| 10 | If any callback prop is in props (onClick / onChange / onSubmit), at least one story uses `fn()` in `args` | If component uses callback props (heuristic: file matches `on[A-Z]`), then `grep "fn()"` must match somewhere in the file |
### Group D — Project conventions (2 checks, optional, only if `.storybook/preview.ts` declares a `storySort.order`)
| # | Check | Pattern |
|---|---|---|
| 11 | Title prefix matches one of the declared sort order roots | Parse `storySort.order` from `.storybook/preview.ts`; `grep -oE "title:\s*['\"][^'\"/]+"` must produce a value present in the order list |
| 12 | Labs stories include `'!autodocs'` and `'!test'` tags | If title starts with one of the declared Labs section names (`Labs/`, `Sandbox/`, etc.), `grep "!autodocs"` and `grep "!test"` must both match |
### Group E — Story-as-proof (1 check + project tally, from `storybook ai setup`)
| # | Check | Pattern |
|---|---|---|
| 13 | `play` earns its place — WARN (not FAIL) on a no-op play whose only assertion is `toBeVisible`/`toBeInTheDocument` with no interaction/async/portal/computed-style signal | If file has `play:`, `grep` for `userEvent\|fireEvent\|findBy\|waitFor\|aria-pressed\|getComputedStyle\|ownerDocument\|toHaveBeenCalled` — absent + only `toBeVisible`/`toBeInTheDocument` → WARN (anti-pattern 34) |
| ★ | **Project tally (multi-file scans only):** exactly ONE story across the project asserts `getComputedStyle` (the `CssCheck` proof, anti-pattern 33) | Count files matching `getComputedStyle`; WARN on 0 (no CSS-loaded proof) or >1 (redundant probes) |
### What the validator does NOT check (judgment — use the sub-agent path)
- **Mega-story smell** — one story with knobs for every prop instead of named stories
- **State coverage** — did Button get all 8 expected states?
- **Missing JTBD prose** — production-grade stories should have `parameters.docs.description.story` with a "Jobs to be Done" or design rationale
- **Weak `play` assertions** — `play` runs interactions but asserts nothing meaningful
- **Hardcoded mock when 3+ stories share a shape** — factory candidate missed
- **Title taxonomy fit** — does this title actually belong where it's placed
- **Lifecycle tag missing** — is this clearly a V2/experimental story that should be tagged
These are the sub-agent review prompt's job.
## How to run the bash validator
```bash
# Single file
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh src/stories/Button.stories.tsx
# Glob (everything under src/stories/)
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh 'src/stories/**/*.stories.tsx'
# Current diff (staged or unstaged)
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh --diff
# Strict mode — also runs tsc + eslint + vitest on the file(s)
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh --strict src/stories/Button.stories.tsx
```
Output shape:
```
src/stories/Button.stories.tsx
[PASS] 01 imports @storybook/react-vite
[PASS] 02 imports storybook/test
[FAIL] 03 satisfies pattern — found annotation form on line 12
[PASS] 04 useArgs source
[PASS] 05 no CSF2
[PASS] 06 no dead SB10 imports
[PASS] 07 no inline hex
[PASS] 08 no disabled in pseudo
[PASS] 09 parameters.layout set
[PASS] 10 fn() used for callback args
[SKIP] 11 storySort match (no order declared in preview.ts)
[SKIP] 12 Labs tag combo (not a Labs story)
11 PASS, 1 FAIL, 2 SKIP — exit 1
```
The agent calls this **after writing a story, before declaring done**. If anything fails, the agent fixes and re-runs. The script is also CI-shaped: non-zero exit on any FAIL.
## The sub-agent review prompt
For the judgment-needed checks, dispatch one Agent call (subagent_type=general-purpose or compound-engineering:ce-code-review) with this prompt template. Keep the prompt self-contained — the sub-agent won't see this conversation.
```
You are reviewing a Storybook CSF3 story file at PATH=<abs path>.
Load the storybook-workbench skill from ~/.claude/skills/storybook-workbench/ for context.
Specifically read references/anti-patterns.md and references/composition-patterns.md.
Report ONLY judgment-needed concerns from this list (deterministic checks are
handled by the bash validator separately):
1. Mega-story smell — one story with every prop as a knob instead of named stories
2. State coverage gap — Button missing Hover/Focus/Disabled/Loading variants;
Input missing Error/Filled; Modal missing Loading. Use the coverage tables in
SKILL.md Step 2.
3. Missing JTBD prose — is this a production story that needs
parameters.docs.description.story explaining what this state means and why?
4. Weak play assertion — play function exists but asserts nothing meaningful
(e.g., clicks a button but never expects(args.onClick).toHaveBeenCalled())
5. Factory candidate — same data shape mocked 3+ times inline; should be a factory
6. Title taxonomy fit — does this title belong where it's placed; is it consistent
with sibling files
7. Lifecycle gap — is this clearly an experimental/V2/deprecated story that should
carry a lifecycle tag (see references/lifecycle-tags.md)
For each concern, output exactly:
- File: <path>:<line>
- Issue: <one sentence>
- Suggest: <one sentence fix>
If no concerns, output exactly: "OK — no judgment concerns."
Do not restate what's already in the file. Do not rewrite the file. Report only.
Max 200 words.
```
The agent reads the sub-agent report, fixes anything actionable, and re-runs the bash validator. Two passes max — if the second pass still flags something, surface it to the user rather than looping.
## Integration with existing skill layers
| Layer | When to invoke validate-workflow |
|---|---|
| Author | After every story write, before declaring done |
| Labs | Same — Labs stories should also pass deterministic checks (the script auto-skips Group D #12 unless title prefix matches) |
| Galleries | After tagging stories for a new gallery — verify tag spelling consistency (`'empty-state'` vs `'emptyState'`) |
| Extract | Run over the full output of an extraction session — quickly surfaces which extracted files need follow-up |
| Iterate + Propagate | Before graduation (Labs → Components) — every check must PASS |
## What this reference deliberately does not cover
- **Visual regression** — that's `ds-test-setup` skill territory (Chromatic / Lost Pixel / Playwright snapshots). The validator's job is conformance, not pixel diffing.
- **Axe rule policies** — `addon-a11y` is configured at install; project-specific axe rule customization is out of scope for this validator. The validator can call `run-story-tests` with `a11y: true` if MCP is wired.
- **TypeScript type narrowing inside `play`** — `tsc --noEmit` (strict mode) catches this in `--strict` runs. The validator doesn't try to encode SB-specific type rules.
- **Performance** — story file size, decorator depth, bundle impact. Out of scope here.
## Verification record
Validator built from:
- Anti-patterns #1–18 in `references/anti-patterns.md` (the deterministic ones)
- The 4 critical SB10 patterns in `references/without-mcp.md`
- Coverage tables in `SKILL.md` Step 2 (used by the sub-agent prompt)
- production survey: 191 stories scanned with prototype version of this script — caught 12 real anti-pattern matches across 8 files (mostly missing `parameters.layout` and dead `@storybook/blocks` imports in older files).
references/with-mcp.md
# With-MCP Workflow (Storybook 10.3+ on Vite)
When `@storybook/addon-mcp` is installed AND wired to your agent (`.mcp.json` lists it, agent session has the tools loaded), defer to MCP for everything mechanical. The skill's job here is to teach the *call sequence* and the *judgment* MCP can't do.
## The 6 tools and when to call them
| Tool | When to call | What it gives you |
|---|---|---|
| `list-all-documentation` | Once at start of any task — discovery | Full component + story index. Use returned IDs in every subsequent call. |
| `get-documentation` | After picking a target component | Description + first 3 stories with source + remaining stories listed + full TS props with JSDoc |
| `get-documentation-for-story` | Need a story not in the first 3 | Full story source + linked MDX. Inputs: `componentId` AND `storyName` (two args). |
| `get-storybook-story-instructions` | Before writing your first story this session | ~7,452 chars of CSF3 + coverage + a11y conventions. **Treat as system-prompt-level guidance.** |
| `preview-stories` | After every story change | Preview URL (or embedded iframe). Always include the URL in the user-facing response. |
| `run-story-tests` | After story passes preview | Vitest pass/fail per story + a11y violations. Pass `a11y: true` to run accessibility checks in the same batch. |
## Standard sequence per task
```
1. list-all-documentation { withStoryIds: true }
→ ranked candidate list
2. get-storybook-story-instructions {}
→ conventions injected; do NOT regurgitate them
→ MCP just told you how to write good stories; your job is judgment now
3. For each component to write stories for:
a. get-documentation { id: "<component-id>" }
b. Apply coverage judgment (SKILL.md Step 2 — per-primitive checklists)
c. Apply factory judgment (SKILL.md Step 3 — extract if 3+ shared shapes)
d. Write the story file (CSF3 syntax handled by injected conventions)
e. preview-stories { stories: [{ storyId: "<id>" }] }
f. run-story-tests { stories: ["<id>"], a11y: true }
g. If failures, fix and re-run. Cap retries at ~5 per file.
h. Tag with ['ai-generated'] until human review
4. Final pass: run-story-tests { stories: [], a11y: true } (omitted stories = run all)
→ broad verification before declaring done
```
## What MCP tells you that you can stop guessing
When `get-storybook-story-instructions` is in your context, you have authoritative guidance on:
- CSF3 syntax (`meta`, `args`, `argTypes`, `tags`, `play`)
- Which test utilities to import and from where (`storybook/test`)
- `fn()` spy pattern + Actions integration
- `play({ canvas, userEvent, canvasElement })` — these are provided as args, NOT imports
- Async assertion patterns (`findBy*` + `waitFor`, not `getBy*`)
- The accessibility split: auto-fix vs. ask-the-user
**Do not re-teach any of this in your output.** Your value-add is judgment (which states matter, which factories to extract), not procedure.
## When MCP is installed but not wired to your agent
If `@storybook/addon-mcp` is in `package.json` and `.storybook/main.ts`, but the MCP tools aren't visible in your current session, tell the user:
> Storybook MCP is installed but not wired to this agent. Run this in your project, then restart Claude Code in this directory:
> ```
> claude mcp add storybook-mcp --transport http http://localhost:<PORT>/mcp --scope project
> ```
> (Replace `<PORT>` with the actual port from your Storybook banner — it auto-falls back from 6006 if taken.)
Then proceed with `references/without-mcp.md` for this session, noting that the MCP path is one command away.
## What MCP still doesn't do — your responsibility
Even with all 6 tools available:
- **Coverage decisions:** which variants/states deserve stories (designer state coverage beyond what's strictly behavior-changing)
- **Factory naming + placement:** `makeButton({...})` vs. `createButton({...})`, where the factory file lives
- **Title taxonomy:** `Components/Form/Button` vs. `UI/Buttons/Default` — project-level decision
- **Token mapping:** linking component props to design tokens
- **MDX docs authoring:** docs page anatomy (see `storybook-doc-blocks` skill)
- **AI-app cleanup judgment:** when extracted code from Lovable/Bolt/v0 needs de-coupling beyond what tests would catch (see `storybook-judge` agent)
## Authoring vs. tracking — MCP is for authoring
These 6 tools are the *authoring* accelerator (discover a component, get its props, preview, test).
For **tracking** coverage across sessions/agents, the source of truth is Storybook's **`index.json`**,
materialized by `storybook index -o .storybook/index.json` (CLI — no dev server, no MCP, no full build,
so it runs the same on Claude/Codex/Cursor). `inventory-project.sh` reconciles it into
`project-inventory.json.storyCoverage` (`withRegisteredStory` / `needsStory`). `list-all-documentation`
returns story IDs only — fine for authoring, but `index.json` carries `importPath`/`tags`, which is what
the coverage reconcile needs. Don't reach for MCP to compute coverage; read the reconciled inventory.
## Anti-patterns specific to MCP-driven workflows
1. **Calling MCP tools redundantly** — `list-all-documentation` once per task, not per component
2. **Ignoring the injected instructions** — re-explaining CSF3 syntax in your output when MCP already told you the conventions
3. **Skipping `preview-stories`** — the injected workflow guide explicitly requires you to include the preview URL in user-facing responses
4. **Calling `run-story-tests` without `a11y: true`** — separate a11y runs cost two passes; one combined pass is cheaper
5. **Inventing component / story IDs** — only use IDs returned by `list-all-documentation`. If a name isn't in the index, the component or story doesn't exist yet.
## Verification record
Live-verified against Storybook 10.4.1 + addon-mcp 0.6.0 + Vite 8 + React 19 on 2026-05-26.
Full report: `docs/publishing/storybook-mcp-verification.md`.
references/without-mcp.md
# Without-MCP Workflow — Manual CSF3 for Storybook 9 / 10
When Storybook MCP is unavailable (Webpack project, non-React framework, Storybook < 10.3, or just not installed), the skill must teach the syntax MCP would otherwise inject. This file documents 13 patterns observed missing in one Without-MCP sub-agent verification run on 2026-05-26 (full report: Fox Brains vault `2026-05-26-without-mcp-switch-verification.md`).
**Honest scope note:** n=1 verification. Expect ~60–70% of these to be durable AI knowledge gaps across models and training cutoffs (the SB10-specific facts in §1-4 are highest-confidence). The rest are agent-and-cutoff specific — re-run the same exercise with a different model or six months later and the gap list will shift. The critical 4 are the most-bulletproof; the completeness 9 are valuable but expect drift.
## The 4 critical patterns (always teach these)
### 1. Imports — the SB10 quartet
```ts
// Story types: from the framework-specific entry
import type { Meta, StoryObj } from '@storybook/react-vite';
// (NOT @storybook/react — that's deprecated in v10)
// For Webpack5: @storybook/react-webpack5
// Test utilities: from the storybook package itself
import { userEvent, within, waitFor, expect, fn, spyOn } from 'storybook/test';
// (NOT @storybook/test — that prefix was retired in v8+)
// Controlled-component sync: from preview-api
import { useArgs } from 'storybook/preview-api';
// Preview types (only in .storybook/preview.tsx):
import type { Preview } from '@storybook/react-vite';
```
The agent will guess `@storybook/react` and `@storybook/test`. Both are wrong in v10. Surface these imports early and verbatim.
### 2. The `fn()` spy pattern — `args` over `argTypes.action`
```ts
// ✓ Modern (preferred): fn() auto-logs to Actions AND is assertable in play
const meta = {
component: Button,
args: {
onClick: fn(), // ← spy created here, used everywhere
},
} satisfies Meta<typeof Button>;
export const Clicked: StoryObj<typeof meta> = {
play: async ({ args, canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
```
```ts
// ✗ Legacy (avoid in new stories): argTypes.action
const meta = {
component: Button,
argTypes: {
onClick: { action: 'clicked' }, // ← only logs to Actions, not assertable
},
};
```
Don't use both — pick `args: { onClick: fn() }`.
### 3. `satisfies Meta<typeof X>` (NOT annotation) for typed `args` in play
```ts
// ✓ Use satisfies: StoryObj<typeof meta> infers args correctly inside play
const meta = {
component: Switch,
args: { checked: false, onChange: fn() },
} satisfies Meta<typeof Switch>;
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ this enables typed args in play
export default meta;
type Story = StoryObj<typeof meta>;
export const Checked: Story = {
args: { checked: true },
play: async ({ args }) => {
// ↑↑↑↑↑↑ args is fully typed as Partial<SwitchProps>
},
};
```
```ts
// ✗ Type annotation widens the type and breaks args inference in play
const meta: Meta<typeof Switch> = {
component: Switch,
};
```
### 4. The `play` function recipe
```ts
import { within, userEvent, expect } from 'storybook/test';
export const FilledForm: StoryObj<typeof meta> = {
play: async ({ canvas, userEvent, args }) => {
// canvas, userEvent, canvasElement come from play args
// do NOT re-import userEvent or write const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText('email'), 'a@b.com', { delay: 50 });
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Prefer findBy* (retries) over getBy* (throws immediately) for async assertions
await expect(await canvas.findByText(/welcome/i)).toBeVisible();
// For portals (modals, tooltips), query via canvasElement.ownerDocument.body:
// await within(canvasElement.ownerDocument.body).findByRole('dialog');
},
};
```
**Rule:** `canvas`, `userEvent`, `canvasElement` are play arguments — destructure them, don't import. The only thing you sometimes import is `within` for portal queries, and `expect` for assertions.
**ARIA roles for queries:** custom toggles render as `<input role="switch">`, not `role="checkbox"`. Use `canvas.getByRole('switch')` for toggle components. Modals use `role="dialog"` (or `'alertdialog'` for destructive confirmations). Tabs use `role="tab"` + `role="tabpanel"`. Default to role-based queries (accessibility-first); fall back to `getByLabelText` only when role doesn't apply.
## The 9 completeness patterns
### 5. Controlled components — `useArgs` for two-way sync
When a component is controlled (e.g., `<Switch checked={x} onChange={setX} />`), Storybook's Controls panel won't reflect user interaction unless you bridge it:
```ts
import { useArgs } from 'storybook/preview-api';
const meta = {
component: Switch,
args: { checked: false, onChange: fn() },
render: function Render(args) {
const [{ checked }, updateArgs] = useArgs();
return (
<Switch
{...args}
checked={checked}
onChange={(next) => {
args.onChange?.(next);
updateArgs({ checked: next }); // ← sync back to Controls
}}
/>
);
},
} satisfies Meta<typeof Switch>;
```
### 6. Tags reference (SB10 recognized values)
| Tag | Effect |
|---|---|
| `'autodocs'` | Generates a Docs page for this story group |
| `'!autodocs'` | Excludes from autodocs (override at story level) |
| `'!dev'` | Hides from the sidebar in dev mode (keeps in test) |
| `'!test'` | Skips in test runner |
| `'ai-generated'` | Project convention — flag for human review |
| `'experimental'`, `'wip'` | Project conventions — use freely |
Story-level tags merge with meta-level. Use the `!` prefix to subtract a meta-level tag at the story level.
### 7. Layout reference
```ts
parameters: { layout: 'centered' } // for small interactive components (default for UI primitives)
parameters: { layout: 'padded' } // for components needing breathing room (default if omitted)
parameters: { layout: 'fullscreen' } // for pages, full-width layouts, top-level shells
```
These are the only three built-ins. Custom layouts require decorators wrapping the story.
### 8. Title-and-grouping conventions
`title: "Group/Sub/Component"` builds the sidebar hierarchy.
Recommended for new design-system projects:
- `Foundations/*` — color, typography, spacing, icons (MDX docs only)
- `Components/{Domain}/{Name}` — atomic + molecular components (incl. composed patterns like FormGroup)
- `Pages/{audience}/{Name}` — full page previews + composed page layouts
- `Flows/{Name}` — the journey layer (App Map + user journeys, from sb-flows)
Alternative for marketing sites:
- `Sections/*` — hero, features, pricing, testimonials
- `Blocks/*` — reusable composed blocks
- `Pages/*` — assembled landing pages
If `.storybook/preview.ts` already has a `parameters.options.storySort`, match its top-level order.
### 9. File placement decision
Two valid patterns — pick one project-wide:
**(a) Flat demo dir** (matches Storybook CLI scaffold):
```
src/stories/
├── Button.tsx
├── Button.stories.tsx
└── button.css
```
**(b) Colocated** (preferred for production design systems):
```
src/components/Button/
├── Button.tsx
├── Button.stories.tsx
├── Button.test.tsx
├── button.css
└── index.ts
```
Tradeoffs: flat is faster to scan as a catalog; colocated is faster to refactor as a library. Don't mix.
### 10. Story registration — when do I edit `main.ts`?
Default `stories` glob in `.storybook/main.ts`:
```ts
stories: [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
]
```
You don't need to register individual stories — auto-discovered by glob. Edit `main.ts` only if:
- Your stories live outside `src/` (e.g., in a `packages/<pkg>/src` monorepo layout)
- You're using a different file extension
- You want to limit which stories load (e.g., `**/*.story.tsx` instead of `**/*.stories.tsx`)
### 11. Verification ladder without dev server
Run in this order — each layer catches different errors:
```bash
# 1. Type safety (catches imports, prop mismatches, story shape)
npx tsc --noEmit
# 2. Story-specific lint (catches CSF mistakes, hierarchy-separator issues)
npx eslint --ext .tsx,.ts src/stories/ # requires eslint-plugin-storybook
# 3. Vitest runs play functions headlessly via Playwright (catches runtime, assertions, a11y)
npx vitest --project storybook run path/to/Foo.stories.tsx
# 4. Last resort — start the dev server for visual check
npm run storybook
```
Cap retries on any single file at ~5. If a story keeps failing after 5 attempts, leave `'needs-work'` tag and move on.
### 12. Scaffold quirks to know
The Storybook CLI scaffold uses these patterns — keep them or replace project-wide, but be consistent:
- Title prefix `Example/*` (replace with your project's prefix; `Example` is placeholder)
- Demo files live in `src/stories/` (production projects often move to `src/components/`)
- Three demo components (Button, Header, Page) — delete after writing real stories
- `tsconfig.app.json` excludes `node_modules` only — you may want to also exclude `dist`, `coverage`
### 13. `argTypes` — wire a real Controls panel (not optional)
A component story's **Controls panel is a deliverable**, not a bonus: it's how a reviewer explores
props without editing code, and it powers the autodocs ArgTypes table. The trap: **the Storybook
react-vite default is `react-docgen` (fast), which does NOT infer TypeScript union types into select
controls.** A `variant?: 'primary' | 'secondary'` prop shows up as a bare text box, and props you
never pass in `args` may not appear at all. So for any component with enum/union props, declare
`argTypes` explicitly:
```ts
const meta = {
component: Button,
tags: ['autodocs'],
args: { children: 'Get started', variant: 'primary' },
argTypes: {
// Unions → pickers (react-docgen won't do this for you). Keep options in sync with the prop type.
variant: { control: 'select', options: VARIANTS, table: { category: 'Appearance' } },
size: { control: 'inline-radio', options: ['sm', 'md', 'lg'], table: { category: 'Appearance' } },
// Flags → toggles · copy → text · bounded numbers → number with min/max.
isLoading: { control: 'boolean', table: { category: 'State' } },
children: { control: 'text', table: { category: 'Content' } },
// Hide what a panel can't set: styling escape hatches, refs, component/function props, data objects.
className: { table: { disable: true } },
icon: { control: false, table: { category: 'Content' } },
onChange: { control: false, table: { category: 'Events' } },
},
} satisfies Meta<typeof Button>;
```
Rules:
- **Every union/enum prop gets `control: 'select'`** (or `'inline-radio'` for ≤4 options) **+ `options`.**
Don't rely on inference under the react-vite default — it won't happen.
- **Group with `table.category`** (Content / Appearance / State / Validation / Events) so the panel and
the ArgTypes table stay legible once a component has >5 props.
- **Hide props a panel can't drive:** `className` and other styling escape hatches, refs,
`LucideIcon`/component props, callbacks, and `{...}`/`[...]` data (`options`, `characterCount`) →
`control: false` or `table: { disable: true }`. Exercise those from dedicated stories instead.
- **Render-only showcase stories ignore args** — the `Variants`/`States` grids that hardcode their own
props via `render`. Their Controls panel is inert and misleading, so disable it:
`parameters: { controls: { disable: true } }`.
(Heavier alternative: set `typescript.reactDocgen: 'react-docgen-typescript'` in `main.ts` to
auto-infer unions + JSDoc — but it's slower and, for props extending DOM attributes, floods the table
with inherited HTML attrs unless you add a `propFilter`. Explicit `argTypes` is the predictable,
per-component choice this skill defaults to.)
### 14. Three more story shapes the basic args-story doesn't cover
Common in real codebases, easy to miss if you only know the args + render forms:
- **Stateful preview wrapper** — when you want the component *playable* in the canvas but don't need Controls-panel sync (so `useArgs` is overkill), wrap it in a tiny local component that holds the state:
```tsx
function Interactive() {
const [open, setOpen] = useState(false);
return <><Button onClick={() => setOpen(true)}>Open</Button><Modal open={open} onClose={() => setOpen(false)} /></>;
}
export const Playable: StoryObj = { render: () => <Interactive /> };
```
Use this over `useArgs` (#5) when the interaction is multi-step and you don't care about reflecting it in Controls. (`useArgs` is for two-way Controls sync; this is for a self-driving demo.)
- **Component-less meta** — showcase / overview stories that compose *several* components have no single subject. Omit `component:` entirely:
```tsx
const meta = { title: 'Overview/Notifications', tags: ['autodocs'] } satisfies Meta; // no `component`
```
`component:` is not mandatory. Forcing a dummy one just to satisfy a template is wrong.
- **Per-story decorator override** — a single story can supply its own `decorators: [...]` that wrap *in addition to* (innermost) the meta-level decorators. Use when one story needs a narrower frame (mobile shell, different provider) than its siblings — don't fork the whole meta.
## Refused anti-patterns (refuse these even Without MCP)
1. CSF2 (`storiesOf`, function-with-`.story` properties) — convert via `npx storybook@latest migrate csf-2-to-3`
2. Imports from `@storybook/addon-essentials` or `@storybook/blocks` — both dead in Storybook 10
3. `getBy*` inside `play` for async assertions — use `findBy*` + `waitFor` instead
4. Inline mock data in 3+ stories when they share a shape — extract to factory (see `factory-patterns.md`)
5. Mega-stories with every-prop knobs — write named stories per state instead
## Verification ladder summary
```
WITHOUT MCP, when you finish writing a story:
tsc --noEmit ← imports + types
eslint storybook plugin ← story shape
vitest --project storybook run ← play functions execute
storybook dev (manual) ← visual sanity check
```
Stop at the first failure that points to your code. Don't bother starting the dev server until the static checks pass.
## Verification record
Live-verified against Storybook 10.4.1 + Vite 8 + React 19 on 2026-05-26 — see Fox Brains vault `2026-05-26-without-mcp-switch-verification.md` for the 13 gaps this file closes.
scripts/check-story-ready.sh
#!/usr/bin/env bash
# check-story-ready.sh — "give me confidence" gate for a Storybook story.
#
# Two phases in one command:
# 1. Setup readiness (advisory) — is the project ready to author stories well?
# • Storybook installed (.storybook/ present)
# • discovery chain run (project-inventory / flows / component-states /
# prop-shapes JSON under .storybook/) — so states + factories aren't guessed
# • dominant design system known (from project-inventory.json)
# 2. Story conformance (the gate) — delegates to the sibling validate-stories.sh
# (13 deterministic checks: SB10 imports, satisfies, no CSF2, layout,
# fn() callbacks, title/sort organization, Explore/Labs tag combo,
# play-earns-its-place, + a project-level CssCheck tally, …).
#
# Readiness is ADVISORY (warns, never fails) unless --require-extraction is set.
# The conformance phase is the gate: its exit code is this script's exit code.
#
# Usage:
# check-story-ready.sh <file.stories.tsx> # readiness + conformance
# check-story-ready.sh --strict <file> # also tsc/eslint/vitest
# check-story-ready.sh --require-extraction <file> # FAIL if discovery JSONs missing
# check-story-ready.sh --diff # changed stories (git)
#
# Exit codes: 0 ready+conformant · 1 conformance failed (or missing extraction
# under --require-extraction) · 2 bad invocation
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VALIDATE="$HERE/validate-stories.sh"
if [[ ! -f "$VALIDATE" ]]; then
SHARED_VALIDATE="$(cd "$HERE/../../.." && pwd)/shared/scripts/validate-stories.sh"
[[ -f "$SHARED_VALIDATE" ]] && VALIDATE="$SHARED_VALIDATE"
fi
[[ -f "$VALIDATE" ]] || { echo "ERROR: validate-stories.sh not found next to this script ($VALIDATE)" >&2; exit 2; }
REQUIRE_EXTRACTION=false
PASSTHROUGH=()
for arg in "$@"; do
case "$arg" in
--require-extraction) REQUIRE_EXTRACTION=true ;;
*) PASSTHROUGH+=("$arg") ;;
esac
done
[[ ${#PASSTHROUGH[@]} -eq 0 ]] && { echo "ERROR: pass a story file, a quoted glob, or --diff." >&2; exit 2; }
# ── Phase 1: Setup readiness (advisory) ─────────────────────────────────────
# Two independent axes, tracked separately:
# discovery_warn — is the ground truth the agent authors AGAINST present? (.storybook/ + JSONs)
# This is what CONFIDENT means and is what this gate uniquely asserts.
# install_warn — has `npm install` finished so dev/build will run? An ENVIRONMENT concern,
# orthogonal to story correctness. Surfaced as a warning, but it does NOT gate
# CONFIDENT — discovery + conformance are legitimately ready before install
# completes (clean checkout / CI / eval, where node_modules is absent).
echo "━━ Setup readiness ━━"
SB_DIR=".storybook"
discovery_warn=0
install_warn=0
if [[ -d "$SB_DIR" ]]; then
echo " [ok] .storybook/ present"
else
echo " [warn] no .storybook/ — run install-wizard (NO_STORYBOOK)"; discovery_warn=$((discovery_warn+1))
fi
# .storybook/main.ts existing ≠ Storybook installed: `storybook init` can edit package.json
# while the package-manager install never completes — stories then "validate" but the dev
# server and `build-storybook` both fail. Verify the binary actually resolves.
if [[ -x node_modules/.bin/storybook ]] || node -e "require.resolve('storybook')" >/dev/null 2>&1; then
echo " [ok] storybook installed (resolvable in node_modules)"
else
echo " [warn] storybook NOT installed in node_modules — main.ts may exist but dev/build will FAIL. Run your package-manager install (npm/pnpm/yarn) before authoring."
install_warn=$((install_warn+1))
fi
missing_json=()
for j in project-inventory flows component-states prop-shapes; do
if [[ -f "$SB_DIR/$j.json" ]]; then
echo " [ok] $SB_DIR/$j.json"
else
echo " [warn] $SB_DIR/$j.json missing — discovery chain not run for this signal"
missing_json+=("$j"); discovery_warn=$((discovery_warn+1))
fi
done
if [[ -f "$SB_DIR/project-inventory.json" ]] && command -v jq >/dev/null 2>&1; then
ds=$(jq -r '.designSystem.dominant // "unknown"' "$SB_DIR/project-inventory.json" 2>/dev/null)
mixed=$(jq -r '.designSystem.mixed // false' "$SB_DIR/project-inventory.json" 2>/dev/null)
echo " [info] dominant design system: $ds$( [[ "$mixed" == "true" ]] && echo ' ⚠ MIXED (project in transition)')"
fi
if [[ ${#missing_json[@]} -gt 0 ]]; then
echo ""
echo " → States/factories may be guessed rather than read from ground truth."
echo " Run the discovery chain first: inventory-project.sh → extract-flows.sh → extract-states.sh → extract-prop-shapes.sh"
if $REQUIRE_EXTRACTION; then
echo " ✗ --require-extraction set and ${#missing_json[@]} discovery JSON(s) missing." >&2
exit 1
fi
fi
# ── Phase 2: Story conformance (the gate) ───────────────────────────────────
echo ""
echo "━━ Story conformance ━━"
bash "$VALIDATE" "${PASSTHROUGH[@]}"
conformance_rc=$?
# ── Verdict ─────────────────────────────────────────────────────────────────
echo ""
if [[ $conformance_rc -eq 0 ]]; then
if [[ $discovery_warn -eq 0 ]]; then
# Discovery ground truth present + story conformant = CONFIDENT. A pending install is
# noted (dev/build won't run yet) but does NOT downgrade the verdict — story correctness
# is established independent of node_modules.
if [[ $install_warn -gt 0 ]]; then
echo "✓ CONFIDENT — discovery ground truth present + story conformant (note: storybook not yet installed — run your package-manager install before dev/build)."
else
echo "✓ CONFIDENT — setup ready + story conformant."
fi
else
echo "✓ Story conformant (with $discovery_warn discovery readiness warning(s) above)."
fi
else
echo "✗ Story conformance FAILED — fix the checks above before shipping."
fi
exit $conformance_rc
scripts/discover-runtime.py
#!/usr/bin/env python3
"""discover-runtime.py — deterministic runtime/preview discovery (native 'storybook ai setup' Step 1).
Detect what the Storybook shared preview must SUPPLY to render a page, as precomputed ground truth:
- entry : the app entry file
- providers : the provider/router tree wrapping <App> (name + import source)
- rootCss : how global CSS loads — JS imports and/or index.html <link>
- portals : createPortal targets (DOM ids the preview must create) + index.html non-root ids
- network : data-fetch libraries/hooks present → whether MSW is needed
Writes .storybook/runtime.json. Reports reality, invents nothing. The native >=12-read Glob/Grep
agent pass is reserved only for judgment a static scan can't make.
Usage:
discover-runtime.py [ROOT] [--out FILE]
"""
import json, os, re, sys
SRC_ROOTS = ("src", "app/frontend")
ENTRY_NAMES = ("main.tsx", "main.jsx", "index.tsx", "index.jsx", "main.ts", "index.ts")
CODE_EXT = (".tsx", ".jsx", ".ts", ".js")
SKIP = (".stories.tsx", ".stories.jsx", ".test.tsx", ".spec.tsx", ".d.ts")
IMPORT_RE = re.compile(r'import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s+["\']([^"\']+)["\']')
PROVIDER_TAG_RE = re.compile(r'<([A-Z]\w*(?:Provider|Router))\b|<(Provider)\b')
CSS_IMPORT_RE = re.compile(r'''import\s+["']([^"']+\.css)["']''')
HTML_LINK_RE = re.compile(r'<link[^>]+href=["\']([^"\']+\.css)["\']', re.I)
HTML_SCRIPT_RE = re.compile(r'<script[^>]+src=["\']([^"\']+\.[jt]sx?)["\']', re.I)
HTML_ID_RE = re.compile(r'\bid=["\']([^"\']+)["\']')
PORTAL_RE = re.compile(r'createPortal\s*\([^,]+,\s*document\.getElementById\(\s*["\']([^"\']+)["\']')
NET_LIBS = {"@tanstack/react-query": "react-query", "react-query": "react-query",
"swr": "swr", "axios": "axios", "@apollo/client": "apollo"}
NET_HOOK_RE = re.compile(r'\b(useQuery|useMutation|useInfiniteQuery|useSWR|useApolloClient|useLazyQuery)\b')
def read(root, rel):
try:
with open(os.path.join(root, rel), encoding="utf-8", errors="ignore") as f:
return f.read()
except (OSError, IsADirectoryError):
return ""
def find_entry(root):
html = read(root, "index.html")
m = HTML_SCRIPT_RE.search(html)
if m:
return m.group(1).lstrip("/")
for base in SRC_ROOTS:
for name in ENTRY_NAMES:
if os.path.isfile(os.path.join(root, base, name)):
return f"{base}/{name}"
return None
def imports_of(src):
out = {}
for m in IMPORT_RE.finditer(src):
default, named, mod = m.group(1), m.group(2), m.group(3)
if default:
out[default] = mod
if named:
for n in named.split(","):
n = n.strip()
if n.startswith("type "):
n = n[5:].strip()
n = n.split(" as ")[-1].strip()
if n:
out[n] = mod
return out
def walk_code(root):
for base in SRC_ROOTS:
d = os.path.join(root, base)
if not os.path.isdir(d):
continue
for dp, dn, fns in os.walk(d):
dn[:] = [x for x in dn if x != "node_modules"]
for fn in fns:
if fn.endswith(CODE_EXT) and not fn.endswith(SKIP):
yield os.path.relpath(os.path.join(dp, fn), root)
def main():
args = sys.argv[1:]
out_path = None
if "--out" in args:
i = args.index("--out"); out_path = args[i + 1]; del args[i:i + 2]
root = args[0] if args else "."
entry = find_entry(root)
html = read(root, "index.html")
# providers — the tree often lives in a <Providers> component the entry renders, not the entry
# itself, so scan entry + App + any *providers* wrapper, each cross-referenced to ITS OWN imports.
pfiles, app_src = [], ""
if entry:
pfiles.append(entry)
for cand in ("src/App.tsx", "src/app.tsx", "app/frontend/App.tsx"):
if os.path.isfile(os.path.join(root, cand)):
pfiles.append(cand)
app_src = read(root, cand)
for rel in walk_code(root):
base = os.path.splitext(os.path.basename(rel))[0].lower().replace("-", "").replace("_", "")
if base in ("providers", "appproviders", "rootproviders", "appshell"):
pfiles.append(rel)
providers, seen, seen_files = [], set(), set()
for pf in pfiles:
if pf in seen_files:
continue
seen_files.add(pf)
psrc = read(root, pf)
pimps = imports_of(psrc)
for m in PROVIDER_TAG_RE.finditer(psrc):
name = m.group(1) or m.group(2)
if name in seen or name in ("ReactStrictMode", "StrictMode"):
continue
seen.add(name)
providers.append({"name": name, "from": pimps.get(name)})
# root CSS
entry_src = read(root, entry) if entry else ""
js_css = sorted({c.lstrip("./") for c in CSS_IMPORT_RE.findall(entry_src) + CSS_IMPORT_RE.findall(app_src)})
html_css = sorted(set(HTML_LINK_RE.findall(html)))
# one walk for portals + network signals (fetch libs/hooks + MSW presence)
pkg = read(root, "package.json")
libs = {label for dep, label in NET_LIBS.items() if f'"{dep}"' in pkg}
portals, ptargets, hooks = [], set(), set()
has_fetch = msw = False
for rel in walk_code(root):
s = read(root, rel)
for tid in PORTAL_RE.findall(s):
if tid not in ptargets:
ptargets.add(tid)
portals.append({"target": tid, "file": rel})
hooks.update(NET_HOOK_RE.findall(s))
if re.search(r'\bfetch\s*\(', s):
has_fetch = True
if re.search(r'''setupWorker|setupServer|/mocks/(browser|server)|["']msw["']''', s):
msw = True
for hid in HTML_ID_RE.findall(html):
if hid != "root" and hid not in ptargets:
ptargets.add(hid)
portals.append({"target": hid, "file": "index.html"})
if msw:
libs.add("msw")
network = {"libraries": sorted(libs), "hooks": sorted(hooks),
"needsMsw": bool(libs or hooks or has_fetch or msw)}
result = {
"entry": entry,
"providers": providers,
"rootCss": {"jsImports": js_css, "htmlLinks": html_css},
"portals": portals,
"network": network,
"summary": {"providers": len(providers), "portals": len(portals), "needsMsw": network["needsMsw"]},
}
txt = json.dumps(result, indent=2)
if out_path:
with open(out_path, "w", encoding="utf-8") as f:
f.write(txt + "\n")
print(f"runtime: {len(providers)} providers, {len(portals)} portals, msw={network['needsMsw']} -> {out_path}")
else:
print(txt)
if __name__ == "__main__":
main()
scripts/extract-prop-shapes.sh
#!/usr/bin/env bash
# extract-prop-shapes.sh — Factory-candidate discovery (Phase 2 ground truth).
#
# Finds TypeScript interfaces and type aliases that appear in component prop
# signatures across the codebase. Clusters by name; surfaces shapes referenced
# in ≥3 component files as factory candidates (matching the SKILL.md threshold
# rule). Locks in the "3+ usage → scaffold-factory.sh, otherwise inline" choice
# instead of leaving it to agent judgment.
#
# Two-pass detection:
# Pass 1 — collect all `interface FooProps` / `type Foo = {...}` definitions
# and `interface Foo {}` / `type Foo = {}` data-shape definitions.
# Pass 2 — for each named type, count how many DIFFERENT component files
# reference it (as `: TypeName`, `<TypeName>`, prop type).
#
# Output: writes .storybook/prop-shapes.json with:
# factoryCandidates[] — types referenced in ≥3 component files
# propInterfaces[] — every `XxxProps` interface (one per component)
# singleUseShapes[] — types used in 1-2 files (inline mocks, no factory)
#
# Usage:
# extract-prop-shapes.sh # scan ./src
# extract-prop-shapes.sh path/to/src # custom scan path
# extract-prop-shapes.sh --out file # custom output path
# extract-prop-shapes.sh --threshold 3 # factory threshold (default 3)
#
# Exit codes:
# 0 shapes written
# 1 zero shapes found (very small codebase)
# 2 bad invocation
set -uo pipefail
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 not found in PATH. Install python3 (Alpine: apk add python3; Debian: apt install python3)." >&2; exit 2; }
OUT_PATH=".storybook/prop-shapes.json"
THRESHOLD=3
SCAN_PATHS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT_PATH="$2"; shift 2 ;;
--threshold) THRESHOLD="$2"; shift 2 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) SCAN_PATHS+=("$1"); shift ;;
esac
done
if [[ ${#SCAN_PATHS[@]} -eq 0 ]]; then
for cand in src app/frontend; do
[[ -d "$cand" ]] && SCAN_PATHS+=("$cand")
done
# Monorepo layouts (packages/*/src, apps/*/src)
for parent in packages apps; do
if [[ -d "$parent" ]]; then
while IFS= read -r d; do
SCAN_PATHS+=("$d")
done < <(find "$parent" -maxdepth 3 -type d \( -name src -o -name app \) 2>/dev/null | head -20)
fi
done
fi
if [[ ${#SCAN_PATHS[@]} -eq 0 ]]; then
echo "ERROR: no scan path found. Tried src/, app/frontend, packages/*/src, apps/*/src. Pass an explicit path." >&2
exit 2
fi
TMP_DEFS=$(mktemp)
TMP_REFS=$(mktemp)
trap 'rm -f $TMP_DEFS $TMP_REFS' EXIT
# ─── Pass 1: collect type definitions ───────────────────────────────────────
# interface FooProps { ... }
# type Foo = { ... }
# (We keep ALL of them; filtering happens in pass 2 by usage count.)
for p in "${SCAN_PATHS[@]}"; do
grep -rEn "^\s*(export\s+)?(interface|type)\s+[A-Z]\w+" "$p" \
--include="*.ts" --include="*.tsx" 2>/dev/null \
| grep -vE "(\.test\.|\.spec\.|\.stories\.|\.d\.ts:)" \
>> "$TMP_DEFS" || true
done
# ─── Pass 2: count component-file references per type ───────────────────────
# We only care about REFERENCES from .tsx/.jsx (component files), not .ts.
# Build a unique sorted list of type names from defs.
TYPE_NAMES=$(awk -F: '{print $3}' "$TMP_DEFS" \
| grep -oE "(interface|type)\s+[A-Z]\w+" \
| awk '{print $2}' | sort -u)
# For each type, find component files referencing it (excluding the def file)
echo "$TYPE_NAMES" | while IFS= read -r tname; do
[[ -z "$tname" ]] && continue
# Skip overly common / generic names that pollute counts
case "$tname" in
Props|State|Options|Config|Data|Item|Element|Node|Component|Children|Ref) continue ;;
esac
# Find files using TypeName in TYPE position only. Excludes JSX `<TypeName>`
# by requiring an identifier (`\w`) before `<` (so `Array<Course>` / `Omit<Course>`
# / `Promise<Course>` match, but `<Course>` in JSX does not).
#
# Type-position contexts:
# : TypeName — type annotation
# extends/implements Type — inheritance
# , TypeName — additional type in param list
# TypeName[] — array of
# Generic<TypeName> — type arg to a generic (requires preceding \w)
# Count a file only if it references the type on a NON-import line. The old
# `grep -l` counted any match, so a comma'd named import (`import { Foo, Course }`)
# falsely registered as a usage and could flip a single-use shape into a
# factory candidate (adv-3). Drop `import …/export … from …` lines first, then
# collect the filenames. `sed` extracts the path colon-safely (greedy up to :line:).
files=$(for p in "${SCAN_PATHS[@]}"; do
grep -rEn "(:|extends|implements|,)\s*${tname}(\b|<|\[)|\w<\s*${tname}\b" "$p" \
--include="*.tsx" --include="*.jsx" 2>/dev/null \
| grep -vE "^.*:[0-9]+:[[:space:]]*(import|export)[[:space:]].*[[:space:]]from[[:space:]]" \
| sed -E 's/^(.*):[0-9]+:.*/\1/' || true
done | sort -u)
count=$(printf "%s" "$files" | awk 'NF{n++} END{print n+0}')
files_csv=$(printf "%s" "$files" | paste -sd ',' -)
echo -e "${tname}\t${count}\t${files_csv}" >> "$TMP_REFS"
done
# ─── Write JSON ──────────────────────────────────────────────────────────────
mkdir -p "$(dirname "$OUT_PATH")"
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
python3 - "$OUT_PATH" "$NOW" "$THRESHOLD" "$TMP_REFS" "$TMP_DEFS" <<'PYEOF'
import json, sys, re, os, tempfile
out, now, threshold_str, refs_path, defs_path = sys.argv[1:]
threshold = int(threshold_str)
# Load dead-component files from the inventory ledger (same .storybook dir) so a
# shape used only by a dead component isn't over-counted as live reuse. Both Codex
# runs corrected "6 usages, but one is a dead component → 5 live." We annotate
# liveUsages rather than change the candidate threshold (keeps classification stable).
dead_files = set()
inv_path = os.path.join(os.path.dirname(out) or ".", "project-inventory.json")
try:
with open(inv_path) as f:
dead_files = {d["file"] for d in json.load(f).get("components", {}).get("dead", [])}
except (FileNotFoundError, ValueError, KeyError):
pass
factory_candidates = []
prop_interfaces = []
single_use = []
# Build def-location index (where each type was declared)
def_locs = {}
with open(defs_path) as f:
for line in f:
m = re.match(r'^([^:]+):(\d+):\s*(?:export\s+)?(?:interface|type)\s+(\w+)', line)
if m:
file, lineno, tname = m.group(1), int(m.group(2)), m.group(3)
def_locs.setdefault(tname, []).append({"file": file, "line": lineno})
with open(refs_path) as f:
for line in f:
parts = line.rstrip().split('\t')
if len(parts) < 3: continue
tname, count_str, files_csv = parts[0], parts[1], parts[2]
try:
count = int(count_str)
except ValueError:
continue
files = [x for x in files_csv.split(',') if x]
live_files = [x for x in files if x not in dead_files]
entry = {
"type": tname,
"componentFileUsages": count,
"liveUsages": len(live_files), # excludes dead-component files
"files": files[:10], # cap to keep JSON tight
"declaredIn": def_locs.get(tname, [])[:3],
}
if tname.endswith("Props"):
prop_interfaces.append(entry)
continue # *Props are per-component, not factory candidates
if count >= threshold:
factory_candidates.append(entry)
elif count >= 1:
single_use.append(entry)
# Sort
factory_candidates.sort(key=lambda e: -e["componentFileUsages"])
prop_interfaces.sort(key=lambda e: -e["componentFileUsages"])
out_obj = {
"generatedAt": now,
"factoryThreshold": threshold,
"factoryCandidates": factory_candidates,
"factoryCandidateCount": len(factory_candidates),
"propInterfaces": prop_interfaces[:50],
"propInterfaceCount": len(prop_interfaces),
"singleUseShapeCount": len(single_use),
"recommendation": (
"Run scaffold-factory.sh for each factoryCandidate; inline mock data for singleUseShapes."
if factory_candidates else
"No shared shapes found; inline mock data per story (no factories needed yet)."
),
}
# Atomic write (temp → os.replace): an interrupted run never leaves half-written JSON.
_fd, _tmp = tempfile.mkstemp(dir=os.path.dirname(out) or '.', suffix='.tmp')
with os.fdopen(_fd, "w") as f:
json.dump(out_obj, f, indent=2)
os.replace(_tmp, out)
print(f"✓ Wrote {out}")
print(f" {len(factory_candidates)} factory candidates (≥{threshold} usages)")
print(f" {len(prop_interfaces)} *Props interfaces")
print(f" {len(single_use)} single/dual-use shapes (inline, don't factor)")
PYEOF
# ─── Human summary ───────────────────────────────────────────────────────────
echo ""
echo "━━ Prop-shape inventory ━━"
python3 -c "
import json
with open('$OUT_PATH') as f:
d = json.load(f)
print(' Factory candidates:')
for c in d['factoryCandidates'][:8]:
live = c.get('liveUsages', c['componentFileUsages'])
suffix = f\" ({live} live)\" if live != c['componentFileUsages'] else ''
print(f\" {c['type']:24s} {c['componentFileUsages']} files{suffix}\")
if not d['factoryCandidates']:
print(' (none — inline mocks per story)')
"
echo ""
exit 0
scripts/extract-states.sh
#!/usr/bin/env bash
# extract-states.sh — Per-component state-branch discovery.
#
# For each real component (from .storybook/project-inventory.json or the scan
# fallback), greps for the conditional branches that determine WHAT STATES
# need separate stories. Replaces "agent guesses minimum coverage" with
# "agent reads the JSON and writes one story per real branch."
#
# Detects (regex — light AST):
# loading branch — isLoading, loading, pending, isPending, isFetching
# error branch — error, err, isError, failed
# empty branch — !data, data.length === 0, data?.length === 0,
# items.length === 0, isEmpty
# disabled prop — disabled, aria-disabled, isDisabled
# open/closed state — open, isOpen (for overlays)
# success branch — success, isSuccess, submitted, isSubmitted
# skeleton — Skeleton, <Loader/>, shimmer
#
# Output: writes .storybook/component-states.json keyed by file path with
# detected branches + recommendedMinimumStories.
#
# Usage:
# extract-states.sh # uses project-inventory.json if present
# extract-states.sh path/to/src # explicit scan path (no inventory needed)
# extract-states.sh --out file # custom output path
# extract-states.sh --inventory f # custom inventory path
#
# Exit codes:
# 0 states written
# 1 no real components found (run inventory-project.sh first)
# 2 bad invocation
set -uo pipefail
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 not found in PATH. Install python3 (Alpine: apk add python3; Debian: apt install python3)." >&2; exit 2; }
OUT_PATH=".storybook/component-states.json"
INVENTORY_PATH=".storybook/project-inventory.json"
EXPLICIT_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT_PATH="$2"; shift 2 ;;
--inventory) INVENTORY_PATH="$2"; shift 2 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) EXPLICIT_PATH="$1"; shift ;;
esac
done
# ─── Gather candidate component files ────────────────────────────────────────
TMP_FILES=$(mktemp)
trap 'rm -f $TMP_FILES' EXIT
if [[ -n "$EXPLICIT_PATH" ]]; then
find "$EXPLICIT_PATH" -type f \( -name "*.tsx" -o -name "*.jsx" \) \
-not -name "*.test.*" -not -name "*.spec.*" -not -name "*.stories.*" \
-not -name "*.d.ts" 2>/dev/null > "$TMP_FILES" || true
elif [[ -f "$INVENTORY_PATH" ]]; then
# Read real components from inventory JSON
python3 -c "
import json
with open('$INVENTORY_PATH') as f:
inv = json.load(f)
for c in inv.get('components', {}).get('real', []):
print(c['file'])
" > "$TMP_FILES"
else
# Fallback: single-app + monorepo layouts
SCAN_FALLBACKS=()
for p in src app/frontend; do
[[ -d "$p" ]] && SCAN_FALLBACKS+=("$p")
done
for parent in packages apps; do
if [[ -d "$parent" ]]; then
while IFS= read -r d; do
SCAN_FALLBACKS+=("$d")
done < <(find "$parent" -maxdepth 3 -type d \( -name src -o -name app \) 2>/dev/null | head -20)
fi
done
for p in "${SCAN_FALLBACKS[@]}"; do
find "$p" -type f \( -name "*.tsx" -o -name "*.jsx" \) \
-not -name "*.test.*" -not -name "*.spec.*" -not -name "*.stories.*" \
-not -name "*.d.ts" 2>/dev/null >> "$TMP_FILES" || true
done
fi
FILE_COUNT=$(wc -l < "$TMP_FILES" | tr -d ' ')
if [[ $FILE_COUNT -eq 0 ]]; then
echo "ERROR: no component files. Run inventory-project.sh first, or pass a scan path." >&2
exit 1
fi
# ─── Detect state branches per file ──────────────────────────────────────────
TMP_RESULTS=$(mktemp)
trap 'rm -f $TMP_FILES $TMP_RESULTS' EXIT
while IFS= read -r file; do
[[ -f "$file" ]] || continue
states=""
# Loading
if grep -qE "\b(isLoading|loading|isPending|pending|isFetching)\b" "$file" 2>/dev/null; then
states="${states}loading,"
fi
# Error — require error reference on a non-comment line. The previous
# implementation file-wide suppressed `error` whenever ANY comment in the file
# mentioned the word; `if (error) return <ErrorState/>` silently went undetected.
if grep -E "\b(isError|hasError|error|failed)\b" "$file" 2>/dev/null \
| grep -vE "^\s*(//|/\*|\*)" \
| grep -q .; then
states="${states}error,"
fi
# Empty — generic: any `.length === 0`, `?.length === 0`, isEmpty, <Empty…>, noResults/noData/noItems
if grep -qE "(\.length\s*===?\s*0|\?\.length\s*===?\s*0|isEmpty|noResults|noData|noItems|<Empty)" "$file" 2>/dev/null; then
states="${states}empty,"
fi
# Disabled
if grep -qE "\b(disabled|isDisabled|aria-disabled)\b" "$file" 2>/dev/null; then
states="${states}disabled,"
fi
# Open/closed (overlay) — require BOTH an open-state reference AND an actual
# overlay JSX render (`<Dialog ...>` etc.) in this file, not just an import.
# The narrower check excludes overlay-importer files that don't render.
if grep -qE "\b(isOpen|open[[:space:]]*[:=])" "$file" 2>/dev/null \
&& grep -qE "<(Dialog|Modal|Sheet|Drawer|Popover|AlertDialog)([[:space:]]|>|$)" "$file" 2>/dev/null; then
states="${states}open,"
fi
# Success — grep -E lacks negative lookahead, so we exclude false-positive identifiers
# (successUrl, successMessage etc.) by requiring success at word boundary not followed by alpha.
if grep -qE "\b(isSuccess|submitted|isSubmitted)\b" "$file" 2>/dev/null \
|| grep -qE "\bsuccess[^a-zA-Z_]" "$file" 2>/dev/null; then
states="${states}success,"
fi
# Skeleton / loader child component
if grep -qE "<(Skeleton|Loader|Spinner|Shimmer)\b" "$file" 2>/dev/null; then
states="${states}skeleton,"
fi
# Variant (size/color/intent)
if grep -qE "\b(variant|intent|size|color)\s*[:=]" "$file" 2>/dev/null; then
states="${states}variants,"
fi
# Strip trailing comma
states="${states%,}"
# Always at least "default"
if [[ -z "$states" ]]; then
states="default"
else
states="default,$states"
fi
echo -e "${file}\t${states}" >> "$TMP_RESULTS"
done < "$TMP_FILES"
# ─── Write JSON ──────────────────────────────────────────────────────────────
mkdir -p "$(dirname "$OUT_PATH")"
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
python3 - "$OUT_PATH" "$NOW" "$TMP_RESULTS" <<'PYEOF'
import json, sys, os, tempfile
out, now, results = sys.argv[1:]
components = {}
total_states = 0
multistate_count = 0
state_freq = {}
with open(results) as f:
for line in f:
line = line.rstrip()
if not line or '\t' not in line: continue
file, states_str = line.split('\t', 1)
states = states_str.split(',') if states_str else ['default']
components[file] = {
"states": states,
"minimumStories": len(states),
# Tier hint: only "default" → probably leaf/primitive (1 story).
# 2-3 states → standard interactive primitive.
# 4+ states → page or container, needs full coverage.
"tier": (
"primitive" if len(states) <= 2
else "composite" if len(states) <= 4
else "container"
),
}
total_states += len(states)
if len(states) >= 3:
multistate_count += 1
for s in states:
state_freq[s] = state_freq.get(s, 0) + 1
# Components most likely to need rich coverage (container + many states)
priority_targets = sorted(
[(f, d) for f, d in components.items() if d["minimumStories"] >= 4],
key=lambda x: -x[1]["minimumStories"]
)[:20]
out_obj = {
"generatedAt": now,
"componentCount": len(components),
"totalRecommendedStories": total_states,
"multistateComponentCount": multistate_count,
"stateFrequency": dict(sorted(state_freq.items(), key=lambda x: -x[1])),
"priorityTargets": [
{"file": f, "states": d["states"], "minimumStories": d["minimumStories"]}
for f, d in priority_targets
],
"components": components,
}
# Atomic write (temp → os.replace): an interrupted run never leaves half-written JSON.
_fd, _tmp = tempfile.mkstemp(dir=os.path.dirname(out) or '.', suffix='.tmp')
with os.fdopen(_fd, "w") as f:
json.dump(out_obj, f, indent=2)
os.replace(_tmp, out)
print(f"✓ Wrote {out}")
print(f" {len(components)} components scanned")
print(f" {total_states} total recommended stories ({total_states / max(len(components), 1):.1f} per component avg)")
print(f" {multistate_count} components with ≥3 distinct states (priority targets)")
PYEOF
# ─── Human summary ───────────────────────────────────────────────────────────
echo ""
echo "━━ Component state inventory ━━"
echo " Top state branches across the codebase:"
python3 -c "
import json
with open('$OUT_PATH') as f:
d = json.load(f)
for k, v in list(d['stateFrequency'].items())[:8]:
print(f' {k:14s} {v}')
"
echo ""
exit 0
scripts/page-patterns.py
#!/usr/bin/env python3
"""page-patterns.py — detect page composition for the real-page-capture story mode.
Deterministic, regex-based static analysis. Scans page/route/view files and reports, per page:
- importable : has a default-export component (→ Mode A: import the real page as-is)
- layout : the <*Layout> wrapper it renders (imported)
- dataHook : the data provider it reads (usePage / useLoaderData / useParams / store / query)
- dataType : the hook's generic type — THE signal for which factory/mock to seed
- sections : domain section components it renders, in JSX appearance order (excludes ui/ primitives)
- gridHint : best-effort column/layout hint from the top container (flagged approximate)
Plus sharedSections[] — a section component rendered by >=2 pages (the reusable pieces).
We only REPORT what the code actually expresses; we never invent layout rules. Inline JSX blocks
are not components, so they never appear as sections (they'd need extracting first).
Usage:
page-patterns.py [ROOT] [--out FILE] # ROOT defaults to "."; prints JSON if no --out
"""
import json, os, re, sys
PAGE_SEGMENTS = ("/pages/", "/app/", "/routes/", "/views/")
SRC_ROOTS = ("src", "app/frontend")
SKIP_SUFFIX = (".stories.tsx", ".stories.jsx", ".test.tsx", ".spec.tsx", ".d.ts")
IMPORT_RE = re.compile(r'import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s+["\']([^"\']+)["\']')
DEFAULT_EXPORT_RE = re.compile(r'export\s+default\s+(?:function|class|\w+)')
DATA_HOOK_RE = re.compile(r'\b(usePage|useLoaderData|useRouteLoaderData|useParams|useSelector|useAppSelector|useStore|useQuery)\b\s*(?:<([^>{}]+)>)?')
LAYOUT_TAG_RE = re.compile(r'<(\w*Layout)\b')
GRID_RE = re.compile(r'(grid-cols-\[[^\]]+\]|(?:lg:|md:|sm:)?grid-cols-\d+)')
def find_page_files(root):
out = []
for base in SRC_ROOTS:
d = os.path.join(root, base)
if not os.path.isdir(d):
continue
for dirpath, _, files in os.walk(d):
rel_dir = "/" + os.path.relpath(dirpath, root).replace(os.sep, "/") + "/"
if "/node_modules/" in rel_dir or not any(seg in rel_dir for seg in PAGE_SEGMENTS):
continue
for fn in files:
if fn.endswith((".tsx", ".jsx")) and not fn.endswith(SKIP_SUFFIX):
out.append(os.path.relpath(os.path.join(dirpath, fn), root))
return sorted(out)
def is_section(name, mod):
if not name[:1].isupper():
return False
if "/ui/" in mod or mod.endswith("/ui"):
return False
return mod.startswith("@/components") or "/components" in mod
def analyze(root, rel):
with open(os.path.join(root, rel), encoding="utf-8", errors="ignore") as f:
src = f.read()
imported = {} # local name -> module path
for m in IMPORT_RE.finditer(src):
default, named, mod = m.group(1), m.group(2), m.group(3)
if default:
imported[default] = mod
if named:
for n in named.split(","):
n = n.strip()
if n.startswith("type "):
n = n[5:].strip()
n = n.split(" as ")[-1].strip()
if n:
imported[n] = mod
importable = bool(DEFAULT_EXPORT_RE.search(src))
# Default-export component name (what a story would import). None = anonymous default.
component = None
m = re.search(r'export\s+default\s+(?:function|class)\s+(\w+)', src)
if m:
component = m.group(1)
else:
m = re.search(r'export\s+default\s+(\w+)', src)
if m and m.group(1) not in ("function", "class", "async"):
component = m.group(1)
layout = next((m.group(1) for m in LAYOUT_TAG_RE.finditer(src) if m.group(1) in imported), None)
# Prefer a TYPED data-hook occurrence (usePage<Foo>) over a bare one — the generic is the
# mock signal, and pages often call usePage() bare elsewhere (e.g. just for `url`).
dataHook = dataType = None
first = typed = None
for m in DATA_HOOK_RE.finditer(src):
if first is None:
first = m
if m.group(2):
typed = m
break
chosen = typed or first
if chosen:
dataHook = chosen.group(1)
dataType = chosen.group(2).strip() if chosen.group(2) else None
section_imports = {n for n, mod in imported.items() if is_section(n, mod) and n != layout}
seen, sections = set(), []
for m in re.finditer(r'<([A-Z]\w+)\b', src):
n = m.group(1)
if n in section_imports and n not in seen:
seen.add(n)
sections.append(n)
g = GRID_RE.search(src)
return {
"file": rel,
"importable": importable,
"component": component,
"layout": layout,
"dataHook": dataHook,
"dataType": dataType,
"sections": sections,
"gridHint": g.group(1) if g else None,
}
def main():
args = [a for a in sys.argv[1:]]
out_path = None
if "--out" in args:
i = args.index("--out")
out_path = args[i + 1]
del args[i:i + 2]
root = args[0] if args else "."
pages = [analyze(root, p) for p in find_page_files(root)]
by_section = {}
for pg in pages:
for s in pg["sections"]:
by_section.setdefault(s, []).append(pg["file"])
shared = [{"section": s, "pages": f} for s, f in sorted(by_section.items()) if len(f) >= 2]
result = {
"pagePatterns": pages,
"sharedSections": shared,
"summary": {
"pages": len(pages),
"importable": sum(1 for p in pages if p["importable"]),
"sharedSections": len(shared),
},
}
txt = json.dumps(result, indent=2)
if out_path:
with open(out_path, "w", encoding="utf-8") as f:
f.write(txt + "\n")
print(f"page-patterns: {len(pages)} pages, {len(shared)} shared sections -> {out_path}")
else:
print(txt)
if __name__ == "__main__":
main()
scripts/scaffold-factory.sh
#!/usr/bin/env bash
# scaffold-factory.sh — append a createMock<Type> factory stub to the project's
# factories module, framework-agnostic (TypeScript only).
#
# The factory pattern is the one a real production codebase uses (527-line factories.ts):
# - Plain TypeScript, no React imports
# - Each factory: createMockX(overrides: Partial<X> = {}): X
# - Deterministic defaults (no Math.random / Date.now / unseeded faker)
# - Returns the production type (Partial<X> overrides)
#
# Where the factory lives — first existing of:
# .storybook/factories.ts
# src/stories/factories/index.ts
# src/stories/factories.ts
# If none exist, defaults to .storybook/factories.ts (creating the file).
#
# Usage:
# scaffold-factory.sh <TypeName> # default type import: '@/types'
# scaffold-factory.sh <TypeName> <type-import-path> # custom import path
# scaffold-factory.sh User '@/types/user'
# scaffold-factory.sh Course '../types' --target .storybook/factories.ts
#
# Output: appends a stub the agent should fill in. The stub compiles only after
# the agent fills the required-by-type fields. Intentional — forces engagement
# with the production type.
#
# Exit codes:
# 0 factory stub appended
# 1 factory already exists for this type (will not overwrite)
# 2 bad invocation
set -uo pipefail
TYPE_NAME=""
IMPORT_PATH="@/types"
TARGET_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET_FILE="$2"; shift 2 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
*)
if [[ -z "$TYPE_NAME" ]]; then TYPE_NAME="$1"
elif [[ "$IMPORT_PATH" == "@/types" ]]; then IMPORT_PATH="$1"
fi
shift
;;
esac
done
if [[ -z "$TYPE_NAME" ]]; then
echo "ERROR: pass a TypeScript type name. e.g. scaffold-factory.sh User '@/types/user'" >&2
exit 2
fi
# Validate TypeName is PascalCase
if ! echo "$TYPE_NAME" | grep -qE '^[A-Z][A-Za-z0-9]*$'; then
echo "ERROR: TypeName must be PascalCase (got '$TYPE_NAME')." >&2
exit 2
fi
# Resolve target file
if [[ -z "$TARGET_FILE" ]]; then
for cand in .storybook/factories.ts src/stories/factories/index.ts src/stories/factories.ts; do
if [[ -f "$cand" ]]; then TARGET_FILE="$cand"; break; fi
done
fi
if [[ -z "$TARGET_FILE" ]]; then
TARGET_FILE=".storybook/factories.ts"
fi
GREEN=$'\033[32m'; YELLOW=$'\033[33m'; DIM=$'\033[2m'; RESET=$'\033[0m'
if [[ ! -t 1 ]]; then GREEN=""; YELLOW=""; DIM=""; RESET=""; fi
# If target file doesn't exist, create with header
if [[ ! -f "$TARGET_FILE" ]]; then
mkdir -p "$(dirname "$TARGET_FILE")"
cat > "$TARGET_FILE" <<'EOF'
/**
* Shared mock factories for Storybook stories.
*
* Framework-agnostic — plain TypeScript, no React imports. Re-usable in tests.
*
* Rules (see references/factory-patterns.md in the sb-stories skill):
* - Each factory: createMockX(overrides: Partial<X> = {}): X
* - Deterministic defaults (no Math.random / Date.now / unseeded faker)
* - Return the production type — Partial<X> overrides allow customization
* - Compose: factories can call other factories for nested entities
*/
EOF
echo "${DIM}Created $TARGET_FILE${RESET}"
fi
# Refuse to clobber an existing factory for this type
if grep -qE "^export function createMock${TYPE_NAME}\b" "$TARGET_FILE" 2>/dev/null; then
echo "${YELLOW}createMock${TYPE_NAME} already exists in $TARGET_FILE — refusing to overwrite.${RESET}"
echo "${DIM}Edit the file directly, or remove the existing factory first.${RESET}"
exit 1
fi
# Ensure type is imported
if ! grep -qE "import\s+type\s+\{[^}]*\b${TYPE_NAME}\b[^}]*\}\s+from\s+['\"]${IMPORT_PATH}['\"]" "$TARGET_FILE" 2>/dev/null; then
# Append import after the comment header, before the first export
# Strategy: find first `export` line, insert import before it
if grep -qE "^export " "$TARGET_FILE"; then
awk -v import="import type { ${TYPE_NAME} } from '${IMPORT_PATH}';" '
/^export / && !inserted { print import; print ""; inserted=1 }
{ print }
' "$TARGET_FILE" > "$TARGET_FILE.tmp" && mv "$TARGET_FILE.tmp" "$TARGET_FILE"
else
# No exports yet — append at end
{
echo ""
echo "import type { ${TYPE_NAME} } from '${IMPORT_PATH}';"
} >> "$TARGET_FILE"
fi
fi
# Append the factory stub
cat >> "$TARGET_FILE" <<EOF
export function createMock${TYPE_NAME}(overrides: Partial<${TYPE_NAME}> = {}): ${TYPE_NAME} {
// TODO(agent): fill in deterministic defaults for every required field of ${TYPE_NAME}.
// Rules: no Math.random, no Date.now, no unseeded faker. Static IDs/dates only.
// Compose with other factories for nested types: ...createMockNested(),
return {
// id: 1,
// createdAt: '2026-01-01T00:00:00.000Z',
// ...
...overrides,
} as ${TYPE_NAME};
}
EOF
echo "${GREEN}✓ Appended createMock${TYPE_NAME} stub to $TARGET_FILE${RESET}"
echo "${DIM}Next: open the file, fill in the TODO with deterministic defaults from ${IMPORT_PATH}.${RESET}"
echo "${DIM}Then: tsc --noEmit will fail until every required field is set — that's intentional.${RESET}"
exit 0
scripts/scaffold-page-story.py
#!/usr/bin/env python3
"""scaffold-page-story.py — Mode A (real-page capture) story scaffolder.
Consumes page-patterns.py detection for ONE page and emits a `Pages/*` story that IMPORTS the
real page component as-is and mocks ONLY its data layer (the detected provider), seeded from a
factory keyed on the detected `dataType`. It never re-authors the page's layout or JSX.
Decision rule: only scaffolds when the page is `importable` (has a default-export component).
Non-importable pages fall back to Page Composition (composition-patterns.md Pattern 4) — out of scope.
Usage:
scaffold-page-story.py <project-root> <page-file-suffix> [--alias @] [--out FILE]
page-file-suffix : enough of the path to match one page (e.g. "author/index.tsx")
"""
import json, os, re, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
def humanize(name):
name = re.sub(r"Page$", "", name or "")
name = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
return name.strip() or "Page"
def import_path(rel, alias):
p = re.sub(r"\.(tsx|jsx)$", "", rel)
for root in ("src/", "app/frontend/"):
if p.startswith(root):
return f"{alias}/" + p[len(root):]
return "./" + p
def slug_of(rel):
m = re.search(r"/(pages|routes|views|app)/(.+?)(/index)?\.(tsx|jsx)$", "/" + rel)
if not m:
return "/"
seg = m.group(2)
seg = re.sub(r"/index$", "", seg)
return "/" + seg
def main():
args = sys.argv[1:]
alias, out_path = "@", None
if "--alias" in args:
i = args.index("--alias"); alias = args[i + 1]; del args[i:i + 2]
if "--out" in args:
i = args.index("--out"); out_path = args[i + 1]; del args[i:i + 2]
if len(args) < 2:
print(__doc__); sys.exit(2)
root, suffix = args[0], args[1]
raw = subprocess.run([sys.executable, os.path.join(HERE, "page-patterns.py"), root],
capture_output=True, text=True)
pages = json.loads(raw.stdout)["pagePatterns"]
matches = [p for p in pages if p["file"].endswith(suffix)]
if not matches:
print(f"ERROR: no page matched '{suffix}'", file=sys.stderr); sys.exit(2)
pg = matches[0]
if not pg["importable"]:
print(f"ERROR: {pg['file']} has no default-export component — use Page Composition "
f"(composition-patterns.md Pattern 4), not real-page capture.", file=sys.stderr)
sys.exit(3)
comp = pg["component"] or "Page"
name = humanize(pg["component"]) if pg["component"] else humanize(os.path.basename(pg["file"]))
imp = import_path(pg["file"], alias)
slug = slug_of(pg["file"])
dtype = pg["dataType"] or "PageProps"
hook = pg["dataHook"]
sections = ", ".join(pg["sections"]) or "(none detected)"
if hook == "usePage":
provider = f'parameters: {{ inertia: {{ url: "{slug}", props: {{}} /* seed: {dtype} factory */ }} }}'
wiring = "Inertia (usePage)"
else:
provider = f'decorators: [/* TODO: wire {hook or "data provider"} mock, seed: {dtype} factory */]'
wiring = hook or "unknown provider"
story = f'''import type {{ Meta, StoryObj }} from "@storybook/react-vite"
import {comp} from "{imp}"
/**
* Pages/{name} — the REAL {comp} page, imported as-is. Only the data layer is mocked
* ({wiring}); the page's own layout and components render untouched (capture reality, don't recreate).
*
* Detected: layout {pg["layout"] or "—"} · provider {hook or "—"}<{dtype}> · sections {sections}
* TODO: seed the props/data from a factory — `scaffold-factory.sh {dtype} <import-path>` — and
* add one story per materially-different data state (empty / populated / error).
*/
const meta = {{
title: "Pages/{name}",
component: {comp},
parameters: {{ layout: "fullscreen" }},
tags: ["autodocs"],
}} satisfies Meta<typeof {comp}>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {{
{provider},
}}
'''
if out_path:
with open(out_path, "w", encoding="utf-8") as f:
f.write(story)
print(f"scaffolded Pages/{name} -> {out_path}")
else:
print(story)
if __name__ == "__main__":
main()
scripts/validate-stories.sh
#!/usr/bin/env bash
# validate-stories.sh — per-story conformance check for storybook-workbench
#
# Runs 13 deterministic checks on each story file + a project-level CssCheck
# tally (multi-file scans only). Returns PASS/FAIL per check,
# exits non-zero if any check failed. For judgment-needed checks, see the
# sub-agent prompt in references/validate-workflow.md.
#
# Usage:
# validate-stories.sh <file>
# validate-stories.sh 'src/**/*.stories.tsx' # quote globs
# validate-stories.sh --diff # stage+unstaged changed stories
# validate-stories.sh --strict <file> # also runs tsc/eslint/vitest
#
# Exit codes:
# 0 all checks passed
# 1 one or more files had failures
# 2 bad invocation / nothing to check
set -uo pipefail
STRICT=false
USE_DIFF=false
TARGETS=()
# ---- args ----
while [[ $# -gt 0 ]]; do
case "$1" in
--strict) STRICT=true; shift ;;
--diff) USE_DIFF=true; shift ;;
-h|--help)
sed -n '2,15p' "$0"
exit 0
;;
*) TARGETS+=("$1"); shift ;;
esac
done
# ---- resolve targets ----
if $USE_DIFF; then
if ! command -v git >/dev/null 2>&1; then
echo "ERROR: --diff requires git in PATH" >&2
exit 2
fi
# staged + unstaged changes matching story pattern
mapfile -t TARGETS < <(
{ git diff --name-only HEAD 2>/dev/null; git diff --cached --name-only 2>/dev/null; } \
| sort -u | grep -E '\.stories\.(ts|tsx)$' || true
)
if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "No changed *.stories.* files found via git diff."
exit 0
fi
fi
if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "ERROR: no targets. Pass a file, a quoted glob, or --diff." >&2
exit 2
fi
# expand targets: a directory → all stories under it (find, robust); a glob
# string → bash globstar; a plain file → itself. The directory case is the most
# reliable way to lint "everything under src/stories" — `**` glob behavior varies
# by shell (a Codex run found `src/stories/**/*.stories.tsx` matched only nested
# dirs), so prefer passing a directory or --diff.
EXPANDED=()
for t in "${TARGETS[@]}"; do
if [[ -d "$t" ]]; then
while IFS= read -r m; do EXPANDED+=("$m"); done \
< <(find "$t" -type f \( -name '*.stories.tsx' -o -name '*.stories.jsx' -o -name '*.stories.ts' \) 2>/dev/null)
continue
fi
# shellcheck disable=SC2206 # we *want* word-splitting here for glob expansion
matches=( $t )
if [[ ${#matches[@]} -eq 1 && ! -e "${matches[0]}" ]]; then
# treat as bash glob (globstar so ** spans nested dirs)
shopt -s globstar nullglob
matches=( $t )
shopt -u globstar nullglob
fi
for m in "${matches[@]}"; do
[[ -f "$m" ]] && EXPANDED+=("$m")
done
done
if [[ ${#EXPANDED[@]} -eq 0 ]]; then
echo "ERROR: no files matched after glob expansion." >&2
exit 2
fi
# ---- helpers ----
# Look for project's preview.ts/.tsx to read storySort.order (Group D)
find_preview() {
for cand in .storybook/preview.tsx .storybook/preview.ts .storybook/preview.js .storybook/preview.jsx; do
[[ -f "$cand" ]] && { echo "$cand"; return; }
done
}
PREVIEW_FILE=$(find_preview || true)
# Parse storySort.order roots ("Foundations", "Components", "Pages", ...)
STORY_SORT_ROOTS=""
# v1.7 — accept Explore alongside Labs (and other established WIP prefixes)
# for backward compat with established projects.
LABS_PREFIXES="Labs|Explore|Sandbox|Playground|Experiments"
if [[ -n "${PREVIEW_FILE:-}" ]]; then
# crude extraction — grabs strings inside `order: [...]`
if grep -qE 'storySort' "$PREVIEW_FILE" 2>/dev/null; then
STORY_SORT_ROOTS=$(
awk '/storySort/,/^[[:space:]]*\}/' "$PREVIEW_FILE" \
| grep -oE "['\"][A-Za-z][A-Za-z _/0-9-]*['\"]" \
| tr -d "'\"" \
| awk -F/ '{print $1}' \
| sort -u | tr '\n' '|' | sed 's/|$//'
)
fi
fi
# Output helpers
GREEN=$'\033[32m'; RED=$'\033[31m'; YELLOW=$'\033[33m'; DIM=$'\033[2m'; RESET=$'\033[0m'
if [[ ! -t 1 ]]; then GREEN=""; RED=""; YELLOW=""; DIM=""; RESET=""; fi
pass() { echo " ${GREEN}[PASS]${RESET} $1 — $2"; }
fail() { echo " ${RED}[FAIL]${RESET} $1 — $2"; FAILED=$((FAILED + 1)); }
skip() { echo " ${YELLOW}[SKIP]${RESET} $1 — $2"; }
# warn() surfaces a preferred-but-not-required issue WITHOUT failing the gate.
# (e.g. check 03: a real-codebase scan found 88% of shipping stories use the
# `Meta<…>` annotation — valid CSF3 — so `satisfies` is a nudge, not a blocker.)
warn() { echo " ${YELLOW}[WARN]${RESET} $1 — $2"; }
# ---- check functions ----
check_01_import_react_vite() {
if grep -E "^import .* from ['\"]@storybook/react['\"]" "$1" | grep -v "react-vite" >/dev/null 2>&1; then
fail "01" "imports '@storybook/react' (must be '@storybook/react-vite')"
else
pass "01" "no bare '@storybook/react' import (react-vite or none)"
fi
}
check_02_storybook_test() {
if grep -E "from ['\"]@storybook/test['\"]" "$1" >/dev/null 2>&1; then
fail "02" "imports '@storybook/test' (must be 'storybook/test')"
else
pass "02" "no '@storybook/test' import (storybook/test or none)"
fi
}
check_03_satisfies() {
if grep -qE "^const meta\s*:\s*Meta<" "$1"; then
line=$(grep -nE "^const meta\s*:\s*Meta<" "$1" | head -1 | cut -d: -f1)
# WARN, not FAIL: the annotation form is valid CSF3 (~88% of real shipping
# stories use it). `satisfies Meta<typeof X>` is preferred for per-story arg
# inference, but this is a nudge, not a gate-blocking error.
warn "03" "uses 'const meta: Meta<...> =' annotation — prefer 'satisfies Meta<typeof X>' for arg inference (line $line)"
else
pass "03" "satisfies pattern"
fi
}
check_04_useargs_source() {
if grep -qE "\buseArgs\b" "$1"; then
if grep -qE "from ['\"]storybook/preview-api['\"]" "$1"; then
pass "04" "useArgs source"
else
fail "04" "useArgs imported from wrong source (must be 'storybook/preview-api')"
fi
else
skip "04" "no useArgs in file"
fi
}
check_05_no_csf2() {
if grep -qE "storiesOf\(|\.story\s*=\s*\{" "$1"; then
fail "05" "found CSF2 syntax (storiesOf or .story = {})"
else
pass "05" "no CSF2"
fi
}
check_06_no_dead_sb10_imports() {
if grep -qE "from ['\"](@storybook/addon-essentials|@storybook/blocks)['\"]" "$1"; then
fail "06" "imports dead SB10 module (addon-essentials or blocks)"
else
pass "06" "no dead SB10 imports"
fi
}
check_07_no_inline_hex_in_render() {
# crude: any 3/6/8 hex literal anywhere in the file outside of comments
# (we accept hex in `parameters.design.url` etc., so we narrow to render: blocks)
# heuristic: hex literal after `render` keyword within 2000 chars
if awk '/render\s*:/{flag=1} flag && /#[0-9a-fA-F]{3,8}\b/{print; exit 1}' "$1" >/dev/null 2>&1; then
pass "07" "no inline hex in render blocks"
else
fail "07" "inline hex literal in render block — extract to design tokens or args"
fi
}
check_08_disabled_not_in_pseudo() {
if grep -qE "pseudo:\s*\{[^}]*disabled" "$1"; then
fail "08" "'disabled' inside parameters.pseudo (disabled is a prop, not a CSS pseudo-class)"
else
pass "08" "disabled not in pseudo"
fi
}
check_09_layout_set() {
if grep -qE "layout:\s*['\"](centered|fullscreen|padded)['\"]" "$1"; then
pass "09" "parameters.layout set"
else
fail "09" "parameters.layout missing (centered | fullscreen | padded)"
fi
}
check_10_fn_for_callbacks() {
# if file references any on[A-Z] prop, expect fn() somewhere in args
if grep -qE "\bon[A-Z][A-Za-z]+\b" "$1"; then
if grep -qE "\bfn\(\)" "$1"; then
pass "10" "fn() used for callback args"
else
fail "10" "callback prop present but no fn() in args (or play asserts)"
fi
else
skip "10" "no callback props in file"
fi
}
check_11_title_sort_match() {
if [[ -z "$STORY_SORT_ROOTS" ]]; then
skip "11" "no storySort.order declared in preview"
return
fi
# Scope to the first `const meta` block so we don't match `title` inside mock data
title_root=$(awk '/^const meta/,/^export default meta/' "$1" \
| grep -oE "title:[[:space:]]*['\"][^'\"]+['\"]" | head -1 \
| sed -E "s/^title:[[:space:]]*['\"]//; s/['\"]$//; s|/.*||")
if [[ -z "$title_root" ]]; then
skip "11" "no title in meta (component-only file?)"
return
fi
if echo "$title_root" | grep -qE "^($STORY_SORT_ROOTS)$"; then
pass "11" "title prefix '$title_root' matches storySort"
else
fail "11" "title prefix '$title_root' not in storySort roots ($STORY_SORT_ROOTS)"
fi
}
check_12_labs_tag_combo() {
# Scope to the first `const meta` block so we don't match `title` inside mock data
title_root=$(awk '/^const meta/,/^export default meta/' "$1" \
| grep -oE "title:[[:space:]]*['\"][^'\"]+['\"]" | head -1 \
| sed -E "s/^title:[[:space:]]*['\"]//; s/['\"]$//; s|/.*||")
if echo "$title_root" | grep -qE "^($LABS_PREFIXES)$"; then
if grep -qE "['\"]!autodocs['\"]" "$1" && grep -qE "['\"]!test['\"]" "$1"; then
pass "12" "Labs story has !autodocs + !test"
else
fail "12" "Labs story missing !autodocs or !test tag"
fi
else
skip "12" "not a Labs story"
fi
}
# check 13 — `play` must earn its place (ai-setup Step 6). A play whose only
# assertion is toBeVisible/toBeInTheDocument, with no interaction, async query,
# portal, or computed-style probe, proves nothing the render didn't already.
check_13_play_earns_its_place() {
if grep -qE "\bplay\s*:" "$1"; then
# Signals that a play asserts something non-trivial:
if grep -qE "userEvent|fireEvent|\.click\(|\.type\(|\.keyboard\(|findBy|waitFor|toHaveValue|aria-pressed|aria-expanded|getComputedStyle|toContain\(|ownerDocument|toHaveBeenCalled" "$1"; then
pass "13" "play asserts an interaction / async / portal / CSS state"
elif grep -qE "toBeVisible\(|toBeInTheDocument\(" "$1"; then
warn "13" "play looks no-op (only toBeVisible/toBeInTheDocument) — drop it, or make it prove an interaction/async/portal/CSS state (ai-setup Step 6)"
else
pass "13" "play present (non-trivial body)"
fi
else
skip "13" "no play function"
fi
}
# ---- strict mode extras ----
run_strict() {
local file=$1
echo " ${DIM}strict mode:${RESET}"
if command -v npx >/dev/null 2>&1; then
if [[ -f tsconfig.json ]]; then
echo " ${DIM} tsc --noEmit (workspace)${RESET}"
npx --no -- tsc --noEmit 2>&1 | grep "$(basename "$file")" || echo " ${DIM} (no tsc errors for this file)${RESET}"
fi
if compgen -G '.eslintrc.*' >/dev/null 2>&1 || compgen -G 'eslint.config.*' >/dev/null 2>&1; then
echo " ${DIM} eslint${RESET}"
npx --no -- eslint "$file" 2>&1 | tail -20 || true
fi
fi
}
# ---- main loop ----
TOTAL_FILES=0
FAILED_FILES=0
FAILED=0
GRAND_TOTAL_FAILS=0
CSSCHECK_COUNT=0 # project-level: stories asserting getComputedStyle (ai-setup Step 5)
for file in "${EXPANDED[@]}"; do
TOTAL_FILES=$((TOTAL_FILES + 1))
FAILED=0
echo
echo "${file}"
if grep -qE "getComputedStyle" "$file" 2>/dev/null; then
CSSCHECK_COUNT=$((CSSCHECK_COUNT + 1))
fi
check_01_import_react_vite "$file"
check_02_storybook_test "$file"
check_03_satisfies "$file"
check_04_useargs_source "$file"
check_05_no_csf2 "$file"
check_06_no_dead_sb10_imports "$file"
check_07_no_inline_hex_in_render "$file"
check_08_disabled_not_in_pseudo "$file"
check_09_layout_set "$file"
check_10_fn_for_callbacks "$file"
check_11_title_sort_match "$file"
check_12_labs_tag_combo "$file"
check_13_play_earns_its_place "$file"
if $STRICT; then
run_strict "$file"
fi
if [[ $FAILED -gt 0 ]]; then
echo " ${RED}→ $FAILED check(s) failed${RESET}"
FAILED_FILES=$((FAILED_FILES + 1))
GRAND_TOTAL_FAILS=$((GRAND_TOTAL_FAILS + FAILED))
else
echo " ${GREEN}→ all checks passed${RESET}"
fi
done
# ---- project-level CssCheck tally (ai-setup Step 5: exactly ONE getComputedStyle
# proof story per project). Only meaningful over a whole-project / multi-file scan,
# so stay silent on a single-file invocation (it would false-warn on every file). ----
if [[ $TOTAL_FILES -gt 1 ]]; then
echo
if [[ $CSSCHECK_COUNT -eq 0 ]]; then
echo " ${YELLOW}[WARN]${RESET} project — no getComputedStyle 'CssCheck' story found; add exactly ONE asserting a real computed token value, to prove the shared preview loaded the app CSS (ai-setup Step 5)"
elif [[ $CSSCHECK_COUNT -gt 1 ]]; then
echo " ${YELLOW}[WARN]${RESET} project — ${CSSCHECK_COUNT} getComputedStyle stories; ai-setup wants exactly ONE CssCheck (variant-only stories should rely on the render, not re-probe CSS)"
else
echo " ${GREEN}[PASS]${RESET} project — exactly one CssCheck (getComputedStyle proof) present"
fi
fi
# ---- summary ----
echo
echo "═══════════════════════════════════════════════════"
if [[ $FAILED_FILES -eq 0 ]]; then
echo " ${GREEN}${TOTAL_FILES} file(s) scanned — all PASS${RESET}"
exit 0
else
echo " ${RED}${FAILED_FILES} of ${TOTAL_FILES} file(s) failed (${GRAND_TOTAL_FAILS} total check failures)${RESET}"
exit 1
fi
SKILL.md
---
name: sb-stories
description: "Write a CSF3 story for ONE React component, covering only its materially-different states (no Cartesian), with a factory when 3+ stories share a shape. Use for 'write a story for X', 'document this component', 'add a Storybook story'."
compatibility: "Requires bash, python3, and Node.js (Storybook; the --strict gate runs tsc + eslint via npx); git optional (--diff mode), jq optional (design-system hint in check-story-ready)."
allowed-tools: Bash Read Glob Grep Write Edit
license: MIT
metadata:
author: strongeron
version: '2.3.0'
bundle: storybook-workbench
vendor:
# Skill-local files live in this skill's scripts/ + references/; shared ones (e.g. discover-runtime.py,
# validate-stories.sh, anti-patterns.md, composition-patterns.md) resolve from shared/ ($CORE) in dev
# and are copied into dist/ by build.sh on export.
scripts: [validate-stories.sh, check-story-ready.sh, scaffold-factory.sh, extract-states.sh, extract-prop-shapes.sh, page-patterns.py, scaffold-page-story.py, discover-runtime.py]
wrappers: false
references: [with-mcp.md, without-mcp.md, anti-patterns.md, validate-workflow.md, factory-patterns.md, extraction-workflow.md, test-wiring.md, directory-structure.md, composition-patterns.md]
templates: [controlled-component-story.tsx]
---
# sb-stories — one component, its real states
The default Build mode. The component exists in `src/components/`; you write its visible-states story.
## Before authoring (ask yourself)
- **Is it actually used?** Check `components.real[]` in `.storybook/project-inventory.json`. If it's
in `dead[]`, remove it — don't write a story. If it's `vendor` (shadcn `ui/`), deprioritize.
- **Which states change behavior/appearance materially?** Refuse Cartesian combinations. Per-primitive
minimum tables in `references/anti-patterns.md` (Button 8, Input 8, Modal 5, Form 6).
Read `.storybook/component-states.json` instead of guessing — **if it's missing, generate it first:**
`scripts/extract-states.sh`. (Bigger extraction context: `references/extraction-workflow.md`.)
To render all those states on **one canvas**, use the `StateGrid` wrapper (variants × states →
`StateMatrix`) from `sb-wrappers` — don't hand-roll the grid. These are the component-time wrappers;
the data wrappers (ProjectInventory / DesignSystemHealth / AppFlowGraph …) come after their own steps.
- **Which states does THIS app actually ship?** Read `.storybook/component-usage.json` (`sb-inventory`'s
`extract-component-usage.sh` — generate it if missing). Use `props.<prop>.values` to **prioritize** the
variants real call sites pass; for anything in `declaredButUnused` (e.g. `variant=danger` never used),
still author the state for completeness but **tag it `['usage:unused']`** and note "not used in this app"
— don't pad the catalog with states prod never renders.
- **You don't author per-component docs by hand — they're composed once.** Every component's autodocs
page already gains a **"Real usage in this app"** band: the `UsageSection` block (wired once into
`preview.ts` `docs.page` by `sb-setup`) renders a **"Where it's used"** map per component — the pages it
lands on, what nests it, what it renders, the tokens it pulls — read from the usage graph
(`component-pages.json`). So when you add a story, **don't hand-add a usage block**; just run
`refresh-usage.sh` so the graph is current and the band populates. To explore the whole graph
interactively (any token / component / page → everywhere it's used, clickable), scaffold the
**`UsageExplorer`** wrapper (`sb-wrappers`). Details + the docs.page composition live in `sb-inventory`
(§ "Real usage in autodocs") and `sb-setup` (docs-page composition).
- **Factory?** YES if 3+ stories share a data shape. Read `.storybook/prop-shapes.json` (candidates
flagged with `liveUsages`) — **if missing, run `scripts/extract-prop-shapes.sh` first.** Then
`scripts/scaffold-factory.sh <Type> <import-path>` and fill the deterministic stub
(`references/factory-patterns.md`); otherwise inline `args`.
- **Title taxonomy?** Match `storySort.order`; if the project has none, pick one via
`references/directory-structure.md`.
- **Does a `play` actually earn its place?** Only write one for an interaction, async data,
a portal, a CSS-driven state, or accessibility — never a bare `toBeVisible()` (anti-pattern 34).
And the **project needs exactly one `CssCheck`** (anti-pattern 33): one story asserting a real
`getComputedStyle` token value, the only proof the shared preview loaded the app's CSS. Both come
from `npx storybook ai setup`'s prompt; `validate-stories.sh` check 13 + the project tally enforce them.
- **A "playground" / showcase story is Controls-driven, not click/hover.** If a reviewer asks "what is
this playground story — can I click or hover it?": a playground is the **default story with its Controls
panel** — you change `args` (variant, size, state) in the Controls tab and the canvas re-renders. It is
NOT canvas interaction; hover/focus/active live in a `StateGrid` interaction matrix (addon-pseudo-states),
and scripted click→assert flows live in a `play` function. So: Controls = try props · pseudo-states =
hover/focus columns · `play` = a real interaction. Say which one the story is.
- **Is the Controls panel wired?** A component story must expose a usable Controls panel — it's the
reviewer's prop sandbox and powers the autodocs ArgTypes table. The react-vite default
(`react-docgen`) does NOT infer TS unions into selects, so declare `argTypes` for every enum/union
prop (`control: 'select'|'inline-radio'` + `options`), group with `table.category`, hide
escape-hatch / non-serializable props (`className`, refs, icon / callback / data props), and disable
controls on render-only showcase stories (`parameters: { controls: { disable: true } }`). Full
pattern + the docgen gotcha: `references/without-mcp.md` §13. (The panel itself must be *visible* —
`sb-setup` writes a `manager.ts` with `showPanel: true`; `sb-audit`'s `audit-controls.sh` flags any
component story missing this wiring.)
## Authoring source (mutually exclusive — load exactly one)
```bash
grep -q '@storybook/addon-mcp' package.json && test -f .mcp.json && echo WITH_MCP || echo WITHOUT_MCP
```
- `WITH_MCP` → `references/with-mcp.md` (MCP injects CSF3 conventions; you focus on judgment).
- `WITHOUT_MCP` → `references/without-mcp.md` (13 verification gaps + 4 critical SB10 patterns).
- Controlled components (Switch/Toggle/Checkbox/Tabs/Accordion/Select) start from
`templates/controlled-component-story.tsx` — the `useArgs` sync is what AI gets wrong.
**Where the file goes (ASK first — don't scatter the repo).** Read `storiesLocation` from
`.storybook/audit/status.md` (the single rule lives in `CONTEXT.md` §STORIES LOCATION).
- **If it's unset** (e.g. the repo already had Storybook so `sb-setup` never asked), **STOP and ASK
the user before writing any story** — never guess, never co-locate silently. Use `AskUserQuestion`
(Claude) / `request_user_input` (Codex), or a numbered list where no blocking tool exists:
> **Where should I save the stories? (everything else already lives under `.storybook/`.)**
> 1. **`.storybook/stories/`** *(recommended — one place, isolated; `src/` untouched, one removable folder)*
> 2. **Co-located** `src/**/<Name>.stories.tsx` *(for a project you own long-term)*
> 3. **A custom folder** *(you name it — still kept to that one place)*
Then record it in `.storybook/audit/status.md` as `storiesLocation: <isolated|colocated|PATH>`, make
sure `main.ts` `stories` includes that path, and proceed. Recommend option 1.
- **`isolated` (or `.storybook/stories/`)** → write under `.storybook/stories/` mirroring the tree
(`.storybook/stories/components/CourseCard.stories.tsx`), importing the component via the `@/` alias.
- **`colocated`** → `src/components/<X>/<X>.stories.tsx`. **A custom path** → write there, every story.
Whatever the answer, **all stories go to that one location** — never a mix.
Title (the in-Storybook path, separate from the file path): match `.storybook/preview.ts`
`storySort.order`; else `Components/<Domain>/<Name>`. An **Overview / Spec / hub** story is an entry
point — pin it to the **top of its root** via `storySort.order` (a root-level hub first; a per-feature
`Overview`/`Spec` first in its group's sub-order with a `'*'` tail), never let it sort alphabetically into
the middle of the content it summarizes. See `references/directory-structure.md` → "Overview / spec / hub".
## Pages — real-page capture (Mode A)
For a **page** (a route/view under `pages/` · `app/` · `routes/` · `views/`) do NOT recreate it.
Run `scripts/page-patterns.py <root>` first — per page it reports `importable`, `component`,
`layout`, `dataHook` + **`dataType`** (the mock signal), `sections` (render order, `ui/` excluded),
`gridHint`; plus `sharedSections[]`. Then pick the mode off `importable`:
- **`importable: true`** (page has a default-export component) → **import the real page as-is and
mock ONLY its data layer.** Scaffold with `scripts/scaffold-page-story.py <root> <page-suffix>`:
it emits a `Pages/<Name>` story that imports the real page + wires the detected provider
(Inertia `usePage` / router / store), with props **seeded from a factory keyed on `dataType`**
(`scripts/scaffold-factory.sh <dataType> <import-path>`, per `references/factory-patterns.md`).
Add one story per materially-different **data** state (empty / populated / error) — different
factory inputs, never different markup. **Never re-author the page's JSX** (anti-pattern 27):
the layout, columns, and components are the real page's, not yours.
- **`importable: false`** (assembled inline / no single component) → fall back to **Page Composition**
(`references/composition-patterns.md` Pattern 4): assemble from the real `sections` —
still factory-backed, still real components.
`sharedSections[]` (a section rendered by ≥2 pages) are the reusable page-pattern pieces — give each
its own `Components/*` story so pages **compose** them, not duplicate them.
## Overlays (Dialog / Modal / Sheet / Drawer / Popover) in autodocs
An open overlay portals a `position:fixed inset-0` overlay to `document.body`. Rendered **inline** on
the autodocs page (as the `Primary` block does), that overlay escapes over the *whole Docs page* —
Title, the "Real usage" section, Controls all vanish behind a blank backdrop. So for any overlay
component with `autodocs`, scope the story to its own iframe:
```ts
parameters: {
layout: 'fullscreen',
docs: { story: { inline: false, height: '640px' } }, // portal stays inside the frame; Docs prose stays readable
}
```
The story view (one story, full canvas) is unaffected — this is only for the Docs page. Don't reach
for it on non-overlay components (inline rendering is lighter).
**The page's own `dataHook` is just its data; the preview must also supply the provider TREE + root
CSS the page renders under.** Read those from `.storybook/runtime.json` (`scripts/discover-runtime.py`) —
`providers[]`, `rootCss`, `portals[]`, `network.needsMsw` — they're set up once in the shared preview
by `sb-setup`, so a page story rarely re-wires them. **Never re-derive by shell scan what a script
already wrote to `.storybook/*.json`** — cite the field.
## Batch (several components)
Write one story per component (each covering only its real states), then gate each with
`scripts/validate-stories.sh`. On Claude Code you can speed a batch up by writing components in
parallel with the Agent tool, but it's the same work — no special sub-agent needed.
## Gate before done
```bash
SKILL=${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}
CORE=${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}
"$CORE/scripts/validate-stories.sh" path/to/Foo.stories.tsx
# --strict adds tsc + eslint · --diff lints only changed stories
# For the "give me confidence" verdict (setup readiness preflight + conformance in one),
# run "$SKILL/scripts/check-story-ready.sh" path/to/Foo.stories.tsx instead — CONFIDENT when discovery JSONs are present.
```
Exits non-zero on any FAIL — fix before continuing. If you wrote a `play`, also dispatch the
judgment sub-agent (see `references/validate-workflow.md`); bash can't verify a `play`
is meaningful. To make stories an agent-runnable CLI gate (headless vitest + a11y), see
`references/test-wiring.md`.
templates/controlled-component-story.tsx
// Controlled-component story template — uses useArgs to keep the Controls panel
// in sync when the component fires onChange. Use this template (not component-story.tsx)
// for: Switch, Toggle, Checkbox, Radio, Tabs, Accordion, Select, anything where the
// component owns internal state but exposes value + onChange as a controlled API.
//
// See references/without-mcp.md §5 for the pattern explanation.
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn, expect } from 'storybook/test';
import { useArgs } from 'storybook/preview-api';
// Adapt the import path to your project:
import { Switch } from './Switch';
const meta = {
title: 'Components/Form/Switch',
component: Switch,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
checked: false,
onChange: fn(),
},
// Wire the Controls panel — the react-vite default (react-docgen) does NOT infer
// TS unions into selects, so declare them. Group with table.category; hide what a
// panel can't drive (onChange callback, className escape hatch). See without-mcp.md §13.
argTypes: {
checked: { control: 'boolean', table: { category: 'State' } },
disabled: { control: 'boolean', table: { category: 'State' } },
size: { control: 'inline-radio', options: ['small', 'medium', 'large'], table: { category: 'Appearance' } },
label: { control: 'text', table: { category: 'Content' } },
onChange: { control: false, table: { category: 'Events' } },
className: { table: { disable: true } },
},
// render bridges component state ↔ Controls panel
render: function Render(args) {
const [{ checked }, updateArgs] = useArgs();
return (
<Switch
{...args}
checked={checked as boolean}
onChange={(next: boolean) => {
args.onChange?.(next);
updateArgs({ checked: next });
}}
/>
);
},
} satisfies Meta<typeof Switch>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Off: Story = {
args: { checked: false, label: 'Email notifications' },
};
export const On: Story = {
args: { checked: true, label: 'Email notifications' },
};
export const Disabled: Story = {
args: { checked: false, label: 'Pro feature', disabled: true },
};
export const DisabledOn: Story = {
args: { checked: true, label: 'Pro feature', disabled: true },
};
export const Small: Story = {
args: { checked: true, label: 'Compact', size: 'small' },
};
export const Large: Story = {
args: { checked: true, label: 'Spacious', size: 'large' },
};
// Interactive — exercises the toggle.
// Note role='switch' query (not 'checkbox') — see references/without-mcp.md §2.
export const Toggled: Story = {
args: { checked: false, label: 'Toggle me' },
play: async ({ args, canvas, userEvent }) => {
const sw = canvas.getByRole('switch');
await userEvent.click(sw);
await expect(args.onChange).toHaveBeenCalledWith(true);
await userEvent.click(sw);
await expect(args.onChange).toHaveBeenLastCalledWith(false);
},
};