Spec 007 — Stories, Variants, and Workspaces¶
Builds on the frame foundation in 002-Frames.md and the testing infrastructure in 008-Testing.md — stories use the same primitives tests use, layered with rendering, args/controls, decorators, play functions, and a Storybook-class UI.
Ownership boundary: 008-Testing.md is the owner of the testing infrastructure surface — fixtures, dispatch-sync, per-test stubbing, headless evaluation, framework adapters, the JVM-runnable test surface. 007 cross-references 008 for portable-stories-as-tests rather than restating; story-as-test plumbing builds on 008's primitives.
Abstract¶
A re-frame2 component-development tool surfaces re-frame components in isolation, in specific states, with rich tooling around them — data-oriented and frame-native. This Spec captures the design space.
The unit of design is a three-way split:
- Story — a topic / component / slice. Defines what's being shown and the surrounding fixtures.
- Variant — one concrete scenario of a Story. Each variant is the Story rendered in a specific state.
- Workspace — a layout that arranges stories/variants on screen for browsing, documentation, or comparison.
The rest of the design — args, decorators, play, tags — slots cleanly into one of the three.
Why a separate Spec¶
Stories/variants/workspaces are downstream concerns. They are enabled by the frame and view designs in 002 and 004; they shouldn't drive those decisions. Keeping the design here:
- Lets 002 and 004 stay focused on the foundation.
- Lets the story-tool design evolve independently of foundation framework decisions.
reg-story/reg-variant/reg-workspaceare sugar; everything is doable by hand withreg-frame+reg-view+frame-provider.
Canonical id grammar¶
The story / variant / workspace id syntax is locked and used consistently throughout the document, the registrar, and the story tool:
| Artefact | Id shape | Example |
|---|---|---|
| Story | :story.<dotted-path> |
:story.auth.login-form |
| Variant | :story.<dotted-path>/<variant-name> |
:story.auth.login-form/empty |
| Workspace | :Workspace.<dotted-path>/<workspace-name> |
:Workspace.Auth/all-states |
Rules:
- The
:story.<...>and:Workspace.<...>prefixes are library-owned by the post-v1 stories library — they are not framework-reserved under:rf/*(see Conventions §Library-owned prefixes). User code MUST NOT register stories/workspaces under conflicting prefixes when this library is loaded. - The dotted path segments organise the tree the story tool renders — split on
.to build the navigator. - Variant names go after
/. A variant id always belongs to exactly one story; the story id is everything before/. - Tools enumerate via
(rf/handlers :story),(rf/handlers :variant),(rf/handlers :workspace). The hierarchy is recoverable from the id alone — no separate:titlefield is required.
The three concepts¶
Story¶
A story is the topic — typically a component, slice, or screen. It declares what's being demonstrated, the shared fixtures across its variants, decorators, args, play, and metadata. A story without variants is a degenerate case.
(rf/reg-story :story.auth.login-form
{:doc "The login form component."
:component login-form ;; the view at the centre of the story
:decorators [[centered-layout]
[theme :light]]
:args {:placeholder "you@example.com"
:submit-label "Sign in"}
:argtypes {:placeholder {:control :text}
:submit-label {:control :text}}
:tags #{:dev :docs}}) ;; inclusion tags — see below
The story is registered under a hierarchical keyword: :story.<path> where path segments organise the story tree.
Variant¶
A variant is a specific scenario — one state of a story. Variants register against a parent story and inherit its decorators, args, etc.; variants override or extend.
(rf/reg-variant :story.auth.login-form/empty
{:doc "Fresh form, nothing entered."
:events [[:auth/initialise]]})
(rf/reg-variant :story.auth.login-form/validation-error
{:doc "Invalid email shown inline after submit."
:events [[:auth/initialise]
[:auth/email-changed "not-an-email"]
[:auth/login-pressed]]
:tags #{:dev :docs :test}}) ;; this one is also used as a test fixture
(rf/reg-variant :story.auth.login-form/loading
{:doc "Submit pressed, server response pending."
:events [[:auth/initialise]
[:auth/email-changed "alice@example.com"]
[:auth/login-pressed]]
:decorators [[force-fx-stub :my-app/http {:status :pending}]]})
(:my-app/http here is a placeholder for a user-supplied fx; the framework ships :rf.http/managed — see 014-HTTPRequests.)
The keyword convention :story.<path>/<variant> keeps stories and their variants discoverable as a group, while still being a single keyword for re-frame's purposes.
Variant artefact contract — variants are data, not functions¶
Locked. A variant's body is a serialisable artefact — every field is plain data (vectors, maps, keywords, strings, numbers), not a function. This makes variants:
- Wire-portable. A variant is round-trippable as EDN/JSON; the visual-regression service, the documentation generator, and the agent-input pipeline all consume the same shape.
- Storable. A frozen variant snapshot (per §Variant snapshot identity) is serialisable.
- Diffable. Two variants compare structurally; the story tool's "what changed" panel is structural diff, not function identity.
Concretely, the keys allowed in a reg-variant body:
| Key | Shape | Notes |
|---|---|---|
:doc |
string | One-sentence what-and-why. |
:extends |
variant-id | Parent variant; merged at registration time per §Composed variants. Resolves to a registered variant id; cycles are a registration error. |
:events |
vector of event vectors | Setup events; dispatch-synced into the variant's frame in order, after :loaders complete. Data only. |
:play |
vector of event vectors (incl. :rf.assert/*) |
Post-render interaction sequence. Data only. |
:args |
map | Override or extend the parent story's args. |
:argtypes |
map (optional override) | Per-arg control description. Auto-derived from the view's Spec 010 schema where present. |
:tags |
set of keyword | Inclusion tags from the registered vocabulary (see §Inclusion tags). |
:decorators |
vector of vectors | Each decorator is [decorator-id args...] — id-valued, not function-valued. |
:loaders |
vector of event vectors | Async setup events; dispatch-synced before :events and before render (see §Loaders for the lifecycle). Data; the handler the loader event ids point to is the only fn-valued part. |
:args->events |
map | Per-arg event-id mapping {<arg-key> <event-id>}; the registered handler at <event-id> receives the new value as its payload. Data only — see §Args mapping to state. |
:platforms |
set | Subset of #{:server :client}; controls where the variant runs. |
No fn-valued slots in variant bodies. Where today's prior art (Storybook decorators, Histoire setup) takes a function, re-frame2 takes a registered id (reg-decorator :centered-layout {...}); the function lives at the registration site, not at the variant call site.
Composed variants — reference parent by id, override by data¶
A variant may reference another variant as its base, overriding selected keys:
(rf/reg-variant :story.auth.login-form/loading-with-prefill
{:extends :story.auth.login-form/loading ;; parent variant id
:events [[:auth/initialise]
[:auth/email-changed "alice@example.com"]
[:auth/password-changed "hunter2"]
[:auth/login-pressed]] ;; override events
:tags #{:dev :docs}}) ;; override tags
:extends resolves at registration time. The library merges the parent's body with the child's (child wins key-by-key); the result is a fully data-shaped variant artefact. Composition is a pure-data transform — no closures, no inheritance ceremony.
The :rf/variant schema enforces both rules (data-only fields; :extends resolves to a registered variant id). See Spec-Schemas §:rf/variant.
Combined reg-story form — a sugar that desugars¶
Two registration forms are canonical, and authors choose by ergonomics:
Form A (separate, hot-reload-friendly): (rf/reg-story :id metadata) + N (rf/reg-variant :story-id/variant-id metadata) calls. Each variant is a top-level form — saving the file invalidates only the changed variant; hot-reload is precise.
Form B (combined): (rf/reg-story :id metadata) with a :variants map in the metadata. The story library desugars this at macro-expansion time into Form A — the registrar receives N independent reg-variant calls, so hot-reload-by-variant still works the same way. Form B is sugar for one-form-per-story authoring.
;; Form B: combined; the macro emits N reg-variant calls at expansion.
(rf/reg-story :story.auth.login-form
{:doc "The login form component."
:component login-form
:decorators [[centered-layout]]
:args {:placeholder "you@example.com"}
:argtypes {:placeholder {:control :text}}
:tags #{:dev :docs}
:variants {:empty {:events [[:auth/initialise]]}
:validation-error {:events [[:auth/initialise]
[:auth/email-changed "not-an-email"]
[:auth/login-pressed]]
:tags #{:dev :docs :test}}
:loading {:events [[:auth/initialise]
[:auth/email-changed "alice@example.com"]
[:auth/login-pressed]]
:decorators [[force-fx-stub :my-app/http {:status :pending}]]}}})
Both forms are first-class.
Workspace¶
A workspace is a layout — multiple stories/variants arranged on screen for browsing, documentation, or side-by-side comparison.
(rf/reg-workspace :Workspace.Auth/all-states
{:doc "Every login-form state side by side, for QA review."
:layout :grid
:variants [:story.auth.login-form/empty
:story.auth.login-form/validation-error
:story.auth.login-form/loading
:story.auth.login-form/rate-limited]})
(rf/reg-workspace :Workspace.Auth/docs
{:doc "Auth flow documentation page."
:layout :prose ;; markdown-flavoured layout
:content [{:type :prose :body "## The login flow\n\n..."}
{:type :variant :id :story.auth.login-form/empty}
{:type :prose :body "When the email is invalid:"}
{:type :variant :id :story.auth.login-form/validation-error}]})
Workspaces are themselves rendered like other re-frame views; the workspace tool reads the registry and lays them out.
Args and controls¶
Storybook's headline UX is the controls panel — interactive props that re-render the story. We need an equivalent.
Args at three levels¶
- Global args — re-frame2 doesn't have a global default beyond what frames give us. Story-tool config can supply defaults (theme, locale).
- Story-level args — declared on
reg-story; inherited by every variant. - Variant-level args — override or extend the story's args.
(rf/reg-variant :story.auth.login-form/customised
{:args {:placeholder "your.email@company.com" ;; override story default
:submit-label "Authenticate"}
:events [[:auth/initialise]]})
Argtypes describe controls¶
:argtypes is a map of arg-name → control specification. The story tool reads this to render sidebar widgets.
{:argtypes
{:placeholder {:control :text}
:submit-label {:control :text}
:variant {:control {:type :select
:options [:primary :secondary :danger]}}
:disabled? {:control :boolean}
:max-length {:control {:type :number :min 1 :max 100}}}}
Control types map to common widgets: :text, :textarea, :number, :boolean, :select, :radio, :date, :color. The tool can extend with custom controls.
Auto-derivation from Spec 010 schemas. A Malli enum on a view's arg becomes a
:select; a string becomes:text; a[:int {:min 1 :max 100}]becomes a bounded number control. The stories library consults the view's Spec 010 schema and synthesises:argtypes; authors write:argtypesonly to override or extend. Single source of truth for arg shape.
Args mapping to state¶
Args are passed to the view as data. By default the view renders with the current args:
(rf/reg-view login-form [args] ;; receives the current args
[:form
[:input {:placeholder (:placeholder args)}]
[:button (:submit-label args)]])
When a control mutates an arg, the story tool dispatches [:story/set-arg <story-id> <arg-key> <new-value>] into the variant's frame; the view re-renders with the new args.
For variants that need args to map into app-db (e.g., a :logged-in? arg controls whether the auth section is rendered), the variant declares an explicit mapping by registered event id:
;; Register an event handler that receives the new arg value as its payload.
(rf/reg-event-fx :story.auth/set-logged-in
(fn [_ [_ v]]
(if v
{:fx [[:dispatch [:auth/restore-session {:user "alice"}]]]}
{:fx [[:dispatch [:auth/log-out]]]})))
(rf/reg-variant :story.auth.login-form/logged-in-arg
{:args {:logged-in? false}
:args->events {:logged-in? :story.auth/set-logged-in} ;; registered id, not a fn
:events [[:auth/initialise]]})
:args->events is {<arg-key> <event-id>} — entries are registered event ids, not inline functions. When the control mutates the arg, the story tool dispatches [<event-id> <new-value>] into the variant's frame. Most stories don't need :args->events — args going to the view directly is enough.
Decorators¶
Decorators wrap stories with shared infrastructure: themes, layout containers, mocked providers, fixed widths. Story-level decorators apply to every variant; variants can add their own.
Three kinds of decorator¶
- Hiccup wrapper. A vector that wraps the rendered view.
- Frame setup. A function that mutates the story's frame at creation — pre-populates
app-db, registers per-frame interceptors. - Fx override. A declaration that swaps an fx for the lifetime of the variant —
[force-fx-stub :my-app/http canned-response].
;; Hiccup wrapper — pure visual
[centered-layout]
[theme :light]
[fixed-width 480]
;; Frame setup — affects state
[mock-auth {:user {:id 42 :name "Alice"}}]
[mock-router {:current-path "/dashboard"}]
;; Fx override — affects effects. The stub payload is data; any handler logic
;; lives in a registered event/fx handler that the decorator references by id.
[force-fx-stub :my-app/http {:status 200 :body {...}}]
[force-fx-stub :localstorage {:value nil}]
Decorators are themselves re-frame artefacts — usually small libraries that ship as re-frame.decorators.theme, re-frame.decorators.auth, etc. Story authors require what they need; decorators register hooks against the framework's interceptor and fx surfaces (no new framework primitives required).
Decorator-as-frame-config-merger¶
A decorator's frame setup mode generalises into "things that should be true of any frame using this decorator." For complex apps, common decorators (auth context, router, theme) get factored into the team's design system — story authors compose them, don't reinvent them.
Play functions¶
Play is a sequence of events fired after the variant has rendered, distinct from :events (which run before render to set up state).
(rf/reg-variant :story.auth.login-form/login-flow
{:doc "Full happy-path login interaction."
:events [[:auth/initialise]] ;; setup before render
:play [[:auth/email-changed "alice@example.com"]
[:auth/password-changed "hunter2"]
[:auth/login-pressed]
[:rf.assert/path-equals [:auth :status] :authenticated]
[:rf.assert/path-equals [:nav :route] :dashboard]]})
:rf.assert/* events are themselves dispatches, handled by the story tool's test runner. In dev/docs mode they're rendered as a checked-step list; in test mode they fail loudly when assertions don't hold; in agent mode they're simulation breakpoints. The :rf.assert/* namespace is the canonical assertion namespace — see §Assertion vocabulary is registered and enumerable below for the full registered set.
Assertion vocabulary is registered and enumerable. The
:rf.assert/*namespace is reserved (see Conventions.md §Reserved namespaces) and registered as a public, queryable set of events. The stories library registers the canonical vocabulary at load time::rf.assert/path-equals,:rf.assert/path-matches,:rf.assert/sub-equals,:rf.assert/dispatched?,:rf.assert/state-is(machine),:rf.assert/no-warnings,:rf.assert/effect-emitted. Tooling enumerates(rf/handlers :event #(re-find #"^:rf\.assert/" (str (:id %))))to discover the vocabulary. Per Principles §Public query surfaces.
Story-as-test duality¶
A variant with :events + :play + :rf.assert/* is a complete component test. Same artefact serves dev-time visualisation, regression testing, and tooling input. Test runners iterate over :story.*/* variants tagged :test and run their setup + play, asserting on the resulting state.
This collapses several artefacts a typical project maintains separately: the dev-time playground, the test suite, the regression-screenshot fixtures, and the documentation. They become facets of one registered thing.
Inclusion tags¶
The standardised inclusion-tag vocabulary controls which contexts include a variant:
| Tag | Meaning |
|---|---|
:dev |
Visible in the development story tool. |
:docs |
Included in generated documentation pages. |
:test |
Run as a test in the test suite (:play + :rf.assert/*). |
:screenshot |
Captured in screenshot/visual-regression runs. |
:experimental |
Hidden in production-ish views; visible in dev. |
:internal |
Excluded from public-facing docs. |
:agent |
Surfaced to AI agents as canonical examples. |
A variant's tags default to #{:dev :docs}. Tools intersect their requested tag set with the variant's tags.
:tags #{:dev :docs :test :screenshot} ;; full coverage
:tags #{:dev :experimental} ;; in dev only, marked experimental
:tags #{:dev :test} ;; not in docs (e.g., edge case)
Tags are a registered, queryable vocabulary¶
Tags are not free-form strings — every tag a project recognises must be registered via reg-tag:
(rf/reg-tag :dev
{:doc "Visible in the development story tool."})
(rf/reg-tag :auth/regression-set
{:doc "Variants used in the auth feature's regression suite."})
The default tag vocabulary above (:dev, :docs, :test, :screenshot, :experimental, :internal, :agent) is registered by the stories library at load time. Project-specific tags must be registered before use. The tag set is queryable:
(rf/handlers :tag) ;; → all registered tags + their docs
(rf/handlers :tag #(contains? (:tags %) :auth)) ;; filtered
Tools enumerate this set before assigning tags to a variant. A variant whose tags include an unregistered keyword fails registration with :rf.error/unknown-tag. This is the AI-first "public query surfaces" principle (Principles.md §Public query surfaces) applied to tag vocabulary.
Loaders (advanced — async setup)¶
Loaders run asynchronously before stories render to fetch data. Deterministic :events are preferred because they're reproducible and replayable. Loaders are an escape hatch for cases that genuinely need async setup (e.g., generating a test image from a remote service).
;; The async work lives in a registered event handler; the variant references it by id.
(rf/reg-event-fx :charts.heatmap/fetch-fixture
(fn [_ _]
{:fx [[:my-app/http {:url "/fixtures/heatmap.json"
:on-success [:charts/load-fixture]}]]}))
(rf/reg-variant :story.charts.heatmap/with-real-data
{:doc "Renders against a fixture fetched from disk."
:loaders [[:charts.heatmap/fetch-fixture]] ;; event vector — the handler does the async work
:events [[:charts/load-fixture]]
:tags #{:dev :docs}})
Loader lifecycle (canonical)¶
The variant setup phases run in this fixed order:
- Loaders. Each event in
:loadersis dispatched into the variant's frame. The library waits for the loader's drain to settle (run-to-completion per 002) and any pending fx the loader emitted (e.g.,:rf.http/managedor a user-supplied HTTP fx) to resolve and dispatch their continuation events. A loader is complete when no further events are in flight against the variant's frame. - Events. Each event in
:eventsis dispatch-synced in order, after every loader has completed. By the time:eventsruns, the loaded data is already inapp-db. - Render. The view renders against the post-events
app-db. - Play. Each event in
:playis dispatched in order against the now-rendered view (per §Play functions).
Hosts that don't have a usable async surface for waiting on loader completion (rare) treat :loaders as a synonym for :events; the canonical flow is the four-phase sequence above. Mark loaders as advanced in docs. The vast majority of variants should use :events only.
Effect mocking — hook design, not policy¶
Stubbing HTTP (and similar effects) for stories uses hooks for per-variant interceptors and fx overrides — not mocking policy baked into reg-variant. The effect being stubbed may be the framework-shipped :rf.http/managed (see 014-HTTPRequests) or a user-supplied fx like :my-app/http; the stubbing mechanism is the same.
The framework hooks (at the foundation level — see 002-Frames.md):
:on-createevents run at frame creation.- Per-frame fx override — a variant can declare fx replacements active for its frame's lifetime. Available via
reg-frame :fx-overrides(see 002 §Per-frame and per-call overrides). - Per-frame interceptor injection — a variant can register interceptors that run only for its frame.
Decorators expose these hooks as composable building blocks (force-fx-stub, inject-interceptor, etc.). Story authors compose decorators; they don't manually wire interceptors. The framework provides the hooks; the decorator library provides the ergonomics.
Portable into tests¶
Variants are runnable outside the story UI. The library exposes a function form for each:
(rf/run-variant :story.auth.login-form/validation-error)
;; → {:frame :story.auth.login-form/validation-error
;; :app-db {...} ;; final state after :events + :play
;; :assertions [{:passed? true ...} ...]
;; :rendered-hiccup [...] ;; if :render? true was supplied
;; :elapsed-ms 12.4}
Use cases:
- Component tests (
deftestin CLJS test suites) — callrun-variant, assert on:assertionsor:app-db. - Screenshot tests — render
:rendered-hiccupto JSDOM/Playwright, capture image, diff. - Tooling input — pass the variant id to an attached agent or inspector; consumers read
:app-dband:assertionsto reason about behaviour. - Manual REPL exploration — call
run-variantinteractively to see what state events produce.
The same data drives every consumer. No artefact duplication.
Live-watching a variant.
(rf/watch-variant variant-id)re-runs the variant whenever any of its dependencies (events, subs, view, schema) re-register. The framework already ships hot-reload notifications;watch-variantis a thin library composition over them. Cycle-prevention via registry-version diffing — only re-run when a dependency's registration metadata actually changed.
Variant snapshot identity¶
Every variant has a stable snapshot identity comprising its :variant-id plus a content hash of its serialised body. The hash includes:
:eventsand:playevent vectors (in order),- the resolved (post-
:extends-merge) args, decorators, and tags, - the parent story's component id (
:component) and decorators, - the registered schema digest of the view (per 011 §
:rf/schema-digest) — so a schema change invalidates the snapshot identity.
The hash is computed over a canonicalised data form (sorted keys, deterministic vector order) so it round-trips across hosts. Visual-regression and screenshot pipelines key against [variant-id content-hash] — when the body changes, the hash changes; when the body doesn't change, the hash is stable across runs. The framework hook is (rf/snapshot-identity variant-id) → {:variant-id ..., :content-hash "..."}.
This is the AI-first "machine-readable invariants" principle: tooling comparing before-and-after a code change asks the runtime which variants' snapshot identities changed without re-rendering them.
Story-tool extension hook¶
The stories library's tool surface is extensible by registering panels. A panel is a registered view with a known kind:
(rf/reg-story-panel :a11y/inspector
{:doc "Accessibility issues for the active variant."
:title "Accessibility"
:placement :right
:render :a11y/inspector-view}) ;; id of a reg-view
Panels are registered against the story-tool's own registry; the tool reads (story/handlers :story-panel) and lays them out. Same shape as everything else in re-frame2 — registry + metadata.
Story maintains its kind-shaped registrations in a tool-owned side-table at tools.story.registry/*. This is internal to the tools/story/ artefact and stays out of production bundles. The bridge fn story/handlers exposes the §Public-query-surfaces parity (e.g. (story/handlers :story) enumerates the side-table). The framework registrar's closed-kinds discipline (001-Registration.md) is preserved — Story does not register with re-frame.registrar.
What the framework supplies vs. what the library adds¶
Framework hooks (in 002): make-frame/destroy-frame/reset-frame; per-frame :fx-overrides/:interceptor-overrides/:interceptors; run-to-completion drain; public registrar query API (handlers/frame-meta/frame-ids/get-frame-db/snapshot-of/sub-topology); hot-reload notifications.
re-frame.stories library: reg-story/reg-variant/reg-workspace; side-table registries; run-variant (programmatic execution + assertions); reset-variant; variants-with-tags; the story-tool UI.
The framework surface is sufficient for any team to roll their own equivalent if they want.
Relationship with frames¶
A variant is a frame, registered under its variant keyword. But variant :events are NOT desugared to reg-frame :on-create — reg-frame :on-create is single-event by design (002 §reg-frame), while variant :events is an explicitly multi-step setup sequence (the whole point of stories is to express setup as a list of user-flavoured steps). The story library handles its own iteration, in the four-phase order locked above:
;; conceptual setup logic for reg-variant
(defn setup-variant! [variant-id]
(let [{:keys [loaders events]} (variant-meta variant-id)
story-events (story-events-for variant-id)
all-events (concat story-events events)]
(rf/reg-frame variant-id {:doc ...}) ;; frame starts with app-db = {}
(doseq [ev loaders] ;; phase 1 — async loaders
(rf/dispatch-sync ev {:frame variant-id})
(await-loader-drain variant-id))
(doseq [ev all-events] ;; phase 2 — :events (incl. story-level)
(rf/dispatch-sync ev {:frame variant-id}))
(record-variant-meta variant-id {:view ..., :decorators ..., :play ..., :tags ...})))
;; phase 3 (render) and phase 4 (play) happen later, driven by the host.
So the variant's frame is a normal frame (no :on-create); the variant library handles the multi-event setup. This keeps reg-frame :on-create semantically simple (one event) while letting stories express their richer setup pattern.
Workspaces are not frames (or not necessarily — they may be ordinary frames containing nested frame-providers, one per included variant). Each variant included in a workspace renders inside its own frame-provider, isolated from siblings. This falls out of 002's design without extra machinery.
Open questions¶
Workspaces — generic or specialised?¶
A :layout field with :grid, :prose, :tabs etc. covers common cases. Custom layouts are just custom views referencing variant ids.
Devcards / Workspaces interop¶
Existing CLJS projects using devcards or other workspace tools should be able to consume re-frame2 stories with adapter shims.
Story composition across libraries¶
Multiple :story.* namespaces can come from different libraries. The story tool reads all registered :story.* ids.
Resolved decisions¶
Should reg-story and reg-variant be separate, or unified?¶
Both forms, with the combined form desugaring to separate registrations. (reg-story :id metadata) + N (reg-variant :story-id/variant-id metadata) is the canonical pair. The combined :variants {...} map on reg-story is sugar that desugars at macro-expansion time to N independent reg-variant calls — hot-reload-by-variant still works. See §Combined reg-story form.
Args mapping — view-direct or via app-db?¶
Args go to the view directly by default; explicit :args->events for variants that need state changes. Simple cases stay simple; complex cases have an opt-in mechanism.
Test integration — built-in runner or test-framework adapter?¶
The story library ships a run-variant-flavoured runner. Test-framework adapters (re-frame-test, etc.) consume run-variant and produce framework-specific test cases. The built-in runner is part of the story library, not the framework; adapters layer on top of it.
Screenshot / visual-regression integration¶
The framework hook is: variants have a stable snapshot identity (:variant-id + content hash) per §Variant snapshot identity. Specific visual-regression service integrations consume the variant registry, run-variant's rendered hiccup, and the snapshot identity.
See also¶
tools/story/— the reference implementation of this spec (day8/re-frame2-story).tools/story/spec/— the implementation contract (decisions, runtime shape, elision, MCP boundary).tools/story-mcp/— the agent-facing MCP server (day8/re-frame2-story-mcp).- Guide chapter 21 — Stories — the narrative walkthrough of this spec.
examples/reagent/counter_with_stories/— the worked example pivoting on the counter from guide chapters 03–10.