pyreon

@pyreon/storybook is a custom Storybook renderer that lets you develop, test, and document Pyreon components in isolation using Storybook's UI. It integrates directly with the Pyreon runtime so your stories behave exactly like real application code -- signals, effects, lifecycle hooks, and context all work as expected.

@pyreon/storybookbeta

It ships:

  • A renderToCanvas(context, canvasElement) renderer that mounts Pyreon VNodes into Storybook's canvas and disposes the previous mount on every re-render.

  • Typed Meta<TComponent> / StoryObj<TMeta> helpers that infer story args from your component's props.

  • A Storybook framework preset (@pyreon/storybook/preset) wired by setting framework: '@pyreon/storybook' — no manual previewAnnotations.

  • Convenience re-exports of the core Pyreon primitives (h, Fragment, signal, computed, effect, mount) so story files need only one import.

Installation

@pyreon/storybook requires storybook itself as a peer dependency (>=8.0.0). Install both as dev dependencies:

npm install -D @pyreon/storybook storybook
bun add -D @pyreon/storybook storybook
pnpm add -D @pyreon/storybook storybook
yarn add -D @pyreon/storybook storybook

If you do not have Storybook scaffolded yet, you can initialize it without installing a built-in framework and then point it at Pyreon:

bunx storybook@latest init --type html --skip-install

Then replace the framework in your Storybook config with @pyreon/storybook (see below).

Setup

.storybook/main.ts

Set framework to @pyreon/storybook. The framework preset (resolved automatically from @pyreon/storybook/preset) wires the renderer and registers the preview entry for you — there is no separate previewAnnotations step.

// .storybook/main.ts
import type { StorybookConfig } from 'storybook'

const config: StorybookConfig = {
  framework: '@pyreon/storybook',
  stories: ['../src/**/*.stories.@(ts|tsx)'],
}

export default config

.storybook/preview.ts

Optionally add global decorators or parameters that apply to every story. Global decorators receive (storyFn, context) and must call storyFn(context.args, context) to render the wrapped story.

// .storybook/preview.ts
import type { DecoratorFn } from '@pyreon/storybook'

const globalDecorators: DecoratorFn[] = [
  (storyFn, context) => (
    <div class="storybook-wrapper" style={{ padding: '1rem' }}>
      {storyFn(context.args, context)}
    </div>
  ),
]

export const decorators = globalDecorators

Writing Stories

Stories use CSF3 (Component Story Format 3). Each story file exports a default meta object describing the component and named exports for individual stories. Use the satisfies operator so TypeScript infers story args from the component's props and flags mismatches.

import type { Meta, StoryObj } from '@pyreon/storybook'
import { Button } from './Button'

const meta = {
  component: Button,
  title: 'Components/Button',
  args: { label: 'Click me' },
  tags: ['autodocs'],
} satisfies Meta<typeof Button>

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
  args: { variant: 'primary' },
}

export const Secondary: Story = {
  args: { variant: 'secondary' },
}

export const Disabled: Story = {
  args: { label: 'Disabled', disabled: true },
}

Meta Options

The meta object describes the component and provides defaults for all stories in the file:

const meta = {
  // The component to document (used by autodocs and the default render)
  component: Button,

  // Sidebar title — use slashes for nesting: "Design System/Atoms/Button"
  title: 'Components/Button',

  // Default args applied to all stories (overridden per-story)
  args: { label: 'Click me', variant: 'primary' },

  // Arg type definitions for the Controls panel
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'ghost'],
    },
    onClick: { action: 'clicked' },
  },

  // Tags for filtering and features ("autodocs" enables auto-generated docs)
  tags: ['autodocs'],

  // Story parameters (backgrounds, viewport, layout, etc.)
  parameters: {
    layout: 'centered',
  },

  // Decorators applied to every story in this file
  decorators: [],

  // Default render function (overrides h(component, args))
  render: undefined,

  // Filter which named exports are treated as stories
  excludeStories: /^_/, // exclude exports starting with _
  includeStories: ['Primary', 'Secondary'], // or include only these
} satisfies Meta<typeof Button>

Every field is optional. The full list of Meta fields:

FieldTypeDescription
componentTComponentThe Pyreon component to document. Drives autodocs and default render
titlestringSidebar title; slashes nest ("Atoms/Button")
decoratorsDecoratorFn<InferProps<TComponent>>[]Decorators applied to every story in the file
argsPartial<InferProps<TComponent>>Default args for all stories
argTypesRecord<string, unknown>Arg type definitions for the Controls panel
parametersRecord<string, unknown>Story parameters (backgrounds, viewport, layout)
tagsstring[]Tags for filtering (e.g. "autodocs")
render(args, context) => VNodeChildDefault render; if omitted, h(component, args) is used
excludeStoriesstring | string[] | RegExpNamed exports to exclude from being treated as stories
includeStoriesstring | string[] | RegExpOnly treat these named exports as stories

StoryObj Options

Each named export is a StoryObj that can override meta-level defaults:

export const WithLongLabel: Story = {
  // Override args for this story (merged onto meta.args)
  args: { label: 'This is a very long button label' },

  // Story-specific decorators (run inside meta decorators)
  decorators: [(storyFn, ctx) => <div style={{ maxWidth: '200px' }}>{storyFn(ctx.args, ctx)}</div>],

  // Story-specific parameters
  parameters: { layout: 'padded' },

  // Custom render for this story
  render: (args) => (
    <div class="button-showcase">
      <Button {...args} />
      <p>Character count: {args.label.length}</p>
    </div>
  ),

  // Display name override (defaults to the export name)
  name: 'Long Label',

  // Tags for this story
  tags: ['!autodocs'], // exclude from auto-generated docs
}

The full list of StoryObj fields:

FieldTypeDescription
argsPartial<MetaArgs>Args for this story, merged onto meta.args
argTypesRecord<string, unknown>Arg type overrides
decoratorsDecoratorFn<MetaArgs>[]Decorators for this story only
parametersRecord<string, unknown>Parameters for this story
tagsstring[]Tags for this story
render(args, context) => VNodeChildOverride the render function for this story
namestringStory name override (defaults to the export name)
play(context) => Promise<void> | voidInteraction-test function, runs after the story mounts

MetaArgs is InferProps<Meta['component']> — the props of the component declared in the file's meta.

Custom Render Functions

Override the default rendering with a custom render function when you need to wrap or compose components:

export const WithIcon: Story = {
  render: (args) => (
    <div class="flex gap-2">
      <span class="icon"></span>
      <Button {...args} />
    </div>
  ),
}

The render function receives the merged args (meta args + story args) and a StoryContext object:

export const WithContext: Story = {
  render: (args, context) => (
    <div>
      <p>Story: {context.name}</p>
      <p>View mode: {context.viewMode}</p>
      <Button {...args} />
    </div>
  ),
}

When no custom render is provided, the default render function is used: h(component, args). This calls the component with the merged args as its props. If a story has neither a render nor a component (in meta or its own), the renderer throws — set one or the other.

Decorators

Decorators wrap stories with providers, layouts, or other contextual elements. They receive the story function and context, and must call storyFn(context.args, context) to render the actual story.

Component-Level Decorators

Applied to all stories in a file via the meta object:

const meta = {
  component: ThemeCard,
  decorators: [
    (storyFn, context) => (
      <ThemeProvider theme="dark">{storyFn(context.args, context)}</ThemeProvider>
    ),
  ],
} satisfies Meta<typeof ThemeCard>

Story-Level Decorators

Applied to a single story:

export const InModal: Story = {
  decorators: [(storyFn, context) => <Modal open={true}>{storyFn(context.args, context)}</Modal>],
}

Global Decorators

Applied to all stories in .storybook/preview.ts:

export const decorators = [
  (storyFn, context) => <div class="app-root">{storyFn(context.args, context)}</div>,
]

Decorator Execution Order

When multiple levels of decorators are present, they execute from the outermost (global) to the innermost (story), wrapping the story in layers:

Global decorator
  └─ Meta decorator
      └─ Story decorator
          └─ Story render

A decorator that does not call storyFn(context.args, context) will suppress the story entirely — make sure every decorator forwards the args and context through to the inner story function.

DecoratorFn Type

type DecoratorFn<TArgs = Props> = (
  storyFn: StoryFn<TArgs>,
  context: StoryContext<TArgs>,
) => VNodeChild

type StoryFn<TArgs = Props> = (args: TArgs, context: StoryContext<TArgs>) => VNodeChild

Reactive Stories

Pyreon's reactivity system works inside stories. The renderer mounts your VNode with mount() from @pyreon/runtime-dom, so signals drive fine-grained DOM updates exactly as they do in production — no re-render-the-whole-story step. Use the re-exported signal / computed / effect so story files need only one import.

import { signal, computed } from '@pyreon/storybook'

export const Interactive: Story = {
  render: (args) => {
    const count = signal(0)
    const label = computed(() => `${args.label} (${count()})`)

    return (
      <div>
        <Button {...args} label={label()} onClick={() => count.update((n) => n + 1)} />
        <p>Clicked {count()} times</p>
        <button onClick={() => count.set(0)}>Reset</button>
      </div>
    )
  },
}

Effects also work as expected — they are created when the story mounts and disposed when the story is replaced or unmounted:

import { signal, effect } from '@pyreon/storybook'

export const WithEffect: Story = {
  render: (args) => {
    const clicks = signal(0)

    effect(() => {
      console.log('Click count:', clicks())
    })

    return <Button {...args} onClick={() => clicks.update((n) => n + 1)} />
  },
}

Interaction Testing

Use play functions to simulate user interactions and run assertions against the rendered output. Play functions run after the story is rendered into the canvas.

export const Clicked: Story = {
  play: async ({ canvasElement }) => {
    const button = canvasElement.querySelector('button')!
    button.click()

    // Wait for reactive updates to flush
    await new Promise((r) => setTimeout(r, 0))

    // Assert the result
    const text = canvasElement.textContent
    console.assert(text?.includes('1'), 'Expected click count to be 1')
  },
}

Step-Based Play Functions

Use the step utility to organize complex interaction tests into named groups:

export const MultiStep: Story = {
  play: async ({ canvasElement, step }) => {
    await step('Click the button three times', async () => {
      const button = canvasElement.querySelector('button')!
      button.click()
      button.click()
      button.click()
    })

    await step('Verify the count', async () => {
      await new Promise((r) => setTimeout(r, 0))
      const count = canvasElement.querySelector('.count')?.textContent
      console.assert(count === '3', `Expected 3, got ${count}`)
    })

    await step('Reset and verify', async () => {
      const resetBtn = canvasElement.querySelector('.reset')!
      ;(resetBtn as HTMLElement).click()
      await new Promise((r) => setTimeout(r, 0))
      const count = canvasElement.querySelector('.count')?.textContent
      console.assert(count === '0', `Expected 0 after reset, got ${count}`)
    })
  },
}

Play Function Context

The play function receives a context object:

PropertyTypeDescription
canvasElementHTMLElementThe DOM element containing the rendered story
argsMetaArgsThe merged args for this story
step(name: string, fn: () => Promise<void>) => Promise<void>Organize interactions into named steps

How It Works

renderToCanvas

renderToCanvas is the core integration point between Storybook and Pyreon. Storybook calls it every time a story needs to be displayed or re-rendered (for example, when the user changes args via the Controls panel).

function renderToCanvas(
  context: {
    storyFn: () => VNodeChild
    storyContext: { component?: ComponentFn; args: Record<string, unknown>; [key: string]: unknown }
    showMain: () => void
    showError: (error: { title: string; description: string }) => void
    forceRemount: boolean
  },
  canvasElement: HTMLElement,
): void

It handles, in order:

  1. Cleanup — Unmounts the previous story by calling the cleanup function stored for that canvas element, removing all DOM nodes and disposing effects.

  2. Rendering — Calls storyFn() to get a VNodeChild, then uses mount() from @pyreon/runtime-dom to render it into canvasElement, and calls showMain().

  3. Error handling — If storyFn() throws, it shows a Storybook error overlay via showError({ title, description }). Non-Error throws are coerced to strings for the description.

Each canvas element tracks its cleanup function via a WeakMap, so switching between stories rapidly never leaks a mount.

defaultRender

defaultRender provides the fallback rendering when no custom render function is specified on a story or its meta. It calls h(component, args), passing the component function and the merged args as props.

function defaultRender(component: ComponentFn, args: Record<string, unknown>): VNodeChild
// → h(component, args)

Subpath exports & the preset

The framework integration is split across three subpaths. Only the first is imported in your story files; the others are loaded by Storybook itself.

SubpathLoaded bySurface
@pyreon/storybookYour story filesMeta, StoryObj, StoryFn, StoryContext, DecoratorFn, InferProps, PyreonRenderer, defaultRender, renderToCanvas, plus Pyreon re-exports
@pyreon/storybook/presetStorybook server (config-load)Framework preset — core.renderer, previewAnnotations, addons. Resolved when framework: '@pyreon/storybook'
@pyreon/storybook/previewStorybook preview iframe (runtime)Registers renderToCanvas + the default render. Wired automatically by the preset's previewAnnotations

You never import /preset or /preview directly — setting framework: '@pyreon/storybook' in .storybook/main.ts is the only wiring required.

Full Example: Card Component

A complete example showing a card component with multiple stories, decorators, and an interaction test:

// Card.stories.tsx
import type { Meta, StoryObj } from '@pyreon/storybook'
import { signal } from '@pyreon/storybook'
import { Card } from './Card'

const meta = {
  component: Card,
  title: 'Components/Card',
  args: {
    title: 'Card Title',
    description: 'This is a card description.',
    variant: 'default',
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'outlined', 'elevated'],
    },
  },
  tags: ['autodocs'],
  decorators: [
    (storyFn, context) => (
      <div style={{ maxWidth: '400px', margin: '0 auto' }}>{storyFn(context.args, context)}</div>
    ),
  ],
} satisfies Meta<typeof Card>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const Outlined: Story = {
  args: { variant: 'outlined' },
}

export const Elevated: Story = {
  args: { variant: 'elevated' },
}

export const WithActions: Story = {
  render: (args) => {
    const liked = signal(false)

    return (
      <Card {...args}>
        <div class="card-actions">
          <button onClick={() => liked.set(!liked())}>{liked() ? 'Unlike' : 'Like'}</button>
        </div>
      </Card>
    )
  },
  play: async ({ canvasElement }) => {
    const likeBtn = canvasElement.querySelector('button')!
    likeBtn.click()
    await new Promise((r) => setTimeout(r, 0))
    console.assert(likeBtn.textContent === 'Unlike', 'Button should show Unlike after click')
  },
}

export const Loading: Story = {
  args: {
    title: 'Loading...',
    description: 'Content is loading.',
  },
  render: (args) => (
    <Card {...args}>
      <div class="skeleton" style={{ height: '100px' }} />
    </Card>
  ),
}

API Reference

Functions

ExportSignatureDescription
renderToCanvas(context, canvasElement: HTMLElement) => voidCore render function for the Storybook integration. Manages mount/unmount lifecycle per canvas element. Called by the preview, not by users
defaultRender(component: ComponentFn, args: Record<string, unknown>) => VNodeChildDefault render: returns h(component, args). Used when no custom render is provided

Type Exports

TypeDescription
Meta<TComponent>Story metadata — component, title, args, decorators, argTypes, parameters, tags, render, excludeStories, includeStories
StoryObj<TMeta>Individual story definition — args, argTypes, decorators, parameters, tags, render, name, play
StoryFn<TArgs>Story function type: (args: TArgs, context: StoryContext<TArgs>) => VNodeChild
DecoratorFn<TArgs>Decorator function type: (storyFn: StoryFn<TArgs>, context: StoryContext<TArgs>) => VNodeChild
StoryContext<TArgs>Context passed to stories and decorators — args, argTypes, globals, id, kind, name, viewMode
PyreonRendererRenderer descriptor for Storybook — component, storyResult, canvasElement
InferProps<T>T extends ComponentFn<infer P> ? P : Props — extracts the props type from a Pyreon component function

StoryContext Properties

PropertyTypeDescription
argsTArgsMerged args for this story
argTypesRecord<string, unknown>Arg type definitions
globalsRecord<string, unknown>Storybook global values
idstringStory ID
kindstringStory kind (component title)
namestringStory name
viewMode'story' | 'docs'Current view mode

Re-exports

The package re-exports the following from Pyreon for convenience, so you do not need to import them separately in story files:

Functions: h, Fragment (from @pyreon/core); signal, computed, effect (from @pyreon/reactivity); mount (from @pyreon/runtime-dom)

Types: ComponentFn, Props, VNode, VNodeChild (from @pyreon/core)

Using these re-exports keeps story files self-contained:

import { signal, computed, effect } from '@pyreon/storybook'
import type { Meta, StoryObj } from '@pyreon/storybook'

Gotchas

  • Args are passed as props. The default render calls h(component, args), so your component must accept the args shape as its props. Use satisfies Meta<typeof Button> so TypeScript catches mismatches between args and props.

  • Import StorybookConfig from storybook. It is not re-exported by @pyreon/storybook. Story-authoring types (Meta, StoryObj, DecoratorFn, …) come from @pyreon/storybook.

  • Stories run in a real iframe. SSR-only paths in your components are never exercised by stories — use happy-dom unit tests and Playwright for those.

  • A story needs a render or a component. With neither set (in the story or its meta), the renderer cannot build a VNode and throws [@pyreon/storybook] No component provided.

  • storyFn() errors show an overlay; component setup errors do not. Wrap a component that can throw during setup in an ErrorBoundary to render fallback UI inside the canvas.

Peer Dependencies

  • storybook >= 8.0.0

Storybook