Skip to content

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-workspace are sugar; everything is doable by hand with reg-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:

  1. 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.
  2. The dotted path segments organise the tree the story tool renders — split on . to build the navigator.
  3. Variant names go after /. A variant id always belongs to exactly one story; the story id is everything before /.
  4. Tools enumerate via (rf/handlers :story), (rf/handlers :variant), (rf/handlers :workspace). The hierarchy is recoverable from the id alone — no separate :title field 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

  1. Global args — re-frame2 doesn't have a global default beyond what frames give us. Story-tool config can supply defaults (theme, locale).
  2. Story-level args — declared on reg-story; inherited by every variant.
  3. 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 :argtypes only 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

  1. Hiccup wrapper. A vector that wraps the rendered view.
  2. Frame setup. A function that mutates the story's frame at creation — pre-populates app-db, registers per-frame interceptors.
  3. 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:

  1. Loaders. Each event in :loaders is 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/managed or 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.
  2. Events. Each event in :events is dispatch-synced in order, after every loader has completed. By the time :events runs, the loaded data is already in app-db.
  3. Render. The view renders against the post-events app-db.
  4. Play. Each event in :play is 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-create events 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 (deftest in CLJS test suites) — call run-variant, assert on :assertions or :app-db.
  • Screenshot tests — render :rendered-hiccup to JSDOM/Playwright, capture image, diff.
  • Tooling input — pass the variant id to an attached agent or inspector; consumers read :app-db and :assertions to reason about behaviour.
  • Manual REPL exploration — call run-variant interactively 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-variant is 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:

  • :events and :play event 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-createreg-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