本文へ移動
cccskills
無料GitHub で公開

writing-stories

Create, update, or validate Storybook stories with comprehensive play functions

インストール方法を見る

含まれるファイル(1)

  • SKILL.md39.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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:

  1. Read story guidelines and type matrix:

    cat docs/file-type-guidelines/stories.md
    
  2. Analyze component characteristics to determine story type

  3. 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 SmokeTest only if its axes interact (see the SmokeTest section - independent axes get their own showcase stories instead)
  • Form components: Add Required, Invalid, Controlled stories
  • Interactive components: Add KeyboardNavigation, 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:

  1. Base/Default - Simplest usage, first story
  2. Sizes - Size variants (if applicable)
  3. Variants - Visual variants (if applicable)
  4. Focused - Focus state (if applicable)
  5. States - Disabled, Invalid, Required, etc.
  6. Controlled - Controlled state example
  7. Complex - Advanced scenarios, edge cases
  8. 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.
  • Component props and variants (drives argTypes)
  • Interactive behavior (click, type, keyboard nav)
  • State management (controlled vs uncontrolled)
  • Portal content (overlays, dropdowns)
  • Accessibility requirements

Step 2: Story Templates

Base Story (REQUIRED)

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);
    });
  },
};

Sizes Story (REQUIRED if component has sizes)

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>
    );
  },
};

Variants Story (REQUIRED if component has variants)

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>
    );
  },
};

Focused Story (if applicable)

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();
},

Disabled Story (REQUIRED for interactive components)

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();
    });
  },
};

Controlled Story (REQUIRED for stateful components)

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");
    });
  },
};

SmokeTest Story (only for interacting axes)

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>
    );
  },
};

Chromatic Snapshots: What Gets Captured

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:

  1. Does it paint? Is there a component-owned pixel at all.
  2. Is the state reachable in this frame? Inert props, zeroing variants, inherited values with nothing to inherit, hover/pressed.
  3. Who owns the pixels? Delegation to children, consumers, thin wrappers, composition patterns.
  4. Does the play land the frame? End state, blur, settled animation, determinism.
  5. 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 focus ring possibly styled on a slot that never receives focus. → 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):

  1. canvas.getByRole() - BEST for interactive elements
  2. canvas.getByLabelText() - BEST for form inputs
  3. canvas.getByTestId() - Use sparingly for non-interactive elements
  4. document.querySelector() - ONLY for portal content when necessary

Interaction Patterns

Click Testing

await step("Test click interaction", async () => {
  const button = canvas.getByRole("button");
  await userEvent.click(button);
  await expect(args.onClick).toHaveBeenCalledTimes(1);
});

Typing Testing

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("");
});

Keyboard Navigation

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);
});

Arrow Key Navigation (Menu, Select, etc.)

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();
  });
});

Async Operations

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();
  });
});

Accessibility Testing

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 }
  );
});

State Verification

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");
});

Common Testing Patterns by Component Type

Form Inputs (TextInput, Select, Checkbox)

MUST test:

  • Initial render and attributes
  • Focus with Tab
  • Type/input value
  • Clear value
  • Required state (aria-required)
  • Disabled state (cannot focus, cannot input)
  • Invalid state (can still interact, has data-invalid)
  • Controlled state synchronization

Buttons (Button, IconButton, ToggleButton)

MUST test:

  • Click interaction
  • Focus with Tab
  • Keyboard activation (Enter, Space)
  • Disabled state (cannot focus, cannot activate)
  • Visual variants render correctly

Overlays (Dialog, Menu, Popover)

MUST test:

  • Open via trigger
  • Portal content appears (use parent element)
  • Keyboard navigation inside overlay
  • Escape key dismissal
  • Focus restoration to trigger
  • Backdrop click (if dismissable)

Navigation (Pagination, Tabs)

MUST test:

  • Navigation between items
  • Keyboard navigation (Arrow keys, Home, End)
  • Current item indication
  • Disabled navigation buttons at boundaries
  • Input validation (for direct input components)

Selection (RadioGroup, CheckboxGroup, Select)

MUST test:

  • Single selection (radio) - only one selected
  • Multiple selection (checkbox) - multiple selected
  • Selection change callbacks
  • Keyboard selection (Space, Enter)
  • Disabled options cannot be selected

Update Mode

Process

  1. You MUST read the current story file
  2. You MUST identify gaps in test coverage
  3. You SHOULD preserve existing story structure
  4. You MUST add missing required stories
  5. You MUST enhance play functions with missing tests

Common Updates

  • Add missing story - Base, Disabled, Controlled, etc.
  • Enhance play function - Add keyboard nav, accessibility tests
  • Add edge cases - Boundary conditions, error states
  • Fix failing tests - Update assertions, fix async timing

Post-Update

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")

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)
  • A large stories file is split by topic into {name}.{topic}.stories.tsx files with the same title and component (see "Splitting a large stories file" in docs/file-type-guidelines/stories.md)
  • 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; in a split stories file, first in {name}.stories.tsx)
  • 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]/[data-pressed] by hand half-styles the frame (recipes are split with Chakra _hover/_active). 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 _motionReduce 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)
  • Uses step() for test organization
  • Uses within() for scoped queries
  • Uses waitFor() for async operations
  • Tests keyboard navigation (Tab, Enter, Space, Arrows)
  • Tests accessibility attributes (aria-, data-)
  • Tests state changes and synchronization
  • Tests disabled states (cannot focus, cannot interact)
  • Tests edge cases and boundaries

Query Strategy

  • Prefers getByRole() for interactive elements
  • Uses getByLabelText() for form inputs
  • Uses getByTestId() sparingly
  • Portal content uses parent element access
  • No hardcoded selectors without good reason

Test Coverage

  • Initial render verified
  • Focus management tested
  • Click/press interactions tested
  • Keyboard interactions tested
  • State synchronization tested
  • Async operations use waitFor
  • Accessibility requirements verified

Story Organization

  • Stories in prescribed order
  • Clear, descriptive story names
  • Consistent args usage
  • Proper use of render function for variants

Validation Report Format

## 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]

Error Recovery

If tests fail:

  1. You MUST check test syntax (async/await, expect calls)
  2. You MUST verify element queries match actual DOM
  3. You MUST check timing (use waitFor for async)
  4. You MUST verify portal content uses parent element
  5. You SHOULD add debugging steps (console.log, screen.debug())

Common issues:

  • Missing waitFor() for async operations
  • Wrong query selectors
  • Portal content not accessible (need parent element)
  • Timing issues (interactions too fast)
  • Missing await on async operations

Clean Testing Patterns

You MUST follow the clean testing patterns documented in:

  • Storybook patterns: docs/file-type-guidelines/stories.md#clean-testing-patterns-storybook
  • JSDOM patterns: docs/file-type-guidelines/unit-testing.md#clean-testing-patterns-jsdom

Key requirements:

  • Add key props when mapping arrays in render functions
  • Use userEvent.tab() for focus management (not element.focus())
  • Await all step() calls including nested ones
  • Add timing delays for React Aria keyboard sequences
  • Provide aria-label for components without visible labels
  • Initialize controlled inputs with defined values

Reference Examples

You SHOULD reference these stories:

  • Simple: packages/nimbus/src/components/button/button.stories.tsx
  • Form: packages/nimbus/src/components/text-input/text-input.stories.tsx
  • Overlay: packages/nimbus/src/components/menu/menu.stories.tsx
  • Complex: packages/nimbus/src/components/dialog/dialog.stories.tsx
  • Selection: packages/nimbus/src/components/select/select.stories.tsx

RFC 2119 Key Words

  • MUST / REQUIRED / SHALL - Absolute requirement
  • MUST NOT / SHALL NOT - Absolute prohibition
  • SHOULD / RECOMMENDED - Should do unless valid reason not to
  • SHOULD NOT / NOT RECOMMENDED - Should not do unless valid reason
  • MAY / OPTIONAL - Truly optional

Execute story operation for: $ARGUMENTS

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Manage restore context files so skills can survive /clear and /compact. Use to write, delete, check, or list .agent-restore-context-* files.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

Set up the restore-context hook so skills can survive /clear and /compact. Detects .claude/ vs .agents/ layout automatically.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

Archive a completed change. Use when the user wants to finalize and archive a change after implementation is complete.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.

日本語の概要は準備中です。原文の説明を表示しています。

commercetools/nimbus52026年10月12日 更新

commercetools のスキルをすべて見る

このスキルの問題を報告する