Goldie Blocks
Coding Rules

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.

StatusTagMeaningA failure means
ProtoprotoExploring a shape. Nothing wired; may mock the component it depicts.Nothing about the app. Delete freely once the shape settles.
SpecspecThe shape is settled, the code is not there yet. The story is the requirement.Not built yet.
LiveliveThe code exists and this story guards it.You broke something.
DeprecateddeprecatedA 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:

  1. Proto: explore alternatives with the owner. It is disposable and not approved.
  2. Spec: the owner has chosen the visible behavior. The story is reviewable evidence for the settled docs.
  3. 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.ts to 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.tsx badges 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 livesTitle
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 Anatomy is a sibling of Stack, not a child of it — a one-character mistake, invisible in the source and obvious only in the sidebar.
  • A version folder adds its vN segment; nothing else may. A title naming a v3/ 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.

On this page