agents/openai.yaml
interface:
display_name: Storybook CSF3 — Ship
short_description: "Graduate ONE Explore experiment to a production component — preserve the experiment (cp, never git mv), pick new-vs-update, close the decision loop. Use for 'ship this', 'promote this experiment', 'graduate to production'."
default_prompt: |
Graduate ONE Explore experiment to production. PRESERVE the experiment — cp, NEVER git mv. Path A (new component → fresh production stories) or Path B (update existing in place / B2 _legacy). Close the decision loop IN PLACE (decision:chosen + parameters.decision). Confirm via scripts/find-stories-by-tag.sh decision:chosen. Load references/propagate-workflow.md FIRST.
policy:
allow_implicit_invocation: true
CONTEXT.md
# CONTEXT — shared vocabulary for the storybook-workbench bundle
Every skill in this bundle assumes the terms below. Read this once; skills reference it
instead of re-explaining. (Pattern borrowed from mattpocock/skills `CONTEXT.md`.)
## The pipeline in one line
**Setup -> Build -> Ship**, with two gates (Lint per-cycle, Audit periodic) and a Navigate verb
(`sb-hub`) that names the next step throughout. Only Setup -> Build -> Ship are sequential;
everything else is on-demand. The default audit order is setup, inventory, health, flows, stories.
| Macro | What | Skill |
|---|---|---|
| **Setup** | install Storybook if missing, discover what's real vs slop, detect the design system, map navigation | `sb-setup`, `sb-inventory`, `sb-health`, `sb-flows` |
| **Build** | author stories / app-maps / comparisons | `sb-stories`, `sb-wrappers` |
| **Ship** | graduate an Explore experiment to production | `sb-ship` |
| **Audit** | drift survey + decision board (periodic gate) | `sb-audit` |
| **Navigate** | inspect state, name the single next step | `sb-hub` (onboarding check + orchestrator + navigator) |
## Core terms
- **real · dead · slop** — a *real* component is exported under `src/components/` AND imported
from outside its own file (it's used). A *dead* component is defined-but-never-imported — this
is the per-component term; the inventory labels them "Dead components" and counts them (`29 dead`).
*Slop* is the aggregate: the share of the app that's dead/unused junk, surfaced as the slop
**rate** (`dead ÷ total`, e.g. `8% slop`) — the ~30% a vibe-coded app ships. So "real vs slop"
is the headline framing; **dead** is the precise label for one unused component. Ground truth
comes from `inventory-project.sh`, never from `CLAUDE.md`/`AGENTS.md` (those drift, lie, or are absent).
- **vendor** — shadcn-style installed primitives under `components/ui/`. They are app code
but NOT the user's authored components, so they are reported in their own `vendor` bucket
and excluded from the real/dead *domain-component* headline. (This is the fix for "the
inventory showed me 40 shadcn components I didn't write.")
- **kind** — every discovered file is bucketed: `component` · `page` · `app` · `vendor`
(shadcn `ui/`) · `module` (types/helpers/hooks/utils/lib/api/services — real code but not UI
components) · `scaffold` (SB init tutorial under `src/stories/`) · `support` (test/factory/mock).
Only `component`+`page`+`app` count toward the real/dead headline and the "most imported"
list — so `vendor`, `module`, `scaffold`, `support` never pollute the view of *your components
used in prod* (a `types.ts` is imported everywhere and would otherwise top the list).
- **Build outputs** (not four equal "modes") — `sb-stories` writes a **Component** story ⓢ, the
production output, one per materially-different state. `sb-wrappers` scaffolds Storybook-only
**views**: A/B Compare (`ABCanvas`), state grids (`StateGrid`/`StateMatrix`), role canvas,
token/health/inventory canvases, the decisions board, and the maps. `sb-flows` produces the
**Flow** view — the whole-app map (`AppFlowGraph`) plus per-flow journey maps (`JourneyGraph`):
connections, not just screens. Separately, **Explore** is a sandboxed iteration track
(`sb-explore`) that lives OUTSIDE `src/components/`; `sb-ship` graduates an Explore experiment
into a Component.
- **ledger** — `.storybook/audit/{findings,extraction-plan,status}.md`. Append-only memory
you steer by editing; the hub navigator honors your edits. Commit it or a `git clean`
loses it.
## STORAGE MAP — where everything lands (answers "where is it stored?")
**One place. `.storybook/` is the single home** for everything the bundle writes, so a client /
vibe-coded repo stays clean and one `rm -rf .storybook` removes the entire audit. **Never scatter
outputs across the repo.** The *only* thing whose location is a real choice is **where the stories
go** — decided once in `sb-setup` (see STORIES LOCATION below), recorded in `status.md`, honored by
every skill.
| What | Path | Commit? |
|---|---|---|
| Discovery ground truth | `.storybook/project-inventory.json`, `flows.json`, `component-states.json`, `prop-shapes.json`, `component-usage.json` (real prop/value usage at call sites) | yes |
| Design-system health | `.storybook/design-system-health.json` | yes |
| Findings ledger | `.storybook/audit/{findings,extraction-plan,status}.md` | yes |
| Decision ledger (pruned) | `.storybook/audit/decisions.md` | yes |
| Scaffolded wrappers | `.storybook/wrappers/*.tsx` (+ `icons.tsx`, `index.ts`) | yes |
| Factories | `.storybook/factories.ts` | yes |
| **Stories** (the one choice) | **isolated:** `.storybook/stories/**/*.stories.tsx` · **co-located:** `src/**/<Name>.stories.tsx` | yes |
| **Agent run artifacts** | `.context/storybook-workbench/<skill>/<run_id>/*.json` | **no** (gitignored — ephemeral) |
Nothing the bundle produces lands anywhere else. If you can't find an output, it is under `.storybook/`.
**Who refreshes what (keep rendered data fresh).** Some JSONs are **rendered** in Storybook (an autodocs
embed or a wrapper reads them) — those must refresh together; others are **authoring inputs** the agent
reads once to write code, regenerated on demand.
| JSON | Producer script | Rendered by | Refreshed by |
|---|---|---|---|
| `project-inventory.json` (incl. `tokens.map`) | `inventory-project.sh` | ProjectInventory · UsageSection (Colors/Typography/Scales/Semantic) · TokenUsageGrid | **`refresh-usage.sh`** |
| `component-usage.json` | `extract-component-usage.sh` | StateGrid/StateMatrix `usage=` · UsageSection (component Docs) | **`refresh-usage.sh`** |
| `flows.json` | `extract-flows.sh` | AppFlowGraph · JourneyGraph | **`refresh-usage.sh`** |
| `design-system-health.json` | `validate-design-system.sh` (sb-health) | UsageSection (Health) · DesignSystemHealth · TokenMatrix | **`refresh-usage.sh`** |
| `component-states.json` · `prop-shapes.json` · `runtime.json` | extract-states · extract-prop-shapes · discover-runtime | — (authoring inputs) | regenerated on demand by sb-stories / sb-setup |
| `index.json` (Storybook's OWN report) | `storybook index` (CLI, no server/build) | — (reconciled into `project-inventory.json.storyCoverage`) | run by `inventory-project.sh` / `refresh-usage.sh` |
**Story coverage is authoritative, not a guess.** When Storybook is installed, `inventory-project.sh`
runs `storybook index` and reconciles `index.json` (the stories Storybook actually registers) into
`storyCoverage` (`source: "storybook-index"`, `withRegisteredStory`, `needsStory`) — far better than the
basename-glob heuristic (`source: "heuristic"`, the fallback when Storybook isn't installed). Cross-agent:
plain CLI, no dev server, no MCP. MCP is the *authoring* accelerator; `index.json` is the *tracking* source.
`refresh-usage.sh` (+ `--docs`) is the one command that re-runs all four **rendered** extractors; `sb-audit`
runs it each pass, and it belongs in CI before `storybook build`. The autodocs import the JSON, so a rebuild
reflects reality with no hand-editing.
### STORIES LOCATION — ask once, recommend, record (the "don't scatter stories" rule)
A demo finding: writing `Foo.stories.tsx` next to every component scatters new files through a
client's `src/` — a mess in a repo you don't own. So **`sb-setup` must ASK the user where stories
live** (via `AskUserQuestion`, or numbered list where no blocking tool exists) and **recommend** based
on intent:
- **Isolated — THE DEFAULT** (this is an audit tool; assume a repo you don't own) — stories live under
`.storybook/stories/`, mirroring the component tree (`.storybook/stories/components/CourseCard.stories.tsx`).
`src/` is never touched; the whole audit is one removable folder. `sb-setup` sets `main.ts`
`stories: ['./stories/**/*.stories.@(tsx|ts)']` (relative to `.storybook/`). Stories import
components via the project's `@/` alias, not deep relative paths.
- **Co-located — opt in only** (Storybook's general convention, for a **project you own long-term** and
want stories to move with components on refactor) — `src/components/<X>/<X>.stories.tsx`.
- **A custom folder** — the user can name any single folder; it's globbed into `main.ts` and treated
as the one place. Still one location, never a mix.
Always **ask** (isolated is the recommended/first option and the fallback). Record the choice in
`.storybook/audit/status.md` as `storiesLocation: isolated|colocated|<path>` so `sb-stories` and the
hub honor it without re-asking. **The ask is enforced at two points** so it can't be skipped: `sb-setup`
asks during install, and if Storybook already existed (so `sb-setup` was skipped), the **first
`sb-stories` refuses to write until it asks** and records the choice. Never co-locate or guess silently.
## What loads when (the load map — answers "is this 200k tokens?")
- **Eager:** only the one skill's `SKILL.md` you triggered (each is ~60–110 lines).
- **Lazy:** a skill's local `references/*.md` plus the few shared references load *only when the
skill body says to* — never all at once. Discovery scripts write JSON the agent reads instead of
grepping source.
- **Never auto-loaded:** `CHANGELOG.md`, other skills' bodies, the wrapper `.tsx` source.
You install the bundle; you pay context only for the verb you run.
## Resume protocol (answers "we stopped mid-session with half-baked files")
Discovery scripts write JSON **atomically** (temp file → move), so a partial run leaves no
half-JSON. Story/audit work records progress in `.storybook/audit/status.md`. On re-entry the
hub (`/sb-hub` / `$sb-hub what's next`) reads `status.md` + checks which discovery
JSONs exist, and resumes from the first incomplete step — it never assumes a file half-written
in a prior session is complete. Rule for every skill: **finish the artifact you started or
mark it `incomplete` in `status.md` before stopping.**
## §wrapper-view-design — the ONE visual language for wrappers
Wrappers are Storybook-only React components scaffolded into `.storybook/wrappers/`. They share
one visual language so they don't look like a pile of different widgets:
1. **No emoji. Ever.** Use the icon set in `wrappers/icons.tsx` (`<Icon.palette/>`,
`<Icon.warning/>`, `<Icon.check/>`, …). Dependency-free inline SVG: 24×24 viewBox,
`currentColor` stroke, 1.6 width — icons inherit text color and size.
2. **Injectable.** Map-style wrappers (`AppFlowGraph`, `JourneyGraph`) accept an `icons` prop
so a project can pass its own lucide/Phosphor/custom set and match the app's language.
`mergeIcons(overrides)` merges over the defaults.
3. **Themed via the app's tokens (CSS vars, not a JS palette).** Wrappers color themselves with the
host app's CSS custom properties and a fallback — `var(--color-foreground, <fallback>)`,
`var(--color-surface, …)`, `var(--color-border-subtle, …)`, and the semantic
`--color-success/warning/error[-surface|-text]` roles. One `.dark` class flip re-skins every
wrapper light↔dark, and the fallback keeps it rendering standalone. This is the single source of
color. Keep *data* colors (token swatches, categorical legend hues, shader output) as-is; only
chrome reads from vars.
4. **`icons.tsx` always travels.** `scaffold-wrapper.sh` force-copies it next to any wrapper, so a
wrapper copied alone never loses its icons.
5. **Status fields hold a `WrapperIcon` component, not a glyph string** — e.g. severity →
`{ error: Icon.x, warning: Icon.warning, info: Icon.info }`, rendered via an aliased
`const SevIcon = style.icon; <SevIcon size={14} />`.
6. **Plain text where a swatch already carries the cue** (e.g. TokensCanvas section headings) —
no decorative glyph prefix.
To add an icon: add one inline-SVG entry to `Icon` in `icons.tsx` (keep the `// emoji → name`
comment so the mapping stays legible), then reference `<Icon.newName/>`.
## Cross-agent rules (answers "slash commands don't work in Cursor/Codex")
All three agents read the same `SKILL.md` (Agent Skills open standard); each skill also ships an
`agents/openai.yaml` for Codex's richer surface. What differs is *invocation UX*, not availability:
- **Claude Code** (`~/.claude/skills/`): each skill is a slash command — `/sb-inventory`, `/sb-hub`.
- **Codex** (`~/.codex/skills/`): custom slash commands are NOT read. Invoke by name —
`$sb-hub <phase>` (e.g. `$sb-hub what's next`) or `/skills` → pick the skill.
The `openai.yaml` `default_prompt` routes the phase.
- **Cursor / cursor-agent** (`~/.cursor/skills/`): reads `SKILL.md` like the others — trigger by
describing the task so the `description` matches, or by name. Custom `/sb-*` shortcuts do NOT fire
(Cursor has rules + skills, not Claude-style slash commands), but the skills themselves work.
- Any skill body that says "use the Agent tool" must branch per platform: Claude `Agent`
(model `sonnet`), Codex `gpt-5.x-mini` equivalent; if no override mechanism, omit the model
and inherit the default rather than fail the dispatch.
### Tool portability — how the SAME tools hold across agents
There is **no cross-agent tool-name registry**, and `allowed-tools` is *experimental* in the open
standard ("support may vary between agents"). So portability is two parts, not the field:
1. **`allowed-tools` = a Claude-only pre-approval** (lists run without a prompt). We keep it to the
**6 universal primitives** (`Bash Read Glob Grep Write Edit`) — present natively in every agent, so
it's safe where read and harmless where ignored (Codex/Cursor apply their *own* permission models:
Codex sandbox + approval, Cursor settings). `test-tool-portability.sh` fails on any non-universal tool.
2. **`compatibility` = the portable runtime contract** — every skill declares what must exist
(`bash, python3, node, git`). This is the field any agent reads to know the skill's real needs; the
gate requires it and `skills-ref validate` (the official validator) confirms each skill against the
standard. There is no install-time *translation* — primitives are universal; install just drops
`SKILL.md` and each agent applies its model. If a skill ever needs a **non-portable** capability,
document the per-platform branch in that skill body and add an eval before shipping it.
references/propagate-workflow.md
# Propagate Workflow — preserve the experiment, ship the winner
**The discipline this reference owns:** when an Explore iteration is chosen, propagate it to production **without destroying the experiment**. Past skill drafts said `git mv src/explore/... src/components/...` — that's wrong. The Explore story is design history; it should stay where it is, visibly marked as the winning version, so the next designer/agent can read the record.
## The layered preservation model (v1.7.1)
History lives in three layers, all in the repo. Nothing external. Designer / team / AI all read from the same place.
| Layer | Where | Visibility | When |
|---|---|---|---|
| **L1 Active** | `src/components/` + `src/explore/` | Shown in Storybook sidebar by default | Recent — current production + last ~3 months |
| **L2 Archive** | Same files, with `'archived'` tag added | Hidden from sidebar by default; toggle to show | 3–12 months old; `decision:chosen` or `decision:rejected` |
| **L3 Ledger** | `.storybook/audit/decisions.md` (markdown table) | Always present in repo | 12+ months old; story file pruned but recoverable from git |
**Lifecycle of a single decision:**
```
Day 0 decision:pending → L1 (in Explore + Compare; dashboard shows pending)
Day 14 decision:chosen → L1 (Ship event; V2 lives in Components; Explore tags flip)
Day 90 add 'archived' tag → L2 (still in code, hidden from default sidebar)
Day 365 prune to ledger → L3 (file git rm'd; one-line markdown row remains)
```
Automation: the agent runs `scripts/audit-archived.sh` periodically (manually or in CI) to surface L1→L2 and L2→L3 transitions. `scripts/prune-to-ledger.sh` handles the L2→L3 move (writes the markdown row + git rm's the file). Both scripts have dry-run defaults.
The DecisionsDashboard wrapper shows L1 by default + a collapsible "Past decisions" section for L2 + a footer pointer to L3.
## When to load this reference
- An Explore (or Labs) iteration graduated — Ship event triggered
- The user says "ship V2", "propagate this", "promote to production"
- The Compare story flipped from `decision:pending` to `decision:chosen`
- Reviewing a recent Ship and noticing the Explore story disappeared
## The core rule — preserve by default
Before v1.7, Ship was destructive: `git mv src/explore/hero/v2.stories.tsx src/components/hero/Hero.stories.tsx`. The Explore record vanished from Storybook (only survived in git history). That breaks four things:
1. **Design history** — six months from now, when someone asks "why did we ship V2?", the proof is gone.
2. **Visual regression archive** — VRT had snapshots of V2 from Explore; those snapshots no longer match any visible story.
3. **A/B record** — the Compare story still references V1 vs V2, but V2 now lives somewhere else.
4. **Decision dashboard integrity** — `<DecisionsDashboard>` queries `decision:chosen` stories; if the chosen story was moved + retagged, the dashboard loses context.
**Preserve by default: the Explore story stays where it is.** Its tags get updated (`decision:pending` → `decision:chosen`, add `archived`), a banner is added, but it doesn't move.
## The two paths
When an Explore story is chosen, you propagate via one of two paths. The decision is: does the Explore have its own component, or is it iterating on an existing one?
### Path A — NEW component (Explore defined a new component)
**Use when:** The Explore story has its own component file (e.g., `src/explore/hero/v2.tsx` defines `HeroV2`). There is no shipping equivalent — this is a net-new addition to the design system.
**Sequence:**
```bash
# 1. Copy (NOT move) the component from explore to components
cp src/explore/hero/v2.tsx src/components/hero/Hero.tsx
# 2. Create a new production stories file (do NOT cp the Explore stories — production stories
# have different concerns: full state coverage, autodocs, no decision metadata)
touch src/components/hero/Hero.stories.tsx
# (agent writes production stories from scratch — see references/with-mcp.md or without-mcp.md)
# 3. Validate the new production story
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh src/components/hero/Hero.stories.tsx
# 4. Propagate to app callsites (if the new component replaces something or is consumed somewhere)
ast-grep --pattern 'import { Hero } from "@/components/old-hero"' \
--rewrite 'import { Hero } from "@/components/hero"' --update-all
# 5. Update the Explore story tags + add a chosen banner (see "Updating the Explore story" below)
```
**Result:**
- New: `src/components/hero/Hero.tsx` + `src/components/hero/Hero.stories.tsx` (production)
- Preserved: `src/explore/hero/v2.tsx` + `src/explore/hero/v2.stories.tsx` (archive, tagged chosen)
### Path B — UPDATE existing component (V2 replaces V1)
**Use when:** A shipping `src/components/<name>/<Name>.tsx` already exists; the Explore iteration is V2 of it. You're updating in place, not adding a new component.
**Sub-paths inside Path B** — what to do with the existing stories file:
- **B1 — Evolve in place (single shipping version):** edit `src/components/hero/Hero.tsx` with V2 contents; update `src/components/hero/Hero.stories.tsx` with new states. No `_legacy/` directory. V1 history lives in git.
- **B2 — Keep V1 alongside (parallel for one release):** move existing `src/components/hero/` into `src/components/_legacy/hero/`, drop new V2 into `src/components/hero/`; old stories tagged `deprecated` with removal date. Use when V1 still has callsites and the migration is staged.
**Sequence (B1 — evolve in place):**
```bash
# 1. Edit src/components/hero/Hero.tsx to V2 contents
# (agent reads src/explore/hero/v2.tsx, applies the changes to src/components/hero/Hero.tsx)
# 2. Update src/components/hero/Hero.stories.tsx — add new states V2 introduced
# (e.g., if V2 added a 'video' variant, add a Story for it)
# 3. Validate
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh src/components/hero/Hero.stories.tsx
# 4. Update Explore story tags + banner (see below)
```
No ast-grep needed — the import path didn't change.
**Sequence (B2 — keep V1 in _legacy):**
```bash
# 1. Move existing component aside
git mv src/components/hero src/components/_legacy/hero
# 2. Create the new V2 component at the original path
mkdir -p src/components/hero
# (agent copies + adapts src/explore/hero/v2.tsx → src/components/hero/Hero.tsx)
# (agent writes src/components/hero/Hero.stories.tsx fresh)
# 3. Tag the _legacy story as deprecated with removal date (see "Deprecating the old version")
# 4. Validate both
${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh \
src/components/hero/Hero.stories.tsx \
src/components/_legacy/hero/Hero.stories.tsx
# 5. ast-grep callsites if the import path changed (it didn't if you used the same name)
# This step is only needed if you renamed the component as part of the V2.
# 6. Update Explore story tags + banner (see below)
```
## Updating the Explore story (both paths)
After Ship, the Explore story doesn't move. Update it in place:
```tsx
// src/explore/hero/v2.stories.tsx — BEFORE Ship
const meta = {
title: 'Explore/Hero/V2',
component: HeroV2,
parameters: {
layout: 'fullscreen',
design: { type: 'figma', url: '...' },
},
tags: ['explore', 'decision:pending', '!autodocs', '!test', 'figma-sync', 'v2-preview'],
} satisfies Meta<typeof HeroV2>;
// AFTER Ship — update tags + add chosen metadata
const meta = {
title: 'Explore/Hero/V2',
component: HeroV2,
parameters: {
layout: 'fullscreen',
design: { type: 'figma', url: '...' },
decision: {
id: 'hero-v2-2026-05-27',
status: 'chosen',
winner: 'V2',
date: '2026-05-29',
shippedTo: 'Components/Marketing/Hero',
rationale: 'Inline CTA cut signup steps from 3 to 1 in user testing',
},
docs: {
description: {
component: '## Chosen on 2026-05-29 — shipped to **Components/Marketing/Hero**\n\nThis Explore iteration was selected over V1 in PM review. Kept here as the historical record of what we tested. The production version lives in [Components/Marketing/Hero](?path=/story/components-marketing-hero--default).',
},
},
},
tags: ['explore', 'decision:chosen', 'archived', '!autodocs', '!test', 'figma-sync'],
} satisfies Meta<typeof HeroV2>;
```
The key tag changes:
- `'decision:pending'` → `'decision:chosen'`
- Add `'archived'` to signal "no longer being iterated on, kept for history"
- Keep `'explore'` and `'!autodocs'` / `'!test'` (the story stays in the Explore sidebar, not in autodocs)
- Drop `'v2-preview'` (it's no longer a preview — it shipped)
The `parameters.decision` block feeds `<DecisionsDashboard>` (see `references/wrapper-library.md`) and the `docs.description.component` makes the chosen status visible to anyone opening the story.
## Deprecating the old version (Path B2 only — when keeping V1 alongside)
```tsx
// src/components/_legacy/hero/Hero.stories.tsx
const meta = {
title: 'Components/_Legacy/Hero',
component: Hero,
tags: ['autodocs', 'deprecated'],
parameters: {
docs: {
description: {
component: '**DEPRECATED 2026-05-29** — replaced by [Components/Marketing/Hero](?path=/story/components-marketing-hero--default). Scheduled for removal in v3.2 (target: 2026-07-01). Kept this release window for migration safety.',
},
},
},
} satisfies Meta<typeof Hero>;
```
Confirm cleanup is visible:
```bash
${CLAUDE_PLUGIN_ROOT}/scripts/find-stories-by-tag.sh deprecated
```
## Updating the Compare story (if one exists)
If the Ship came from a Compare A/B story (typical), the Compare story also needs its tags updated:
```tsx
// src/explore/compare/hero-v1-vs-v2.stories.tsx — BEFORE Ship
tags: ['compare', 'decision:pending', '!autodocs', '!test'],
// AFTER Ship — flip to chosen
tags: ['compare', 'decision:chosen', 'archived', '!autodocs', '!test'],
```
And update the `<TrackedDecision>` wrapper inside:
```tsx
// BEFORE
<TrackedDecision id="hero-v2-2026-05-27" status="pending" rationale="..." target="2026-06-03">
// AFTER
<TrackedDecision id="hero-v2-2026-05-27" status="chosen" winner="V2" date="2026-05-29" rationale="...">
```
## Decision tree — which path?
```
Did the Explore story define a NEW component?
│
├─ YES → does an existing component cover the same concept?
│ │
│ ├─ NO → Path A (NEW component — net addition to design system)
│ │
│ └─ YES → Path B (UPDATE existing — V2 replaces or evolves it)
│ │
│ ├─ Single shipping version? → B1 (evolve in place)
│ └─ Need V1 alongside for migration? → B2 (move V1 to _legacy)
│
└─ NO (Explore iterated on an existing component)
→ Path B (UPDATE existing)
→ typically B1 (evolve in place), unless migration safety needs B2
```
## Anti-patterns
1. **`git mv` instead of preserving the Explore story.** Destroys design history. The Explore story should stay; only its tags + parameters update.
2. **Copying the Explore stories file to `src/components/`** (instead of writing fresh production stories). Explore stories carry decision metadata, fullscreen layout, and `!autodocs` — none of which belong in production stories. Production stories serve a different purpose (state coverage + autodocs).
3. **Forgetting to update the Explore tags.** Leaving `'decision:pending'` on a shipped iteration breaks the dashboard. Always flip to `'decision:chosen'` + add `'archived'`.
4. **Skipping the `shippedTo` metadata.** Without it, the Explore story is just "an experiment that happened" — with it, future readers can jump straight to the production version.
5. **Path B2 without a removal date** on the `_legacy/` story. Deprecation without an end date becomes permanent. Always include a target date in `parameters.docs.description.component`.
6. **Running ast-grep when the import path didn't change** (Path B1). Wastes a CI run. Only ast-grep when the path actually changes.
7. **Mixing the Compare story flip with the Explore story flip.** Both need to update, but they're separate edits — the Compare is the decision artifact, the Explore is the implementation artifact. Don't conflate.
## Worked example — production Hero V2 ship
```bash
# State before Ship:
# src/components/hero/Hero.tsx (V1, shipping)
# src/components/hero/Hero.stories.tsx (Components/Marketing/Hero, autodocs)
# src/explore/hero/v2.tsx (V2, isolated)
# src/explore/hero/v2.stories.tsx (Explore/Hero/V2, decision:pending)
# src/explore/compare/hero-v1-vs-v2.stories.tsx (Compare/Hero/V1-vs-V2, decision:pending)
# Decision tree → Path B1 (evolve in place, single shipping version):
# 1. Apply V2 changes to the production component
# (agent reads src/explore/hero/v2.tsx, applies diff to src/components/hero/Hero.tsx)
# 2. Update production stories — add any new states V2 introduced
# (agent edits src/components/hero/Hero.stories.tsx to add e.g. VideoVariant story)
# 3. Validate
$ scripts/validate-stories.sh src/components/hero/Hero.stories.tsx
all checks passed
# 4. Update Explore story tags + chosen banner (in place — no file move)
# Edit src/explore/hero/v2.stories.tsx:
# tags: ['explore', 'decision:pending', ...] → ['explore', 'decision:chosen', 'archived', ...]
# Add parameters.decision = { status: 'chosen', winner: 'V2', date: '2026-05-29', shippedTo: 'Components/Marketing/Hero' }
# Add docs.description.component with the chosen banner
# 5. Update Compare story
# Edit src/explore/compare/hero-v1-vs-v2.stories.tsx:
# tags: ['compare', 'decision:pending', ...] → ['compare', 'decision:chosen', 'archived', ...]
# Update <TrackedDecision> status='chosen', winner='V2', date='2026-05-29'
# 6. (Optional) ast-grep if any callsites changed import path — here they didn't, so skip.
# 7. Confirm dashboard updated
$ scripts/find-stories-by-tag.sh decision:chosen
src/explore/hero/v2.stories.tsx:25
src/explore/compare/hero-v1-vs-v2.stories.tsx:32
# State after Ship:
# src/components/hero/Hero.tsx (V2, shipping)
# src/components/hero/Hero.stories.tsx (Components/Marketing/Hero, autodocs, V2 states)
# src/explore/hero/v2.tsx (UNCHANGED — historical record)
# src/explore/hero/v2.stories.tsx (Explore/Hero/V2, decision:chosen, archived)
# src/explore/compare/hero-v1-vs-v2.stories.tsx (Compare/Hero/V1-vs-V2, decision:chosen)
```
The experiment is preserved. The shipping version is updated. The decision is documented. Future readers can:
- Open `Explore/Hero/V2` to see the original Figma-driven iteration
- Open `Compare/Hero/V1-vs-V2` to see how V1 and V2 looked side-by-side at decision time
- Open `Components/Marketing/Hero` to see what's currently shipping
- Open `Decisions/Dashboard` to see this decision in the Chosen column with rationale + date
## Quarterly pruning ritual (L2 → L3)
Run quarterly (or when `audit-archived.sh` flags HEAVY). Three commands total.
```bash
# 1. Survey the archive — what's old enough to prune?
${CLAUDE_PLUGIN_ROOT}/scripts/audit-archived.sh
# Output: lists L1 chosen >90d (suggest add 'archived'), L2 archived >12m (suggest prune),
# and a HEAVY warning if archived count > 20.
# 2. For each prune candidate, run prune-to-ledger.sh (dry-run by default)
${CLAUDE_PLUGIN_ROOT}/scripts/prune-to-ledger.sh src/explore/hero/v1.stories.tsx
# Output: shows the markdown row that would be added to .storybook/audit/decisions.md
# plus the suggested git rm commands.
# 3. Once happy, --execute appends the row + git rm's the file
${CLAUDE_PLUGIN_ROOT}/scripts/prune-to-ledger.sh src/explore/hero/v1.stories.tsx --execute
# Then: git commit -m 'design: prune <decision-id> to ledger'
```
### When NOT to prune
Keep in L2 (don't move to L3) if any of:
- Still referenced by an active Compare story (`<ABCanvas>` or similar pulls it via `<StorySet>`)
- Has unique visual regression baseline value (catches a regression class no other story does)
- Designer flags as "canonical reference" (add a `'canonical-reference'` tag and prune-to-ledger will skip it)
For everything else: prune. The git history preserves the file; the ledger preserves the rationale.
## How "system tells you when it's big"
`audit-archived.sh` emits a `🚨 HEAVY` warning when the L2 count exceeds `--threshold` (default 20). At that point, Storybook's sidebar starts to feel cluttered and pruning becomes worthwhile. The agent should surface this warning to the user whenever it runs the audit — usually after a Build cycle when the sidebar feels heavy.
Default thresholds:
- **90 days** — auto-suggest adding `'archived'` tag to chosen stories (L1 → L2)
- **12 months** — auto-suggest pruning archived stories (L2 → L3)
- **20 archived stories** — emit HEAVY warning + suggest aggressive pruning
All thresholds are configurable per project via CLI flags (`--older-than`, `--threshold`).
## Verification record
- Preserve-by-default decision derived from the user's design-history concern: "preserve experiment and create new one or update current components - with new version."
- Path A vs Path B decision tree derived from a real production app's structure (some components have V2 candidates with their own files, others are just being iterated on).
- B1 vs B2 split derived from real-world migration patterns: B1 for clean cuts, B2 for staged migrations where V1 still has live callsites.
- The `parameters.decision` block format is consumed by `<DecisionsDashboard>` (see `references/wrapper-library.md`).
- This replaces the destructive `git mv` recipe in earlier drafts of `figma-to-storybook.md` and `end-to-end-flow.md`.
- Layered model + L1/L2/L3 lifecycle derived from `docs/specs/2026-05-27-preservation-brainstorm.html`. Quarterly ritual implemented via `audit-archived.sh` + `prune-to-ledger.sh`. Template: `templates/design-decisions.md`.
scripts/find-stories-by-tag.sh
#!/usr/bin/env bash
# find-stories-by-tag.sh — list stories carrying a given tag.
#
# Two main use cases:
# 1. Gallery prep — find every story tagged 'empty-state' to scaffold a TagGallery
# 2. Deprecation audit — find every story tagged 'deprecated' to plan cleanup
#
# Also: lifecycle audits (`v2-preview`, `experimental`, `ai-generated`,
# `needs-design-review`) and arbitrary user-defined tags.
#
# Usage:
# find-stories-by-tag.sh empty-state
# find-stories-by-tag.sh deprecated --count # just print count
# find-stories-by-tag.sh ai-generated --files-only # print unique filenames
#
# Exit codes:
# 0 scan completed (zero matches is still PASS)
# 2 bad invocation
set -uo pipefail
TAG=""
COUNT_ONLY=false
FILES_ONLY=false
SCAN_PATHS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--count) COUNT_ONLY=true; shift ;;
--files-only) FILES_ONLY=true; shift ;;
-h|--help) sed -n '2,15p' "$0"; exit 0 ;;
*)
if [[ -z "$TAG" ]]; then TAG="$1"
else SCAN_PATHS+=("$1")
fi
shift
;;
esac
done
if [[ -z "$TAG" ]]; then
echo "ERROR: pass a tag to search for. e.g. find-stories-by-tag.sh empty-state" >&2
exit 2
fi
if [[ ${#SCAN_PATHS[@]} -eq 0 ]]; then
for cand in src stories app/frontend; do
[[ -d "$cand" ]] && SCAN_PATHS+=("$cand")
done
if [[ ${#SCAN_PATHS[@]} -eq 0 ]]; then
echo "ERROR: no default scan path found (looked for src/, stories/, app/frontend/). Pass a path." >&2
exit 2
fi
fi
GREEN=$'\033[32m'; YELLOW=$'\033[33m'; DIM=$'\033[2m'; RESET=$'\033[0m'
if [[ ! -t 1 ]]; then GREEN=""; YELLOW=""; DIM=""; RESET=""; fi
# Match `tags: [..., '<tag>', ...]` or `"<tag>"` inside a tags array
# Tolerate single + double quotes, optional ! prefix, surrounding whitespace
PATTERN="tags:\s*\[[^]]*['\"]${TAG}['\"]"
matches=()
for path in "${SCAN_PATHS[@]}"; do
if [[ -d "$path" ]]; then
while IFS= read -r line; do
matches+=("$line")
done < <(grep -rnE "$PATTERN" "$path" --include="*.stories.ts" --include="*.stories.tsx" 2>/dev/null || true)
fi
done
count=${#matches[@]}
if $COUNT_ONLY; then
echo "$count"
exit 0
fi
if [[ $count -eq 0 ]]; then
echo "${DIM}No stories found with tag '${TAG}' under: ${SCAN_PATHS[*]}${RESET}"
exit 0
fi
if $FILES_ONLY; then
# Unique files only
printf '%s\n' "${matches[@]}" | cut -d: -f1 | sort -u
exit 0
fi
# Default — pretty list with file:line
echo "${YELLOW}━━━ ${count} stories with tag '${TAG}' ━━━${RESET}"
for m in "${matches[@]}"; do
file=$(echo "$m" | cut -d: -f1)
lineno=$(echo "$m" | cut -d: -f2)
echo " ${GREEN}${file}${RESET}:${lineno}"
done
# Hint at next step based on tag
case "$TAG" in
empty-state|loading|error|success|*-state)
echo
echo "${DIM}↳ Gallery candidate: scaffold src/stories/galleries/${TAG}Gallery.stories.tsx with <TagGallery tag=\"$TAG\" />${RESET}"
;;
deprecated)
echo
echo "${DIM}↳ Cleanup audit: confirm each story has a removal date in parameters.docs.description.component${RESET}"
;;
experimental|v2-preview)
echo
echo "${DIM}↳ Lifecycle audit: check if any of these should graduate (Labs gate criteria — see references/labs-workflow.md)${RESET}"
;;
ai-generated)
echo
echo "${DIM}↳ Review queue: these stories need human visual sign-off before tag is removed${RESET}"
;;
esac
exit 0
scripts/validate-stories.sh
#!/usr/bin/env bash
# validate-stories.sh — per-story conformance check for storybook-workbench
#
# Runs 13 deterministic checks on each story file + a project-level CssCheck
# tally (multi-file scans only). Returns PASS/FAIL per check,
# exits non-zero if any check failed. For judgment-needed checks, see the
# sub-agent prompt in references/validate-workflow.md.
#
# Usage:
# validate-stories.sh <file>
# validate-stories.sh 'src/**/*.stories.tsx' # quote globs
# validate-stories.sh --diff # stage+unstaged changed stories
# validate-stories.sh --strict <file> # also runs tsc/eslint/vitest
#
# Exit codes:
# 0 all checks passed
# 1 one or more files had failures
# 2 bad invocation / nothing to check
set -uo pipefail
STRICT=false
USE_DIFF=false
TARGETS=()
# ---- args ----
while [[ $# -gt 0 ]]; do
case "$1" in
--strict) STRICT=true; shift ;;
--diff) USE_DIFF=true; shift ;;
-h|--help)
sed -n '2,15p' "$0"
exit 0
;;
*) TARGETS+=("$1"); shift ;;
esac
done
# ---- resolve targets ----
if $USE_DIFF; then
if ! command -v git >/dev/null 2>&1; then
echo "ERROR: --diff requires git in PATH" >&2
exit 2
fi
# staged + unstaged changes matching story pattern
mapfile -t TARGETS < <(
{ git diff --name-only HEAD 2>/dev/null; git diff --cached --name-only 2>/dev/null; } \
| sort -u | grep -E '\.stories\.(ts|tsx)$' || true
)
if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "No changed *.stories.* files found via git diff."
exit 0
fi
fi
if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "ERROR: no targets. Pass a file, a quoted glob, or --diff." >&2
exit 2
fi
# expand targets: a directory → all stories under it (find, robust); a glob
# string → bash globstar; a plain file → itself. The directory case is the most
# reliable way to lint "everything under src/stories" — `**` glob behavior varies
# by shell (a Codex run found `src/stories/**/*.stories.tsx` matched only nested
# dirs), so prefer passing a directory or --diff.
EXPANDED=()
for t in "${TARGETS[@]}"; do
if [[ -d "$t" ]]; then
while IFS= read -r m; do EXPANDED+=("$m"); done \
< <(find "$t" -type f \( -name '*.stories.tsx' -o -name '*.stories.jsx' -o -name '*.stories.ts' \) 2>/dev/null)
continue
fi
# shellcheck disable=SC2206 # we *want* word-splitting here for glob expansion
matches=( $t )
if [[ ${#matches[@]} -eq 1 && ! -e "${matches[0]}" ]]; then
# treat as bash glob (globstar so ** spans nested dirs)
shopt -s globstar nullglob
matches=( $t )
shopt -u globstar nullglob
fi
for m in "${matches[@]}"; do
[[ -f "$m" ]] && EXPANDED+=("$m")
done
done
if [[ ${#EXPANDED[@]} -eq 0 ]]; then
echo "ERROR: no files matched after glob expansion." >&2
exit 2
fi
# ---- helpers ----
# Look for project's preview.ts/.tsx to read storySort.order (Group D)
find_preview() {
for cand in .storybook/preview.tsx .storybook/preview.ts .storybook/preview.js .storybook/preview.jsx; do
[[ -f "$cand" ]] && { echo "$cand"; return; }
done
}
PREVIEW_FILE=$(find_preview || true)
# Parse storySort.order roots ("Foundations", "Components", "Pages", ...)
STORY_SORT_ROOTS=""
# v1.7 — accept Explore alongside Labs (and other established WIP prefixes)
# for backward compat with established projects.
LABS_PREFIXES="Labs|Explore|Sandbox|Playground|Experiments"
if [[ -n "${PREVIEW_FILE:-}" ]]; then
# crude extraction — grabs strings inside `order: [...]`
if grep -qE 'storySort' "$PREVIEW_FILE" 2>/dev/null; then
STORY_SORT_ROOTS=$(
awk '/storySort/,/^[[:space:]]*\}/' "$PREVIEW_FILE" \
| grep -oE "['\"][A-Za-z][A-Za-z _/0-9-]*['\"]" \
| tr -d "'\"" \
| awk -F/ '{print $1}' \
| sort -u | tr '\n' '|' | sed 's/|$//'
)
fi
fi
# Output helpers
GREEN=$'\033[32m'; RED=$'\033[31m'; YELLOW=$'\033[33m'; DIM=$'\033[2m'; RESET=$'\033[0m'
if [[ ! -t 1 ]]; then GREEN=""; RED=""; YELLOW=""; DIM=""; RESET=""; fi
pass() { echo " ${GREEN}[PASS]${RESET} $1 — $2"; }
fail() { echo " ${RED}[FAIL]${RESET} $1 — $2"; FAILED=$((FAILED + 1)); }
skip() { echo " ${YELLOW}[SKIP]${RESET} $1 — $2"; }
# warn() surfaces a preferred-but-not-required issue WITHOUT failing the gate.
# (e.g. check 03: a real-codebase scan found 88% of shipping stories use the
# `Meta<…>` annotation — valid CSF3 — so `satisfies` is a nudge, not a blocker.)
warn() { echo " ${YELLOW}[WARN]${RESET} $1 — $2"; }
# ---- check functions ----
check_01_import_react_vite() {
if grep -E "^import .* from ['\"]@storybook/react['\"]" "$1" | grep -v "react-vite" >/dev/null 2>&1; then
fail "01" "imports '@storybook/react' (must be '@storybook/react-vite')"
else
pass "01" "no bare '@storybook/react' import (react-vite or none)"
fi
}
check_02_storybook_test() {
if grep -E "from ['\"]@storybook/test['\"]" "$1" >/dev/null 2>&1; then
fail "02" "imports '@storybook/test' (must be 'storybook/test')"
else
pass "02" "no '@storybook/test' import (storybook/test or none)"
fi
}
check_03_satisfies() {
if grep -qE "^const meta\s*:\s*Meta<" "$1"; then
line=$(grep -nE "^const meta\s*:\s*Meta<" "$1" | head -1 | cut -d: -f1)
# WARN, not FAIL: the annotation form is valid CSF3 (~88% of real shipping
# stories use it). `satisfies Meta<typeof X>` is preferred for per-story arg
# inference, but this is a nudge, not a gate-blocking error.
warn "03" "uses 'const meta: Meta<...> =' annotation — prefer 'satisfies Meta<typeof X>' for arg inference (line $line)"
else
pass "03" "satisfies pattern"
fi
}
check_04_useargs_source() {
if grep -qE "\buseArgs\b" "$1"; then
if grep -qE "from ['\"]storybook/preview-api['\"]" "$1"; then
pass "04" "useArgs source"
else
fail "04" "useArgs imported from wrong source (must be 'storybook/preview-api')"
fi
else
skip "04" "no useArgs in file"
fi
}
check_05_no_csf2() {
if grep -qE "storiesOf\(|\.story\s*=\s*\{" "$1"; then
fail "05" "found CSF2 syntax (storiesOf or .story = {})"
else
pass "05" "no CSF2"
fi
}
check_06_no_dead_sb10_imports() {
if grep -qE "from ['\"](@storybook/addon-essentials|@storybook/blocks)['\"]" "$1"; then
fail "06" "imports dead SB10 module (addon-essentials or blocks)"
else
pass "06" "no dead SB10 imports"
fi
}
check_07_no_inline_hex_in_render() {
# crude: any 3/6/8 hex literal anywhere in the file outside of comments
# (we accept hex in `parameters.design.url` etc., so we narrow to render: blocks)
# heuristic: hex literal after `render` keyword within 2000 chars
if awk '/render\s*:/{flag=1} flag && /#[0-9a-fA-F]{3,8}\b/{print; exit 1}' "$1" >/dev/null 2>&1; then
pass "07" "no inline hex in render blocks"
else
fail "07" "inline hex literal in render block — extract to design tokens or args"
fi
}
check_08_disabled_not_in_pseudo() {
if grep -qE "pseudo:\s*\{[^}]*disabled" "$1"; then
fail "08" "'disabled' inside parameters.pseudo (disabled is a prop, not a CSS pseudo-class)"
else
pass "08" "disabled not in pseudo"
fi
}
check_09_layout_set() {
if grep -qE "layout:\s*['\"](centered|fullscreen|padded)['\"]" "$1"; then
pass "09" "parameters.layout set"
else
fail "09" "parameters.layout missing (centered | fullscreen | padded)"
fi
}
check_10_fn_for_callbacks() {
# if file references any on[A-Z] prop, expect fn() somewhere in args
if grep -qE "\bon[A-Z][A-Za-z]+\b" "$1"; then
if grep -qE "\bfn\(\)" "$1"; then
pass "10" "fn() used for callback args"
else
fail "10" "callback prop present but no fn() in args (or play asserts)"
fi
else
skip "10" "no callback props in file"
fi
}
check_11_title_sort_match() {
if [[ -z "$STORY_SORT_ROOTS" ]]; then
skip "11" "no storySort.order declared in preview"
return
fi
# Scope to the first `const meta` block so we don't match `title` inside mock data
title_root=$(awk '/^const meta/,/^export default meta/' "$1" \
| grep -oE "title:[[:space:]]*['\"][^'\"]+['\"]" | head -1 \
| sed -E "s/^title:[[:space:]]*['\"]//; s/['\"]$//; s|/.*||")
if [[ -z "$title_root" ]]; then
skip "11" "no title in meta (component-only file?)"
return
fi
if echo "$title_root" | grep -qE "^($STORY_SORT_ROOTS)$"; then
pass "11" "title prefix '$title_root' matches storySort"
else
fail "11" "title prefix '$title_root' not in storySort roots ($STORY_SORT_ROOTS)"
fi
}
check_12_labs_tag_combo() {
# Scope to the first `const meta` block so we don't match `title` inside mock data
title_root=$(awk '/^const meta/,/^export default meta/' "$1" \
| grep -oE "title:[[:space:]]*['\"][^'\"]+['\"]" | head -1 \
| sed -E "s/^title:[[:space:]]*['\"]//; s/['\"]$//; s|/.*||")
if echo "$title_root" | grep -qE "^($LABS_PREFIXES)$"; then
if grep -qE "['\"]!autodocs['\"]" "$1" && grep -qE "['\"]!test['\"]" "$1"; then
pass "12" "Labs story has !autodocs + !test"
else
fail "12" "Labs story missing !autodocs or !test tag"
fi
else
skip "12" "not a Labs story"
fi
}
# check 13 — `play` must earn its place (ai-setup Step 6). A play whose only
# assertion is toBeVisible/toBeInTheDocument, with no interaction, async query,
# portal, or computed-style probe, proves nothing the render didn't already.
check_13_play_earns_its_place() {
if grep -qE "\bplay\s*:" "$1"; then
# Signals that a play asserts something non-trivial:
if grep -qE "userEvent|fireEvent|\.click\(|\.type\(|\.keyboard\(|findBy|waitFor|toHaveValue|aria-pressed|aria-expanded|getComputedStyle|toContain\(|ownerDocument|toHaveBeenCalled" "$1"; then
pass "13" "play asserts an interaction / async / portal / CSS state"
elif grep -qE "toBeVisible\(|toBeInTheDocument\(" "$1"; then
warn "13" "play looks no-op (only toBeVisible/toBeInTheDocument) — drop it, or make it prove an interaction/async/portal/CSS state (ai-setup Step 6)"
else
pass "13" "play present (non-trivial body)"
fi
else
skip "13" "no play function"
fi
}
# ---- strict mode extras ----
run_strict() {
local file=$1
echo " ${DIM}strict mode:${RESET}"
if command -v npx >/dev/null 2>&1; then
if [[ -f tsconfig.json ]]; then
echo " ${DIM} tsc --noEmit (workspace)${RESET}"
npx --no -- tsc --noEmit 2>&1 | grep "$(basename "$file")" || echo " ${DIM} (no tsc errors for this file)${RESET}"
fi
if compgen -G '.eslintrc.*' >/dev/null 2>&1 || compgen -G 'eslint.config.*' >/dev/null 2>&1; then
echo " ${DIM} eslint${RESET}"
npx --no -- eslint "$file" 2>&1 | tail -20 || true
fi
fi
}
# ---- main loop ----
TOTAL_FILES=0
FAILED_FILES=0
FAILED=0
GRAND_TOTAL_FAILS=0
CSSCHECK_COUNT=0 # project-level: stories asserting getComputedStyle (ai-setup Step 5)
for file in "${EXPANDED[@]}"; do
TOTAL_FILES=$((TOTAL_FILES + 1))
FAILED=0
echo
echo "${file}"
if grep -qE "getComputedStyle" "$file" 2>/dev/null; then
CSSCHECK_COUNT=$((CSSCHECK_COUNT + 1))
fi
check_01_import_react_vite "$file"
check_02_storybook_test "$file"
check_03_satisfies "$file"
check_04_useargs_source "$file"
check_05_no_csf2 "$file"
check_06_no_dead_sb10_imports "$file"
check_07_no_inline_hex_in_render "$file"
check_08_disabled_not_in_pseudo "$file"
check_09_layout_set "$file"
check_10_fn_for_callbacks "$file"
check_11_title_sort_match "$file"
check_12_labs_tag_combo "$file"
check_13_play_earns_its_place "$file"
if $STRICT; then
run_strict "$file"
fi
if [[ $FAILED -gt 0 ]]; then
echo " ${RED}→ $FAILED check(s) failed${RESET}"
FAILED_FILES=$((FAILED_FILES + 1))
GRAND_TOTAL_FAILS=$((GRAND_TOTAL_FAILS + FAILED))
else
echo " ${GREEN}→ all checks passed${RESET}"
fi
done
# ---- project-level CssCheck tally (ai-setup Step 5: exactly ONE getComputedStyle
# proof story per project). Only meaningful over a whole-project / multi-file scan,
# so stay silent on a single-file invocation (it would false-warn on every file). ----
if [[ $TOTAL_FILES -gt 1 ]]; then
echo
if [[ $CSSCHECK_COUNT -eq 0 ]]; then
echo " ${YELLOW}[WARN]${RESET} project — no getComputedStyle 'CssCheck' story found; add exactly ONE asserting a real computed token value, to prove the shared preview loaded the app CSS (ai-setup Step 5)"
elif [[ $CSSCHECK_COUNT -gt 1 ]]; then
echo " ${YELLOW}[WARN]${RESET} project — ${CSSCHECK_COUNT} getComputedStyle stories; ai-setup wants exactly ONE CssCheck (variant-only stories should rely on the render, not re-probe CSS)"
else
echo " ${GREEN}[PASS]${RESET} project — exactly one CssCheck (getComputedStyle proof) present"
fi
fi
# ---- summary ----
echo
echo "═══════════════════════════════════════════════════"
if [[ $FAILED_FILES -eq 0 ]]; then
echo " ${GREEN}${TOTAL_FILES} file(s) scanned — all PASS${RESET}"
exit 0
else
echo " ${RED}${FAILED_FILES} of ${TOTAL_FILES} file(s) failed (${GRAND_TOTAL_FAILS} total check failures)${RESET}"
exit 1
fi
SKILL.md
---
name: sb-ship
description: "Graduate ONE Explore experiment to a production component — preserve the experiment (cp, never git mv), pick new-vs-update, close the decision loop. Use for 'ship this', 'promote this experiment', 'graduate to production'."
compatibility: "Requires bash and git; ast-grep optional for rewriting callsites on graduation; Node.js optional (validate-stories --strict runs tsc/eslint via npx)."
allowed-tools: Bash Read Glob Grep Write Edit
license: MIT
metadata:
author: strongeron
version: '2.3.0'
bundle: storybook-workbench
vendor:
# Skill-local files live in scripts/; references/ + templates/ here are resolved from
# shared/ ($CORE) in dev and copied into dist/ by build.sh on export.
scripts: [validate-stories.sh, find-stories-by-tag.sh]
wrappers: false
references: [propagate-workflow.md]
templates: [design-decisions.md] # templates/ — propagate-workflow.md points readers to it; vendored on export
---
# sb-ship — graduate, preserving history
Event-triggered when an Explore iteration meets the graduation gate. **The one rule that matters:
preserve the experiment — `cp`, never `git mv`.** The Explore story stays as design history;
`git mv` here is the destructive bug `propagate-workflow.md` exists to prevent. **Load
`references/propagate-workflow.md` before any Ship action** — it's the *only* reference
this skill needs; **Do NOT load** the sb-wrappers/sb-audit refs (lifecycle, galleries, composition) for
a Ship.
## Decide the path (ask yourself)
- **Did the Explore define its own component file, or iterate on an existing one?** → Path A vs Path B.
- **Path B: evolve in place (B1) or keep V1 in `_legacy/` (B2)?** B2 only when V1 still has live
callsites needing a migration window.
- **Did the import path change?** If yes → `ast-grep` callsites. If not → skip it.
- **Other tagged stories that should flip?** (a Compare `decision:pending` → `decision:chosen` + winner.)
## Execute
- **Path A — NEW component.** `cp` the component explore→components, write a *fresh* production
stories file **to the recorded `storiesLocation`** (`.storybook/audit/status.md`; the rule lives in
`CONTEXT.md` §STORIES LOCATION — never scatter) — don't `cp` the Explore stories (production has
different concerns: autodocs, no decision metadata), validate, then `ast-grep` callsites only if a path changed.
- **Path B — UPDATE existing.** Apply the Explore's diffs into the production component + stories
in place; `ast-grep` only if the import path changed.
- **Both — close the loop IN PLACE** (no `git mv`):
`tags: ['explore','decision:chosen','archived','!autodocs','!test']` +
`parameters.decision = { status, winner, date, shippedTo }`. Confirm with
`find-stories-by-tag.sh decision:chosen`.
## Gate + next
Run the bundled `scripts/validate-stories.sh` on the new production story (in the bundle:
`${CLAUDE_PLUGIN_ROOT}/scripts/validate-stories.sh`, or
`${CLAUDE_PLUGIN_ROOT}/scripts/`). Confirm the flip with `scripts/find-stories-by-tag.sh
decision:chosen`. Append the decision to the ledger (`templates/design-decisions.md`); the graduated experiment stays put.
**Re-enter the usage flow.** A graduated component is new to `src/` — it isn't in the rendered JSONs
yet, so its Docs "Real usage" band and `storyCoverage` are blank. Trigger the one-command usage refresh
(`refresh-usage.sh`, owned by `sb-audit`/`sb-inventory`) so the new component enters `component-usage.json`
/ `project-inventory.json`; a Storybook rebuild then shows its real usage. Then `/sb-hub`.
templates/design-decisions.md
# Design decisions ledger
History of pruned Storybook experiments (L3 of the layered preservation model). For active experiments (L1) and recently archived ones (L2), open Storybook's `Decisions/Dashboard` story.
## How to read this
| Column | What it tells you |
|------------|-----------------------------------------------------------------------------------------|
| Date | When the decision was made (YYYY-MM-DD) |
| Decision | One-line description matching `parameters.decision.id` |
| Winner | The winning variant label (or `—` if rejected) |
| Rationale | One-line "why" from `parameters.decision.rationale` |
| Shipped to | Storybook title of the production version (or `—` if rejected) |
| PR | GitHub PR number for the Ship (link prefix below) |
| Git ref | Commit SHA where the experiment story still exists in history |
## To recover a pruned experiment
```bash
git show <git-ref>:src/explore/<topic>/<file>.stories.tsx
```
The component code (`src/components/<name>/<Name>.tsx`) is shipping — only the original Explore story has been pruned. If you need to revive the experimental variant, the file is recoverable from git history at the listed ref.
## PR link prefix
(Edit this once per project to your repo's URL pattern, e.g. `https://github.com/<org>/<repo>/pull/`)
## Decisions (newest first)
| Date | Decision | Winner | Rationale | Shipped to | PR | Git ref |
|------|----------|--------|-----------|------------|----|---------|