Plan 022 — Storybook design-system workbench
Status: approved Written from spec.md (approved) and research.md (complete). Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Spec: specs/022-storybook/spec.md · Research: specs/022-storybook/research.md
Context
Spec 019 shipped four closed components (Button, Input, Dialog, Tooltip) and a hand-maintained /ui gallery whose own header comment marks it a stopgap "until Storybook lands"; design-system.md names Storybook as the intended catalogue home. As the design system grows, one shared gallery page a developer edits by hand does not scale for iteration — no per-component isolation, no interactive controls. Discovery proved the whole integration end-to-end against a discarded install-and-build spike (research V1–V4, F1): Storybook 10.5.8 (@storybook/react-vite) builds on this app's Vite 8.1.5 (Rolldown/Oxc); importing src/index.css puts Panda's tokens, recipe classes and the @media (prefers-color-scheme: dark) layer into the preview; the React Compiler runs clean in the Storybook build; and a scoped Biome *.stories.tsx override admits colocated story files while the styled-system import ban still binds.
Goal
apps/web gains a local Storybook (Vite builder) that renders Button, Input, Dialog and Tooltip in isolation with their Panda styling applied (light, and _osDark under prefers-color-scheme: dark), each with its variants sweepable via controls; the /ui gallery and its feature folder are removed; and design-system.md / react.md record Storybook as the catalogue home. It is a developer tool only — no CI job builds or deploys it, no host (spec SC6).
Approach
Four tasks, sequenced so each lands on a green tree. Task 1 stands the whole pipeline up end-to-end behind one story (the riskiest integration, proven once); Task 2 adds the other three stories; Task 3 retires the now-redundant /ui stopgap; Task 4 writes the docs that describe what the first three made true (so SC5 is checkable by reading).
1 · Storybook boots with the Button story, styled. Add the two devDeps (storybook, @storybook/react-vite, exact 10.5.8, in lockstep); flip postcss.config.cjs to array syntax (F1) and update its comment; create .storybook/main.ts (framework @storybook/react-vite, stories glob ../src/**/*.stories.@(ts|tsx)) and .storybook/preview.ts (import '../src/index.css'); add the storybook script to apps/web/package.json; add the apps/web/**/*.stories.tsx Biome override (R4); add storybook-static/ to .gitignore; and create shared/ui/Button.stories.tsx exposing variant/size/disabled as controls plus a story per variant. Deliverable: pnpm --filter @gw2priory/web storybook boots and shows Button styled, and pnpm typecheck/lint/build/test stay green.
2 · The remaining three stories. Create Input.stories.tsx (a default with label + placeholder, and an invalid story with error), Dialog.stories.tsx (a render function holding local open state, opened by a Button trigger — useState only, no styled-system import), and Tooltip.stories.tsx (a Button trigger wrapped with content). Each imports the finished component from ./ and nothing from @base-ui/react or styled-system. Deliverable: all four appear in the sidebar, styled; pipeline green.
3 · Retire the /ui stopgap. Delete features/ui-gallery/ (UiGalleryPage.tsx, routes.tsx, styles.ts, __tests__/UiGalleryPage.test.tsx) and remove the uiGalleryRoutes import and spread from main.tsx (the only two external references — grep-confirmed). Deliverable: no /ui route, no features/ui-gallery/; pnpm typecheck/lint/build/test green without them.
4 · Documentation. Rewrite design-system.md's §"Interim surface": Storybook is the catalogue home and /ui is retired, carrying the integration facts (array-syntax PostCSS, preview imports src/index.css, the compiler runs in the Storybook build, dark mode is viewed via prefers-color-scheme emulation with no class toggle, stories cannot import styled-system). Add to react.md the *.stories.tsx Biome override and the "stories typecheck but are never run as tests" fact (Vitest include excludes them; tsconfig includes them). Deliverable: pnpm docs:build green; SC5 true by reading.
Architecture
apps/web/
package.json + devDeps storybook 10.5.8, @storybook/react-vite 10.5.8; + "storybook" script
postcss.config.cjs object → array syntax (F1); comment updated
.gitignore (or root) + storybook-static/
.storybook/
main.ts framework @storybook/react-vite; stories: ../src/**/*.stories.@(ts|tsx)
preview.ts import '../src/index.css' (Panda tokens/recipes/_osDark reach the preview)
src/
shared/ui/
Button.stories.tsx variant/size/disabled controls + a story per variant
Input.stories.tsx default (label+placeholder) + invalid (error)
Dialog.stories.tsx local open state, opened by a Button trigger
Tooltip.stories.tsx Button trigger wrapped with content
main.tsx − uiGalleryRoutes import & spread
features/ui-gallery/ DELETED (page, routes, styles, test)
biome.json + apps/web/**/*.stories.tsx override (export|camelCase|PascalCase)
../../docs/architecture/design-system.md §"Interim surface" rewritten (Storybook landed)
../../docs/architecture/react.md + stories naming override & vitest/tsconfig noteThe workbench is local-only (SC6). No build-storybook script, no CI workflow, no static host — the one script is storybook dev. Adding either later is a separate rung (spec Out of scope).
Stories honour the closed-component boundary (spec R3, research V3). A story imports the finished component from ./ and drives it through props; it imports nothing from @base-ui/react or styled-system (the Biome ban still binds under apps/web/src/**), so layout inside a story uses plain elements and useState, never css().
Dark mode has no bespoke toggle (research V2). _osDark compiles to @media (prefers-color-scheme: dark), so the workbench renders dark when the browser reports dark (OS setting or DevTools "Emulate CSS prefers-color-scheme: dark"). A class/data-attribute theme addon would not fire _osDark, and design-system.md rejects a _dark class variant — so none is added.
Tech stack
React 19.2.8, React Router 8.3.0, @base-ui/react 1.6.0, Panda 1.11.5, Vite 8.1.5 (Rolldown/Oxc) with @vitejs/plugin-react 6.0.4 and the React Compiler via @rolldown/plugin-babel (panicThreshold: 'all_errors'), Biome 2.5.5, Vitest 4.1.10, TypeScript 7. Node ≥ 22.18, pnpm 11.15.1.
New dev dependencies (two direct, ~74 transitive): storybook 10.5.8 and @storybook/react-vite10.5.8 — exact pins, no ^, and the same version (Storybook packages move in lockstep). No new runtime dependency; Storybook is devDependencies only and is not part of vite build.
Global Constraints
Copied verbatim from the architecture docs and the approved spec. Every task inherits these.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in stories. Useunknownplus narrowing, or model the type properly. - No non-null assertions (
!) to silence the compiler, and no@ts-expect-errorwithout a comment.
From docs/architecture/react.md and design-system.md:
styled-systemis imported only inside astyles.ts. A*.stories.tsxis underapps/web/src/**, so the ban binds — a story imports nothing fromstyled-system(research V3).@base-ui/reactandstyled-systemrecipes are imported only insideshared/ui. A story imports the finished component, never a Base UI part (spec R3 / 019 R2 boundary).- Tokens, never literals. No literal hex/
rgb/hslinapps/web/src; the existingconventions.test.tsguard already covers stories. - No hand-written memoization — the React Compiler owns it, and it runs in the Storybook build too.
- Filenames equal an export or are camelCase, except where an override widens it — the new
*.stories.tsxoverride addsPascalCase(research V3), like the__tests__override.
From the approved spec.md:
- Local dev tool only (SC6). No CI build, no host, no visual-regression, no interaction tests.
- No API endpoint / no HTTP contract change.
apps/api/openapi.jsonuntouched;pnpm verify:contractstays green. - Exact version pins,
storybook==@storybook/react-vite==10.5.8.
From CLAUDE.md: typecheck clean, tests pass, no unexplained escape hatches, the human reviews the diff, and the spec's status moves to implemented inside the branch before merge.
File Structure
| Path | Change | Responsibility |
|---|---|---|
apps/web/package.json | modified | + storybook & @storybook/react-vite devDeps (10.5.8); + "storybook": "storybook dev -p 6006" |
apps/web/postcss.config.cjs | modified | object → array syntax (F1); comment updated to cite the Storybook Vite-builder requirement |
apps/web/.storybook/main.ts | new | framework: '@storybook/react-vite'; stories: ['../src/**/*.stories.@(ts|tsx)'] |
apps/web/.storybook/preview.ts | new | import '../src/index.css' — Panda tokens/recipes/_osDark reach the preview |
apps/web/src/shared/ui/Button.stories.tsx | new | variant/size/disabled controls + a story per variant |
apps/web/src/shared/ui/Input.stories.tsx | new | default (label + placeholder) + invalid (error) |
apps/web/src/shared/ui/Dialog.stories.tsx | new | render fn with local open state, opened by a Button trigger |
apps/web/src/shared/ui/Tooltip.stories.tsx | new | Button trigger wrapped with content |
apps/web/src/main.tsx | modified | remove uiGalleryRoutes import + spread |
apps/web/src/features/ui-gallery/ | deleted | page, routes.tsx, styles.ts, __tests__/UiGalleryPage.test.tsx |
biome.json | modified | + apps/web/**/*.stories.tsx override → filenameCases: ["export","camelCase","PascalCase"] |
.gitignore | modified | + storybook-static/ (guards against an accidental ad-hoc build being committed) |
docs/architecture/design-system.md | modified | §"Interim surface" rewritten — Storybook landed, /ui retired, integration facts |
docs/architecture/react.md | modified | *.stories.tsx naming override; "stories typecheck but never run as tests" |
Data & contracts
No HTTP contract change and no new runtime type surface — stories consume the existing component props (spec 019's Button/Input/Dialog/Tooltip APIs) via @storybook/react-vite's Meta/StoryObj generics. apps/api/openapi.json is untouched.
Test strategy
This is a tooling feature: behaviour and accessibility of the four components are already covered by their Vitest tests (019 R14), and interaction tests are out of scope (SC6). So no new Vitest test is added — verification is the existing suite staying green plus running the workbench and reviewing it. Stated honestly, in the spec's own DoD style:
| Spec criterion | How it is verified |
|---|---|
| SC1 · P1 #1, #2, #4 | Manual: pnpm --filter @gw2priory/web storybook boots; the sidebar lists the four; each renders styled; controls sweep variants. The build proving Panda CSS is present is research V2's evidence. |
| SC2 · P1 #5 | Human review of the four story files' imports (finished component only; no @base-ui/react/styled-system), backed by pnpm lint (the styled-system ban) staying green. |
| SC3 · P1 #3 | Manual: with DevTools "Emulate prefers-color-scheme: dark" (or OS dark), a story shows the _osDark values (e.g. surface flips). |
| SC4 · P2 #1, #2, #3 | pnpm build + pnpm test green with features/ui-gallery/ and the /ui route removed; grep confirms no ui-gallery/uiGalleryRoutes reference remains. |
| SC5 · P2 #3 | Human review of design-system.md / react.md; pnpm docs:build compiles both. |
| SC6 | Human review: no CI workflow, no build-storybook script, no host added; the only script is storybook dev. |
| SC7 | The full command set at step 5: pnpm typecheck, test, lint, build, verify:contract, docs:build, and storybook boots. |
Alternatives considered
- A lighter tool (Ladle, or improving
/ui) — rejected:design-system.mdalready commits to Storybook as the catalogue home; discovery confirmed it works on this stack. Not re-litigated. @storybook/addon-themeswithThemeByClassNamefor dark mode — rejected: it toggles a class/data attribute, which does not trigger_osDark'sprefers-color-schememedia query (research V2). No addon earns its place; DevTools/OS emulation covers it.- Stories in a
stories/subfolder — rejected: the human chose colocation inshared/ui, matching the design system's "colocated" instinct. - A
build-storybookscript / CI smoke build / hosted Storybook — rejected: spec SC6 is local-only; these are later rungs (spec Out of scope). - Interaction / play-function tests — rejected: 019's Vitest tests already cover the four components' behaviour and a11y (spec Out of scope).
- Keeping
/uialongside Storybook — rejected: the human chose to delete it in this spec; two catalogues would drift.
Risks
- Install footprint & supply chain. Storybook adds ~74 transitive devDeps. Mitigation: the spike's
pnpm installreported "Lockfile passes supply-chain policies"; pins are exact; it is dev-only and not invite build. - Only
storybook buildwas spiked, notstorybook dev. Both drive the same builder, but step 5 must bootstorybook devonce and confirm a styled render (SC1) to close research V1's caveat. - Version lockstep.
storybookand@storybook/react-vitemust be the identical version, or the CLI errors. Mitigation: both pinned to10.5.8; the task adds them in one command. - A future component that bails the compiler would fail
storybook buildtoo (research V4) — but there is nostorybook buildin CI, so a bail-out surfaces viapnpm buildas today. No new failure surface. prefers-color-schemehas no in-toolbar toggle. Accepted (research V2): dark is viewed via OS/DevTools emulation; SC3 is written to that mechanism.
Open questions
- Storybook port. The
storybookscript pins-p 6006(Storybook's default). Flag only in case a local conflict argues for another. (Non-blocking.) - Everything
research.mdraised is closed: statuscomplete, four verdicts, one criterion refined (SC3), no refutations.