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.
def deps do
[
{:statifier_ui, "~> 0.1"}
]
endThe :kino (Livebook) and :phoenix_live_view integrations are optional
dependencies - add whichever your host actually renders with.
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.
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.
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.
notebooks/inspector.livemd
walks the whole widget end to end and doubles as its manual acceptance test.
| 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.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) |
Everything except StatifierUI.Kino is pure and dependency-free: build the
strings, render them wherever you like.
Published guides on hexdocs:
- Architecture - the layers of statifier-ui, what each piece is for, and the boundary with the engine it visualizes.
- 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.
mise install # provision erlang + elixir
mix deps.get
mix quality # the full gate: format, compile, credo, dialyzer, docs, testsmix 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.
MIT. See LICENSE.