Storybook
The story lifecycle (Proto / Spec / Live), how the gate reads it, and the assertion discipline that keeps the story suite worth its cost
Story lifecycle: Proto / Spec / Live
Storybook is often where a feature is specified before it is built, so a story
needs to say what its failure means. Three statuses, carried as Storybook
tags.
| Status | Tag | Meaning | A failure means |
|---|---|---|---|
| Proto | proto | Exploring a shape. Nothing wired; may mock the component it depicts. | Nothing about the app. Delete freely once the shape settles. |
| Spec | spec | The shape is settled, the code is not there yet. The story is the requirement. | Not built yet. |
| Live | live | The code exists and this story guards it. | You broke something. |
| Deprecated | deprecated | A retired stage kept for the historical record while its component is still in the tree. | Nothing about the app. |
| Unjudged | (none) | Not a status. Nobody has classified this story yet. | Unknown — which is the problem. |
A deprecated story never blocks (bun run test excludes it; the advisory
run reports it) and stays visible in Storybook's sidebar filter. When the
component it depicts is deleted, the story cannot stay in src/ — move the
snapshot to plans/deprecated/ (the retirement archive; the opposite
direction of plans/references/, which is the landing pad for incoming
prototypes) or rely on git history.
Proto → Spec → Live. The transition that carries weight is Spec → Live, where a failure changes meaning from not built to broken.
Owner review pipeline
For visible work, these statuses are also the review path:
- Proto: explore alternatives with the owner. It is disposable and not approved.
- Spec: the owner has chosen the visible behavior. The story is reviewable evidence for the settled docs.
- Live: implementation matches the approved story, which now blocks regressions.
Use Storybook when the owner can meaningfully judge a component, screen, interaction, or state there. Not every change needs a story. Plans and Fumadocs remain the written authority; Storybook is the visual review and proof surface.
Every story carries a tag; untagged is a gap, not a status
Owner ruling, 2026-08-16: Live is an explicit live tag, and every story
carries one of live, spec, proto, or deprecated.
In plain terms: every story says out loud what it is. A story with nothing written on it is not "fine by default" — it is a story nobody has read yet, and now it looks like one.
Live was once the absence of a tag, which could not tell judged from unread: a status you cannot distinguish from an absence is a silence that reads as approval. The fail-safe that design bought is kept — an untagged story still runs in the blocking project, so forgetting a tag makes the build loud rather than silent — but untagged now means nobody has judged this yet, is reportable, and is meant to reach zero.
Judge, never bulk-stamp. The point of the tag is that someone looked. A
sweep that stamps live across a directory to clear the count produces exactly
the silence-reading-as-approval this ruling exists to remove, and destroys the
evidence of which stories were genuinely assessed. Tag a file only after reading
what its stories assert, and record the reason in a one-line comment. Promotion
to live is a claim that the story fails only when something is broken, so
it must also pass the assertion discipline below — a story pinning chrome rather
than behaviour turns the build red on ordinary design work and should be
tightened before it is promoted, not promoted and then endured.
How the gate reads it
bun run test # node + storybook — blocks. Runs `live` and untagged.
bun run test:advisory # proto, spec, deprecated — reports, always exits 0.
bun run test:all # both
bun run stories:tags # reports every untagged story. Advisory; exits 0.The blocking project excludes proto, spec and deprecated rather than
including live. That is what keeps the fail-safe: a story whose tag someone
forgot still runs where a failure is loud, instead of vanishing from both runs.
spec warns rather than fails on purpose: a red Spec means "not built yet",
which is true of every unbuilt requirement and would keep the gate permanently
red — training everyone to ignore it.
Applying a tag
At file level via meta, or per-story, with the story overriding the file:
const meta = { title: "…", component: BlockCard, tags: ["live"] } satisfies Meta<typeof BlockCard>;
export const NewShape: Story = {
tags: ["proto"],
args: { … },
};Add a one-line comment saying why it carries that status and what would promote it — a bare tag tells the next reader the status but not the intent.
Where the status is visible
- Sidebar filter — the funnel icon above the story list. Tags must be
declared in
.storybook/main.tsto appear there; a tag that only exists in a story file drives the gate but is invisible in the UI. - A pill above the story — a decorator in
.storybook/preview.tsxbadges all four lifecycle states: Live (green), Spec (blue), Proto (amber), and Deprecated (grey), with the meaning in its tooltip. The open canvas must say its status directly; nobody should have to infer Live from an absent badge or navigate back to the sidebar filter. bun run stories:tags— lists every story still carrying no tag. This is the one that answers "what has nobody read yet?", which the sidebar cannot, because an unjudged story looks exactly like a Live one there.
Where a story goes in the sidebar
A story's title is its path in the architecture. The sidebar is a map of
the code, so finding a component's stories never requires guessing which of
several drawers someone filed it in.
In plain terms: the filing cabinet has the same drawers as the code. If you know where a thing lives, you know where its stories are, and the other way round.
The versioning rule already states the second half of this — "Storybook titles follow the folder of the component under test". This states the first half: which root that folder path starts from.
| Where the file lives | Title |
|---|---|
src/shared/ui/atoms/… | Shared/Atoms/<Component> |
src/shared/ui/molecules/… | Shared/Molecules/<Component> |
src/entities/<slice>/… | Entities/<Slice>/<Component> |
src/widgets/<slice>/… | Widgets/<Slice>/<Component> |
src/pages/<slice>/… | Pages/<Slice>/<Component> |
The roots are the Feature-Sliced layers,
because those are the layers the code is actually built from. Atoms/,
Molecules/ and Organisms/ as top-level roots are Atomic Design, the layout
FSD replaced; they survive only as the two sub-groups inside Shared/, where
they match real directories.
Three further rules, each of which has been broken in this repository:
- Segments are separated by
/, never by a space.Stack Anatomyis a sibling ofStack, not a child of it — a one-character mistake, invisible in the source and obvious only in the sidebar. - A version folder adds its
vNsegment; nothing else may. A title naming av3/that does not exist advertises a version boundary the code lacks. - Status never appears in a title. Proto, Spec and Deprecated are carried by
tags, which the gate reads and the sidebar filter shows. A title saying
(prototype)is a second copy of the status, and the copies drift.
The one deliberate exception is Design Tokens/…, which documents the palette
rather than a component and belongs to no layer.
Story assertion discipline
The story suite is the repo's behavioural safety net, and it is also the slowest gate — browser-executed, minutes per run where the node suite takes seconds. Two rules keep it worth its cost:
Assert behaviour, not chrome
A Live story pins a contract: what saves, what refuses, what state a
control reaches. It should survive a redesign. Assert data-state,
aria-expanded, roles, and outcomes — "the input reopens after a refused
save and preserves the attempted text" — not the presence of a particular
row, a colour, a typeface, or literal chrome text. A story that asserts
"there is a gold row containing Goldie ·" breaks on every visual decision
without ever having protected one; rewriting such assertions is cost with no
safety. Older stories predate this rule; tighten them toward behaviour as you
touch them rather than in a sweep.
A design number lives in one table, never in a story
Every design measurement (a card's height, a gap, a track, a popover's width)
is stated once, in a CSS token or a *-geometry.ts table beside its component,
and pinned by that table's one *.node.test.ts. A story that measures the page
asserts behaviour, a relationship read from the table
(expect(label.left - card.left).toBe(LEADING_REGION)), or an invariant
(nothing overflows, a hidden control is hidden at rest), never the bare number.
Changing a ruling then means editing the table and its one test, and no story
turns red for a number it only restated. One geometry story per shared part
(Stack Anatomy, the review gutter, the drag handle, the composer, the work bar)
proves the classes produce the table's numbers. bun run stories:geometry
enforces the rule and runs with the other gates.
In plain terms: the measurements are one schedule with one check. Stories check that things work and line up, so changing a measurement means amending the schedule, not a dozen stories.
A shared component's DOM contract lives in its own suite
getByRole("tab", { name: "About" }) is fine anywhere — it is Testing
Library's standard, resilient way to interact with any correctly-built
control, and it survives an internal reshape as long as the accessible role
and name hold. What does not belong in a consumer story is a raw
querySelector('[data-slot=...] [role=...]') chain that re-derives a shared
component's internal markup — its data-slot names, which element carries
which role, which attribute marks the active state. That is the shared
component's own contract to state, once, in its own suite; a consumer that
restates it has not added coverage, only a second copy that breaks
independently the next time the shared component's markup changes.
In plain terms: querying "the tab named About" is asking a shop assistant for a thing by name — the shop can rearrange its shelves and the assistant still finds it. Querying the shared component's raw internal selectors is reading the shop's own stockroom map instead of asking — and five people each keeping their own copy of that map is what breaks when the stockroom is reorganised.
The remedy is a small, colocated <component>.testing.ts module exporting the
handful of shape-checking queries a consumer genuinely needs — "does this
container hold a real X, with the right roles and the right number of parts".
A consumer story imports those instead of writing its own selector chain, so
only the shared component's own suite and its .testing.ts change when its
markup does. Nothing ships one today: the Stack's raw data-slot reads are
tolerated until stack.testing.ts exists, at which point its consumers import
from there.
A cross-cutting policy is stated where it is resolved
A rule that spans components — who may edit what, which role earns which
chrome, what a lock withholds — belongs to the surface that resolves it,
and its stories belong there with it. A component that merely receives the
answer as a prop keeps stories about its own rendering, never about who gets
it: hand-passing capabilities and locked into a card proves the card
renders what it was told, which is not the question the policy asks.
In plain terms: the permission clause is written once, in the deed. Each page does not restate it in its own words — if it did, the two wordings would part company the first time one was amended, and nobody could say which one governed.
This is not theoretical: the Maker-editing rule was once stated in both the Drafting Editor's capability stories and the BlockCard suite. A ruling updated the card's copy and not the editor's, and the card's copy could not have caught it — hand-passing the lock handler proved the menu item renders when bound, while the canvas was binding it only inside an open Goldie panel, so a Builder scanning a locked block saw no lock at all. Consolidating the cells into one matrix found the defect on the first run.
Two things stay outside the matrix, and both are the same rule read the other
way. The pure half of a policy — the resolver that answers "may this be
done?" — belongs in a plain module under node tests (here
drafting-editor/model/block-affordances), because a story is the slowest
possible place to test a function. And the component's own contract —
its rows, its name projection, what its header strip draws — stays in its own
suite, because that is what the component, not the policy, decides.
Keep fat logic out of the slow suite
Pure logic must live in a plain module with node tests, and reach the
component as a prop or import — never be testable only by driving a story.
A derivation chain, a formatter, a guard, a state reducer: extract it, test
it in the node suite, and let stories assert only what is genuinely
interactive — focus, keyboard flows, pointer sequences, visible state
transitions. The 2026-08-08 naming work is the shape to copy:
packages/domain/src/naming/ carries the naming rules under fast node tests,
while stories cover only the click-to-edit interaction. If a story is the
only place a piece of logic is exercised, that is an extraction signal, not
a coverage achievement.
A static render does not earn its own story
Every per-snapshot visual tool bills and diffs per story, so the story
count is the cost. Selection states, proposal states, agent-line states, an
at-rest view: render the matrix together as one fixture gallery rather than one
story per prop value. A live story with no play function is a claim that only
a render crash can fail it — allowed, but never one per cell.
How to build the gallery, when to keep surfaces apart instead, and what
consolidation costs are in the
economical-stories skill. Load
it before collapsing a matrix; this page does not restate it.
A story that restates a node-tested rule is deleted, not tightened
Where a rule already has a node suite, a story survives only if it proves the wiring — that the canvas or the card calls the rule — and then asserts one outcome, not the rule's whole table. Deleting the copy is the fix; tightening it keeps two homes for one rule.
UI Components
Where UI components live, how upstream source flows through the landing zone into owned tiers, and the rules for importing and promoting components
The Stack
Orientation for the furniture primitive every Folio kind is drawn with — what it is, where its authority lives, and the few rules that are not visible in the code