storybook-to-design-system
Two related capabilities:
- Build an in-app "design system" gallery — a single page in your running app that renders every component story, grouped and browsable, generated from the same
*.stories.tsx files Storybook uses. One source of truth, two surfaces (real Storybook + an in-app page that needs no separate deploy and lives behind your app's auth).
- Reach ~100% Storybook coverage — a parallel-agent fan-out that authors stories for every component across its states, one autonomous PR per domain.
Step 0 — ask the user first (do not assume)
Before building, ask:
- What should the page be called / what route? (e.g.
/design-system, /components, /styleguide, /ui.) Use their answer everywhere below in place of /design-system.
- Coverage scope & target — every component, or specific domains? A hard 100% or "cover all presentational components, skip data/router-coupled ones with documented reasons"? (The latter is almost always the right answer — see "What to skip".)
- Merge autonomy — should per-domain PRs auto-merge once CI is green + review approves, or stop for a human merge? (On a shared/production repo, default to "open PRs, human merges," unless the user opts into auto-merge.)
- Implementer model — the fan-out is mechanical; run those agents on a cheaper tier to cut cost (see
delegate-implementation). The orchestrator plans + reviews.
Part A — the in-app gallery
Principle: stories are the single source of truth
Author CSF3 stories co-located with components (Button.stories.tsx next to Button.tsx). Both Storybook and the in-app page consume the same files. Never maintain a second hand-written component index — it drifts.
A generated manifest with LAZY importers
A small codegen script scans **/*.stories.@(ts|tsx) and emits a manifest the page imports. Two non-obvious requirements, both learned the hard way:
- Lazy
() => import(path) thunks, not eager imports. If the page statically imports hundreds of story modules, your production build pulls the entire component graph into one route chunk and can OOM the bundler / blow memory budgets. Lazy importers keep the route small and load each module on demand.
- Emit the importer pointing at the STORY file, not the component. Strip only the language extension (
.tsx), keep the .stories segment — import("…/button.stories"), not …/button. Getting this wrong silently imports the component (no stories) or fails the build.
- Gitignore the generated manifest and regenerate it in
dev/build/postinstall (alongside whatever other codegen your app runs). Because it's generated, parallel PRs that only add story files never touch a shared committed file — see the next point.
Derive the nav grouping from the file PATH, not a registry
Group stories into domains by their directory (components/flow/** → "Flows", components/ui/** → "UI", …) via a committed path→domain map. This means adding a story requires zero registration — and, crucially, a fan-out of parallel PRs never edits a shared index, so they don't conflict with each other. (If you instead group by a central list, every PR edits it and they collide.)
Render each story in an ISOLATED IFRAME (the most important lesson)
The in-app page renders arbitrary feature components for preview. Some of them:
- open modals (e.g. a confirm/alert dialog rendered
open) that portal to document.body, paint a full-screen overlay, and lock body { pointer-events: none } — hijacking the entire gallery so you can't click anything else;
- enter render loops (a stray effect) that peg the main thread and make every other story appear stuck on "Loading…";
- emit console logs/errors as a normal side effect (e.g. an error-boundary component logging on mount), spamming the gallery console.
Rendering stories inline in the gallery page exposes you to all three. Render each selected story inside a same-origin <iframe> that loads a bare preview route — exactly how Storybook isolates its canvas. Overlays, focus traps, loops, and logs are all contained to the iframe document; the gallery chrome stays responsive.
The bare preview route must:
- be auth-gated the same way the rest of your app is (the iframe carries the parent's cookies);
- provide the same React context providers the components need (query client, theme, any app-wide context like a sidebar/layout context) but render no visual chrome — otherwise components that call those hooks throw. This provider-wiring is the fiddly part; enumerate the providers your components actually use.
- key the iframe by
storyId + theme so switching reloads it.
Never default to an error-state story
If you sort the manifest and auto-select the first entry, you may land on an error/empty/loading-state story and greet users with "Something went wrong." Default to a welcome/overview panel (story counts per domain, "pick a component on the left"); only render a story once the user selects one.
Also give the page your app's chrome treatment
If your app has a "settings"-style full-screen sub-layout (its own sidebar + a back arrow, replacing the main nav), apply the same to the gallery — it's a focused tool, not a dashboard page. Keep it behind the same providers so components render correctly.
Part B — reach ~100% coverage with a parallel fan-out
Foundation first, then fan out
- Foundation PR (do this carefully; everything depends on it): Storybook config, the manifest codegen, the path→domain map, a minimal CSF renderer for the in-app page, the iframe preview route, the gallery page, and one pilot domain authored end-to-end to prove the recipe. Merge it.
- Fan out: one agent per domain in its own git worktree, each rebased on the merged foundation. It authors
*.stories.tsx for every component in its domain, verifies, opens a PR, and (if the user opted in) runs the autonomous CI→review→merge loop. Because domains map by path and only add files, the PRs don't conflict — they can run concurrently (throttle to a few at a time; each runs a full build).
Story conventions (put these in the agent's brief)
- CSF3, co-located, typed from your Storybook
Meta/StoryObj.
- Cover the states that apply: Default, Empty, Loading, Error, Disabled, Overflow/long-content, and variant/size permutations. Use realistic mock data.
- Keep stories pure — no data fetching, no live router; pass everything via props/args. (If a wrapping provider makes a query-driven component degrade gracefully to empty, that's fine.)
- Render portal components (dialogs/popovers/sheets) open so their content is visible — safe because the gallery isolates them in an iframe.
- Never name a story export
Error — it shadows the global Error and breaks the type-check.
- For
next/dynamic-style ssr:false wrappers with no props, story the underlying implementation component directly.
What to skip (and say so)
Don't fake coverage. Skip — with a one-line reason in the PR — components that genuinely can't render in isolation: live editor/store-coupled views, things that fetch-on-mount with no prop seam, column-definition factories (not components), instrumentation that renders null. Story their presentational children instead. Honest "skipped: needs a live X" beats a blank panel.
The verification gate (the #1 place fan-out agents fail)
Bake a hard gate into every agent's brief: "not done until all green." Run, fix, re-run:
- typecheck — including any secondary tsconfig. If a separate build (serverless functions, a Node package) also type-checks shared files, it has no DOM lib, so a browser global like
window throws there even though the app build is fine. Guard browser globals via globalThis ((globalThis as { location?: { pathname?: string } }).location), not bare window.
- lint (respect the repo's own rules — many ban
useEffect, hardcoded colors, raw console.*, etc.; read the repo's agent/contributor guide first);
- the production build (stories are now in the build graph — a broken story breaks the build);
- the Storybook build;
- formatter.
Give the implementer agent the repo's AGENTS.md/CLAUDE.md so it inherits these rules and the gate — agents under-verify most when the acceptance criteria are vague.
Gotchas worth their own line
- Secret scanners scan commit HISTORY, not just the diff. Mock data with high-entropy secret-like strings (
whsec_…, sk_…) trips them — use obvious LOW-entropy placeholders ("example_placeholder_secret"). If one ever landed in a commit, scrub it via git commit --amend/squash + --force-with-lease; deleting it in a later commit is not enough.
- A pre-existing unformatted file anywhere the format check covers (including server/functions trees) fails the format gate on every open PR. Run the repo's format check across the whole tree once; fix drift in its own small PR to unblock everyone.
- Auto-format bots' pushes often don't re-trigger CI (anti-recursion on bot tokens). If checks go "missing" after the bot pushes a formatting commit, push a trivial/empty commit to re-run.
watch-style CI tools can exit early before all checks register. Gate merges on the explicit required-check conclusions (each named check == success) + review approved, not just a watch's exit code.
- Suppress the gallery's own console noise: components previewed on the gallery route may log as designed. A small guard in your logger ("no-op client logs while the gallery route is open") keeps the console clean without hiding real runtime logs elsewhere.
Cost
The fan-out is mechanical, high-volume work — run those agents on a cheaper model tier and keep the expensive orchestrator for the foundation + per-PR review. See delegate-implementation for the orchestrator-implementer pattern and model pairings.
1---2name: storybook-to-design-system3description: Turn co-located Storybook stories into a live in-app design-system gallery page (you pick the route), AND drive the codebase to ~100% Storybook coverage with a parallel agent fan-out. Use when turning Storybook into an in-app component gallery, building a /components page from stories, driving a thin-coverage library to full coverage, or a large component library has thin story coverage.4---56# storybook-to-design-system78Two related capabilities:9101. **Build an in-app "design system" gallery** — a single page in your running app that renders every component story, grouped and browsable, generated from the same `*.stories.tsx` files Storybook uses. One source of truth, two surfaces (real Storybook + an in-app page that needs no separate deploy and lives behind your app's auth).112. **Reach ~100% Storybook coverage** — a parallel-agent fan-out that authors stories for every component across its states, one autonomous PR per domain.1213## Step 0 — ask the user first (do not assume)1415Before building, ask:16171. **What should the page be called / what route?** (e.g. `/design-system`, `/components`, `/styleguide`, `/ui`.) Use their answer everywhere below in place of `/design-system`.182. **Coverage scope & target** — every component, or specific domains? A hard 100% or "cover all presentational components, skip data/router-coupled ones with documented reasons"? (The latter is almost always the right answer — see "What to skip".)193. **Merge autonomy** — should per-domain PRs auto-merge once CI is green + review approves, or stop for a human merge? (On a shared/production repo, default to "open PRs, human merges," unless the user opts into auto-merge.)204. **Implementer model** — the fan-out is mechanical; run those agents on a cheaper tier to cut cost (see `delegate-implementation`). The orchestrator plans + reviews.2122## Part A — the in-app gallery2324### Principle: stories are the single source of truth2526Author CSF3 stories co-located with components (`Button.stories.tsx` next to `Button.tsx`). Both Storybook and the in-app page consume the *same* files. Never maintain a second hand-written component index — it drifts.2728### A generated manifest with LAZY importers2930A small codegen script scans `**/*.stories.@(ts|tsx)` and emits a manifest the page imports. Two non-obvious requirements, both learned the hard way:3132- **Lazy `() => import(path)` thunks, not eager imports.** If the page statically imports hundreds of story modules, your production build pulls the entire component graph into one route chunk and can OOM the bundler / blow memory budgets. Lazy importers keep the route small and load each module on demand.33- **Emit the importer pointing at the STORY file, not the component.** Strip only the language extension (`.tsx`), keep the `.stories` segment — `import("…/button.stories")`, not `…/button`. Getting this wrong silently imports the component (no stories) or fails the build.34- **Gitignore the generated manifest** and regenerate it in `dev`/`build`/`postinstall` (alongside whatever other codegen your app runs). Because it's generated, parallel PRs that only *add* story files never touch a shared committed file — see the next point.3536### Derive the nav grouping from the file PATH, not a registry3738Group stories into domains by their directory (`components/flow/** → "Flows"`, `components/ui/** → "UI"`, …) via a committed path→domain map. This means **adding a story requires zero registration** — and, crucially, **a fan-out of parallel PRs never edits a shared index**, so they don't conflict with each other. (If you instead group by a central list, every PR edits it and they collide.)3940### Render each story in an ISOLATED IFRAME (the most important lesson)4142The in-app page renders arbitrary feature components for preview. Some of them:43- open **modals** (e.g. a confirm/alert dialog rendered `open`) that portal to `document.body`, paint a full-screen overlay, and lock `body { pointer-events: none }` — **hijacking the entire gallery** so you can't click anything else;44- enter **render loops** (a stray effect) that peg the main thread and make every *other* story appear stuck on "Loading…";45- emit **console logs/errors** as a normal side effect (e.g. an error-boundary component logging on mount), spamming the gallery console.4647Rendering stories inline in the gallery page exposes you to all three. **Render each selected story inside a same-origin `<iframe>` that loads a bare preview route** — exactly how Storybook isolates its canvas. Overlays, focus traps, loops, and logs are all contained to the iframe document; the gallery chrome stays responsive.4849The bare preview route must:50- be **auth-gated** the same way the rest of your app is (the iframe carries the parent's cookies);51- **provide the same React context providers the components need** (query client, theme, any app-wide context like a sidebar/layout context) **but render no visual chrome** — otherwise components that call those hooks throw. This provider-wiring is the fiddly part; enumerate the providers your components actually use.52- key the iframe by `storyId` + theme so switching reloads it.5354### Never default to an error-state story5556If you sort the manifest and auto-select the first entry, you may land on an error/empty/loading-*state* story and greet users with "Something went wrong." **Default to a welcome/overview panel** (story counts per domain, "pick a component on the left"); only render a story once the user selects one.5758### Also give the page your app's chrome treatment5960If your app has a "settings"-style full-screen sub-layout (its own sidebar + a back arrow, replacing the main nav), apply the same to the gallery — it's a focused tool, not a dashboard page. Keep it behind the same providers so components render correctly.6162## Part B — reach ~100% coverage with a parallel fan-out6364### Foundation first, then fan out65661. **Foundation PR** (do this carefully; everything depends on it): Storybook config, the manifest codegen, the path→domain map, a minimal CSF renderer for the in-app page, the iframe preview route, the gallery page, and **one pilot domain** authored end-to-end to prove the recipe. Merge it.672. **Fan out**: one agent per domain in its own git worktree, each rebased on the merged foundation. It authors `*.stories.tsx` for every component in its domain, verifies, opens a PR, and (if the user opted in) runs the autonomous CI→review→merge loop. Because domains map by path and only add files, the PRs don't conflict — they can run concurrently (throttle to a few at a time; each runs a full build).6869### Story conventions (put these in the agent's brief)7071- CSF3, co-located, typed from your Storybook `Meta`/`StoryObj`.72- Cover the states that apply: **Default, Empty, Loading, Error, Disabled, Overflow/long-content, and variant/size permutations.** Use realistic mock data.73- **Keep stories pure** — no data fetching, no live router; pass everything via props/args. (If a wrapping provider makes a query-driven component degrade gracefully to empty, that's fine.)74- Render portal components (dialogs/popovers/sheets) **open** so their content is visible — safe *because* the gallery isolates them in an iframe.75- **Never name a story export `Error`** — it shadows the global `Error` and breaks the type-check.76- For `next/dynamic`-style `ssr:false` wrappers with no props, story the underlying implementation component directly.7778### What to skip (and say so)7980Don't fake coverage. Skip — with a one-line reason in the PR — components that genuinely can't render in isolation: live editor/store-coupled views, things that fetch-on-mount with no prop seam, column-definition factories (not components), instrumentation that renders `null`. Story their presentational children instead. Honest "skipped: needs a live X" beats a blank panel.8182### The verification gate (the #1 place fan-out agents fail)8384Bake a hard gate into every agent's brief: **"not done until all green."** Run, fix, re-run:85- typecheck — **including any *secondary* tsconfig.** If a separate build (serverless functions, a Node package) also type-checks shared files, it has no DOM lib, so a browser global like `window` throws there even though the app build is fine. Guard browser globals via `globalThis` (`(globalThis as { location?: { pathname?: string } }).location`), not bare `window`.86- lint (respect the repo's own rules — many ban `useEffect`, hardcoded colors, raw `console.*`, etc.; read the repo's agent/contributor guide first);87- the production build (stories are now in the build graph — a broken story breaks the build);88- the Storybook build;89- formatter.9091Give the implementer agent the repo's `AGENTS.md`/`CLAUDE.md` so it inherits these rules and the gate — agents under-verify most when the acceptance criteria are vague.9293## Gotchas worth their own line9495- **Secret scanners scan commit HISTORY, not just the diff.** Mock data with high-entropy secret-like strings (`whsec_…`, `sk_…`) trips them — use obvious LOW-entropy placeholders (`"example_placeholder_secret"`). If one ever landed in a commit, scrub it via `git commit --amend`/squash + `--force-with-lease`; deleting it in a later commit is not enough.96- **A pre-existing unformatted file anywhere the format check covers** (including server/functions trees) fails the format gate on *every* open PR. Run the repo's format check across the whole tree once; fix drift in its own small PR to unblock everyone.97- **Auto-format bots' pushes often don't re-trigger CI** (anti-recursion on bot tokens). If checks go "missing" after the bot pushes a formatting commit, push a trivial/empty commit to re-run.98- **`watch`-style CI tools can exit early** before all checks register. Gate merges on the *explicit* required-check conclusions (each named check == success) + review approved, not just a watch's exit code.99- **Suppress the gallery's own console noise**: components previewed on the gallery route may log as designed. A small guard in your logger ("no-op client logs while the gallery route is open") keeps the console clean without hiding real runtime logs elsewhere.100101## Cost102103The fan-out is mechanical, high-volume work — run those agents on a cheaper model tier and keep the expensive orchestrator for the foundation + per-PR review. See [`delegate-implementation`](../delegate-implementation/SKILL.md) for the orchestrator-implementer pattern and model pairings.