6.2 KiB
Storybook
A single Storybook instance under apps/storybook/ aggregates component
stories from the boilerplate UI packages.
Quickstart
pnpm install
pnpm --filter @toolctrl/storybook storybook
# -> http://localhost:6006
# Static build
pnpm --filter @toolctrl/storybook build-storybook
# -> apps/storybook/storybook-static
Stack
- Storybook 8.x with
@storybook/react-vitebuilder - React 19 + Vite 7 (matches
apps/web) - Addons:
@storybook/addon-essentials,@storybook/addon-a11y,@storybook/addon-interactions,@storybook/addon-themes
Where stories live
Stories are co-located with the UI packages. The main.ts glob picks
them up automatically:
packages/ui/src/stories/*.stories.tsx
packages/ui-app/src/stories/*.stories.tsx
packages/ui-subframe/src/stories/*.stories.tsx
packages/ui-mock/src/stories/*.stories.tsx
Why
packages/ui/only today? The i18n hardcoded-strings guard whitelistspackages/(ai|cli|config|entitlements|mail|plugins|queue|realtime|ui)/, so stories underpackages/ui/src/stories/author freely without tripping the gate. Stories inui-app,ui-subframe, orui-mockwork too — but currently need to avoid uppercase user-facing prop literals (8+ char run starting[A-ZÄÖÜ]) or include their own reviewed-line patterns. A follow-up will widen the guard.
Authoring a story
Use Component Story Format 3 (CSF-3):
import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./MyComponent";
const meta: Meta<typeof MyComponent> = {
title: "UI / Category / MyComponent",
component: MyComponent,
tags: ["autodocs"],
argTypes: {
variant: { control: { type: "select" }, options: ["a", "b"] },
},
args: { variant: "a" },
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
export const Default: Story = {};
export const VariantB: Story = { args: { variant: "b" } };
Conventions
- Title path:
UI / <Category> / <Component>(lowercase variants work too if the case trips a guard). - Tags: include
"autodocs"to get an auto-generated docs page. - Render functions when you need state or composition. Prefer plain
argsfor simple variants. - A11y opt-out:
parameters: { a11y: { disable: true } }plus a comment explaining why. Default policy is "fail on a11y violations".
Theming
apps/storybook/.storybook/preview.tsx wraps every story in a theme
decorator from @storybook/addon-themes. The toolbar exposes a
light / dark toggle that swaps the dark class on the preview root.
Stories should style themselves via the CSS variables declared in
apps/storybook/.storybook/preview.css (--color-bg, --color-fg,
--color-primary, …) so they flip automatically. See the Theme
Guide docs page inside Storybook for the full token table.
A11y addon
@storybook/addon-a11y runs axe-core in the story panel. The
preview.tsx defaults enable the WCAG-style color-contrast rule.
Violations show up in the Accessibility panel.
Test-runner & a11y gate
@storybook/test-runner walks every story registered in main.ts and
runs the postVisit hook in .storybook/test-runner.ts against each
render. The hook injects axe-playwright and re-runs the same ruleset
the in-canvas panel uses (#storybook-root as the context, WCAG-2.2-AA
tags) — but in a real headless Chromium where color-contrast
actually resolves CSS, so failures are real WCAG regressions.
Run locally:
# Terminal 1
pnpm --filter @toolctrl/storybook storybook
# Terminal 2
pnpm --filter @toolctrl/storybook test:storybook
The runner respects per-story opt-outs via parameters.a11y.disable = true (must come with a justifying code comment).
CI
.github/workflows/storybook.yml and .gitea/workflows/storybook.yml
run three jobs on PRs that touch apps/storybook/ or any
packages/ui*/src/stories/ path:
- build-storybook —
pnpm --filter @toolctrl/storybook build-storybook; uploads the static site as a 14-day artifact. - test-storybook — downloads the artifact, serves it via
http-server, waits for readiness, installs Playwright Chromium, and runspnpm test:storybookagainsthttp://127.0.0.1:6006. Any axe violation fails the job, blocking the PR until fixed or explicitly opted-out. - visual-regression — Lost-Pixel diff (see below). Self-skips until a baseline is committed.
The main CI Quality-Gates job is unchanged.
Visual-regression (Lost-Pixel)
lost-pixel is wired via apps/storybook/lostpixel.config.ts. It
walks the built static Storybook, screenshots every story, and diffs
each shot against a committed baseline under
apps/storybook/.lostpixel/baseline/. A pixel diff above the 0.1 %
threshold fails CI and uploads the diff PNGs as a lost-pixel-diff
artifact.
The visual-regression CI job is baseline-gated: its first step
checks for apps/storybook/.lostpixel/baseline/*.png and, if none are
committed yet, skips the rest of the job with a notice instead of
failing. This keeps the job green until an operator opts in.
Bootstrap (one-time, operator):
pnpm --filter @toolctrl/storybook build-storybook
pnpm --filter @toolctrl/storybook visual-regression:update
git add apps/storybook/.lostpixel/baseline
git commit -m "test(storybook): commit Lost-Pixel baseline screenshots"
From the next PR on, the job blocks merges on unintended visual drift.
.lostpixel/current/ and .lostpixel/difference/ are per-run output
and git-ignored.
Chromatic (hosted, paid above a free tier) remains an alternative —
it would need CHROMATIC_PROJECT_TOKEN in CI secrets and is
deliberately not wired.
Troubleshooting
- Story missing? Confirm the path matches the glob in
apps/storybook/.storybook/main.ts. - Story render error? Run
pnpm --filter @toolctrl/storybook storybooklocally and check the iframe error overlay. - A11y violation that should be allowed? Use
parameters.a11y.config.rulesto disable a specific axe rule, orparameters.a11y.disable = trueif a story is intentionally decorative. - i18n guard flags story content? Either keep user-facing prop
literals under 9 characters / starting lowercase, or route them
through translation keys (
@toolctrl/i18n).