1. Your first story¶
Three lines of code; one registered variant; one canvas paint.
(ns counter-with-stories.stories
(:require [re-frame.story :as story]
[counter-with-stories.views])) ; loads the view registry
(story/reg-story :story.counter
{:doc "The counter — every state, all in one place."
:component :counter-with-stories.views/counter-card
:args {:label "Count"}
:tags #{:dev :docs}
:substrates #{:reagent}})
(story/reg-variant :story.counter/empty
{:doc "Fresh counter at zero."
:events [[:counter/initialise 0]]
:play [[:rf.assert/path-equals [:count] 0]]
:tags #{:dev :docs :test}})
Save. Reload. Open #/stories. You're in.

What just happened¶
reg-storyregisters the parent story — a logical group of variants that share a component, default args, decorators, tags. It carries no state on its own; it's a registry entry.reg-variantregisters one variant — one scenario, one frame, one canvas paint.
The id grammar is locked. Stories live under :story.<dotted-path>. Variants live under :story.<path>/<variant-name>. The sidebar tree is built from the dotted path — no separate :title field.
Three slots in reg-variant carry the meat:
:events— a sequence of regular event vectors. They dispatch in source order through the same router that the live app uses. By the time the canvas renders, the variant frame'sapp-dbis exactly what those events left behind.:args— the variant's prop overrides. Resolved through a three-layer chain (see below).:play— the assertion sequence. Runs after:eventssettle. Records its results; doesn't throw.
A variant body is plain EDN. No fn slots; no closures; no JSX-shaped DSL. The :component is a view-id keyword, not a function ref. That's the contract that lets variants round-trip through MCP, through visual-regression services, through agent input pipelines.
Three-level args + schema-derived controls¶
Args resolve through a three-layer chain:
global-args ← set once via (story/configure! {:global-args {...}})
↓
story-args ← the :args slot on reg-story (parent default)
↓
variant-args ← the :args slot on reg-variant (per-scenario override)
↓
cell-overrides ← live edits from the controls panel
Deep-merge, left-to-right. Variants that don't say anything inherit; variants that override win. Modes (next chapter) sit between global and story for a fourth point of leverage.
The controls panel in the right-side pane auto-derives editors. If the parent story carries :argtypes {:label {:control :text}}, the panel renders a text input wired to dispatch a cell-override. If you've gone further and tagged the schema in re-frame.schemas (see Guide 04a — Schemas), the panel reads the schema directly — a :keyword schema becomes a select, an :int schema with :max/:min becomes a number-input or slider, an :enum becomes a radio group. No argTypes plumbing; the schema is the source of truth.
A variant can also declare its own schema inline via the :rf/schema slot — useful when one variant exercises a narrower or stricter shape than the component-wide registered schema:
(story/reg-variant :story.counter/labelled
{:rf/schema [:map
[:label :string]
[:colour [:enum :red :green :blue]]
[:count [:int {:min 0 :max 99}]]]
:args {:label "Count" :colour :green :count 0}
:events [[:counter/initialise 0]]})
The args editor renders one row per :map entry (:colour as a select, :count as a number-input clamped 0–99). Schema-violation rows flag args that don't conform. One declaration; two pieces of UI.
Decorators — three kinds¶
Decorators wrap the canvas with reusable concerns: layout, theming, mock data, fx mocking. Three kinds:
:hiccupdecorators — wrap the rendered tree. The closure lives at the decorator's registration site, not the variant body.:frame-setupdecorators — patch the variant's frame at allocation time. Used when several variants share an "assume the user is logged in" setup. The events dispatch before the variant's own:events; the patch merges into the variant frame'sapp-db. Pure data, no closures.:fx-overridedecorators — stub a network call. The marquee shape isforce-fx-stub, MSW-flavoured, which Story ships built-in:
Variant bodies reference decorators by id; closures live on the decorator registrations. This is what keeps variant bodies serialisable.
Play sequences + assertions¶
The :play slot is a sequence of regular event-vectors. They dispatch through the same router as :events — but the seven canonical assertion events don't throw on failure; they append a record to [:rf.story/assertions] in the variant frame's app-db.
The seven:
| Event id | Payload | Semantics |
|---|---|---|
:rf.assert/path-equals |
[path expected] |
(= (get-in @app-db path) expected) |
:rf.assert/path-matches |
[path malli-schema] |
Malli validates the value at path |
:rf.assert/sub-equals |
[sub-vec expected] |
(= @(subscribe sub-vec) expected) |
:rf.assert/dispatched? |
[event-or-pred] |
Did this event dispatch during play? |
:rf.assert/state-is |
[machine-id state] |
Is the machine in state? |
:rf.assert/no-warnings |
[] |
No :warning trace events since play start? |
:rf.assert/effect-emitted |
[fx-id] (or [fx-id pred]) |
Was this fx-id emitted? |
The record-don't-throw shape is the design call. A play sequence with eight assertions where three fail still runs all eight; you get the full picture, not the first failure. Tests then read the accumulator:
(deftest counter-loaded
(cljs.test/async done
(-> (story/run-variant :story.counter/loaded)
(story.async/then
(fn [result]
(is (story/assertions-passing? result))
(story/destroy-variant! :story.counter/loaded)
(done))))))
run-variant returns a promise (CLJS) or future (JVM) of {:frame :app-db :assertions :rendered-hiccup :elapsed-ms :snapshot :lifecycle}. The same result map the MCP surface returns when an agent asks for a preview. Same shape; same vocabulary.
Beyond counter: a five-state login form¶
Counter is the simplest possible variant body — one event in :events, one :rf.assert/path-equals in :play. The login-form testbed at tools/story/testbeds/login_form/ is the richer shape — five variants over the five FSM states the welcome page opened with:
;; The :submitting variant — a request is in flight.
(story/reg-variant :story.login/submitting
{:doc "Inputs disabled, button reads 'Signing in…'. The
fx-stub records the request and resolves nothing
so the canvas locks at :submitting."
:events [[:login/flow [:login/submit {:email "ada@example.com"
:password "correct-horse"}]]]
:decorators [[story/force-fx-stub-id :rf.http/managed {}]]
:play [[:rf.assert/state-is :login/flow :submitting]
[:rf.assert/effect-emitted :rf.http/managed]]
:tags #{:dev :docs :test}})
The :events slot dispatches a real submit event into the FSM. The force-fx-stub decorator intercepts the :rf.http/managed call the machine's :issue-request action emits, so the request never resolves and the canvas locks at :submitting. The play sequence asserts the FSM state and that the fx was emitted. Same EDN shape; richer domain.
Open http://127.0.0.1:8030/login-form/#/stories after npm run test:examples builds it, and the five variants — /idle, /submitting, /error, /submitting-retry, /authenticated — show up in the sidebar. The :Workspace.login/all-states workspace mounts all five side-by-side. The five FSM states from the tutorial's index page, in one panel paint.
Next: mode tabs — Canvas, Docs, Tests, viewport, a11y, locale.