Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Create, update, or validate Storybook stories with comprehensive play functions
argument-hint
create|update|validate ComponentName [details]
Writing Stories Skill
You are a Nimbus story specialist. This skill helps you create, update, or
validate Storybook stories (*.stories.tsx) with comprehensive play functions
for testing component behavior.
Critical Requirements
Stories are BOTH documentation AND tests. Every interactive component MUST
have play functions that test user interactions, state changes, and
accessibility.
A story's three testing roles
A story is documentation and a test at once, and it can serve three roles, often
simultaneously (not as separate stories):
Interaction test - the play function drives the component and asserts
behavior (clicks, typing, keyboard nav, resulting ARIA/DOM state).
Visual snapshot - Chromatic captures the story's end state and diffs it
against the baseline (opt-in via disableSnapshot: false).
Accessibility check - axe and the APCA contrast check run on every story
via addon-a11y and fail the run on a violation (test: "error"); play
functions may add targeted a11y assertions on top.
Unit tests of utilities and hooks live in *.spec.tsx, and consumer examples in
*.docs.spec.tsx - separate test categories, not story roles.
The play requirement is per component, not per story. Give a story its own
play when its name makes a behavioral claim, or when its snapshot needs an
interaction to exist (focus ring, opened overlay, scroll-fired sticky/overflow).
Do NOT add one when the claim is a resting visual props alone produce - the
snapshot and the always-on a11y check already test it. Never add a play for
completeness.
Whichever role(s) a story serves, it MUST be:
Concise - the minimal setup to exercise one thing.
Deterministic - identical output every run: no live dates or random values
in a snapshot, wait for async-derived state before the capture, don't leave a
stray focus ring.
True to its name - it does exactly what its title says and nothing more.
Focused tests focus, Disabled shows the disabled look, SmokeTest is the
matrix, WithRef asserts ref forwarding. One job per story.
Mode Detection
Parse the request to determine the operation:
create - Generate new story file with complete test coverage
update - Add stories, enhance play functions, or modify existing tests
validate - Check story compliance with guidelines and test coverage
If no mode is specified, default to create.
Required Research (All Modes)
Before implementation, you MUST research in parallel:
Read story guidelines and type matrix:
cat docs/file-type-guidelines/stories.md
Analyze component characteristics to determine story type
Review similar story implementations - patterns live outside
components/, so search both:
find packages/nimbus/src -name "*.stories.tsx"
Conditionally, for a first-pass VRT audit only - when you are deciding which
stories snapshot rather than writing one to a settled spec:
cat docs/chromatic-visual-testing.md
That doc is rationale and worked precedent, not instructions; it is long and
adds nothing for mechanical authoring. See
"Chromatic Snapshots: What Gets Captured" below for the specific calls that
warrant reading it. For CI behavior (triggers, baselines, gating) see
docs/chromatic-ci.md - never needed to author a story.
Story Requirements by Component Type
You MUST determine which story types are needed based on component category. See
docs/file-type-guidelines/stories.md for the complete story type matrix and
decision flowchart.
Quick reference:
Simple components: Base, Sizes, Variants, Disabled, plus SmokeTestonly
if its axes interact (see the SmokeTest section - independent axes get their
own showcase stories instead)
Form components: Add Required, Invalid, Controlled stories
Portal components: Add Placement, Dismissal stories with special portal
testing patterns
File Structure
Story File Template
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within, expect, waitFor, fn } from "storybook/test";
import { ComponentName } from "@commercetools/nimbus";
const meta: Meta<typeof ComponentName> = {
title: "Components/ComponentName", // StartCase, organized by category
component: ComponentName,
parameters: {
layout: "centered", // or "fullscreen", "padded"
},
tags: ["autodocs"],
argTypes: {
// Define controls for props
variant: {
control: { type: "select" },
options: ["solid", "outline", "ghost"],
},
},
};
export default meta;
type Story = StoryObj<typeof ComponentName>;
// Stories follow below...
Story Organization (REQUIRED)
Stories MUST be exported in this order:
Base/Default - Simplest usage, first story
Sizes - Size variants (if applicable)
Variants - Visual variants (if applicable)
Focused - Focus state (if applicable)
States - Disabled, Invalid, Required, etc.
Controlled - Controlled state example
Complex - Advanced scenarios, edge cases
SmokeTest - the interacting-axes matrix. Omit it entirely when the axes
are independent; don't substitute a cross-product that adds no coverage
Create Mode
Step 1: Component Analysis
Start with the recipe. Read it, don't recall it.
# .tsx as well as .ts; patterns live in src/patterns/, not src/components/
find packages/nimbus/src -path "*{component}*" -name "*.recipe.*"
Every VRT decision is derived from this file, so read it before deciding anything
about snapshots:
Every painting selector - _hover, _focusWithin, _disabled, _active,
data-invalid, data-readonly, :has(), and any comma-separated selector list
(which counts once per child type it names).
Every variant / size / colorPalette key, and any the recipe
hardcodes - a pinned value was never an axis.
What it does not style. A state with no rule here renders identically to
the default and gets no story of its own. If the recipe sets no color, border or
spacing at all, the component gets no VRT (see "What Gets Captured").
Not every component has one, and recipes come in both .ts and .tsx.
Genuinely having none is itself the answer for pass-through style primitives, so
glob both extensions before concluding a component has no recipe.
Then analyze:
The component source - every conditional render or prop that changes what
paints (e.g. isDisabled={x || isReadOnly}). Coverage is the cross-product of
recipe states x conditional branches, not the recipe states alone.
Give each distinctly-styled focusable sub-element its own Focused story, not
just the primary one (a split button's dropdown trigger, an input's
stepper/clear button, a date field's calendar toggle) - each styles its own
:focus-visible, and since only one element holds focus per snapshot, one story
can't capture two rings. Verify the ring actually renders before opting in -
if it's styled on a slot that never gets the focus state (a non-focusable
indicator/track keyed off data-focus / _focusWithin), the snapshot captures
nothing (see DropZone).
Text-entry inputs: hide the caret so the focused snapshot is deterministic
(Chromatic can't pause the native caret blink). caret-color is inherited, so
one line on the canvas cascades to the input:
Pack the interacting axes your component actually has into one matrix:
iterate whichever of sizes, variants, colorPalettes, and
selected/unselected (toggles) apply, covering every combination that produces a
distinct visual. Content edge cases that change layout (long labels, icon +
text) can go here too.
A matrix is only for interacting axes. Build one only when a cross-cell is a
visual neither axis produces alone (checked ร invalid โ a distinct critical
fill). compoundVariants over two axes is the recipe declaring exactly that -
build the matrix. If the axes are independent - no novel cross-cell, one just
scales/recolors the other (size ร colorPalette, size ร on/off) - do not
build a matrix, even a small 2ร2; snapshot each axis as its own showcase story
(Sizes, ColorPalettes, a states story). The cross-product adds cells, not
coverage. So not every component has a SmokeTest - components with only
independent axes (Avatar, Badge, Switch) use dedicated showcase snapshots
instead.
(Hover and pressed can't be forced from a play at all, so they're not matrix
axes either - see "What Gets Captured".)
Span the recipe's live keys, not a dev subset - an array that trims a value
the recipe still ships (three sizes when the component has five) leaves those
cells covered by nothing. Mirroring one the recipe itself comments out is fine:
Button's sizes omits lg and 2xl because the recipe does. A component with
no recipe of its own takes the range of the one it borrows - IconButton has none
and extends ButtonProps, so Button's live keys are its supported range. A
value the recipe hardcodes collapses the axis entirely: MultilineTextInput
pins colorPalette: "neutral", so its matrix is state x size x variant.
Palette scope depends on which frame you're in. As a matrix axis,
palettes iterate SEMANTIC_COLOR_PALETTES - the BRAND and SYSTEM sets run
the same token machinery and would multiply every cell for no new coverage. A
standalone ColorPalettes showcase is a single frame however many swatches
it holds, so it uses the shared DisplayColorPalettes helper (@/utils), which
renders all three groups labelled.
Cover distinct state-combinations, not just single flags - selected-disabled
is a separate look from unselected-disabled, so a Disabled story showing only
the unselected case leaves a gap.
disabled folds out of the grid only while what it dims is uniform - it
normally resolves to one shared style regardless of palette/size/variant, so a
dedicated Disabled story captures it once instead of every cell rendering at
half opacity. Once another state repaints that surface (a tinted
[data-selected]), the combination goes back in the matrix.
Thin wrappers get no matrix - a component that only forwards a wrapped
component's props snapshots only the axis it introduces (+ Focused/Disabled
if added), not a re-render of the wrapped grid. FloatingActionButton wraps
IconButton at a fixed shape, so it snapshots ColorPalettes + Focused +
Disabled only - size/variant are IconButton's.
The visual snapshot role. Snapshots are opt-in: preview.tsx defaults to
disableSnapshot: true; a story opts in with disableSnapshot: false + tags: ["vrt"]. Chromatic reads only disableSnapshot; vrt is just a label so tooling
can find snapshot stories. Crop padding is global (a preview.tsx decorator wraps
non-fullscreen stories in 1rem), so focus rings aren't clipped.
Four questions decide every snapshot call, then a fifth step packs the survivors:
Does it paint? Is there a component-owned pixel at all.
Is the state reachable in this frame? Inert props, zeroing variants,
inherited values with nothing to inherit, hover/pressed.
Who owns the pixels? Delegation to children, consumers, thin wrappers,
composition patterns.
Does the play land the frame? End state, blur, settled animation,
determinism.
Packing the surfaces into frames. Into as few as the axes allow.
The rules themselves live in docs/file-type-guidelines/stories.md (its
"Chromatic Visual Regression Snapshots" section) - terse rules plus paste-ready
snippets, already loaded by Required Research. This skill's Chromatic
Snapshots validation checklist repeats them as checkboxes under the same five
headings, and is what you tick off when validating.
Escalate to docs/chromatic-visual-testing.md only when a rule doesn't settle
the case - it is a long file of rationale and worked examples, so don't load
it by default. Read it when you hit one of these, because each is a call the terse
rule states but cannot decide for you. Follow the link and read that section
only - none of these needs the whole file:
Does this state paint differently from the default? Read-only is the
classic - the answer is component-dependent (MoneyInput yes, TextInput no).
โ 1. Does it paint?
Any "renders like default" verdict you are about to write without having
named the exact delta you checked.
โ 1. Does it paint?
One rule with a comma-separated selector list - how many frames it needs.
โ 1. Does it paint?
A recipe variant that zeroes the surface, or an inherited property with
nothing to inherit - the state is inert in the default frame.
โ 2. Is the state reachable in this frame?
A compound component with optional slots - which arrangements are genuine
surfaces rather than the same slots rearranged.
โ 3. Who owns the pixels?
Do these two axes interact, or are they independent? Decides SmokeTest
matrix vs. separate showcase stories.
โ 5. Packing the surfaces into frames
Rule of thumb: auditing a component's coverage for the first time โ read it.
Authoring a story into an already-audited component, or fixing a play โ don't.
When a VRT pattern changes, sync all three canonical docs at their set depth -
rationale and worked examples in docs/chromatic-visual-testing.md;
docs/file-type-guidelines/stories.md gets the terse rule + snippet; this file
gets the checklist item, under the matching numbered heading. One statement per
depth - don't restate a rule at two depths, or they drift.
Step 3: Portal Content Handling
For components that render portal content (Dialog, Menu, Popover, Select):
export const PortalExample: Story = {
play: async ({ canvasElement, step }) => {
// CRITICAL: Use parent element to capture portal content
const canvas = within(
(canvasElement.parentNode as HTMLElement) ?? canvasElement
);
await step("Open portal content", async () => {
const trigger = canvas.getByRole("button");
await userEvent.click(trigger);
// Wait for portal content to appear
await waitFor(() => {
expect(canvas.getByRole("dialog")).toBeInTheDocument();
});
});
},
};
Play Function Patterns (CRITICAL)
Structure Requirements
You MUST use this structure:
play: async ({ canvasElement, args, step }) => {
const canvas = within(canvasElement); // or parent for portals
await step("Descriptive test name", async () => {
// Test implementation
});
await step("Next test", async () => {
// Test implementation
});
};
Query Strategy
Prefer accessible queries (in order of preference):
canvas.getByRole() - BEST for interactive elements
canvas.getByLabelText() - BEST for form inputs
canvas.getByTestId() - Use sparingly for non-interactive elements
document.querySelector() - ONLY for portal content when necessary
# components/ for a component; patterns/{group}/ for a pattern
pnpm test:dev $(find packages/nimbus/src -name "{component}.stories.tsx")
Validate Mode
Validation Checklist
You MUST validate against these requirements:
File Structure
Story file location - src/components/{name}/{name}.stories.tsx for a
component, src/patterns/{group}/{name}/{name}.stories.tsx for a pattern
(groups: buttons, actions, dialogs, fields, pages)
Imports from @storybook/react-vite and storybook/test
Meta configuration with title, component, tags
Default export of meta
Story type from StoryObj<typeof ComponentName> (the component, not
typeof meta - see note in docs/file-type-guidelines/stories.md)
Required Stories
Base/Default story exists (MUST be first)
Sizes story (if component has sizes)
Variants story (if component has variants)
Focused story (if component is focusable)
Disabled story (for interactive components)
Controlled story (for stateful components)
SmokeTest story if the component has interacting axes; independent axes use dedicated showcase stories instead
Chromatic Snapshots
One line per check; the rule and its reasoning are in Chromatic Snapshots: What
Gets Captured above.
0. Is the opt-in actually wired? Mechanical, and checked first - the rest of
this list assumes the frames exist.
Every story with tags: ["vrt"] also carries
parameters: { chromatic: { disableSnapshot: false } }, on that same
story. The tag alone captures nothing (Chromatic never reads it), so a tag
without the parameter is a story that claims a baseline and has none - the
failure is silent in both directions
No story restates the project default (disableSnapshot: true); a
deliberate omission is recorded as a comment, not a redundant parameter
1. Does it paint?
Surfaces enumerated from the recipe + component source (painting
selectors, variant/size keys, conditional props, ambient RTL/locale/theme
axes), and snapshots cover their cross-product - not recalled from memory
Every child type a comma-separated selector names appears in some frame
Diffed against sibling components' story sets; each surface a peer
snapshots and this one doesn't is either covered or named as not applicable
Any "renders like default" verdict names the exact delta checked
A state with no distinct recipe surface gets no dedicated story
(read-only with no data-readonly rule renders like default)
Primitives that paint no surface get no VRT at all, with a one-line
meta note in the format
// No VRT: <reason> (see chromatic-visual-testing.md). - pass-through
style props, a recipe that paints nothing (Group, CollapsibleMotion),
headless display: contents (Region), no DOM of its own (providers,
MakeElementFocusable, VisuallyHidden), or every axis owned by another
recipe (InlineSvg โ Icon, whose covering stories the note names). Check
whether the recipe paints, not whether it exists (Separator and Icon
get normal audits)
2. Is the state reachable in this frame?
Condition-triggered states get a frame where the trigger actually holds:
sticky scrolled in the play (bounded overflow: auto ancestor,
offsetHeight-derived target, each combination its own frame, and a
scrollHeight > clientHeight wait before scrolling),
scrollBehavior="inside" given overflowing content, a variant-zeroed surface
pinned to the variant that paints it, an inherited property given a value to
inherit
A separate Focused story per independent focus surface (fused/adjacent
controls, or multiple _focusWithin regions) - not one story tabbing through
all, since only one element holds focus per snapshot. Each opts in
The focus ring is confirmed to render before opting in - not styled on a
slot that never gets the focus state (DropZone)
Overlays snapshot the open state (rendered open, left open); each
distinct open surface is its own story; open/close & dismissal stay behavioral
Portal components (Toast/overlays): transient UI held open
(duration: Infinity) and awaited; the component's own focus reached via
its real keyboard path, not .focus(); an overlay hanging below a
short root given minHeight so the crop keeps it
State that outlives unmount (toast queue, React Aria drag session) is
cleared in the teardown a beforeEach returns - it runs after the
capture, so it can't disturb the frame, and it still fires when the
leaking story is last in the file. The run is isolate: false; a file's
stories share one page. Not cleaned at the top of the next play
placement snapshotted only when it changes the layout (Drawer
side/top/bottom panels), not a mere reposition (Dialog = center only;
Menu/Tooltip RA-positioning = behavioral)
No hand-rolled hover/pressed capture - a play can't force the
pseudo-class, and setting [data-hovered]/ by hand
half-styles the frame (recipes are split with Chakra /).
Pressed is not categorically a no-op - the recipe was checked. Everything
else a play can drive is snapshotted settled
3. Who owns the pixels?
Snapshotted stories render the component directly - no debug read-outs,
value dumps, or demo-wrapper scaffolding in the frame. Load-bearing,
static scaffolding is admissible and named as such
Thin wrappers snapshot only the axis they introduce (+ Focused/Disabled
if added), not a re-rendered copy of the wrapped component's matrix
A composition pattern snapshots what it hardcodes, not what children paint
or consumers pass
A *Field pattern has exactly two snapshots, with every delegation
named; FormField.Input's cloneElement checked before calling a state
un-forwarded
For a compound component with optional slots, each frame names the recipe
rule it alone fires (typically a :has() selector); plausible-but-duplicate
compositions stay off-snapshot with a pointer to the frame that holds them
A child inheriting colorPalette from its host is snapshotted per host
4. Does the play land the frame?
Each snapshotted story's play ends in the state the snapshot is named for -
no stray focus ring, cleared/mutated value, or left-open overlay unless
intended. A play can be assertion-honest yet snapshot the wrong picture
A story left focused by userEvent.click is blurred - React Aria keeps
data-focus-visible set after a synthetic click
Nothing is left un-snapshotted because of its play's end state - fix the
play; snapshotted stories that end focused call blur()
Nothing is left un-snapshotted because a play already asserts the state -
aria-* values, callback arguments and attributes prove state, not pixels
Every step() name matches what it asserts; raise the assertion to the
name, never lower the name. No tautological assertions, no un-awaited
async helpers
No play added for completeness - each is there because the story's name
makes a behavioral claim or its frame needs an interaction to exist
Any frame ending with focus in a text input hides the caret
(canvasElement.style.caretColor = "transparent") - not just Focused
stories: an open Combobox keeps focus in its input too
Determinism: dates pinned to a fixed anchor (live "today" stays
off-snapshot), no random values, async-derived state awaited
Animated states: paused frame confirmed to show the target. Frozen via
the component's own animation-off prop where it has one
(animation="none"); the play-level pin (animation: none + explicit
transform) used only where it doesn't, and required where an infinite
animation's endpoints both hide the content (indeterminate progress)
Reduced motion left off-snapshot - no Chromatic mode is configured and
JS can't fake @media (prefers-reduced-motion). The story instead asserts
the compiled rule still ships, anchored to the element's
own hashed class rather than a hardcoded selector
5. Packing the surfaces into frames
SmokeTest matrix is exhaustive over the interacting axes the component
has (e.g. size x variant x palette, plus selected/unselected for toggles)
Axis arrays span the recipe's live keys - mirroring a value the recipe
itself comments out is fine (Button's lg/2xl); trimming one it still
ships is a gap. A component with no recipe takes the range of the one it
borrows (IconButton โ Button), and a value the recipe hardcodes
collapses the axis (MultilineTextInput gives state x size x variant)
Palette scope matches the frame - a matrix axis uses
SEMANTIC_COLOR_PALETTES; a standalone ColorPalettes showcase costs one
frame regardless, so it uses DisplayColorPalettes (@/utils), covering
all three groups
Distinct state-combinations are covered, not just single flags
(selected-disabled โ unselected-disabled), where the selection model makes
them reachable
disabled is folded out into its own story - but only while what it
dims is uniform; once another state repaints that surface (a tinted
[data-selected]), the combination is back in the matrix
Each state checked for being rendered more than one way (mode-/variant-
driven); each distinct surface gets its own story, not a folded gallery
(MoneyInput: Focused + FocusedWithCurrencyLabel)
The interacting-axes matrix is named SmokeTest (not
Variants/VariantsAndSizes); the axis list lives in the doc comment
Only behavior-only stories and stories whose look is already in SmokeTest left
snapshot-off (project default) - never drop a visual state to save cost
Play Functions (CRITICAL)
ALL interactive components have play functions
Every step() name is backed by its assertions - if the name claims a
behavior the checks don't prove, strengthen the checks to prove it; only
rename/drop the claim when the behavior is genuinely another story's
concern (and note where it's covered)