Story Spec
The full *.stories.tsx authoring contract the @takazudo/zudo-sg catalog discovers and renders.
This is the complete authoring contract for *.stories.tsx files consumed by the @takazudo/zudo-sg engine's catalog (/, /, /). If you are adding or changing a component's stories, everything you need is here.
Where the contract lives
The canonical types are @takazudo/ (packages/ in this repo): StoryMeta, Story<P>, StoryControl<P>, StoryModule, defineStory.
A component provider package (this repo's @zudo-sg/ui) keeps a byte-equivalent copy of the same type body at packages/ so it stays installable and type-resolvable with no engine installed — external consumers of the provider typecheck stories from source, without a dependency on the engine package. A drift guard (scripts/, wired into pnpm check) fails the build if the two copies diverge.
File location & discovery
A story file is named <name>.stories.tsx and lives under one of the project's configured componentsRoots[].dir (see zudo-sg.config.mjs). Two layouts are supported, and the engine's discoverStories() walks either one recursively, at any depth:
Flat, one directory deep:
<componentsRoot>/— e.g.<name>/ <name>. stories. tsx ui/.button/ button. stories. tsx Category-nested:
<componentsRoot>/— e.g.<category- slug>/ <name>/ <name>. stories. tsx packages/.ui/ src/ cards/ stat- card/ stat- card. stories. tsx
Stories must live at least one directory below the components root
discoverStories() derives each entry's import * as <name> identifier from its containing directory's path relative to the components root — never from the file name alone. A story file placed directly at the components root (a flat <componentsRoot>/, with no directory in between) has no containing directory to derive an identifier from; every such file resolves to the same (empty) identifier and the registry generator throws on the second one it finds. Always nest at least one directory, even for a single-file component: <componentsRoot>/, not<componentsRoot>/.
Two different categories MAY scaffold a same-named component (e.g. layout/badge/ and forms/badge/) — identifiers are derived from the FULL relative directory path (every path segment, hyphen-joined then camelCased), so layout/badge and forms/badge fold to distinct identifiers (layoutBadge, formsBadge) and both are registered, not collided.
Discovery is codegen, not import.meta.glob. zfb does not statically inline import.meta.glob, so the literal call would survive into the shared client islands bundle and throw in the browser. Instead, zudo-sg gen-registry (see CLI) globs every configured components root on the filesystem at codegen time and writes an explicit-import registry file (zudo-sg.config.mjs's registryOut). Run it after adding, renaming, or removing a story file, and commit the regenerated file; zudo-sg gen-registry
--check (wired into CI) fails the build on drift. Never hand-edit the generated file.
Module shape
Every *.stories.tsx exports exactly:
a default export
meta: StoryMeta, andone or more named exports, each a
Story<P>.
Nothing else should be exported. The registry treats default as the meta and every other own enumerable export as a story.
meta — StoryMeta
import { defineStory, type StoryMeta } from "@takazudo/zudo-sg/stories";
const meta: StoryMeta = {
title: "Button",
category: "Actions",
description: "Primary action control with three variants and three sizes.",
usage: `import { Button } from "@zudo-sg/ui";\n\n<Button>Save</Button>`,
order: 1,
};
export default meta;| field | type | required | meaning |
|---|---|---|---|
title | string | yes | Component display name; unique within its category. |
category | string | yes | Sidebar bucket — see "Categories are host data" below. |
description | string | yes | One sentence shown under the title. |
usage | string | yes | Verbatim import + minimal JSX shown in the catalog's "Usage" block, as a plain string. |
order | number | no | Sort hint within a category; alphabetical by title when omitted. |
previewRoute | string | no | A real page route that demos the component live — see "The previewRoute escape hatch" below. |
Categories are host data, not a closed type
StoryMeta.category is an open string — any value is valid. A host declares its preferred display order as categoryOrder: string[] in zudo-sg.config.mjs; the catalog renders those categories first, in that order, then appends any category actually used by a story that isn't on the list, alphabetically. A new category needs no contract change to work.
Named exports — Story<P>
Story is generic over the driving component's props — Story<ButtonProps> — so a variant's controls are checked against the component's real props: prop must name an actual key of P, and where P is informative (e.g. a string-literal union), options/defaultValue are restricted to that union too. P defaults to Record<string, unknown>, so the bare Story name still works for a variant with no controls, or whose render composes more than one component's props.
export const Variants = defineStory<ButtonProps>({
name: "Variants",
render: () => <div>…</div>,
controls: [
{ type: "select", prop: "variant", label: "Variant",
options: ["primary", "secondary", "ghost"], defaultValue: "primary" },
],
source: `<Button>Primary</Button>`,
});| field | type | required | meaning |
|---|---|---|---|
name | string | yes | Variant label. Unique within the file. |
render | (args?: Partial<P>) => VNode | yes | Pure, synchronous. Returns the preview node. args is the merged control values, typed to P. No effects, no async, no data fetching. |
controls | StoryControl<P>[] | no | Declarative knob descriptors, keyed to real props of P — metadata only, see below. |
source | string | no | Verbatim JSX for the code panel — see "Source extraction" below. |
defineStory(story) is an identity helper that pins the type for editor autocomplete; a plain object literal that satisfies Story<P> is equally valid.
Controls convention (optional, metadata-only)
controls describes the knobs a variant could expose; it does not wire them up. The catalog decides whether and how to render live controls. A story with no controls renders fine as a static preview.
{ type: "select", prop: "variant", label: "Variant", options: ["primary", "ghost"], defaultValue: "primary" }
{ type: "boolean", prop: "block", label: "Full width", defaultValue: false }
{ type: "text", prop: "label", label: "Label", defaultValue: "Click me" }
{ type: "number", prop: "count", label: "Count", defaultValue: 1, min: 0, max: 10 }
{ type: "color", prop: "accent", label: "Accent", defaultValue: "#2563eb" }prop is typed keyof P & string — it must name a real prop of the component Story<P> is parameterized over; renaming or removing that prop fails the control to typecheck. defaultValue (and options, for select) narrow to that prop's own value type where P is informative.
Source extraction
The catalog shows a code panel per variant. Resolution order:
Explicit
source— if aStorysetssource, the catalog shows that string verbatim. Recommended for any variant whoserenderbody is non-obvious.Fallback to
meta.usage— if a variant has nosource, the catalog may showmeta.usageas the component-level example.No automatic AST extraction of
renderbodies is promised or required.
Browser-only / MSW rules
The catalog renders stories during a static build. Therefore:
rendermust be pure and synchronous. NouseEffect, no top-levelawait, no network calls, no timers, no reliance onwindow/documentat render time.No data fetching, no MSW inside
renderor component source. If a component genuinely needs mocked network data to be honestly demoed, use thepreviewRouteescape hatch below — never introduce request mocking into the story layer itself.Self-contained markup. A
rendermust not depend on ambient page chrome (sticky offsets, global providers). Where a component is normally sticky/fixed, wrap it in arender-local container that neutralizes the effect for the catalog cell.
The previewRoute escape hatch
previewRoute is a different mechanism from the catalog's built-in variant iframes (every Story already renders inside an isolated / iframe that re-invokes render). previewRoute is an optional StoryMeta field naming a REAL page route the host builds and owns itself (e.g. /), completely outside the render/variant-iframe system. When set, the catalog shows it as a plain "Live demo" link, not an embedded frame.
renderstays pure/synchronous/no-MSW regardless —previewRouteis an escape hatch alongsiderender, never a way to relax that rule.Request mocking is permitted only inside the page(s) reachable via
previewRoute, never in any*.stories.tsxfile or component source.Must be a real, same-origin path starting with
/— not a protocol-relative URL, not the preview-iframe URL, not arender-produced node.
"use client" islands and stories
Catalog thumbnails and the detail page are SSR-only — a story component that is itself a "use client" island still renders as a plain (server-rendered) component inside the catalog. The one client island in the whole catalog is the preview app used by /, which re-invokes a variant's render inside an iframe; the registry travels into that island as an in-bundle argument, never as island props (island props serialize to JSON, which would drop render closures).
Authoring checklist
When adding a component, ship its story in the same change:
<componentsRoot>/exists, at least one directory below the components root (see "File location & discovery").<name>/ <name>. stories. tsx Default export is a
StoryMetawithtitle,category,description,usage.At least one named
Story<P>export withnameand a pure, synchronousrender.sourceset on any non-trivial variant.controlsadded where live editing is meaningful;propnames a real key ofP.zudo-sg gen-registryrun and the regenerated registry committed.Typecheck and unit tests pass.
Use zudo-sg new-component <name> --category <c> [--nested] [--skip-barrel] (see CLI) to scaffold the whole checklist above in one command.