用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/commercetools/nimbus --skill writing-stories命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
基于 SOC 职业分类
正在显示 SKILL.md
| name | writing-stories |
| description | Create, update, or validate Storybook stories with comprehensive play functions |
| argument-hint | create|update|validate ComponentName [details] |
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.
Stories are BOTH documentation AND tests. Every interactive component MUST have play functions that test user interactions, state changes, and accessibility.
A story is documentation and a test at once, and it can serve three roles, often simultaneously (not as separate stories):
disableSnapshot: false).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:
Focused tests focus, Disabled shows the disabled look, SmokeTest is the
matrix, WithRef asserts ref forwarding. One job per story.Parse the request to determine the operation:
If no mode is specified, default to create.
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.
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:
SmokeTest only
if its axes interact (see the SmokeTest section - independent axes get their
own showcase stories instead)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...
Stories MUST be exported in this order:
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:
_hover, _focusWithin, _disabled, _active,
data-invalid, data-readonly, :has(), and any comma-separated selector list
(which counts once per child type it names).variant / size / colorPalette key, and any the recipe
hardcodes - a pinned value was never an axis.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:
isDisabled={x || isReadOnly}). Coverage is the cross-product of
recipe states x conditional branches, not the recipe states alone.argTypes)export const Base: Story = {
args: {
children: "Button",
onPress: fn(),
["data-testid"]: "test",
["aria-label"]: "test-button",
},
play: async ({ canvasElement, args, step }) => {
const canvas = within(canvasElement);
const element = canvas.getByTestId("test");
await step("Test description of what's being tested", async () => {
await expect(element).toBeInTheDocument();
// Test specific behavior
});
await step("Test interaction", async () => {
await userEvent.click(element);
await expect(args.onPress).toHaveBeenCalledTimes(1);
});
await step("Test keyboard accessibility", async () => {
await userEvent.tab();
await expect(element).toHaveFocus();
await userEvent.keyboard("{Enter}");
await expect(args.onPress).toHaveBeenCalledTimes(2);
});
},
};
const sizes: ComponentProps["size"][] = ["sm", "md", "lg"];
export const Sizes: Story = {
args: {
children: "Demo",
},
render: (args) => {
return (
<Stack direction="row" gap="400" alignItems="center">
{sizes.map((size) => (
<ComponentName key={size} {...args} size={size} />
))}
</Stack>
);
},
};
const variants: ComponentProps["variant"][] = ["solid", "outline", "ghost"];
export const Variants: Story = {
args: {
children: "Demo",
},
render: (args) => {
return (
<Stack direction="row" gap="400" alignItems="center">
{variants.map((variant) => (
<ComponentName key={variant} {...args} variant={variant} />
))}
</Stack>
);
},
};
Captures the keyboard-focus state, which no other story renders:
export const Focused: Story = {
tags: ["vrt"],
parameters: {
chromatic: { disableSnapshot: false },
},
args: {/* minimal render */},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.tab();
await expect(canvas.getByRole("button")).toHaveFocus();
},
};
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:
play: async ({ canvasElement }) => {
canvasElement.style.caretColor = "transparent"; // deterministic focused snapshot
await userEvent.tab();
await expect(/* the input */).toHaveFocus();
},
export const Disabled: Story = {
args: {
isDisabled: true,
["data-testid"]: "test",
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const element = canvas.getByTestId("test");
await step("Cannot be clicked", async () => {
await userEvent.click(element);
// Verify no action occurred
});
await step("Cannot be focused", async () => {
await userEvent.tab();
await expect(element).not.toHaveFocus();
});
},
};
export const Controlled: Story = {
render: () => {
const [value, setValue] = useState("");
return (
<Stack gap="400">
<ComponentName value={value} onChange={setValue} />
<Text data-testid="value-display">Current value: {value}</Text>
</Stack>
);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox");
const valueDisplay = canvas.getByTestId("value-display");
await step("Updates controlled value", async () => {
await userEvent.type(input, "Hello");
await expect(input).toHaveValue("Hello");
await expect(valueDisplay).toHaveTextContent("Current value: Hello");
});
},
};
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.
export const SmokeTest: Story = {
tags: ["vrt"],
parameters: { chromatic: { disableSnapshot: false } },
args: {
children: "Demo",
["data-testid"]: "test",
},
render: (args) => {
return (
<Stack gap="600">
{sizes.map((size) => (
<Stack key={size} direction="row" gap="400">
{variants.map((variant) => (
<ComponentName
key={variant}
{...args}
size={size}
variant={variant}
/>
))}
</Stack>
))}
</Stack>
);
},
};
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:
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:
SmokeTest
matrix vs. separate showcase stories.
→ 5. Packing the surfaces into framesRule 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.
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();
});
});
},
};
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
});
};
Prefer accessible queries (in order of preference):
canvas.getByRole() - BEST for interactive elementscanvas.getByLabelText() - BEST for form inputscanvas.getByTestId() - Use sparingly for non-interactive elementsdocument.querySelector() - ONLY for portal content when necessaryawait step("Test click interaction", async () => {
const button = canvas.getByRole("button");
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalledTimes(1);
});
await step("Test text input", async () => {
const input = canvas.getByRole("textbox");
await userEvent.type(input, "Test value");
await expect(input).toHaveValue("Test value");
await userEvent.clear(input);
await expect(input).toHaveValue("");
});
await step("Test keyboard navigation", async () => {
const element = canvas.getByRole("button");
// Tab to focus
await userEvent.tab();
await expect(element).toHaveFocus();
// Enter to activate
await userEvent.keyboard("{Enter}");
await expect(args.onPress).toHaveBeenCalled();
// Space to activate
await userEvent.keyboard(" ");
await expect(args.onPress).toHaveBeenCalledTimes(2);
});
await step("Test arrow key navigation", async () => {
// Navigate down
await userEvent.keyboard("{ArrowDown}");
await waitFor(() => {
const secondItem = canvas.getByRole("menuitem", { name: /Item 2/ });
expect(secondItem).toHaveFocus();
});
// Navigate up
await userEvent.keyboard("{ArrowUp}");
await waitFor(() => {
const firstItem = canvas.getByRole("menuitem", { name: /Item 1/ });
expect(firstItem).toHaveFocus();
});
});
await step("Test async state changes", async () => {
await userEvent.click(triggerButton);
// Wait for async content to appear
await waitFor(() => {
expect(canvas.getByText("Loaded content")).toBeInTheDocument();
});
});
You MUST test these accessibility features:
await step("Test ARIA attributes", async () => {
const element = canvas.getByRole("button");
// Required attribute
await expect(element).toHaveAttribute("aria-label", "Close");
// Disabled state
await expect(element).toHaveAttribute("aria-disabled", "true");
// Invalid state
await expect(element).toHaveAttribute("data-invalid", "true");
});
await step("Test focus management", async () => {
// Initial focus
const firstButton = canvas.getByRole("button", { name: "First" });
await userEvent.tab();
await expect(firstButton).toHaveFocus();
// Focus restoration after dialog close
await userEvent.keyboard("{Escape}");
await waitFor(
() => {
expect(firstButton).toHaveFocus();
},
{ timeout: 1000 }
);
});
await step("Verify state changes", async () => {
const checkbox = canvas.getByRole("checkbox");
// Initial state
await expect(checkbox).not.toBeChecked();
// After interaction
await userEvent.click(checkbox);
await expect(checkbox).toBeChecked();
// Visual indication (data attributes)
await expect(checkbox).toHaveAttribute("data-selected");
});
MUST test:
MUST test:
MUST test:
MUST test:
MUST test:
You MUST verify the changes:
# components/ for a component; patterns/{group}/ for a pattern
pnpm test:dev $(find packages/nimbus/src -name "{component}.stories.tsx")
You MUST validate against these requirements:
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)@storybook/react-vite and storybook/testStoryObj<typeof ComponentName> (the component, not
typeof meta - see note in docs/file-type-guidelines/stories.md)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.
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 directionsdisableSnapshot: true); a
deliberate omission is recorded as a comment, not a redundant parameter1. Does it paint?
data-readonly rule renders like default)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?
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
inheritFocused 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 induration: 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 itbeforeEach 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 playplacement snapshotted only when it changes the layout (Drawer
side/top/bottom panels), not a mere reposition (Dialog = center only;
Menu/Tooltip RA-positioning = behavioral)[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 settled3. Who owns the pixels?
Focused/Disabled
if added), not a re-rendered copy of the wrapped component's matrix*Field pattern has exactly two snapshots, with every delegation
named; FormField.Input's cloneElement checked before calling a state
un-forwarded:has() selector); plausible-but-duplicate
compositions stay off-snapshot with a pointer to the frame that holds themcolorPalette from its host is snapshotted per host4. Does the play land the frame?
userEvent.click is blurred - React Aria keeps
data-focus-visible set after a synthetic clickblur()aria-* values, callback arguments and attributes prove state, not pixelsstep() name matches what it asserts; raise the assertion to the
name, never lower the name. No tautological assertions, no un-awaited
async helperscanvasElement.style.caretColor = "transparent") - not just Focused
stories: an open Combobox keeps focus in its input tooanimation="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)@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 selector5. 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)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)SEMANTIC_COLOR_PALETTES; a standalone ColorPalettes showcase costs one
frame regardless, so it uses DisplayColorPalettes (@/utils), covering
all three groupsdisabled 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 matrixFocused + FocusedWithCurrencyLabel)SmokeTest (not
Variants/VariantsAndSizes); the axis list lives in the doc commentSmokeTest left
snapshot-off (project default) - never drop a visual state to save coststep() 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)step() for test organizationwithin() for scoped querieswaitFor() for async operationsgetByRole() for interactive elementsgetByLabelText() for form inputsgetByTestId() sparingly## Story Validation: {ComponentName}
### Status: [✅ PASS | ❌ FAIL | ⚠️ WARNING]
### Files Reviewed
- Story file: `{component}.stories.tsx`
- Guidelines: `docs/file-type-guidelines/stories.md`
### ✅ Compliant
[List passing checks]
### ❌ Violations (MUST FIX)
- [Violation with guideline reference and line number]
### ⚠️ Warnings (SHOULD FIX)
- [Non-critical improvements]
### Test Coverage
- Required Stories: [X/Y present]
- Play Functions: [X/Y stories have tests]
- Interaction Testing: [Complete | Partial | Missing]
- Accessibility Testing: [Complete | Partial | Missing]
### Recommendations
- [Specific improvements needed]
If tests fail:
console.log, screen.debug())Common issues:
waitFor() for async operationsawait on async operationsYou MUST follow the clean testing patterns documented in:
docs/file-type-guidelines/stories.md#clean-testing-patterns-storybookdocs/file-type-guidelines/unit-testing.md#clean-testing-patterns-jsdomKey requirements:
key props when mapping arrays in render functionsuserEvent.tab() for focus management (not element.focus())step() calls including nested onesaria-label for components without visible labelsYou SHOULD reference these stories:
packages/nimbus/src/components/button/button.stories.tsxpackages/nimbus/src/components/text-input/text-input.stories.tsxpackages/nimbus/src/components/menu/menu.stories.tsxpackages/nimbus/src/components/dialog/dialog.stories.tsxpackages/nimbus/src/components/select/select.stories.tsxExecute story operation for: $ARGUMENTS
[data-pressed]_hover_active_motionReduce