StatifierUI
UI components for authoring, observing, inspecting, and debugging statifier statecharts and predicator expressions.
Debug-first and text-first: SCXML is the source of truth, and the visualization reads it. See the GUI research and direction document for how that direction was reached, and the architecture decision records for the decisions themselves.
Statifier already emits trace effects at every Appendix D phase boundary,
stamps them with (macrostep, round) counters, and retains source locations on
states, transitions, and expressions. A UI is one more interpreter of those
effects; the engine needs nothing changed to support it.
Installation
def deps do
[
{:statifier_ui, "~> 0.3"}
]
end
The :kino (Livebook) and :phoenix_live_view integrations are optional
dependencies - add whichever your host actually renders with.
Observing a run
The panes are pure folds over a trace message list, so the whole package is usable without Livebook, without Phoenix, and without a display. This is a card authorization that settles - the chart, a subscriber, one event, and the rendered panes.
xml = """
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" initial="pending" version="1.0">
<datamodel>
<data id="amount_cents" expr="1999"/>
<data id="captured_cents" expr="0"/>
</datamodel>
<state id="pending">
<transition event="authorize.approved" target="authorized"/>
<transition event="authorize.declined" target="declined"/>
</state>
<state id="authorized">
<transition event="capture.settled" target="captured">
<assign location="captured_cents" expr="amount_cents"/>
</transition>
</state>
<final id="captured"/>
<final id="declined"/>
</scxml>
"""
{:ok, machine} = Statifier.compile(xml)
# Start the subscriber first and hand it to the session as a subscriber, so it
# sees the initialize burst. `Session.start_link/2` initializes to quiescence
# before it returns, so anything that attaches afterwards has already missed it.
{:ok, sub} = StatifierUI.Trace.Subscriber.start_link(machine: machine, source: xml)
{:ok, session} =
Statifier.Session.start_link(machine,
trace: true,
subscribers: [sub],
session_id: "sess_card_demo"
)
:ok = StatifierUI.Trace.Subscriber.attach(sub, session, subscribe: false)
# `send_event/2` is a cast: it enqueues, and the run happens in the session.
:ok = Statifier.Session.send_event(session, "authorize.approved")
Process.sleep(50)
messages = StatifierUI.Trace.Subscriber.messages(sub)
messages is the whole run so far. Every pane is a function of it.
StatifierUI.Inspector.diagram(machine, messages) renders the configuration as
Mermaid, with the active state classed active:
stateDiagram-v2
state "pending" as s1
state "authorized" as s2
state "captured (final)" as s3
state "declined (final)" as s4
[*] --> s1
s1 --> s2 : authorize.approved
s1 --> s4 : authorize.declined
s2 --> s3 : capture.settled
classDef active fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e
class s2 active
StatifierUI.Inspector.event_log(messages) renders the run as collapsible
Markdown, one section per macrostep, newest open:
# Event log: sess_card_demo
<details>
<summary>Macrostep 1: initialize, 2 rounds, quiescent at <scxml>, pending</summary>
| round | event | selected | exited | entered |
| --- | --- | --- | --- | --- |
| 0 | - | - | | <scxml>, pending |
| 1 | (eventless) | none | | |
</details>
<details open>
<summary>Macrostep 2: authorize.approved, 2 rounds, quiescent at <scxml>, authorized</summary>
| round | event | selected | exited | entered |
| --- | --- | --- | --- | --- |
| 0 | authorize.approved | authorize.approved: pending -> authorized | pending | authorized |
| 1 | (eventless) | none | | |
- authorize.approved: pending -> authorized:
</details>
The trailing bullet is the transition's executable content, listed with the
source location of each element. This transition carries none; the one on
capture.settled would list its <assign>.
StatifierUI.Inspector.datamodel(messages) renders the live datamodel with a
marker on what the last macrostep changed, and
StatifierUI.Inspector.status(StatifierUI.Trace.Subscriber.stats(sub)) renders
the one-line session header. Sending capture.settled next moves the highlight
to captured and marks captured_cents as changed.
To hand the same stream to a UI written in something other than Elixir,
StatifierUI.Trace.Json.encode_lines(messages) emits it as JSON Lines in the
language-neutral trace wire format.
Checking expressions
Datasets and expressions are the other half of the fixtures contract (ADR-0006): named example datamodels, named predicator expressions, and the expected result of each pairing. Here they cover a signup wizard's A/B completion check.
{:ok, fixtures} =
StatifierUI.Fixtures.new(
datasets: %{
"variant-a-early" => %{"signup" => %{"steps_completed" => 1, "variant" => "A"}},
"variant-b-complete" => %{"signup" => %{"steps_completed" => 4, "variant" => "B"}}
},
expressions: %{
"is-complete-variant-b" => %{
"source" => "signup.steps_completed >= 3 and signup.variant == 'B'",
"expect" => %{"variant-a-early" => false, "variant-b-complete" => true}
}
}
)
fixtures
|> StatifierUI.TruthTable.build()
|> StatifierUI.TruthTable.Markdown.render()
renders every expression against every dataset:
# Truth table
**true**, false, and _undefined_ are three separate results. _undefined_ means the expression's inputs were absent, not that it evaluated to false.
| dataset | is-complete-variant-b |
| --- | --- |
| variant-a-early | false |
| variant-b-complete | **true** |
Expressions:
- **is-complete-variant-b**: `signup.steps_completed >= 3 and signup.variant == 'B'`
The "expect" entries are executable, not documentation:
StatifierUI.Fixtures.Expectations.check(fixtures) returns :ok when the
table matches and {:error, results} naming each disagreement, so a host can
run its own fixtures as part of its test suite. See
fixture bundles for the per-fragment layout and the
sidecar file format.
Editing an expression
StatifierUI.Live.ExpressionInput is an expression field with completion:
predicator's own grammar - operators, keywords, literal words, duration units,
and every function the host's providers resolve - plus the datamodel paths the
host declares, offered at the caret.
It is written for
statifier_blocks' ADR-0005
expression_component seam, which is the affordance that record defers to this
package, and it drops into an editor as a function reference:
<StatifierBlocks.Editor.editor
...
expression_component={&StatifierUI.Live.ExpressionInput.expression_input/1}
path_candidates={StatifierBlocks.Datamodel.candidates(document, datamodel)}
/>
Nothing about it is specific to that package - any host with an expression to edit can render it directly:
<StatifierUI.Live.ExpressionInput.expression_input
id="rule-cond"
name="rule[cond]"
value={@cond}
candidates={["order.total", "customer.tier"]}
/>
The field carries no event of its own. It renders an <input> with the name
it was given, so an edit arrives through the phx-change of whatever form the
host already has around it - which is also true of a completion the author
picks with the keyboard.
Without JavaScript the field is bound to a native <datalist> of the
word-shaped completions, and that is a working field. With the hook
registered it drops the datalist and offers a caret-aware list instead: the
token under the cursor is the prefix, arrow keys move, Enter or Tab inserts,
Escape dismisses.
Registering the hook is the two steps ADR-0009 describes - the JavaScript ships
as source and your bundler compiles it. In assets/package.json:
"dependencies": {
"statifier_ui": "file:../deps/statifier_ui/assets"
}
and in assets/js/app.js:
import { StatifierUIHooks } from "statifier_ui"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...StatifierUIHooks }
})
StatifierUIHooks is every hook this package ships, keyed by the name its
component renders; StatifierUIExpressionInput is the one this field uses and
can be imported on its own. Hook names and export names are public API
(ADR-0009).
Completion needs Predicator.Vocabulary, and a host on a predicator without it
gets its declared paths and no grammar entries rather than an error - the
rendered input stamps data-vocabulary so the two cases are told apart.
StatifierUI.Expression.completions/2 is the same list without any of the
markup, for a host that would rather render its own control.
The field ships no CSS either. It stamps data-hook once the hook has
upgraded it and data-vocabulary for the case above, and the hook builds the
completion popup at runtime under its own classes;
the embedding guide lists every selector, including
the ones no template renders.
The Livebook inspector
StatifierUI.Kino.inspect/3 composes the four panes above - configuration
diagram, datamodel explorer, event injection, event log - into one live widget
over a running session:
{:ok, session} = Statifier.Session.start_link(machine, trace: true, record: true)
StatifierUI.Kino.inspect(session, fixtures, source: xml)
record: true is what lets the widget catch up on everything that happened
before the cell was evaluated (statifier ADR-0049); without it the panes are
labeled Live-only rather than presenting a partial stream as whole.
A scrubber above the diagram - |< First, < Prev, Next >, Live -
moves the diagram from the live tip to any macrostep in the event log and back.
Selecting a macrostep draws the configuration that macrostep settled in, opens
its entry in the log and marks it shown in the diagram, and prints a line
saying which point is on screen. Nothing is recomputed to do it: every
configuration shown was stamped by the engine on a trace.macrostep_stable,
and a caught-up stream got there through replay, which re-drives the core
rather than rewinding a live session (statifier ADR-0034). The decisions are
StatifierUI.Inspector's - active_configuration/2, points/1, step/3,
selection_note/2 - so a LiveView or other host gets the same behaviour
without Kino.
notebooks/inspector.livemd
walks the whole widget end to end and doubles as its manual acceptance test.
What is in the package
| Module | Renders |
|---|---|
StatifierUI.Diagram | Mermaid stateDiagram-v2 source for a machine and a configuration |
StatifierUI.EventLog | the run as macrosteps and rounds, with the transitions each selected |
StatifierUI.DatamodelExplorer | the datamodel, live from a trace or authoring-time from a chart |
StatifierUI.EventInjection | the palette of example events a fixture set defines |
StatifierUI.TruthTable | expressions evaluated across datasets |
StatifierUI.Expression | the completion source: predicator's grammar plus declared datamodel paths |
StatifierUI.Fixtures | the example-data contract: scenarios, events, datasets, expressions |
StatifierUI.Trace.Subscriber | a session's effect stream, normalized and buffered |
StatifierUI.Inspector | the four panes composed, as strings |
StatifierUI.Kino | the same, as Livebook widgets (optional :kino) |
StatifierUI.Live.ExpressionInput | an expression field with completion (optional :phoenix_live_view) |
Everything except StatifierUI.Kino and StatifierUI.Live.* is pure and
dependency-free: build the
strings, render them wherever you like.
Documentation
Published guides on hexdocs:
- Architecture - the layers of statifier-ui, what each piece is for, and the boundary with the engine it visualizes.
- Embedding - putting the components on a host's own page: the asset pipeline that compiles the hooks, the classes and data-attributes a host's stylesheet themes them with, and the contract for rendering your own surfaces off the wire format instead.
- Fixture bundles - the per-fragment fixture layout, the sidecar file format, and how bundles are discovered.
- Trace wire format - the normative specification of the language-neutral JSON trace stream a UI consumes.
- Telemetry and the OTel bridge half - what this package emits about its own work, what it will never emit, and how a host hands it OpenTelemetry correlation ids without this package calling an OTel API.
Development
mise install # provision erlang + elixir
mix deps.get
mix quality # the full gate: format, compile, credo, dialyzer, docs, tests
mix quality --profile loop is the faster inner-loop variant - it skips
dialyzer and coverage and runs only the tests covering changed code.
CI runs the same gate on every push and pull request. See .quality.exs.
License
MIT. See LICENSE.