StatifierUI

CIHex.pm VersionHex DownloadsHex DocsLicense

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.1"}
]
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.

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.

notebooks/inspector.livemd walks the whole widget end to end and doubles as its manual acceptance test.

What is in the package

ModuleRenders
StatifierUI.DiagramMermaid stateDiagram-v2 source for a machine and a configuration
StatifierUI.EventLogthe run as macrosteps and rounds, with the transitions each selected
StatifierUI.DatamodelExplorerthe datamodel, live from a trace or authoring-time from a chart
StatifierUI.EventInjectionthe palette of example events a fixture set defines
StatifierUI.TruthTableexpressions evaluated across datasets
StatifierUI.Fixturesthe example-data contract: scenarios, events, datasets, expressions
StatifierUI.Trace.Subscribera session's effect stream, normalized and buffered
StatifierUI.Inspectorthe four panes composed, as strings
StatifierUI.Kinothe same, as Livebook widgets (optional :kino)

Everything except StatifierUI.Kino is pure and dependency-free: build the strings, render them wherever you like.

Documentation

Published guides on hexdocs:

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.