-
Read the spec. Extract Props, Events, Slots, the Stories list, and the ## Usage fenced code block (everything between the first ```vue and the next ``` inside the section; ignore HTML comments).
-
Extract the Purpose paragraph (first paragraph under ## Purpose). Use it — and only it — as parameters.docs.description.component (a short prose lead). Do not concatenate the spec's ## Usage block into the description; the runnable usage is surfaced by the "Show code" panel (step 3).
-
Wire the "Show code" panel via explicit source.code. Read the spec's ## Usage fenced block only to learn the component's canonical import line (import <PascalName> from '@aziontech/webkit/<name>'). Declare it once as const IMPORT = "import <PascalName> from '@aziontech/webkit/<name>'", import toSfc relative to the story's folder (../../_shared/story-source or ../../../_shared/story-source depending on depth). Keep parameters.docs a plain object literal at the meta level (description + canvas.sourceState: 'shown'), and give every story an explicit source.code: toSfc(IMPORT, TEMPLATE):
import { toSfc } from '../../../_shared/story-source'
const IMPORT = "import Button from '@aziontech/webkit/button'"
docs: {
description: {
component:
'Interactive control for user actions. Supports label text, optional icon, loading state, and link rendering when `href` is set.'
},
canvas: { sourceState: 'shown' }
}
Do NOT use Storybook's dynamic source (source.transform / source.type: 'dynamic'): it lowercases the tag (the .vue file name — <button>, <chips>), double-wraps <template>, and when docs is a function call it prints the raw CSF story object. Do NOT set parameters.docs to a helper call (docs: runnableDocs({...})) — same fallback. The reliable path is an explicit source.code per story.
Every story sets source.code = toSfc(IMPORT, TEMPLATE). For composite / state stories, define the markup as a const, render it, and pass the SAME const to toSfc (zero drift). For the arg-driven Default, keep render: Template and set source.code to the canonical default markup. The TEMPLATE body uses PascalCase tags and contains no <template> (toSfc adds the one wrapper). See step 7.
-
Write the meta following this template (substitute spec values):
import Component from '@aziontech/webkit/<name>'
import { toSfc } from '../../../_shared/story-source'
const IMPORT = "import Component from '@aziontech/webkit/<name>'"
const meta = {
title: 'Components/<Category>/<PascalName>',
component: Component,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' },
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'focus-order-semantics', enabled: true }
]
}
},
docs: {
description: {
component:
},
canvas: { sourceState: 'shown' }
}
},
argTypes: {
},
args: {
}
}
export default meta
layout: 'centered' is the canonical choice — same as Button.stories.js. Override to 'padded' only when the component is full-width (header, sidebar, drawer).
-
Build argTypes. For each row:
- Props row.
<name>: { control, options?, description, table: { type, defaultValue, category: 'props' } }. Use select for union-literal types, boolean for booleans, text for strings, number for numbers.
- Events row. Key is camelCase
on<EventName> (e.g. onClick, 'onUpdate:open'). Value is { action: '<emitted-name>', description, table: { type, category: 'events' } }. The key MUST be camelCase — kebab-case keys silently fail in Vue 3.
- Slots row. Key is the slot name. Value is
{ control: false, description, table: { type, category: 'slots' } }.
-
Write the reusable render at module scope — forward args reactively. Storybook 8's args is a reactive proxy; destructuring it (const { onClick, ...props } = args) silently breaks Controls because the rest-spread freezes property references at setup time, so changing a control in the panel no longer updates the rendered story. The canonical pattern returns args itself and binds it directly:
Shape — stateless components (Button, Tag, etc.):
const Template = (args) => ({
components: { Component },
setup() {
return { args }
},
template: '<Component v-bind="args" />'
})
Shape — stateful / v-model components (InputText, InputSelect, FieldText, Checkbox, RadioGroup, anything with modelValue):
The story MUST hold local state and explicitly forward the update so the field actually reflects user typing/selection AND the update:modelValue action fires in the Actions panel. Binding v-bind="args" alone makes modelValue a one-way prop — the field appears frozen.
const Template = (args) => ({
components: { Component },
setup() {
const value = ref(args.modelValue ?? '')
watch(
() => args.modelValue,
(next) => {
value.value = next ?? ''
}
)
const onUpdate = (next) => {
value.value = next
args['onUpdate:modelValue']?.(next)
}
return { args, value, onUpdate }
},
template: '<Component v-bind="args" :model-value="value" @update:model-value="onUpdate" />'
})
-
Events are auto-wired for stateless components. Vue 3 treats onClick / onUpdate:open properties on v-bind as event listeners, so any event declared in argTypes with { action: '<name>' } is dispatched automatically via v-bind="args". Do NOT manually wire @click="onClick" for events without local state.
-
For v-model, the auto-wiring still applies for the dispatch — but you also need a local ref so the field updates visually. Always call args['onUpdate:modelValue']?.(next) inside your update handler so the Action still fires.
-
Composite stories carry context too. Whenever a composite story (Sizes, Types, Icons, …) renders a stateful component, each instance gets its own local ref (or one shared ref when the spec intends them to be linked) and forwards updates the same way:
export const Sizes = {
render: (args) => ({
components: { Component },
setup() {
const small = ref('')
const medium = ref('')
const large = ref('')
const log = (size) => (next) => args['onUpdate:modelValue']?.({ size, value: next })
return { args, small, medium, large, log }
},
template: `
<div class="flex flex-col gap-4 w-[280px]">
<Component v-bind="args" size="small" placeholder="Small" v-model="small" @update:model-value="log('small')" />
<Component v-bind="args" size="medium" placeholder="Medium" v-model="medium" @update:model-value="log('medium')" />
<Component v-bind="args" size="large" placeholder="Large" v-model="large" @update:model-value="log('large')" />
</div>
`
})
}
-
Slot composites and wrappers follow the same rule — pass args through, and if the component is stateful, hold a local ref and call args['onUpdate:modelValue']?.(next) in the handler.
-
Never destructure args, spread its properties, or copy them into another reactive ref. Any indirection loses the proxy and breaks the Controls panel.
-
Write one story per item in spec.Stories. The spec lists only the canonical set; do not add extras. For each, emit one of the two shapes:
Shape A — uses the reusable Template with an args delta (Default, Loading, Disabled). Controls drive the canvas; source.code shows the canonical usage for that state (a const markup reflecting the story's args):
const DEFAULT_MARKUP = '<Component kind="primary" size="large" label="Button" />'
export const Default = {
render: Template,
parameters: {
docs: {
description: { story: 'Default primary button at large size.' },
source: { code: toSfc(IMPORT, DEFAULT_MARKUP) }
}
}
}
const LOADING_MARKUP = '<Component label="Button" loading />'
export const Loading = {
args: { loading: true, label: 'Button' },
render: Template,
parameters: {
docs: {
description: { story: 'Loading state with spinner replacing the icon.' },
source: { code: toSfc(IMPORT, LOADING_MARKUP) }
}
}
}
const DISABLED_MARKUP = '<Component label="Button" disabled />'
export const Disabled = {
args: { disabled: true, label: 'Button' },
render: Template,
parameters: {
docs: {
description: { story: 'Disabled state.' },
source: { code: toSfc(IMPORT, DISABLED_MARKUP) }
}
}
}
Shape B — composite story with inline template (Types, Sizes):
const TYPES_TEMPLATE = `<div class="flex flex-wrap items-center gap-4">
<Component kind="primary" label="Button" />
<Component kind="secondary" label="Button" />
<Component kind="outlined" label="Button" />
<Component kind="text" label="Button" />
</div>`
export const Types = {
render: () => ({ components: { Component }, template: TYPES_TEMPLATE }),
parameters: {
docs: {
controls: { disable: true },
description: { story: 'All kind variants side by side.' },
source: { code: toSfc(IMPORT, TYPES_TEMPLATE) }
}
}
}
const SIZES_TEMPLATE = `<div class="flex flex-wrap items-center gap-4">
<Component size="small" label="Button" />
<Component size="medium" label="Button" />
<Component size="large" label="Button" />
</div>`
export const Sizes = {
render: () => ({ components: { Component }, template: SIZES_TEMPLATE }),
parameters: {
docs: {
controls: { disable: true },
description: { story: 'All size variants side by side.' },
source: { code: toSfc(IMPORT, SIZES_TEMPLATE) }
}
}
}
Define the template once as a const, reuse it for both render's template and toSfc(IMPORT, TEMPLATE), so they can never drift. The body uses PascalCase tags and contains no <template> (toSfc adds the one wrapper). Any story built from a raw multi-element render template (a row of variants, a Toaster + several trigger buttons, …) needs this explicit source.code.
- Always-composite stories. Whenever a prop or slot has more than one meaningful option, render one composite story that shows every option side by side — never one story per option. Canonical composites and their backing concept:
Types — the kind prop (visual variants).
Sizes — the size prop.
Icons — the iconLeft / iconRight slots (or any pair/triple of icon-like slots) shown alone and combined.
Slots — for components with one slot whose content shape varies (long text, short, with leading content, etc.), if applicable.
- Project-specific composites are allowed when the spec lists them with justification (e.g.
Densities, Tones, Placements).
- One story per state is correct only for mutually-exclusive boolean states that aren't variants of the same axis:
Loading, Disabled, Filled, Invalid. These cannot be shown side by side because they would each need their own composite axis.
- Skip any story whose backing prop/slot is not in
spec.Props / spec.Slots (e.g. no loading prop → no Loading story; no second icon slot → no Icons composite, prefer a single inline example inside Default or a state story).
- Forbidden — one story per variant value (
Small + Medium + Large, Primary + Secondary + …) when a composite axis exists. Replace with Sizes / Types.
- Forbidden unless explicitly listed in the spec:
LightDark, Accessibility (with play), Playground, WithSlots (covered by the composite Slots/Icons pattern), WithComposition, Controlled, Uncontrolled. Storybook's autodocs + a11y addon + backgrounds already cover dark/light, axe checks, and consumer-driven exploration via Controls.