Tasks 022 — Storybook design-system workbench
For agentic workers: REQUIRED SUB-SKILL —
superpowers:subagent-driven-development(one implementer per task, then a two-stage review: spec/plan compliance, then code quality). Reach forsuperpowers:systematic-debuggingon any surprise rather than guessing. Steps use- [ ]for tracking.
Execution skill: superpowers:subagent-driven-development, superpowers:systematic-debugging on any surprise.
Derived from plan.md (approved, 2026-08-17). Each task is small, independently verifiable, and reviewed as its own diff. A task is done only when it satisfies the Definition of Done in CLAUDE.md: typecheck clean, tests pass, no any / no unexplained escape hatches, the human has reviewed the diff.
A note on TDD here (constitution override, stated openly). The constitution's step 4 pairs test-driven-development with every task, and test-driven-development says "no production code before a failing test that demands it." This feature writes no production logic — it wires a dev tool (config), authors declarative story files, deletes a folder, and edits docs. The four components' behaviour and accessibility are already covered by their 019 Vitest tests, and interaction tests are out of scope (spec SC6). So there is no new unit for a failing test to bite on, and no new Vitest test is added (the approved plan's Test strategy says so). In its place, every task ends with a concrete, re-runnable verification — a command whose output proves the deliverable, plus the existing suite staying green. That is the honest analogue of red→green for a tooling change; where a story file does contain a small render function, systematic-debugging (not guessing) resolves any compiler surprise.
Global Constraints live in plan.md (copied verbatim from the architecture docs) and apply to every task below — not repeated per task. The load-bearing ones here: no any; exact version pins, storybook == @storybook/react-vite == 10.5.8; a *.stories.tsx imports the finished component and nothing from @base-ui/react or styled-system (the Biome ban binds under apps/web/src/** — research V3); no hand-written memoization (the React Compiler runs in the Storybook build too — research V4); local dev tool only — no build-storybook script, no CI, no host (SC6).
Verification vocabulary (used below):
- Pipeline green = all of:
pnpm --filter @gw2priory/web exec tsc -p tsconfig.json --noEmit,pnpm lint,pnpm --filter @gw2priory/web build,pnpm --filter @gw2priory/web exec vitest run. - Storybook compiles =
pnpm --filter @gw2priory/web exec storybook buildcompletes. This is an ad-hoc local proof only — it is not committed as a script and not run in CI (SC6 intact); it emitsstorybook-static/(git-ignored by T1). Grep its output to prove what rendered.
Build order follows plan.md §Approach. Tasks are drawn so a reviewer can accept one without the next.
T1 — Storybook boots with the Button story, styled
Satisfies: R1, R2, R4, F1, P1 #1, P1 #2, P1 #4, P1 #5 (Button), SC1, SC6; R5 in part (pipeline stays green).
Files:
- Modify:
apps/web/package.json(two devDeps + thestorybookscript) - Modify:
apps/web/postcss.config.cjs(object → array syntax; comment) - Create:
apps/web/.storybook/main.ts - Create:
apps/web/.storybook/preview.ts - Modify:
biome.json(add the*.stories.tsxfilename override) - Modify:
.gitignore(addstorybook-static/) - Create:
apps/web/src/shared/ui/Button.stories.tsx
Interfaces produced: the .storybook/ config and the storybook script that T2's stories rely on; the Biome override that admits every *.stories.tsx.
- [ ] Install the two devDeps, exact-pinned, in one command (they must be the same version):
pnpm --filter @gw2priory/web add -D --save-exact storybook@10.5.8 @storybook/react-vite@10.5.8Confirm apps/web/package.json shows "storybook": "10.5.8" and "@storybook/react-vite": "10.5.8" (no ^).
- [ ] Add the script to
apps/web/package.jsonscripts(local dev only — nobuild-storybook):
"storybook": "storybook dev -p 6006"- [ ] Flip
apps/web/postcss.config.cjsto array syntax (F1 — the Storybook Vite builder requires it, and it stays compatible withvite build):
// Panda CSS has no Vite plugin — the integration point is PostCSS. Storybook's Vite builder needs the
// array plugin form (Panda docs), which the app's own `vite build` also accepts (research 022 F1).
module.exports = {
plugins: [require('@pandacss/dev/postcss')()],
};- [ ] Create
apps/web/.storybook/main.ts:
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
framework: '@storybook/react-vite',
};
export default config;- [ ] Create
apps/web/.storybook/preview.ts(this import is what carries Panda's tokens, recipes and the_osDarklayer into the preview — research V2):
import '../src/index.css';
import type { Preview } from '@storybook/react-vite';
const preview: Preview = {};
export default preview;- [ ] Add the Biome override to
biome.json, as the last entry ofoverrides(after the__tests__override; overrides replace per-rule, so it must list every case — research V3):
{
"includes": ["apps/web/**/*.stories.tsx"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": { "filenameCases": ["export", "camelCase", "PascalCase"] }
}
}
}
}
}[ ] Ignore the ad-hoc build output. Add
storybook-static/to the repo's root.gitignore(beside the existing build-output ignores such asdist).[ ] Create
apps/web/src/shared/ui/Button.stories.tsx— controls sweep every variant, and one story per variant mirrors what/uishows today:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
const meta = {
component: Button,
args: { children: 'Button' },
argTypes: {
variant: { control: 'inline-radio', options: ['solid', 'outline', 'ghost'] },
size: { control: 'inline-radio', options: ['sm', 'md'] },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Solid: Story = { args: { variant: 'solid' } };
export const Outline: Story = { args: { variant: 'outline' } };
export const Ghost: Story = { args: { variant: 'ghost' } };
export const Small: Story = { args: { variant: 'solid', size: 'sm' } };
export const Disabled: Story = { args: { variant: 'solid', disabled: true } };- [ ] Verify the filename override works (it fails without the override, proving the override is load-bearing — the "teeth" check):
pnpm --filter @gw2priory/web exec biome check src/shared/ui/Button.stories.tsxExpected: Checked 1 file … No fixes applied. (Sanity: temporarily remove the new override and re-run — it errors useFilenamingConvention; restore it.)
- [ ] Prove Storybook compiles and the Button is styled (the task's runnable check — research V2's method):
pnpm --filter @gw2priory/web exec storybook build
grep -rl "button--variant_solid" apps/web/storybook-static/assets/*.css
grep -rl "prefers-color-scheme" apps/web/storybook-static/assets/*.css
grep -o '"title":"[^"]*Button"' apps/web/storybook-static/index.json | headExpected: storybook build completes; the button recipe class and the @media (prefers-color-scheme: dark) block are in the built CSS (Panda styling incl. dark reaches the preview); index.json lists the Button story.
[ ] Pipeline green (see vocabulary above) — Storybook's presence and the array-syntax PostCSS leave the app's own build, typecheck, lint and tests untouched.
[ ] Boot it once, visually (closes research V1's
storybook devcaveat — this is the human's Step-5 check, noted here so it is not forgotten):pnpm --filter @gw2priory/web storybook, openhttp://localhost:6006, confirmButtonrenders styled and thevariant/size/disabledcontrols change it.[ ] Commit (
web: Storybook workbench + Button story (022 T1)). The commit must not includestorybook-static/(git-ignored).
Verified by: storybook build compiles with the Button recipe + _osDark in the preview CSS; the *.stories.tsx Biome override passes where it failed before; pipeline green; manual storybook dev boot.
T2 — The remaining three stories (Input, Dialog, Tooltip)
Satisfies: R3, P1 #5, SC2, SC3 (all four viewable, dark included).
Files:
- Create:
apps/web/src/shared/ui/Input.stories.tsx - Create:
apps/web/src/shared/ui/Dialog.stories.tsx - Create:
apps/web/src/shared/ui/Tooltip.stories.tsx
Each imports the finished component from ./ and nothing from @base-ui/react or styled-system.
- [ ] Create
apps/web/src/shared/ui/Input.stories.tsx— a default and the invalid state (mirrors the gallery's two inputs):
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Input } from './Input';
const meta = {
component: Input,
args: { label: 'Email', placeholder: 'you@example.com' },
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Invalid: Story = { args: { error: 'Required' } };- [ ] Create
apps/web/src/shared/ui/Dialog.stories.tsx— a controlled dialog opened by aButtontrigger, holding its ownopenstate (ephemeral UI state —useState, nostyled-systemimport). Therenderargument takes(args)without a destructured default (the React Compiler rejects a default on a destructured param — react.md; it runs in the Storybook build):
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { Button } from './Button';
import { Dialog } from './Dialog';
const meta = {
component: Dialog,
args: {
title: 'Connect key',
description: 'Paste a GW2 API key.',
children: <p>Dialog body.</p>,
},
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="solid" onClick={() => setOpen(true)}>
Open dialog
</Button>
<Dialog {...args} open={open} onOpenChange={setOpen} />
</>
);
},
};- [ ] Create
apps/web/src/shared/ui/Tooltip.stories.tsx— aButtontrigger wrapped withcontent:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
import { Tooltip } from './Tooltip';
const meta = {
component: Tooltip,
args: { content: 'Copy to clipboard' },
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OnAButton: Story = {
render: (args) => (
<Tooltip {...args}>
<Button variant="ghost">Copy</Button>
</Tooltip>
),
};- [ ] Prove all four are present and compile (the runnable check):
pnpm --filter @gw2priory/web exec storybook build
grep -o '"title":"[^"]*"' apps/web/storybook-static/index.json | sort -uExpected: storybook build completes and the four titles (Button, Input, Dialog, Tooltip) all appear.
[ ] Pipeline green. In particular
pnpm lintconfirms no story importsstyled-system(the ban binds to*.stories.tsx), andtsctypechecks the story files.[ ] Boot it once, visually:
pnpm --filter @gw2priory/web storybook— the sidebar lists all four; the Dialog story opens on click; the Tooltip shows on hover/focus. With DevTools "Emulate CSS prefers-color-scheme: dark", a story'ssurface/textflip to the_osDarkvalues (SC3).[ ] Commit (
web: Input/Dialog/Tooltip stories (022 T2)).
Verified by: index.json lists all four components; pipeline green (incl. the styled-system ban on stories); manual boot showing the four, the dialog opening, and dark via emulation.
T3 — Retire the /ui stopgap
Satisfies: R6, P2 #1, P2 #2, SC4.
Files:
Delete:
apps/web/src/features/ui-gallery/(all four files:UiGalleryPage.tsx,routes.tsx,styles.ts,__tests__/UiGalleryPage.test.tsx)Modify:
apps/web/src/main.tsx(remove theuiGalleryRoutesimport and its spread)[ ] Delete the feature folder:
git rm -r apps/web/src/features/ui-gallery[ ] Remove the two references in
apps/web/src/main.tsx(grep-confirmed to be the only external references): delete the import lineimport { routes as uiGalleryRoutes } from './features/ui-gallery/routes';and remove...uiGalleryRoutes,from the layout route'schildrenarray. LeaveApp.tsxuntouched (it has no/uiNavLink).[ ] Confirm nothing still points at it:
grep -rn "ui-gallery\|uiGalleryRoutes\|UiGalleryPage" apps/web/srcExpected: no matches.
[ ] Pipeline green without the gallery —
tsc,pnpm lint,pnpm --filter @gw2priory/web build, andpnpm --filter @gw2priory/web exec vitest run(the gallery's own test is gone; nothing else referenced it, so the suite stays green).[ ] Confirm the route is gone at runtime:
pnpm --filter @gw2priory/web dev, navigate to/ui, confirm it no longer resolves to a gallery (falls through to the router's default error, as any unrouted path does — react.md).[ ] Commit (
web: retire the /ui gallery — Storybook is the catalogue now (022 T3)).
Verified by: grep finds no ui-gallery/uiGalleryRoutes/UiGalleryPage; pipeline green without the folder; /ui no longer resolves.
T4 — Documentation, and close the traceability
Satisfies: R7, P2 #3, SC5; fills the spec's traceability table; transcribes the DoD status.
Files:
Modify:
docs/architecture/design-system.mdModify:
docs/architecture/react.mdModify:
specs/022-storybook/spec.md(traceability table; then, on the human's decision, status)[ ] Rewrite
design-system.md's §"Interim surface". It currently says Storybook is "the intended home" and/uiis "the stopgap until it lands." Replace it with a §"Storybook workbench" that states: Storybook (@storybook/react-vite, Vite builder) is the catalogue home, run locally withpnpm --filter @gw2priory/web storybook; the/uigallery has been retired. Carry the integration facts: PostCSS is in array syntax for the Vite builder (F1);.storybook/preview.tsimportssrc/index.cssso Panda's tokens, recipes and the_osDarklayer reach the preview (V2); the React Compiler runs in the Storybook build too (V4); dark mode is viewed viaprefers-color-schemeemulation (OS/DevTools) — there is deliberately no class-based toggle, because_osDarkis media-query-driven and a_darkclass variant is rejected (V2); a*.stories.tsxcannot importstyled-system, so story layout uses plain elements anduseState(V3).[ ] Update
react.md. Add the*.stories.tsxnaming override beside the__tests__note (it addsPascalCasesoButton.stories.tsx— checked asButton— passes), and add one line under the testing/enforcement notes: story files are typechecked (tsconfig includessrc/**/*.tsx) but are never run as tests (Vitestincludeis*.test.ts(x)only) — research V4.[ ] Validate docs compile:
pnpm -w run docs:build(VitePress rendersdocs/**andspecs/**).[ ] Fill
specs/022-storybook/spec.md's traceability table from this plan's Test-strategy table — each row names its verification honestly (a command, or human review), since this feature adds no Vitest test:
| Criterion | Test / verification |
|---|---|
| P1 #1, #2, #4 · SC1 | Manual storybook dev boot (T1) — sidebar lists the four, Button styled, controls sweep; the build-CSS proof (research V2) |
| P1 #3 · SC3 | Manual (T2) — DevTools prefers-color-scheme: dark flips surface/text in a story |
| P1 #5 · SC2 | Human review of the four story files' imports + pnpm lint (the styled-system ban on stories) |
| P2 #1, #2 · SC4 | grep finds no ui-gallery/uiGalleryRoutes; pipeline green without the folder (T3) |
| P2 #3 · SC5 | Human review of design-system.md/react.md; pnpm docs:build green |
| SC6 | Human review — no build-storybook script, no CI workflow, no host; only storybook dev |
| SC7 | The full command set at Step 5: pnpm typecheck, test, lint, build, verify:contract, docs:build, and storybook boots |
[ ] Confirm the workflow invariants still pass (zero files under
docs/superpowers/, etc.):pnpm -w exec vitest run tests/workflow/repo-invariants.test.ts(run the project's workflow-invariant suite; if its path differs, run the repo-invariants test named inspecs/001-workflow-tooling).[ ] Commit (
docs: 022 Storybook workbench; retire /ui note; traceability (022 T4)).[ ] DoD status (human decision, agent transcribes). Once the human confirms the Definition of Done is met, transcribe
specs/022-storybook/spec.mdStatus → implemented inside this branch (part of the PR diff, before merge — never a later edit tomain), and say in the commit that the human decided it. Commit (specs: 022 status implemented (human)).
Verified by: pnpm docs:build green; the workflow-invariant suite green; human review that design-system.md/react.md describe the repository; the traceability table filled.
Step 5 · Verify (before the PR)
Run the full set and record the output (superpowers:verification-before-completion), then superpowers:requesting-code-review:
- [ ]
pnpm --filter @gw2priory/web exec tsc -p tsconfig.json --noEmit— clean - [ ]
pnpm test— green - [ ]
pnpm lint— green - [ ]
pnpm --filter @gw2priory/web build— green (React Compiler clean) - [ ]
pnpm verify:contract— green (no HTTP change) - [ ]
pnpm -w run docs:build— green - [ ]
pnpm --filter @gw2priory/web storybookboots; the four components render styled; dark viaprefers-color-schemeemulation
Notes
Staging area for decisions and surprises found during implementation. Move each into spec.md, research.md, or docs/ before closing the feature — this section is not a home.
- If
storybook devbehaves differently fromstorybook build(research V1's caveat): the spike only exercisedbuild. Ifdevsurfaces a Rolldown/HMR issue, usesystematic-debuggingand record the resolution here before it moves intoresearch.md. - If a story's
renderfunction trips the React Compiler (panicThreshold: 'all_errors'runs in the Storybook build — V4): it is almost certainly a default on a destructured parameter (react.md's known gotcha) — move the default into the body, do not add"use no memo". build-storybook/ CI / hosting stay out (SC6). If a later spec wants a CI smoke build or a hosted Storybook, that is a new rung with its own spec — do not add it here.- As-built deviation (T2): Dialog's and Tooltip's
meta.argscarry extra keys the verbatim snippets above don't show.Dialog.stories.tsx'smeta.argsaddsopen: falseandonOpenChange: () => {};Tooltip.stories.tsx's addschildren: <Button variant="ghost">Copy</Button>. Both components require those props, and CSF3'sStoryObj<typeof meta>types a story'sargsagainstmeta.args— without them,tscrejects the story as missing required props. Each story's ownrenderoverridesopen,onOpenChangeandchildrenat runtime (Dialog.stories.tsxpasses its ownuseState-backedopen/onOpenChange;Tooltip.stories.tsxwraps its ownButtonchild), so themeta.argsvalues are dead at runtime — present only to satisfy the type, not to be read. Copying either snippet verbatim without this note would read as if those defaults render; they don't.