How a reusable chart fragment carries its own executable examples, so a palette entry can show a "test this step" panel of its own rather than borrowing a whole chart's fixtures.
This is the convention sui-13q settles. It adds no new fixture shape:
everything below is the ADR-0003 bundle with ADR-0006's datasets and
expressions keys, addressed by fragment name instead of by chart path.
Read those two records for why the shapes are what they are; read this page
for how one travels with a fragment.
The problem this solves
ADR-0003 pairs a bundle with a chart. A chart at authorization.scxml
carries authorization.fixtures.json beside it, and every consumer
downstream of the loader gets one StatifierUI.Fixtures struct whichever
delivery path produced it.
An embedder composing charts from a palette of reusable fragments has no such file to sit beside. The fragment is a module in the host's code, or an entry in a palette the host assembles at runtime, and the chart it will eventually be dropped into does not exist yet. Its examples still want to travel with it: an author picking "Authorize a card" out of a palette wants to see what that step's guard evaluates to under a transaction inside the account's budget and one over it, before committing to it and without authoring a chart first.
StatifierUI.Fixtures.Bundle is that: a StatifierUI.Fixtures struct plus
the fragment's name and a record of where it came from.
The convention
A fragment supplies its bundle by answering a zero-arity callback -
fixtures/0 by default - with any of four spellings.
| Spelling | Recognized by | Validated through |
|---|---|---|
%StatifierUI.Fixtures{} | the struct | already validated at construction |
%{scenarios: ..., events: ..., datasets: ..., expressions: ...} | atom top-level keys | StatifierUI.Fixtures.new/1 |
%{"version" => 1, "datasets" => ...} | string top-level keys | StatifierUI.Fixtures.Sidecar.from_json/2 |
"palette/authorize.fixtures.json" | a binary path | StatifierUI.Fixtures.Sidecar.load/1 |
The atom-versus-string top-level key is the whole discriminator. Atom keys
are the Elixir spelling a host writes by hand in a module; string keys are
the JSON spelling that survives a file. ADR-0003 requires both delivery paths
to converge on one struct rather than one to be primary, and this keeps that
true one level up. A map mixing the two is rejected as :mixed_bundle_keys
rather than guessed at.
Unknown top-level keys behave differently by spelling, on purpose. The
JSON spelling keeps the sidecar's ignore-unknown-keys discipline (ADR-0006),
so a file written by a newer producer still loads and reports what it
ignored. The Elixir spelling rejects an unknown atom key, because an atom key
in a host's own module is compiled code the author is looking at, and a
silently ignored :datsets typo there is a bundle reporting zero datasets
with no reason why. Forward compatibility is a property of a wire format, not
of a function call.
A fragment that ships no examples at all is never an error. Discovery reports it as an absence.
Fragment names and invoke types are two namespaces
A fragment name here is spelled with a dot - myapp.authorize. The
family's invoke types are spelled with a colon - myapp:authorize. That
is not a typo on either side, and the difference is load-bearing.
The operator's ruling of 2026-08-27 settles it:
D5) two namespaces, with a documented one-to-one mapping convention.
So: two namespaces, and this is the documented mapping convention.
- A fragment name (
myapp.authorize) names an entry in a palette. It is the keyBundle.load/2andBundle.discover/1are given, the name a bundle is reported under, and - vianame_from_path/1- the stem of a sidecar filename. It is an authoring-surface identifier: what an author picks out of a palette. - An invoke type (
myapp:authorize) names something the engine can invoke at runtime. It is an execution identifier, resolved through the per-session invoke registry, and it is statifier-ex's contract, not this package's.
Both spellings are <namespace><separator><local name>, and the mapping
between them is one to one in both directions: swap the separator, change
nothing else. myapp.authorize is the palette entry for the invoke type
myapp:authorize; myapp:capture is invoked by the fragment named
myapp.capture. The namespace segment and the local name are identical
character for character, so the mapping is total and mechanical - there is
no lookup table, and there is nothing for a host to configure.
Two consequences worth stating, because they are what the convention buys:
- A dot is the only separator a fragment name uses, and a colon never appears in one. Filenames are why: a sidecar is named for its fragment, and a dot survives every filesystem and every archive format a palette travels through. A fragment name carrying more than one dot has no invoke counterpart under this convention.
- A fragment need not be invoke-backed at all. A fragment that expands to
ordinary SCXML rather than to an
<invoke>has a name and no invoke type, and that is not a gap - the mapping is one to one over the invoke-backed fragments, not over every palette entry. Nothing here requires a host to mint an invoke type for a fragment that does not need one.
Neither namespace is derived from the other at runtime. This package never
turns a fragment name into an invoke type or back; the convention is a rule
for humans naming things, so that a reader seeing myapp.authorize in a
palette and myapp:authorize in a trace knows without asking that they are
the two faces of one step.
Wiring a palette entry
A host's fragment type - a block type, a step type, whatever the host calls it - answers the callback:
The corpus below is the card-processing one, deliberately not the
signup-wizard corpus that
test/support/fixtures/expressions.fixtures.json carries and ADR-0006
illustrates with. A fragment's bundle is self-contained by construction (see
the last bullet of this page), so a fragment gets the corpus its own domain
calls for; the two are canonical example domains, not a primary and a
variant. Both stay inside the family's two domains - card processing, and a
signup wizard with A/B testing - and nothing here reads a chart-level corpus
to build a fragment-level one.
defmodule MyApp.Blocks.Authorize do
@doc "Executable examples for this palette entry."
def fixtures do
%{
datasets: %{
"within-budget" => %{
"transaction" => %{"amount" => 14, "currency" => "USD"},
"account" => %{"budget_remaining" => 500}
},
"over-budget" => %{
"transaction" => %{"amount" => 900, "currency" => "USD"},
"account" => %{"budget_remaining" => 500}
}
},
expressions: %{
"exceeds-budget" => %{
"source" => "transaction.amount > account.budget_remaining",
"expect" => %{"within-budget" => false, "over-budget" => true}
}
}
}
end
endLoad one fragment's bundle directly:
{:ok, bundle} =
StatifierUI.Fixtures.Bundle.load("myapp.authorize", MyApp.Blocks.Authorize.fixtures())Or discover every fragment's bundle across the palette at once:
palette = %{
"myapp.authorize" => MyApp.Blocks.Authorize,
"myapp.capture" => MyApp.Blocks.Capture,
"myapp.assign_variant" => MyApp.Blocks.AssignVariant,
"myapp.signup" => MyApp.Blocks.Signup
}
discovery = StatifierUI.Fixtures.Bundle.discover(palette)
discovery.bundles # loaded, sorted by name
discovery.without # ["myapp.signup"] - ships no examples, which is fine
discovery.errors # [{name, reason}] - meant to load, did notDiscovery is never all-or-nothing. One fragment's malformed bundle is reported against that fragment's name and every other fragment still loads, because a palette is exactly the setting where one bad entry hiding every good one is least useful. A callback that raises is caught the same way, for the same reason.
Fragments that travel as files
The other delivery path needs no host code at all. Put a directory of sidecars beside the fragments they describe, one file per fragment, named for it:
palette/
authorize.fixtures.json
capture.fixtures.json
README.md{:ok, discovery} = StatifierUI.Fixtures.Bundle.discover_dir("palette")
Enum.map(discovery.bundles, & &1.name)
#=> ["authorize", "capture"]Each bundle is named after its file with the .fixtures.json suffix stripped
(StatifierUI.Fixtures.Bundle.name_from_path/1), so a fragment named
myapp.assign_variant is a file named
myapp.assign_variant.fixtures.json. Files that are not
sidecars are ignored rather than reported. The directory is not walked
recursively: a palette directory is a flat list of fragments, and a nested
one is a second palette rather than a deeper part of this one.
This is the corpus and CLI half of ADR-0003's two-paths rule, and it is what lets a palette of fragments move between repositories - or to a non-Elixir consumer - with nothing but plain JSON.
The test panel
StatifierUI.Fixtures.Bundle.Markdown.render/2 renders one bundle as its
panel, and StatifierUI.Kino.test_panel/2 wraps that for a Livebook cell:
{:ok, bundle} =
StatifierUI.Fixtures.Bundle.load("myapp.authorize", MyApp.Blocks.Authorize.fixtures())
StatifierUI.Kino.test_panel(bundle)The panel is deliberately both halves of the fixture contract at once:
- the truth table (
StatifierUI.TruthTable) - what every expression actually evaluates to under every dataset, whether or not an expectation was stated; - the expectations (
StatifierUI.Fixtures.Expectations) - whether every statedexpectvalue still holds.
Neither alone is enough for a fragment's panel. The table without the expectations says what happens but not what was meant; the expectations without the table confirm a stated belief while staying silent about every expression that stated none.
For a whole palette, render_discovery/2 (or
StatifierUI.Kino.palette_panel/2) prints one panel per bundle, then names
every entry that failed to load. Fragments that ship no examples are not
listed - a palette where most fragments carry none is the normal case, and
reciting them says nothing a reader can act on.
The summary counts rather than passes
The expectations summary reports four counts and never collapses them into a pass or a fail:
3 matched, 1 mismatched, 0 errored, 1 stated against a dataset this bundle does not carry.That last count is not folded into the others because this package's two
consumers of the same fact already disagree about it.
StatifierUI.Fixtures.Expectations.check/2 counts a dangling expect key as
a failure, since an expectation naming no dataset was never actually checked.
StatifierUI.Fixtures.Lint reports the same key as a warning, per ADR-0006's
severity reasoning. Both are right about their own question; a panel printing
one verdict would silently pick a side. Four counts let the reader see which
of the two situations they are in.
Running a palette's expectations in a suite
The panel is the reading surface. The checking surface is the same one
ADR-0006 already fixed - StatifierUI.Fixtures.Expectations.check!/2 - now
reachable per fragment, so a host's suite goes red naming the fragment whose
examples drifted rather than naming a chart:
defmodule MyApp.PaletteFixturesTest do
use ExUnit.Case, async: true
alias StatifierUI.Fixtures.Bundle
alias StatifierUI.Fixtures.Expectations
@discovery Bundle.discover(MyApp.Palette.types())
test "no palette entry's bundle failed to load" do
assert @discovery.errors == []
end
for bundle <- @discovery.bundles do
@bundle bundle
test "#{bundle.name} fixture expectations still hold" do
Expectations.check!(@bundle.fixtures)
end
end
endWhat this convention does not decide
- Matrix orientation.
:orientationis forwarded verbatim toStatifierUI.TruthTable.Markdown.render/2, whose own default stands. Nothing here states a preference between the two axes. - Inner keys of an expression entry. Keys beyond
"source"and"expect"are preserved verbatim, exactly as the sidecar loader already preserved them. This convention adds no reading of them and no rule about them. - The
:missing_datasetseverity split betweenExpectations.check/2andFixtures.Lint, described above. The panel shows both rather than reconciling them. - Duration-valued datasets, which
StatifierUI.Fixturesrejects for the convergence reason its moduledoc gives. A bundle loaded here is validated by exactly that code and inherits exactly that answer. - Whether a fragment's bundle may reference a chart-level one. Every bundle here is self-contained. Sharing datasets across fragments, or overlaying a fragment's dataset on a chart's scenario, is ADR-0006's carried overlay question and is untouched by this record.