Story Authoring — CSF3 Patterns
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/storybook-designer/story-authoring.md |
| Description | Not specified |
Source Content
Story Authoring — CSF3 Patterns
The Canonical Meta Block
import type { Meta, StoryObj } from '@storybook/react-vite'import { createComponentDocs } from '@/lib/storybook-docs'import { MyComponent } from './my-component'
const meta = { component: MyComponent, tags: ['autodocs'], parameters: { docs: { description: { component: createComponentDocs({ summary: 'One-sentence plain-English description.', when: 'Use when the user needs to do X in context Y.', whenNot: 'Do not use for Z — use AlternativeComponent instead.', motion: 'Fades in on mount, 150ms ease-out.', reducedMotion: 'No animation. Component appears immediately.', }), }, }, },} satisfies Meta<typeof MyComponent>
export default metatype Story = StoryObj<typeof meta>Rules:
- Always
satisfies Meta<typeof Component>— neverMeta<typeof Component>without satisfies. - Always
tags: ['autodocs']. - Always
createComponentDocs(...)— never write raw strings into the description. motionandreducedMotionare required for any component with animation.
Story Naming Conventions
Name stories after the state or scenario, not the variant prop:
// GOOD — describes what the user seesexport const Default: Story = { args: { ... } }export const Loading: Story = { args: { isLoading: true } }export const ErrorState: Story = { args: { error: 'Failed to load' } }export const Empty: Story = { args: { items: [] } }export const Disabled: Story = { args: { disabled: true } }export const WithLongContent: Story = { args: { title: 'A very long title that might wrap...' } }export const InsideCard: Story = { /* shows component in a card host */ }
// BAD — describes the prop, not the experienceexport const IsLoadingTrue: Story = { }export const VariantDestructive: Story = { }Args vs Render
Use args when:
- The story is fully driven by serializable props
- Controls should work automatically
- No special layout or wrapper needed
export const Default: Story = { args: { label: 'Save changes', variant: 'default', size: 'md', },}Use render when:
- The component needs a wrapper for layout (but NOT for a border — see ADR-008)
- The component has children that need to be concrete (not abstract args)
- You need to show compound component composition
export const WithActions: Story = { render: (args) => ( <div className="flex gap-2"> <Button {...args} variant="default">Save</Button> <Button {...args} variant="ghost">Cancel</Button> </div> ),}Never define function Demo() inside a render callback — use the arrow function directly:
// BADexport const Default: Story = { render: (args) => { function Demo() { return <MyComponent {...args} /> } return <Demo /> }}
// GOODexport const Default: Story = { render: (args) => <MyComponent {...args} />,}Realistic Data
Product stories must use plausible, domain-appropriate data:
// BAD — placeholder dataconst mockUser = { id: '1', name: 'string', email: 'email@test.com', role: 'role' }
// GOOD — realistic dataconst mockUser = { id: 'usr_01HXYZ', name: 'Sarah Okafor', email: 'sarah.okafor@lawfirm.com', role: 'admin' as const, createdAt: '2026-03-14T09:30:00Z',}Compound Component Stories (ADR-011)
Register all sub-components in meta.subcomponents. Use the compound API directly in render — no wrapper components:
import { FilterPanel } from './filter-panel'
const meta = { component: FilterPanel, subcomponents: { 'FilterPanel.Search': FilterPanel.Search, 'FilterPanel.DateRange': FilterPanel.DateRange, 'FilterPanel.Status': FilterPanel.Status, }, tags: ['autodocs'], parameters: { docs: { description: { component: createComponentDocs({ summary: 'Collapsible filter panel with named content regions.', when: 'Sidebar filters for list or table views.', whenNot: 'Inline filter chips — use ChipGroup instead.', }), }, source: { // Paste a complete, realistic copy-paste-ready usage example code: `import { FilterPanel } from '@dmwd-io/design-system'
<FilterPanel title="Filter cases"> <FilterPanel.Search placeholder="Search by case name..." /> <FilterPanel.DateRange label="Filed between" /> <FilterPanel.Status options={[{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }]} /></FilterPanel> `.trim(), }, }, },} satisfies Meta<typeof FilterPanel>
export const Default: Story = { render: (args) => ( <FilterPanel {...args} title="Filter cases"> <FilterPanel.Search placeholder="Search by case name..." /> <FilterPanel.DateRange label="Filed between" /> <FilterPanel.Status options={mockStatusOptions} /> </FilterPanel> ),}Interaction Tests (play functions)
Write play functions for any story testing user interaction. These run in CI via the Storybook test runner:
import { userEvent, within, expect } from '@storybook/test'
export const SelectsOption: Story = { args: { options: [ { label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }, ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement)
// Open the dropdown await userEvent.click(canvas.getByRole('combobox'))
// Verify options are visible await expect(canvas.getByRole('option', { name: 'Open' })).toBeVisible()
// Select an option await userEvent.click(canvas.getByRole('option', { name: 'Closed' }))
// Verify selection await expect(canvas.getByRole('combobox')).toHaveTextContent('Closed') },}
export const KeyboardNavigation: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) const trigger = canvas.getByRole('combobox')
// Focus and open with keyboard await userEvent.tab() await expect(trigger).toHaveFocus() await userEvent.keyboard('{Enter}')
// Navigate with arrow keys await userEvent.keyboard('{ArrowDown}') await userEvent.keyboard('{ArrowDown}') await userEvent.keyboard('{Enter}')
// Verify dismissal on Escape await userEvent.keyboard('{Escape}') await expect(trigger).toHaveFocus() },}Story File Location
Stories live in the component’s own directory, not a shared stories folder:
components/ filter-panel/ index.tsx filter-panel.tsx filter-panel.stories.tsx ← here, not in a /stories folder filter-panel.test.tsxFor Layer 2 components in the design system, the Storybook title determines the sidebar position:
const meta = { title: 'Forms/TextField', // Category/ComponentName component: TextField, // ...} satisfies Meta<typeof TextField>Title conventions:
Foundations/...— token galleries, foundation documentationPrimitives/...— buttons, badges, chips, linksForms/...— inputs, selects, date pickers, file uploadFeedback/...— toasts, banners, callouts, skeletons, spinnersNavigation/...— tabs, breadcrumbs, pagination, side navData Display/...— tables, data grids, cards, chartsOverlays/...— dialogs, drawers, popovers, tooltips, command paletteApplication Patterns/...— Layer 3 composed patterns only
After changing any title, run pnpm run catalog.