Skip to content

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 for superpowers:systematic-debugging on 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 build completes. This is an ad-hoc local proof only — it is not committed as a script and not run in CI (SC6 intact); it emits storybook-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 + the storybook script)
  • 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.tsx filename override)
  • Modify: .gitignore (add storybook-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):
bash
pnpm --filter @gw2priory/web add -D --save-exact storybook@10.5.8 @storybook/react-vite@10.5.8

Confirm apps/web/package.json shows "storybook": "10.5.8" and "@storybook/react-vite": "10.5.8" (no ^).

  • [ ] Add the script to apps/web/package.json scripts (local dev only — no build-storybook):
json
"storybook": "storybook dev -p 6006"
  • [ ] Flip apps/web/postcss.config.cjs to array syntax (F1 — the Storybook Vite builder requires it, and it stays compatible with vite build):
js
// 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:
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 _osDark layer into the preview — research V2):
ts
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 of overrides (after the __tests__ override; overrides replace per-rule, so it must list every case — research V3):
json
{
  "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 as dist).

  • [ ] Create apps/web/src/shared/ui/Button.stories.tsx — controls sweep every variant, and one story per variant mirrors what /ui shows today:

tsx
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):
bash
pnpm --filter @gw2priory/web exec biome check src/shared/ui/Button.stories.tsx

Expected: 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):
bash
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 | head

Expected: 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 dev caveat — this is the human's Step-5 check, noted here so it is not forgotten): pnpm --filter @gw2priory/web storybook, open http://localhost:6006, confirm Button renders styled and the variant/size/disabled controls change it.

  • [ ] Commit (web: Storybook workbench + Button story (022 T1)). The commit must not include storybook-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):
tsx
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 a Button trigger, holding its own open state (ephemeral UI state — useState, no styled-system import). The render argument takes (args) without a destructured default (the React Compiler rejects a default on a destructured param — react.md; it runs in the Storybook build):
tsx
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 — a Button trigger wrapped with content:
tsx
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):
bash
pnpm --filter @gw2priory/web exec storybook build
grep -o '"title":"[^"]*"' apps/web/storybook-static/index.json | sort -u

Expected: storybook build completes and the four titles (Button, Input, Dialog, Tooltip) all appear.

  • [ ] Pipeline green. In particular pnpm lint confirms no story imports styled-system (the ban binds to *.stories.tsx), and tsc typechecks 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's surface/text flip to the _osDark values (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 the uiGalleryRoutes import and its spread)

  • [ ] Delete the feature folder:

bash
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 line import { routes as uiGalleryRoutes } from './features/ui-gallery/routes'; and remove ...uiGalleryRoutes, from the layout route's children array. Leave App.tsx untouched (it has no /ui NavLink).

  • [ ] Confirm nothing still points at it:

bash
grep -rn "ui-gallery\|uiGalleryRoutes\|UiGalleryPage" apps/web/src

Expected: no matches.

  • [ ] Pipeline green without the gallery — tsc, pnpm lint, pnpm --filter @gw2priory/web build, and pnpm --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.md

  • Modify: docs/architecture/react.md

  • Modify: 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 /ui is "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 with pnpm --filter @gw2priory/web storybook; the /ui gallery has been retired. Carry the integration facts: PostCSS is in array syntax for the Vite builder (F1); .storybook/preview.ts imports src/index.css so Panda's tokens, recipes and the _osDark layer reach the preview (V2); the React Compiler runs in the Storybook build too (V4); dark mode is viewed via prefers-color-scheme emulation (OS/DevTools) — there is deliberately no class-based toggle, because _osDark is media-query-driven and a _dark class variant is rejected (V2); a *.stories.tsx cannot import styled-system, so story layout uses plain elements and useState (V3).

  • [ ] Update react.md. Add the *.stories.tsx naming override beside the __tests__ note (it adds PascalCase so Button.stories.tsx — checked as Button — passes), and add one line under the testing/enforcement notes: story files are typechecked (tsconfig includes src/**/*.tsx) but are never run as tests (Vitest include is *.test.ts(x) only) — research V4.

  • [ ] Validate docs compile: pnpm -w run docs:build (VitePress renders docs/** and specs/**).

  • [ ] 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:

CriterionTest / verification
P1 #1, #2, #4 · SC1Manual storybook dev boot (T1) — sidebar lists the four, Button styled, controls sweep; the build-CSS proof (research V2)
P1 #3 · SC3Manual (T2) — DevTools prefers-color-scheme: dark flips surface/text in a story
P1 #5 · SC2Human review of the four story files' imports + pnpm lint (the styled-system ban on stories)
P2 #1, #2 · SC4grep finds no ui-gallery/uiGalleryRoutes; pipeline green without the folder (T3)
P2 #3 · SC5Human review of design-system.md/react.md; pnpm docs:build green
SC6Human review — no build-storybook script, no CI workflow, no host; only storybook dev
SC7The 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 in specs/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.md Status → implemented inside this branch (part of the PR diff, before merge — never a later edit to main), 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 storybook boots; the four components render styled; dark via prefers-color-scheme emulation

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 dev behaves differently from storybook build (research V1's caveat): the spike only exercised build. If dev surfaces a Rolldown/HMR issue, use systematic-debugging and record the resolution here before it moves into research.md.
  • If a story's render function 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.args carry extra keys the verbatim snippets above don't show. Dialog.stories.tsx's meta.args adds open: false and onOpenChange: () => {}; Tooltip.stories.tsx's adds children: <Button variant="ghost">Copy</Button>. Both components require those props, and CSF3's StoryObj<typeof meta> types a story's args against meta.args — without them, tsc rejects the story as missing required props. Each story's own render overrides open, onOpenChange and children at runtime (Dialog.stories.tsx passes its own useState-backed open/ onOpenChange; Tooltip.stories.tsx wraps its own Button child), so the meta.args values 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.