All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Entries for unreleased work are not written here directly. Each issue drops a
fragment in changelog.d/; the fragments are assembled
into a version section at release. See that README for the format and for when a
change warrants an entry at all.
[0.4.0] 2026-08-30
Added
- The inspector's Config tab is two labelled sections. Block states the selected block's type label, its id and the slot it sits in, and renders with nothing selected too - three rows in the same place, reading as a dash.
StatifierBlocks.Shell.slot_label/2answers which slot a block sits in, by the slot's label rather than its name, with"root"for the document root.- A block type may declare a
:stringconfig field keyedlabel, and the editor draws that value as the card's title with the type's own label as a subtitle underneath - so a host's steps read as the names an author gave them without the type ever being hidden. - A card carrying
invoke_typein its config draws it in mono on a third line, which is the fact an author checks most on a step that calls out to a handler. StatifierBlocks.ViewModel.title/1andsubtitle/1answer what a card's two name lines say, andViewModel.Nodecarriestitleandinvoke_typefor a host rendering its own cards.- The compiler refuses a
core.subchartwhosechartnames the document the block sits in, as an:emit-stage:self_referencefinding against that block: a document cannot run itself. A cycle through two or more documents needs the host's document graph and stays the host resolver's to refuse. StatifierBlocks.Shell.drawer_tabs/0,drawer_tab/1anddrawer_title/1, in the same shape as the inspector's tab helpers - an unknown tab from a craftedphx-value-tabresolves to the first one..sb-findings__row,__severity,__subject,__label,__idand__message, the row's parts. The severity colour stays on.sb-findingand its severity modifier, so a host that had restyled one severity keeps that styling with no edit. No new custom property.- A slot's header shows the condition it is subject to: the expression source,
read-only, in a monospaced chip under the slot's name, clipped to one line
with the whole of it in the chip's
title. A branch's arms on the canvas now say what picks between them instead of only naming themselves. StatifierBlocks.ViewModel.Slot.conditioncarries that source. It is derived from the container's own:expressionconfig field keyed by the slot's name, read through the field's declaredvalue_path, so a host block type that guards a slot the waycore.branchguards an arm gets the same chip without the editor learning either type's name.- The palette and the inspector render as framed panes with a header row. The
palette's names the pane and carries a chevron that folds it to a rail,
giving its width back to the canvas; the inspector's names the pane and
states its subject on the right - the selected block type's label, or
no selection. --sb-palette-collapsed-width, the width the folded palette narrows to (tier 2, default2.25rem).- The palette carries a count line under its search box: the size of the palette when nothing is filtering it, and how much of it is left plus what is doing the narrowing when something is - a query, or the acceptance set of the slot the palette was opened from.
- Each group header carries the number of rows currently under it, so a filtered section says how much of it survived the filter.
- The editor draws the join marker under a container whose slots sit side by
side, reading the words the block type's
join_labelcallback returns -core.parallelcompleting on its first lane says "continue at first" - and falling back to the editor's own word for a type that declares none. StatifierBlocks.ViewModel.Nodecarriesjoin_label, the normalized words that callback returned for the block's config, ornilwhen it declared none.- The editor's canvas is a named panel: the toolbar is its header row, with a
Canvaslabel, anested treechip, one segmented zoom control, and the depth and block-count metrics as right-aligned chips. - The canvas sits on a bordered, dotted ground, and
--sb-canvas-gridis the tier-2 token a theme sets to change the dots' colour and spacing together. - The editor draws a
ONE OFpill above a container whose body slots are alternatives and anALL OFpill above one whose lanes run concurrently, which is the only place that distinction is stated on the canvas. StatifierBlocks.ViewModel.arrangement/1,body_slots/1andfan_label/1derive how a container arranges its body slots, shared by the renderer and the connector geometry so the layout and the lines cannot disagree.StatifierBlocks.Connectors.fan_anchor/1andjoin_anchor/1name the two markers a fan leaves from and rejoins at, so an edge is no longer drawn through the words that say what it means.--sb-card-widthsets the width of a block's card, which is what keeps the measured connector geometry from collapsing every edge onto one spine.
Changed
- Configuration's empty state is a box standing where the form stands, saying what selecting a block would let the author do, rather than the one-line sentence the other tabs use for having nothing to read.
- A required field is marked with the word
Requiredbeside its label instead of an asterisk on the end of it - the asterisk needed a legend the editor does not have and is read aloud as "star". - The Findings tab's count is a pill in the error hue rather than a tinted rectangle, and it is still the block's own findings, never the subtree's.
- The delete control on a card is revealed on hover, on keyboard focus and on the selected card, and is hidden at rest. It is still in the DOM and still focusable, so the keyboard path is unchanged.
- The card title reads as a title rather than as a native button, and the count badge, the subtitle and the invoke line are placed by a grid on the card's chrome.
- The per-block-type accent stripe is drawn on cards whose type declared an
accent_tokenand on no others. A type that declared nothing keeps a plain card; its icon tile is unchanged. - The document-level findings list is the drawer's Findings tab, beside Truth tables, and no longer a block of text under the canvas (operator ruling R4, 2026-08-29, under ADR-0005 ruling 1A: a list of findings is a grid of rows about the whole document). Each row carries the finding's severity, the block it is about - label and id - and the message, and clicking one selects and reveals that block. The inspector's Findings tab is unaffected and stays the selected block's findings (3A), as do the per-card counts.
- The collapsed drawer strip reports the active tab. An author who has
not picked a tab gets the first one that holds something, so a document
with findings and no fixtures reads
Findings 4rather thanTruth tables 0; once a tab is picked the pick stands. A host swapping the open document resets the pick along with the drawer's open state. StatifierBlocks.Shell.drawer_view/1accepts:tab,:findingsand:orphan_findings, and its result gainstab,tabs,findingsandorphans.titleandcountnow describe the active tab rather than the truth tables specifically; the truth-tablestatusvalues are unchanged.StatifierBlocks.Editor.Findings.findings/1takesfindings,orphans,rootandtargetinstead ofview_model, and renders the tab's panel rather than a headed section: the tab is the heading.- Slot labels are small, uppercased and letter-spaced - the treatment the fan pill and the join marker already carry for chrome that labels a structure. The transform is presentation only: the string a block type declares for a slot is unchanged, and every other reader still sees it as written.
- Concurrent lanes carry a rule in the block accent across the top of each
lane's header, drawn off
data-arrangement="lanes". The pill above saysALL OFonce; the rule is what carries that distinction down a document taller than one screen, where a set of lanes and a set of branch arms otherwise look alike. - An interrupt rail's dashed edge and its heading take the colour the connector layer already draws an interrupt edge in, so the rail and the edge leaving it read as one thing.
StatifierBlocks.Editor.PaletteBrowsertakes acollapsedattribute, and the editor answers apalette-collapseevent with one boolean and no hook, in the same shape as the shell amendment's other gestures. The fold is not reset when the host swaps the open document: it is a preference about the pane rather than state about the document.- The narrow arrangement (ADR-0005 ruling 7A) is unchanged. Below a container width of 780 the strip and its sheet are still the palette's whole chrome and the pane header stands down, so the fold has nothing to do there.
- A container draws a box around its body only when it is a boundary - a container with a slot in the rail partition (ADR-0005 decision 10c, as amended by 10h). Every other container draws none: its own card stays at the head of its body and its children sit under it with the connectors, where a box around each of them turned a deep document into nested rectangles.
- A boundary's box is the border, the radius and the inset that enclose its body and its rail, rather than the border colour it was before.
- A container's card carries its own border, its accent stripe and its selection ring, so the block is still a card on the canvas when the box around its subtree is gone. A leaf card is unchanged.
- A palette row is now a tile, a name, and the type's description on a second line, at a row height that gives the description room to wrap. The tile is a slot rather than an icon: a block type that declared no icon still renders the box, so every name in the list lines up.
- A row's accent moved from a stripe down its leading edge onto the tile, which is where the card the pick produces carries it. The stamp itself is unchanged - a type that declares no accent token still gets neither the attribute nor the custom property.
- A
core.waitmints its delayed send under the reservedsendrole, so a chart containing one now compiles tos_<block id>__sendwhere it compiled tos_<block id>__timer. - The package's default JavaScript export now carries both hooks, so
hooks: { ...StatifierBlocks }registersStatifierBlocksDragandStatifierBlocksMeasuretogether - a host that registered only the drag hook got an editor with no connectors and no error explaining it. Both names are still exported individually, andstatifier_blocks/measurestill resolves for a host that wants measurement alone. - The canvas stage renders inside a
.sb-canvas-panelelement, which is now the scrolling box;#sb-canvasstays the drag hook's element, the stage anchor, and where thethemeassign's declarations land. - A container with more than one body slot now lays its slots out side by side
and fans into them, as a container declaring
layout: :columnsalready did - a branch's arms no longer stack full-width with each fan edge running down through the arm above its target. - Columns are a CSS grid taking their natural heights, cards are a fixed width centred in the box they sit in, and a column's header is card-width and centred over the first card it governs.
- The "+" between two blocks is now the insertion marker: subtle at rest, highlighted on hover, on keyboard focus and for the whole of a drag, and drawn as a placeholder ring in a slot that is still empty. It is the same button with the same events, so nothing about the keyboard path changed.
StatifierBlocks.Editor.Slotstampsdata-empty, andStatifierBlocks.Editor.BlockNodestampsdata-containeranddata-arrangement, on the markup a host may style against.- The collapsed drawer's strip reads as a label and a quantity: a small-caps title with letter-spacing, and the count as a chip carrying a bare number rather than a parenthesised one inline with the title.
Removed
- The stub exit tick a rail drew below itself in CSS. The exit edge is measured and drawn now, and a fixed-length mark beside it was a second claim about the same thing that pointed somewhere else.
Fixed
- Leaving the scope around a
core.waitcancels the wait's delayed timer, so a wait abandoned before its duration elapses no longer leaves an armed timer behind in a durable host. - A
core.subchartoutcome-routing condition is no longer attributed to theoutcomesconfig field, so a chart finding landing inside it reportsfault: :packagewith noconfig_keyrather than blaming the author for bytes the compiler composed.
[0.3.0] 2026-08-29
Charts get more shapes to compile into. Campaign 015 adds four emitters to the
core.* vocabulary - core.subchart, which runs another chart and routes on
the outcome the child reported; core.foreach, a container whose body runs
once per item of a datamodel list; scope-correct cancellation for a delayed
core.send; and core.parallel's complete: "first", which finishes the
block at the first lane's completion and exits the losing lanes. The compiler
gains two root-document options, terminate: and declare:, a typed
datamodel index that refuses a document reading a path the host declared
sensitive, and predicator duration strings wherever a duration is typed. In
the editor, connectors graduate out of the spike, a default icon set ships so
a host needs no asset pipeline to get one, and the shell is laid out as the
arrangement record describes it. This is a minor bump because every block's
conventional <final> moves from s_<block>__done to s_<block>__o_done:
the compiled bytes of every outcome-bearing chart move with it, so a host that
stores compiled charts or provenance maps recompiles them, and a chart-level
position saved against the old bytes no longer resolves.
Dependency floor: unchanged - statifier ~> 2.2 and predicator ~> 9.0.
Added
- A
core.subchartblock type: a step that runs another chart and waits, routingdone.invokeon the outcome the child reported (_event.data.outcome) to one slot per declared outcome, withcore.invoke'son_errorslot unchanged for a failed invocation. - A
core.foreachblock type: a container whosebodyruns once for each item of a datamodel list, compiled as a plain SCXML loop - a per-loop cursor and a snapshot of the list taken once on entry, theitem_asandindex_asbindings re-assigned from that snapshot on each pass, and the body compiled once with an internal loop-back transition. Iteration ends on the out-of-bounds read,snapshot[cursor] === undefined. core.parallelaccepts acompleteconfig key choosing when the block is done:"all"(the default, and what a document stored before the key reads as) keeps the shipped rule, and"first"compiles the racing rule - one transition per lane on the<parallel>element itself, taken on that lane's own completion event and targeting the block's done final, so the block finishes at the first lane's completion and the engine exits the losing lanes with their<onexit>content and oneCancelInvokeper live invocation.- Block types may declare
outcomes/1, an optional callback returning ordered{name, label}pairs for the ways a block can finish; a type that does not export it has exactly one outcome,done, and behaves as before. - A child summary in the compiler context carries an
outcomesfield: the child's declared outcomes in declaration order, each with the<final>it compiled to and the completion event a parent wires on. It is never empty. StatifierBlocks.Compiler.compile/3acceptsterminate: true, which emits one top-level<final>per root-block outcome with no<donedata>, so a compiled root document reaches:donewhen its root block completes; without it a root document never terminates, and passing it together withchild_use: trueis refused with an:emitfinding.StatifierBlocks.Compiler.compile/3acceptschild_use: true, which compiles a document for use as another chart's child: the emission gains one top-level<final>per outcome the root block declares, carrying that outcome name as done data, so a parent session can see which way the child finished.StatifierBlocks.Compiler.compile/3takes a:declareoption - a list of{id, expr}pairs - so a host can declare the<data>roots its root document assigns to and guards on, hoisted ahead of block-declared roots into the chart's single<datamodel>.Compiler.compile/3takes a:datamodeloption and refuses a document that reads a path the host declaredsensitive?: trueinto a position the chart evaluates against the datamodel; with no datamodel supplied nothing is produced.- A block type may now contribute declared
<data>roots to the chart:StatifierBlocks.Compiler.DeclaredRoots.declare/2emits a declaration among the block's own children and the compiler lifts every one of them into a single top-level<datamodel>, in document order. A document that declares no roots emits no<datamodel>element, so charts compiled before this change are byte-identical. - A new Emit-stage finding,
:duplicate_binding: a declared root whose name a block it sits inside already declares is refused against the declaring block and the config field the name was typed into, because early binding makes both roots global and the inner one would silently overwrite the outer. - The compiler refuses a block type that declares a malformed or duplicated
outcome name with an
:invalid_outcomeEmit finding, against the block whose type declared it. - A chart-stage finding for an expression the author typed carries
config_value_span, the byte range of the offending sub-expression within that config value, so an editor can underline the sub-expression rather than the whole field. StatifierBlocks.Predicates.Datamodelindexes a datamodel document - the typed, three-scope declaration sb ADR-0006 defines - into a path/entry index: the type of a path, the entries under a prefix, whether a path is declared, and the record's one total derivation of the declared-path set. The index is advisory; an undeclared path is unknown, never wrong.StatifierBlocks.Datamodel.declared_paths/1accepts such a document as a fourth shape, alongsidenil, a list and aMapSet, and projects it through that derivation. A document declaring no entries normalizes to the empty set - a host claim - rather than tonil, which stays reserved for no datamodel at all.- The editor renders the arrangement ADR-0005's shell amendment records: a palette, canvas and inspector across three columns with a full-width drawer row beneath them, at container-query breakpoints of 1280, 1024, 900, 780 and 640.
- A canvas toolbar with stepped zoom, Fit width, Fit active, and the document's block count and depth.
- The inspector is tabbed - Config, Findings and Condition - where Findings is the selected block's own and Condition reads the per-arm predicator source.
- A drawer that is never open-or-gone: collapsed it is a strip carrying a title and the document's table count, and opened on a block with no table it shows an index of the blocks that have one.
fixtures, an assign carrying%{block_id => [TruthTable.t()]}, is what the drawer's truth-table tab reads; with none supplied the drawer is still present and reads 0.drawer_heightandon_drawer_resizeare the host's seam for the drawer's resizable height, which the host remembers per viewer.- A
:headerslot the host fills with the outer header - document identity, the switcher, the theme control, compile and publish - which this package now explicitly does not draw. - Below 780 the palette collapses to a strip that opens as a sheet.
StatifierBlocks.Shellexposes the shell's arrangement as pure functions - the zoom ladder, the document metrics, the drawer's five states, and which of a block's fields are conditions.- Three tier-2 theme tokens:
--sb-palette-width,--sb-inspector-widthand--sb-drawer-height. - The editor draws connectors. Adjacency inside a slot, a container's entry, the fan and rejoin around a container arranged side by side, and a rail's exit are rendered as SVG in the LiveView tree, derived from the document's shape rather than authored.
- A second JavaScript entry point,
statifier_blocks/measure, exporting theStatifierBlocksMeasureLiveView hook. Its whole job is measurement: after a render it reads the boxes the browser laid out for the anchors the server stamped and pushes them, and it issues no commands and mutates no DOM. A host that wants connectors adds one import; a host that does not gets the editor it had before, minus the drawn connectors. StatifierBlocks.Connectors: the connector geometry as pure functions from measured rectangles to SVG path data, outside the Phoenix guard, so a host can route its own connectors and a test can assert them without a browser.- Three
--sb-*tokens for the connector layer:--sb-edge,--sb-edge-interruptand--sb-edge-width. StatifierBlocks.Editor.Icons, a default icon set the editor uses when the host passes noiconcomponent. Inline SVG for the eleven names the core block types declare, with no font, no CDN and nothing to register in a host's asset pipeline. Every glyph paints withcurrentColorand fills its tile, so--sb-block-accentand--sb-block-accent-tintstill decide the colour and a per-block-typeaccent_tokenstill moves a type's tile with its stripe.- Palette entries render their icon. The
iconassign the editor passes the palette browser was declared and never rendered, so no host could put an icon on a palette row; a type now looks the same in the palette as on the card the pick produces. - A slot declaring
slot_style: :failurerenders in its own vocabulary - a solid error-family edge, its ownsb-slot--failureclass, and an ordinary flow edge where an interrupt rail draws a dashed escape. StatifierBlocks.ViewModel.exit_edge/1says which edge vocabulary a slot's exit is drawn in, and the editor stamps it asdata-exit-edge.- The editor takes an optional
datamodelassign - the paths the host declares - and reports a config field whose declared datamodel path is not among them as an:infofinding in the findings panel; with no datamodel supplied nothing is produced. - Block types may declare a config field with
datamodel_path?: true, saying its value is a path into the host's datamodel;core.assign'spathfield carries it. StatifierBlocks.Core.Parallel.join_label/1, declared on the type's palette entry, so a renderer draws "continue at first" or "continue when all" from the block's config without learning the type's name.StatifierBlocks.DurationInputreads a typed duration for that control, accepting exactly whatStatifierBlocks.Core.Durationcompiles and naming the limit a refused value hit.
Changed
- Every block's conventional
<final>moves froms_<block>__donetos_<block>__o_doneand now raisesdone.outcome.<state id>.doneon entry, so compiled SCXML moves for every document; a host that stores compiled charts or provenance maps recompiles them, and a chart-level position saved against the old bytes no longer resolves. Compiler.compiler_version/0(and every compilation record'scompiler_version) moves to0.3.0with the package, per ADR-0004 decision 6, and is the third input to the byte-determinism guarantee: this release is where the outcome-final byte movement is recorded.core.sendnow emits its<send>withid="<the block's state id>__send", so a delayed send can be named after it is armed.- A delayed
core.sendis now cancelled by its scope: the compiler emits<cancel sendid="..."/>in the<onexit>of the nearest enclosing<state>, so a pending send does not outlive the sequence, group, region or lane that armed it. Charts containing a delayedcore.sendchange bytes; every other chart is unchanged,core.waittimers included. core.waitaccepts a predicator duration string (1h30m,2d,3d8h) as well as ISO-8601, stores whichever spelling the author typed, and compiles it to the emitteddelayattribute; its refusal message names both spellings.core.wait's declareddurationdefault is now the predicator string1hrather than the ISO-8601PT1H, so a newly inserted block starts from the spelling an author types. Both spellings stay accepted and each compiles to the samedelayattribute, so no chart's emitted SCXML changes and no stored document has to be retyped.- A
:durationconfig field renders as one text control taking predicator duration strings, with30s,15m,1h30m,2dand3d8hshown beside it; ISO-8601 is still accepted and the author's string is stored verbatim. - An empty
:durationfield omits its config key rather than storing an empty string, so a cleared field and a never-set field are the same value. - An icon entry that declares no icon renders no tile, rather than an empty
one, and a host's
iconcomponent is never called with anilname. - An icon name the shipped set does not have renders a neutral mark with the
name in
data-icon. - A
slot_stylethis editor does not recognize renders as an ordinary body slot instead of reaching the markup unresolved; its children are still rendered, still selectable and still saved.
Removed
StatifierBlocks.Editor.Field.units/0,format_duration/2andparse_duration/1, which served the retired value/unit control. CallStatifierBlocks.Core.Duration.to_iso/1to canonicalise a stored duration.
Fixed
- The editor no longer renders a
U+25A1white square in every icon tile when the host passes noicon. Passing one still overrides every tile, on the canvas cards and the palette rows alike. - A delayed
core.sendin the body of acore.groupthat carries interrupt rules is now cancelled when the group is abandoned. Its<cancel>is emitted in the body region's<onexit>rather than the group's own, and abandoning the group exits the region without exiting the group, so the old placement never fired.
Known limits
- A
core.foreachlist holding anilitem iterates to its end rather than stopping at it:===is strict, so only an out-of-bounds read isundefined. - Two
core.foreachblocks in one document may not bind the same name, even when neither is inside the other; the second is refused with a duplicate-id finding on itsitem_asfield.
0.2.0 2026-08-29
The editor ships. Campaign 014 graduated the authoring spike into the
package: StatifierBlocks.Editor renders from assets/ with a documented
--sb-* theming surface, a drag marks the slots that accept a block and can
say why a slot refused, and a host registers its own block types through
Palette.from_modules/2. The core.* vocabulary grows by core.invoke,
core.raise, core.assign and core.send; the compiler now evaluates
predicator conditions, refuses slot-arity and undeclared-slot violations, and
adapts its findings into the shape the editor renders.
Dependency floor: statifier ~> 2.2 (was ~> 2.0); predicator ~> 9.0 is
now a direct dependency.
Added
core.raiseraises an event for an enclosing group's interrupt rules.core.assignwrites a literal to a datamodel path.docs/theming.md: the theming guide - the three tiers of the--sb-*surface, the scheme token, per-block-type accents, and a complete host theme that sets custom properties and nothing else.core.sendsends an event, now or after a delay.- A block type may declare
slot_outcome_keyin its palette entry, naming the config key the blocks in one of its slots carry their outcome under, so a renderer can route an interrupt rule's escape without branching on a type name; the declaration reaches the view model asSlot.outcome_keyand the resolved value asNode.outcome. StatifierBlocks.BlockType.slot_outcome_key/2andStatifierBlocks.BlockType.outcome_name/2read that declaration totally: a malformed declaration or value is refused rather than repaired, and reads as no declared outcome.- A block type may declare
accent_token, the NAME of a--sb-*property, and the editor stamps it on that type's cards and palette rows. Two rules in the stylesheet read it; no rule and no module names a block type (ADR-0005 amendment 14d, consumption side). StatifierBlocks.Finding.severity_class/1, and:infoas a third severity for advisory findings (decision 11, amended 2026-08-29). Nothing emits one yet; only:lintmay.- A form whose config the gate has not accepted names the fields that are outstanding, says why nothing is stored, and offers "Discard edits". A draft was never a command, so it cannot be undone - it can only be thrown away, and that gesture had nowhere to live.
:expressionand:durationcontrols carry a placeholder. A bare:stringstill carries none: there is nothing a type that wide can suggest.- A theme audit test over the stylesheet, failing in both directions: a
var(--sb-*)with no declaration, and a declared token no rule reads (14e). StatifierBlocks.SlotValidation, a palette-aware whole-document check for a block's declared slots (:undeclared_slot) and each declared slot's arity (:slot_arity_violated).StatifierBlocks.Predicatesevaluates a condition expression against a binding context through predicator, returning a boolean or a tagged error.StatifierBlocks.Predicates.TruthTablebuilds a checked truth table over fixture rows, applying first-match-wins arm ordering.StatifierBlocks.Finding.from_compiler/2andfrom_compiler_all/2adapt a compiler finding into the presentation shape the editor renders, so a host can route compile findings throughViewModel.build/3.StatifierBlocks.Assignability.seam_reason/4,finding_reason/2andseam_reasons/3name why a data-flow seam came out the way it did::not_assignableand{:fixable_by, block_id}for a refusal, and:source_untyped/:target_untyped/:both_untypedfor a seam that passed only because a block declared no type.seam_reasons/3is how a host finds the parts of its palette it has not typed yet.StatifierBlocks.Assignability.target_verdicts/4returns every positionvalid_targets/4enumerates with its full verdict, andStatifierBlocks.Edit.Targets.slot_verdicts/3projects those to slots - the accepting ones and the reason each refusing one gives.- The editor stamps a refused slot's reason as
data-drop-reasonbesidedata-drop, so a hover affordance can explain a refusal with no round-trip and no JavaScript. - A
core.invokeblock type: it names an invoke type for the host to run, sends datamodel values along as<param>s, writes the result where itsassign_tonames, and takes an optionalon_errorsubtree entered on a permanent invoke failure. StatifierBlocks.Compiler.Context.outcome_id/2andoutcome_event/2, for a block type with more than one way to finish: one<final>per outcome, and thedone.outcome.<state id>.<outcome>event a parent wires on.StatifierBlocks.Palette.from_modules/2, the registration API a host uses to contribute its own block types: an ordered, explicit list of{type_name, module}registrations, withcore: trueto sit on top of thecore.*vocabulary. Later entries win. It is still a value - no global registry, no application-configuration lookup, and no discovery pass.- A palette entry may declare
badge, a short chip for the card header, andjoin_label, a one-argument function of the block's config phrasing the join marker under a side-by-side arrangement (ADR-0002 amendment B).StatifierBlocks.BlockType.badge/1andjoin_label/2read them. - Both readers are total and refuse rather than repair: a chip that is
blank, carries a newline or tab, or runs past 24 characters is dropped,
not clipped, and a
join_labelthat raises degrades to the editor's own word rather than taking the canvas down. accent_token,badgeandjoin_labelare admitted keys ofStatifierBlocks.BlockType.palette_entry/0.- The README carries a worked host example - a
myapp.risk_holdblock type registered beside the core vocabulary, with a badge and an accent token - and it is executed on every build rather than trusted.
Changed
Compiler.compiler_version/0(and every compilation record'scompiler_version) moves to0.2.0with the package, per ADR-0004 decision 6: a record compiled by 0.1.0 identifies itself as such.--sb-drop-ok-bordermoves from#2f9e5fto#2c945a. The outline that says a slot accepts a drop was 2.93:1 on the sunken surface, under the 3:1 a mark carrying information is held to; the tint follows it.predicatoris now a direct dependency (~> 9.0), becauseStatifierBlocks.Core.DurationcallsPredicator.Duration.parse/1. It already resolved transitively throughstatifier, so the resolved version does not move; naming it records the call.- The editor's stylesheet carries a scoped reset, and every selector in it
matches the container through
:where(.sb-editor)so a component rule always wins (ADR-0005 amendment 14b). --sb-color-schemeis declared and read ascolor-schemeon the editor's own container, so the parts of a control the browser paints - a<select>'s drop-down, the scrollbars, the caret - follow the theme (14a).- The
--sb-*surface gains the space, type and shape scales, a third text step, a strong border, status tints, the drag seam's drag-time height, and the canvas sizing constants that were literals in rules. - A
:secondaryand a:failureslot are both placed as attached rails, and a container declaring either is drawn as a boundary box - the rail partition, not the:secondarypartition (amendments 10c and 10h). --sb-drop-no-opacityis retired. A drag now marks the slots that accept the block and leaves the rest alone rather than dimming them; the disabled-control opacity it doubled as is--sb-disabled-opacity.- The compiler now refuses a document whose slots violate their declared arity or name a slot the block type does not declare, instead of silently dropping those children from the emission.
- Reasons change no verdict:
:unknownstays permissive in both positions,Assignability.validate/3reports exactly the findings it did before, and neither finding tuple gained a field. slot_styleadmits a third value,:failure, for a slot whose children are an in-band continuation taken on a bad outcome;core.invokedeclares it foron_error.- The role namespace beginning
o_is reserved for outcome finals;Context.role_id/2now refuses such a role with a:reserved_rolefinding.
Fixed
- The editor's root rule sets
font-familyrather than thefontshorthand, so--sb-fontreaches the editor.font: <family-list>is not a valid shorthand, so the whole declaration was dropped and the editor's text did not inherit the host page's font as the token promised.
0.1.0 2026-08-27
First release: the authoring layer above the
statifier statechart engine. A block
document is the source of truth - a tree of typed blocks, each with a declared
shape - and it compiles one way to SCXML plus a provenance map that points a
runtime position back at the block that produced it. Block types are
host-pluggable: a host registers the types its own domain needs, and the
compiler and the editor work off that registry rather than a closed built-in
vocabulary. The core.* structural vocabulary, the compiler, the edit algebra,
and the LiveView editor shell all ship here.
The Changed entries below describe the shape of callbacks and metadata as
they stand at this first release; there is no earlier published version to have
changed from.
Added
StatifierBlocks.Document.validate/1checks a block document's structure: schema version, envelope shape, per-block shape, and document-wide id uniqueness.StatifierBlocks.Document.to_json/1encodes a document to ADR-0001's deterministic canonical JSON: sorted object keys, no insignificant whitespace, emptyslots/config/metadataomitted, no floats.StatifierBlocks.Document.content_hash/1returns a"sha256:" <> hexdocument identity overto_json/1's canonical bytes.StatifierBlocks.Document.from_json/1decodes canonical JSON back into a document, structurally and registry-free: unknown block types decode successfully, and every refusal is one of ADR-0001's typed error arms.StatifierBlocks.BlockTypebehaviour: the nine-callback authoring-time extension seam (ADR-0002), five required (slots/1,config_schema/1,validate_config/1,current_version/0,emit/2) and four optional (io/1,migrate_config/2,fixtures/0,palette_entry/0).StatifierBlocks.Palette: a caller-suppliedtype_name => modulevalue (ADR-0002 decision 2), withnew/1to build one and a totalfetch/2that returns{:ok, module}or{:error, {:unknown_block_type, type_name}}and never raises (ADR-0002 decision 3).StatifierBlocks.Palette.resolve/2: resolves a block through the palette and migrates its config in memory when the storedtype_versionis below the type'scurrent_version/0(ADR-0002 decision 8). Migration is applied to the returned struct only and never written back to a document; a stored version abovecurrent_version/0hard-errors as{:error, {:block_type_too_new, id, version}}rather than reading best-effort, and a failing or missingmigrate_config/2surfaces as{:error, {:migration_failed, id, reason}}.- The
core.*structural block types (ADR-0002 decision 10), oneStatifierBlocks.BlockTypemodule each:StatifierBlocks.Core.Sequence,.Group,.Branch,.Parallel,.Wait,.ResumableGroupand.OnEvent.core.branchderives one slot and one:expressionfield per declared arm,core.parallelone slot per declared lane, and each type is the authority on its own config throughvalidate_config/1. StatifierBlocks.Palette.core/0andStatifierBlocks.Palette.core_types/0: the core vocabulary as a palette, and as the plaintype_name => modulemap a host merges its own entries into.- Structural placement through ADR-0003 decision 3 kind tags:
core.on_eventdeclareskinds: [:interrupt_handler]and the group types accept only that kind in theirinterruptsslot, so an interrupt handler is admitted there and refused everywhere else, and an ordinary step is refused there - in both directions, from the declarations alone, with no special-cased rule. fixtures/0oncore.branch(an arm condition evaluated against two datasets) andcore.on_event(one example event payload). The bundle shape follows an amendment to ADR-0002 decision 9 that is not yet accepted, and is documented as provisional until it is.StatifierBlocks.Assignability: the one decision function for whether a block may land in a slot, checking structural admission by kind tag and data-flow compatibility by type-expression identity plus an optional host-supplied widening relation (ADR-0003).check/5decides a single candidate position;valid_targets/4lists every position a candidate may occupy in a document;validate/3reports every finding already present in a document;inbound_type/4andassignable?/3are the two primitives both are built from.StatifierBlocks.Assignability.Relation: the behaviour a host implements to widen data-flow compatibility beyond exact type-expression identity. A host module can only grow the accepted set, never shrink it.StatifierBlocks.Palettegains anassignabilityfield naming the host'sAssignability.Relationmodule, set viaPalette.new(types, assignability: MyApp.Blocks.Types). Defaults tonil, meaning no widening relation is declared; existing calls toPalette.new/1are unaffected.StatifierBlocks.Compiler: the one-way compile (ADR-0004 decisions 1-4, 6-7).compile/3is a total function of{document, palette}returning{:ok, %StatifierBlocks.Compiled{}}or{:error, [%StatifierBlocks.Compiler.Finding{}]}- no process state, no clock, no IO, and no arm that raises. The pipeline runs Document, Resolve, Config and Emit, stopping at the first stage that produces errors and reporting every error from that stage.StatifierBlocks.Emission: the structural representation of one SCXML subtree a block type returns fromemit/2, withelement/3and thechild_ref/1placeholder the compiler splices its children into.StatifierBlocks.Compiler.Serializer: the deterministic serializer. Attributes sorted, one canonical empty-element form, no incidental whitespace at all. It is identity-bearing code - chart identity hashes source bytes (st-ADR-0052) - andserializer_test.exsnow enforces the whitespace sensitivity ADR-0004 decision 6 named and left unenforced.StatifierBlocks.Compiler.StateId:state_id/1,state_id/2,unstate_id/1anddone_event/1. State ids derive from block ids ("s_" <> block_id,"__" <> rolefor an auxiliary state), so they are unique, invertible and total over generated states.StatifierBlocks.Compiler.Context: what a block type is entitled to know while emitting - its own ids, the document id, its children's summaries (block id, state id, done event) and the role-minting function. No palette, and no child's emitted SCXML.StatifierBlocks.CompiledandStatifierBlocks.CompilationRecord: the artifact, and the join between document identity and chart identity.chart_namecarries the document id andchart_versionstaysnil, so a revision bump or a metadata-only edit still matches the identity a running session holds.StatifierBlocks.Core.Emit: the SCXML shapes thecore.*vocabulary compiles to, and the builders a host block type follows to compose with them.StatifierBlocks.Provenance: the map from generated SCXML back to the blocks that produced it (ADR-0004 decision 5). Keyed by state id for highlighting a running session's configuration, and by byte span for routing findings that carry no element reference.owner_at/2,owner_of_state/2,owners_of_states/2, and canonicalto_json/1/from_json/1so a host can store the map beside the chart.StatifierBlocks.Compilednow carries all five of ADR-0004 decision 1's fields:provenance,invoke_typesandwarningsjoinscxmlandrecord.invoke_typespublishes the sorted set of invoke types the chart emits, unconditionally, so a host can compare it against itsStatifier.Sessionregistration at deploy time (ADR-0004 decision 8).Compiler.compile/3accepts:known_invoke_types, an opt-in lint that warns - never errors - for every emitted invoke type absent from the set the caller believes will be registered.Compiler.compile/3accepts:entry_type, ADR-0003 decision 4's caller-supplied context, which the new Structure stage passes toStatifierBlocks.Assignability.validate/3.- The compiler now runs a Structure stage (assignability) and a Chart stage (statifier's own pipeline over the generated bytes), and maps every upstream finding back to the block that caused it.
StatifierBlocks.Emission.attributed_to/2,from_config/2andattribute_from_config/3: the hints a block type leaves so a finding lands on the block an author would recognise, and on the config field they typed into.StatifierBlocks.Edit: the editor's command algebra - insert, remove, move, and update-config - as a purely structural, invertible rewrite over a document with no palette involved (ADR-0005).apply/2applies one command and returns both the new document and the command that undoes it;check_config/3is the separate config-validity gate one layer up.StatifierBlocks.Edit.History: undo and redo overEditcommands.commit/4is the one funnel a host calls - it runsEdit.check_config/3beforeEdit.apply/2, then pushes the inverse and clears the redo stack, so invalid config never reaches the document on any path, undo and redo included.StatifierBlocks.Edit.Targets:droppable_slots/3anddroppable_slots_for/3, which slots would accept a dragged block, at slot granularity rather than gap granularity, built as a reduction ofStatifierBlocks.Assignability.valid_targets/4.StatifierBlocks.Finding: the presentation finding ADR-0005 specifies, anchored to a block, a slot, or a config field so the editor knows where to render it. Distinct from the existingStatifierBlocks.Compiler.Finding, which serves the compile pipeline.StatifierBlocks.ViewModel: the structure the editor actually renders, derived from a document, a palette, and a list of findings. Resolves and normalizes every block's slots, form fields, and palette presentation metadata, and routes every finding to the position that renders it.StatifierBlocks.Editor: the LiveView editor shell (ADR-0005). ALiveComponenta host embeds over a%Document{}and a%Palette{}; it is the only stateful module in the package's rendered half, and everything it does is translate aphx-event into one ofStatifierBlocks.Edit's four commands. Drag is two round-trips - one atdragstartto enumerate valid slots, one atdrop- with zero per hover, because validity reaches the client asdata-dropmarkup rather than as client-side logic.StatifierBlocks.Editor.Canvas,.BlockNode,.Slot,.ConfigForm,.Field,.PaletteBrowser,.Findings: the function components the shell renders, each independently renderable in a test.BlockNodeandSlotrecurse into each other, and there is no per-block-type component: a block type'slayoutandslot_stylepresentation metadata is the only thing that distinguishes a group from a set of lanes.assets/js/statifier_blocks.js: the package's entire client-side surface, one hook namedStatifierBlocksDrag, shipped as source. A host adds"statifier_blocks": "file:../deps/statifier_blocks"to itsassets/package.jsonand imports the hook inapp.js; this repository bundles nothing and has no Node toolchain.assets/css/statifier_blocks.css: one stylesheet of structural CSS and no visual opinion beyond it. Every class is prefixedsb-, every color, space, radius and drag treatment is a--sb-*custom property with a default, and every top-level component takes aclassattr appended to its own.- A headless CI job that resolves the dependency tree with
phoenix_live_viewabsent, compiles it with warnings as errors, and runs the non-LiveView suite- the acceptance property that makes the optional dependency's guard
trustworthy rather than decorative.
STATIFIER_BLOCKS_HEADLESS=1reproduces it locally without disturbing the ordinary build.
- the acceptance property that makes the optional dependency's guard
trustworthy rather than decorative.
StatifierBlocks.BlockType: a config field declaration may now carry an optionalvalue_path, a list of keys and list indexes from the config root down to the value it edits (ADR-0002 decision 7, amended 2026-08-27). A declaration without one behaves exactly as before - itskeyaddressesconfig[key]. Thekeyremains the field's identity in both cases: the DOM id, the form param name, and what a{:config, block_id, key}finding anchors to.StatifierBlocks.BlockType.value_path/1,fetch_value/2andput_value/3: the reader and writer that resolve a declaration to a path and then read or write through it.value_path/1answers[key]for a declaration that declares none, so a caller never branches on which case it has.fetch_value/2is total and answers:errorfor a path that does not resolve.put_value/3writes the last segment whether or not a value was already there - an arm with no condition yet is exactly the one an author is about to type into - but never invents an intermediate map or list a block type did not write.StatifierBlocks.ViewModel.Fieldcarriesvalue_path, andViewModel.Field.value_path/1reads it with the same[key]default.
Changed
StatifierBlocks.BlockType.io/1's return type isStatifierBlocks.Assignability.io/0instead ofterm(). Every core block type already returns a value of this shape; a custom block type implementingio/1should confirm its return value conforms.- All seven
core.*block types implementemit/2for real; the{:error, {:not_implemented, block_id}}placeholder andStatifierBlocks.Core.Config.emit_deferred/1are gone. StatifierBlocks.BlockType.emit/2is narrowed from(Block.t(), term()) :: {:ok, term()} | {:error, term()}to(Block.t(), StatifierBlocks.Compiler.Context.t()) :: {:ok, StatifierBlocks.Emission.t()} | {:error, StatifierBlocks.BlockType.emit_error()}. A host block type that was returning something else now has a type to conform to.StatifierBlocks.Compiler.Findinggainspath,severity,faultandcode.faultis:authorwhen a document edit fixes the finding and:packagewhen it is a bug in this package or a host's block type - which is what lets an editor say "this cannot be fixed here" rather than blaming the author for a generated state id.- Findings from every stage come back in document order over blocks rather than in the order a stage happened to collect them.
- A bad
:expressionconfig field now surfaces as an:authorfinding naming the arm's config key, rather than as an unrouted upstream error. StatifierBlocks.BlockType.palette_entry/0's return type isStatifierBlocks.BlockType.palette_entry/0instead ofmap(). Every core block type already returns a value of this shape; a custom block type implementingpalette_entry/0should confirm its return value conforms.phoenix_live_viewis a declared optional dependency at~> 1.0, matching statifier_ui's floor. Every module underStatifierBlocks.Editor.*is compiled behindCode.ensure_loaded?(Phoenix.LiveView), and no module outside that namespace references Phoenix - so a host that only compiles documents adds no Phoenix dependency and compiles no editor code.- The hex package's
files:list includesassets. The hook and the stylesheet ship as source, and source that is not in the tarball is not public API. - Every illustrative example in the package - ADR worked examples, doc examples, and the shipped fixture bundles - uses one of the family's two canonical example domains: credit-card authorization and capture, or a signup wizard with A/B testing.
StatifierBlocks.Core.Branch.fixtures/0ships budget-decision datasets ("approved"/"declined") and the expression"budget_remaining > amount". A host rendering the bundle in a palette panel sees those names.- The arm-slot and lane-name validation messages on
core.branchandcore.parallelname"arm_approved"and"capture"as their exemplars.
Fixed
- A
core.brancharm's condition is now readable and editable in the editor.Core.Branch.config_schema/1keys one:expressionfield per arm by the arm's slot name, but the condition is stored atconfig["arms"][i]["cond"]; the form previously read and wrote the slot name as a top-level config key, so every branch condition rendered empty, no edit to one reached the arm, and a junkconfig["arm_approved"]accumulated beside it. Each per-arm field now declaresvalue_path: ["arms", i, "cond"], andStatifierBlocks.ViewModelandStatifierBlocks.Editor.ConfigFormread and write through it.iis the arm's index in the stored list rather than its index among the well-formed ones, so a good arm below a malformed one still addresses its own condition while an author is mid-edit. StatifierBlocks.Editor'sfield-list-addandfield-list-removeevents read and write the rows through the field'svalue_pathas well, rather than through the top-level key. A key naming no field in the selected block's schema now edits nothing, matching the guardConfigForm.decode/3already applied.StatifierBlocks.Assignability.check/5andvalid_targets/4no longer raise aMatchErrorwhen the candidate is the document root.Document.fetch_path/2answers{:ok, []}for the root, and the vacated-seam check now reads that as what it is - the root occupies no slot, so it leaves no seam behind - instead of callingList.last/1on the empty path.StatifierBlocks.Edit.Targets.droppable_slots/3answers[]for the root rather than crashing, so a caller no longer has to guard around it.