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.22.0 2026-09-06
0.22.0 makes failure a first-class outcome across the block vocabulary, and
widens what a document can declare. core.invoke now declares the two
outcomes it has always emitted, done and error, and classes error as a
failure; every host type built on StatifierBlocks.InvokeStep classes its
own error the same way by default, and opts out with failure_outcomes/1
returning []. core.invoke, core.map and core.subchart emit their
error outcome's <final> whether or not the failure slot is occupied, and
a failure-classed outcome a block below the document root leaves unhandled
now reaches the document's own ending, through one shared top-level <final>
carrying the reserved statifier_persistence:run_status donedata param.
Beside that, core.on_event takes an optional payload declaration naming a
type the datamodel document declares, and a capture pair reading a member
that payload does not carry is refused at compile; core.map's collect
accepts any datamodel path rather than only a bare identifier; and the editor
takes a compile_options assign, so the recompile behind the Run pane's
marks resolves against the chart the run is a run of.
It is a minor, and a minor with notes. Compiled bytes move for three kinds of
document: one containing a core.invoke, core.map or core.subchart whose
failure slot is empty; one with a failure-classed outcome left unhandled
below its root, compiled under child_use: true or terminate: true; and
one containing a host type built on InvokeStep. Each of those is a new
chart revision. Every other document compiles to the bytes it compiled to at
0.21.0. ADR-0002's amendment of 2026-09-06, section 6, works through what
this costs a host and what it does not. The statifier_datamodel floor also
moves to ~> 0.3; a host on 0.1 or 0.2 updates it with the rest of the
dependency tree.
Added
core.on_eventtakes an optionalpayloadconfig key: the name of a type the datamodel document declares, saying what_event.datacarries for the event that handler names. With one declared, acapturepair whose source path reads a member the payload does not carry is refused at compile - one:configfinding on thecapturekey, naming the pairs and the payload - so the interpreter's unbound marker is never written for a captured path on a typed document. A handler with nopayload, apayloadnaming a type the document does not declare, and a compile with no:datamodelare all unchanged: no new finding, and the same compiled bytes.payloaditself emits nothing.The editor takes a
compile_optionsassign: the option list the host compiled the open document with, forwarded to the recompile behind the Run pane's marks so a run of aterminate: truechart resolves against the chart it is a run of.A failure-classed outcome a block below the document root leaves unhandled now reaches the document's own ending: under
child_use: trueorterminate: truethe compiler emits one shared top-level<final>carrying the reservedstatifier_persistence:run_statusdonedata param, and one transition into it per unhandled pair.core.invokedeclares the two outcomes it has always emitted,doneanderror, so a parent may wiredone.outcome.<state id>.errorand the editor offers both on the outcome-event candidate list.
Changed
core.map'scollectaccepts any datamodel path, not only a bare lowercase identifier, so a fan-out can assemble its answers atcards.batchand not only atbatch. All four fields this package writes an<assign location="...">from now read one grammar and one refusal wording.core.invokeclasses itserroroutcome as a failure, and so does every host type built onuse StatifierBlocks.InvokeStep. A host whoseerroris routine definesfailure_outcomes/1returning[]beside itsoutcomes/1to keep the old classing.core.invoke,core.mapandcore.subchartemit theirerroroutcome's<final>whether or not the failure slot is occupied; with the slot empty the failure transition targets that final directly instead of being selected by nothing. A document containing one of the three with an empty failure slot, one with an unhandled failure below its root under the two compile options, or a hostInvokeSteptype, compiles to different bytes and so is a new chart revision; every other document is byte-identical.The
statifier_datamodelfloor moves to~> 0.3, so a datamodel entry whosetypenames a declaration contributes the declaration's fields as declared paths beneath its own: they are offered as expression candidates, they carry their declared types in the Datamodel tab, and a block that writes one gets no undeclared-path advisory, exactly as an inlinedobjectentry'sfieldsdo. A host onstatifier_datamodel0.1 or 0.2 updates it with the rest of the dependency tree.The Datamodel tab renders an entry typed by a declaration as the name it names, rather than raising on the
{:declared, name}that release added.
0.21.0 2026-09-06
0.21.0 makes the editor a debugger and widens what a block can say. The
drawer gains a Source tab - the compiled SCXML with every run of bytes
traced back to the block that emitted it, and a click on a config-emitted
span opening the Config tab on the field it came from - and the canvas
takes a seat in a run pane, with statifier-ui's status, scrubber and event
log around it and a send control that pushes a block type's fixture events
straight into a live session. core.branch declares a third slot,
undecided, for an arm whose condition cannot be decided (ADR-0012); a
block type may class one of its outcomes as a failure, and the compiler
stamps a reserved <donedata> param on that outcome's final so a durable
stepper can tell a chart that finished badly from one that merely finished
(ADR-0008's accepted amendment); a host can state a rule about a whole
document through validate_document/1 and the palette's new :validators
list; core.invoke's and StatifierBlocks.InvokeStep's assign_to take
any datamodel path and declare the path they write; and core.map names
what a child run sees its item and its position under, through item_as
and index_as.
It is a minor, and nothing stored is invalidated by it: a core.branch
that leaves undecided empty, and every type that classes no outcome as a
failure, compile to the bytes they compiled to at 0.20.0. statifier_ui
is now an optional dependency at ~> 0.9, the release that added the
state-id reads StatifierBlocks.Runtime.Marks.from_trace/2 composes;
statifier_datamodel stays at ~> 0.1.
Added
core.branchdeclares a third slot,undecided, labelled "Cannot be decided". A branch that puts children in it emits one extra transition, after every arm and beforeotherwise, taken when an arm's condition could not be decided - a comparison predicator answers with its undefined sentinel rather thantrueorfalse, such as a path missing under a bound datamodel root, or operands whose types do not match.The drawer has a Source tab: the compiled SCXML as numbered lines, with the block that emitted each run of bytes, and a click on any run selecting that block.
StatifierBlocks.SourceViewbuilds that listing from a document and a palette, without the editor.The editor takes a
run_session- aStatifier.Session.server()- and the Run pane draws a send control over it: one button per event in the selected block's type's fixture sample, sending straight into the session through statifier-ui'sEventInjection.The send control is enabled only over a live stream with a session supplied; over a persisted run, or with no session, every button renders disabled with a one-line note, and a send never writes to the document.
StatifierBlocks.Runtime.Marks.from_trace/2turns a statifier-ui trace read model and a provenance map into the run marks the editor canvas already accepts, so a host observing a run marks the blocks a configuration is inside without naming them itself.A block type may class one of the outcomes it declares as a failure through the new optional
failure_outcomes/1callback, and the compiler emits a reserved<donedata>param -statifier_persistence:run_statuswith the valuefailed- on that outcome's top-level<final>under both the:child_useand the:terminatecompile options, so a durable stepper can tell that a chart finished badly rather than merely finished.core.mapandcore.subchartclass theirerroroutcome; every other type classes nothing.A host can state a rule about a whole document, not just about one block's config:
StatifierBlocks.DocumentValidatoris the behaviour,validate_document/1its one callback, and a module implementing it goes in the palette's newvalidatorslist (StatifierBlocks.Palette.new/2's:validatorsoption, defaulting to[]).A validator says where and what -
{anchor, message}or{anchor, message, severity: ...}on decision 11's existing anchors - and the package stamps the source:lintand defaults the severity to:warning. The findings render in the editor's Findings drawer tab and count towardStatifierBlocks.Editor.findings_count/3like every other finding.core.mapdeclaresitem_asandindex_as- the names a child run sees its item and its position under, defaulting toitemand to no position name - and carries both into its<invoke>beside the list's path, so a child recipe reads what the author named rather than whatever the fan-out handler chose.Clicking a config-emitted span in the Source tab opens the Config tab and focuses the field it came from; a span with no owning field still selects the block as before.
The editor takes a
run- statifier-ui'sStatifierUI.Live.State, live or persisted - and seats the canvas in a run pane: statifier-ui's status and scrubber above it, its event log below, and no Mermaid diagram. Scrubbing or clicking a log entry moves the canvas's run marks, and clicking a log entry also selects the block whose state handled that step.While a run is seated the Datamodel drawer tab's "what is known here" table shows what the run held at each path, beside the type the position declares.
StatifierBlocks.Runtime.Handled.block/3answers which block's state handled one macrostep of a run, andStatifierBlocks.Runtime.RunValues.at/1what a run held at its selection; both are pure and neither needs statifier-ui to be present.StatifierBlocks.Runtime.Selection.scrub/2andselect/2move a run's selection, which is what the pane's two events do.
Changed
A
core.branchthat leavesundecidedempty compiles to exactly the bytes it compiled to at 0.20.0, so every stored document is unaffected until its author wires the slot.statifier_uiis now an optional dependency at~> 0.9, the release that added the state-id readsfrom_trace/2composes; hosts on0.8upgrade the package to use the new module and are otherwise unaffected.A document whose root block is a
core.mapor acore.subchart, compiled withchild_use: trueorterminate: true, gains the reserved param on itserrorfinal. Its content hash changes with it, so it is a different chart revision under statifier-ex ADR-0052 - the same one-time choice opting intoterminatealready is. Every other document compiles to the bytes it compiled to.A palette entry's
singletondeclaration is now derived through the same document-rule path a validator's findings take, and runs first among them. Its findings are unchanged: still:config, still:error, still anchored at the root.A
core.mapcompiled before this release gains anitem_asparam carrying the default name; a stored config that never had the key still validates and needs no migration.core.invoke'sassign_tois declared as a datamodel path field, so the editor offers the host's declared paths as candidates on it and a path it does not declare draws the usual advisory rather than a refusal.core.invoke's andStatifierBlocks.InvokeStep'sassign_toaccept any datamodel path, dotted or not, in place of a bare lowercase identifier - the rulecore.assignandcore.subchartalready accept for the same<assign>element.core.map'scollectis unchanged.
Fixed
- A path
core.invokewrites is visible to the environment: it was emitted without being declared, so nothing downstream saw the write.
0.20.0 2026-09-06
0.20.0 is about types. The datamodel document's declarations become
something the whole package reads: StatifierBlocks.Environment carries the
path-to-type map at any position in a document with one pre-order walk,
every core.* block type declares what it reads and writes at datamodel
paths, and the data-flow check is defined over that environment rather than
over the block immediately before this one. The path/type index itself moved
out to the statifier_datamodel package, taken from Hex at ~> 0.1; the
drawer's Datamodel tab answers what is known at the selected block and lists
the declared records and shapes; and the condition editor is handed the
declared kinds, so a clause on a path declared integer offers the numeric
operators.
It is a minor with two breaking edges to read before upgrading.
StatifierBlocks.Predicates.Datamodel is gone: its functions are
StatifierDatamodel.Index's, under the same names, so a caller swaps the
module name and nothing else. And a type expression spelled exactly
unknown now reads as the permissive :unknown rather than as an opaque
expression that happens to be spelled that way - it only ever admits, so
nothing that passed before is refused now. The signature changes under
Changed keep their arities; {:type_mismatch, ...} gains a sixth member
naming the datamodel path the read was checked at.
Added
The kinds a datamodel document declares reach the condition editor. With a document supplied,
StatifierBlocks.Datamodel.path_types/1projects it to the expression language's own value kinds and the editor hands that map to statifier-ui's expression editor, so a clause on a path declaredintegeroffers the numeric operators and one on adatepath the date set, rather than whichever set its current source happens to imply. It draws a control and decides nothing: the operator the source carries is still offered, the value in it is still kept, a disagreement renders as an advisory beside the clause, and a path the document does not declare renders exactly as it did. A host that supplies its ownexpression_componentis handed the same map.A
:type_mismatchfinding names a declared type by its label. A pair of names the datamodel document'stypeskey declares reads as the two human names an author recognises; a pair of opaque spellings a host carries reads exactly as it did before there were declarations. The rule is one function,StatifierBlocks.Environment.type_label/2, so the finding and the editor cannot disagree about what a type is called.The drawer's Datamodel tab answers what is known here: the paths the environment holds at the selected block's position, with their types, as computed by the pre-order walk at that exact position - before the block's own writes land. Nothing selected and nothing known are two different states and the panel says which it is.
The same tab lists the declared records and shapes, each with its ordered fields, their types and their required marks, through
StatifierBlocks.Datamodel.declared_types/1. An author told that a record does not cover a shape can now read what that shape requires without leaving the editor.Write signatures across the
core.*vocabulary, so the environment survives one.core.assignwrites itspathandcore.subchartitsassign_toas known-but-untyped;core.mapwritescollectas{:list, :unknown}, so the block after a fan-out knows it is looking at a list;core.on_eventwrites one path percapturepair on the interrupt path;core.wait,core.send,core.raiseandcore.awaitwrite nothing and leave the environment exactly as it reached them; and a container hands its children's writes out through the per-path merge.StatifierBlocks.Environment- the datamodel path to type map at any position in a document, carried by one pre-order walk.at/3answers it,subject_path/2names the path a document's subject lives at,read_signatures/3andwrite_signatures/3say what a block declares there, andtype_of/2andsatisfies/3read a declaration's spelling against the datamodel document's own type declarations.palette_entry/0gains an optionalsubjectkey: the datamodel path a document's subject lives at, read from the document's entry block.StatifierBlocks.Assignability.context/0gains an optional:datamodelkey, andStatifierBlocks.Compiler.compile/3threads its existing:datamodeloption into it, so the read check can consult the document'srecordandshapedeclarations.The reason vocabulary gains
{:shape_not_satisfied, missing}: the environment holds a record at the path, the read expects a shape, andmissingnames the required fields the record does not cover.A
{:path, opts}field declares what it reads and writes at its path:expects: Tis a read signature the environment at the block's position must satisfy, andwrites: TputsTthere for every block after it. Both keys are optional, and a field carrying neither behaves exactly as it did - a write of:unknown, known but untyped, that refuses nothing.field_candidates, the values a host offers for one field, keyed{type_name, field_key}. A:stringfield with a closed list -[{value, label}]- renders a<select>; an open one -{:open, [{value, label}]}- renders the text input with a<datalist>; no list renders the input the field already had. It draws a control and decides nothing:validate_config/1is still the only authority on a value, and a stored value a closed list does not offer is drawn as its own option rather than silently rewritten. It is an editor assign and aStatifierBlocks.Compiler.compile/3option, where a value outside a closed list is a warning on the compiled artifact and never an error.core.on_event'scapturehas an authoring surface: a repeated two-control row, one row per pair - the datamodel path written beside the path read inside the firing event's payload - with the source control offered the block type's ownfixtures/0payload for the configured event. There is always one blank row at the end, which is what adds a pair; clearing both controls of a row removes one. The key was authored through the document before this and had no control at all.A capture's target paths reach the declared-path advisory, anchored on the
capturekey. They are datamodel paths that no field declaration names, so the pass covering every other datamodel path could not see them.
Changed
The optional
statifier_uidependency's floor moves to~> 0.8, which is the release that takes:path_types. The dependency is optional in the same sense it always was: a tree without it renders the plain source input, and nothing adds it for you.statifier_datamodelis a Hex dependency at~> 0.1rather than a pinned git revision, so a host resolves this package's dependencies from Hex alone.StatifierBlocks.Datamodelreads the datamodel document throughstatifier_datamodelrather than through an index of its own.declared_paths/1,candidates/3,candidates_under/2,value_candidates/2anddeclared_view/3keep their signatures and their behaviour; what changed is where the projection lives.The declared type set gains
datealongside the eight it already carried, because the re-homed record widened it. A document that used nodateentry is unaffected.Breaking. The data-flow check is no longer a question about the block before this one. A block declares what it reads and writes at datamodel paths, and
check/5,valid_targets/4,validate/3,inbound_type/4andseam_reason/4are defined over the environment at a position. They keep their arities.io/1'sconsumesandproduceskeep their meaning as sugar:consumesis a read at the document's subject path andproducesis a write there. A palette that declares nosubjecton its entry block's palette entry has no subject path, so that sugar declares nothing and the document validates exactly as an untyped one always did.Breaking.
{:type_mismatch, block_id, ref, held, expected}gains a sixth member, the datamodel path the read was checked at. A block may carry several read signatures on several paths, so a message that says which two types disagreed without saying where is one an author cannot act on.{:kind_not_admitted, ...}is unchanged.Breaking. A type expression spelled exactly
"unknown"now reads as the permissive:unknownrather than as an opaque expression that happens to be spelled that way. It only ever admits: an opaque"unknown"compared by identity was already satisfied against another"unknown", so nothing that passed before is refused now.The refusal a
{:type_mismatch, ...}names is the block whose write signature put the type at the path, found by name rather than by adjacency. A refusal at index 0 of a slot that used to answer:not_assignable- because there was no previous sibling to name - now answers{:fixable_by, block_id}when a block upstream of the container did declare the type.The read check itself is
StatifierDatamodel.Types.satisfies/3: unknown, then identity, then a record covering a shape's required set. The palette's host relation still runs, and now runs last, after that coverage step - so the floor a host cannot lower is higher than it was, and a host that was widening records into shapes by hand can delete that half of its module.A
core.branch's arms and acore.parallel's lanes no longer blank everything downstream. What leaves a container is the per-path merge: a path every arm holds at one type keeps it, and only a path the arms disagree about drops to:unknown.core.subchart'sassign_toaccepts a datamodel path, not only a bare lowercase identifier: any non-empty string with no whitespace in it, which is exactly whatcore.assignaccepts for the path it writes. The validation and the emission widen together. It is a widening and nothing else - a bare identifier is a one-segment path, so every document that validated before still validates and compiles to the same bytes - and it settles the field offering dotted candidates its own validation refused. The identical refusal on an<assign>location elsewhere in the vocabulary is untouched.
Removed
StatifierBlocks.Predicates.Datamodelis gone. The datamodel document's path/type index moved to thestatifier_datamodelpackage asStatifierDatamodel.Index, which carries the same functions under the same names (index/1,declared_paths/1,sensitive_paths/1,datamodel/1,entries/1,fetch/2,type/2,declared?/2,under/2); a caller swaps the module name and nothing else.
Fixed
- The editor's drop check consults the datamodel document the editor already
holds, instead of asking the data-flow question with an empty context. The
coverage step - a record satisfying a shape by covering its required set -
could not run there, so a placement the compiler accepts was drawn as
refused.
StatifierBlocks.Edit.Targets.droppable_slots/3,droppable_slots_for/3andslot_verdicts/3each take an optional context as a fourth argument; called with three, every one of them behaves exactly as it did.
0.19.0 2026-09-05
0.19.0 is about what a chart does with the world outside it. core.map
(ADR-0009) runs another chart once per item of a datamodel list and waits
for the whole batch, compiling to exactly one <invoke> whose bytes do not
scale with the list; core.await holds until a named event arrives, with an
optional cancel-scoped deadline; and core.on_event gains a capture map
that writes values out of the firing event's payload into the datamodel. The
editor learns the datamodel's shape alongside them - a {:path, opts} field
type with declared-path candidates, event-name candidates read out of the
enclosing body, and a chart_outcomes assign the host fills so a subchart's
declared outcomes can be checked against the chart's own. The drawer's tab
strip answers the WAI-ARIA arrow keys.
Added
core.on_eventtakes an optionalcapturemap: each pair writes one value out of the firing event's payload into the datamodel, the key naming the datamodel path written and the value the path inside_event.datait is read from. The pairs compile to one<assign>each on the handler's own transition, ahead of the raise that carries the outcome, ordered by their datamodel paths so a document compiles to one byte sequence. A handler without the key, or with an empty map, compiles exactly as it did before the key existed. A source path the payload does not carry is written as the interpreter's explicit unbound marker rather thannil, so a consumer of a captured path tests for that marker. The key has no editor control yet - the field-type set has no member describing a map - so it is authored through the document.{:path, opts}is the eighth value in a block type's closed field-type set. A field declared with it holds a path into the host's datamodel, and the editor reaches its control by the type alone: a text input bound to a<datalist>of the declared paths, and the:infoadvisory for a path the datamodel does not declare, anchored on the field.optscarries no defined key today, so a declaration writestype: {:path, %{}}. It suggests and never constrains - free text stays valid andvalidate_config/1remains the only authority on what a value may be.The editor offers value candidates on
core.on_event'seventfield: the completion events the blocks in the handler's enclosing body raise, written as the generateddone.outcome.<state id>.<outcome>names the compiler mints and labelled by each block's own card label and outcome. The body is read through the enclosing type'sslot_acceptsdeclaration - a slot that admits ADR-0003's:stepkind - so a host group is offered on the same terms acore.groupis, and only blocks that implementoutcomes/1contribute. The field is still a plain:string: the list is a<datalist>that suggests and never constrains, a free-typed event name validates exactly as it did, and a body with nothing to offer draws the plain input rather than an empty picker.core.map, the durable fan-out block type (ADR-0009): it runs another chart once per item of a datamodel list, all at once, and waits for the whole batch. The palette calls it "For every item, run a chart" - it sits besidecore.foreach, which runs the blocks inside it one item at a time, and is not a mode of it. Four fields:items(the datamodel path holding the list),chart(the document id run for each item),collect(where the assembled answer is written, optional), andon(all, the default, orfirst_error).itemsandcollectare{:path, opts}fields, so the editor offers the host's declared datamodel paths on both;collectaccepts whatcore.subchart'sassign_toaccepts, refused with the same wording. Anyonoutside the two permitted values is a config finding at authoring time, which is what reserves the wordquorumfor its own walk.It compiles to exactly one
<invoke>of the constant type"statifier_blocks:map", carrying the block's own id, the document id assrc, and the four values as literal<param>children. The compiled bytes do not scale with the length of the list and cannot: the list is a runtime value the compiler never sees, so the params carry the path and the host's registered handler is what resolves it and starts the runs. Nothing about the size of a batch is validated here - a bound on it, if one is needed, is the fan-out runtime's to enforce and to refuse against. The type is deliberately a different string from"statifier_blocks:subchart": a host that wired a single-child handler has not thereby wired a fan-out handler, andStatifierBlocks.Compiler.InvokeTypesreports that gap at deploy time.Two fixed outcomes,
doneanderror, each with an optional slot (on_done,on_error), because N children report N outcomes and there is no branch target to be had by joining them - the per-child answers go tocollectinstead, and an author who wants to branch on them reads that list with acore.branchafter the block. The collected answer is written once, on the success transition, in the shapecore.subchartalready uses.The core vocabulary is now seventeen types, and
core.mapis registered inStatifierBlocks.Palette.core_types/0beside the rest.core.awaitjoins the core vocabulary: an in-flow leaf that holds until a named event arrives, with an optional deadline. It is valid wherevercore.waitis, takesevent(required) andtimeout(an optional duration), and declares two outcomes,receivedandtimed_out, so a parent can route the two ways it can end. A configuredtimeoutarms a cancel-scoped delayed send, so leaving the await early - because the event arrived, because an interrupt fired, because a group was abandoned - leaves no timer behind. Both outcomes are declared whether or not a deadline is stored; with notimeout, no timer is armed and notimed_outfinal is emitted.A
chart_outcomeseditor assign,%{document id => [outcome]}: what the host says each of the documents it compiles with:child_usefinishes with. Acore.subchartnames a chart by document id and declares its outcomes by hand - a block type cannot read the document it references - so the assign is where that knowledge enters. Two things read it: theoutcomesfield offers the named chart's finals as a<datalist>, andStatifierBlocks.ViewModel.outcome_findings/3reports a disagreement between that list and what the block declares, anchored on theoutcomeskey and also reachable throughStatifierBlocks.Editor.findings_count/3's new:chart_outcomesoption.The disagreement is a
:warning, never an error: the document compiles either way, and what a mismatch costs is a conditioneddone.invoketransition that can never match. A chart the map does not name produces nothing at all - unknown is not disagreement - and so does an entry holding an empty list, so%{}(the default) leaves the editor drawing exactly what it drew before. The field is still a plain:string: the list suggests and never constrains, and a free-typed name validates as it did.The drawer's tab strip answers the WAI-ARIA tablist arrow keys. With the strip's one Tab stop focused, Left and Right move one tab and wrap, Home and End go to the ends, and the tab moved to is both selected and focused - so a tab past the clipped edge at the narrow breakpoint is reachable from the keyboard and is scrolled into view when it is reached. Host-contributed tabs are walked alongside the package's own, in the order the strip draws them. The movement is server-side and adds no JavaScript hook: the strip reports the key through
phx-keydownand the editor picks the neighbour out of the same tab list a pointer pick reads.
Changed
core.subchart'sassign_tois declared{:path, %{}}rather than:string, so it offers the declared datamodel paths as candidates and gets the undeclared-path advisory. What it accepts is unchanged - a bare lowercase identifier, which is a one-segment path - and it compiles to the same<assign location=...>it always did.StatifierBlocks.BlockType.datamodel_path?/1answerstruefor a{:path, opts}field by construction. Thedatamodel_path?: truekey is not withdrawn: it keeps its meaning, its control and its advisory, a declaration carrying both spellings says one thing twice rather than contradicting itself, and every declaration written before the type behaves exactly as it did.
Fixed
- The palette's count line no longer counts a recipe as a block type. It reports the two kinds apart - "16 block types, 1 recipe" - and when a slot's acceptance set is narrowing the list it counts types alone, naming the recipes in a clause of their own ("2 of 16 block types fit here; 1 recipe also listed"), because a recipe is not a block type and no set of type names can answer whether one fits.
0.18.0 2026-09-05
0.18.0 lets a palette put down more than one block at a time. A palette may
name recipes beside block types - the core palette ships one,
"deadline", whose single pick writes the core.send / core.on_event
pair that spells a clock interrupt - and the edit vocabulary grows a
compound, so an arrangement like that undoes in one gesture. Alongside it a
palette entry may declare how many blocks of its type a document holds, the
two core timer types rewrite a stored duration written in the retired
spelling as the block resolves, and the editor toolbar's Fit active
follows the run marks a host paints.
Added
A palette may name recipes beside block types: arrangements an author picks the way they pick a type. A recipe is a module implementing
StatifierBlocks.Recipe-insert/2, handed the armed position and the document, answering with the commands that build the arrangement, andpalette_entry/0, which draws it in the palette browser exactly as a type draws. Register recipes with the new:recipesoption onStatifierBlocks.Palette.new/2(aname => modulemap) or onfrom_modules/2(an ordered list, later entries winning), and resolve one withfetch_recipe/2. Recipe names and type names are two namespaces, not one: a recipe named"deadline"and a block type named"deadline"do not collide. A recipe may target the armed position and any slot of the block enclosing it, and nothing above that.The core palette registers one recipe,
"deadline"(StatifierBlocks.Palette.core_recipes/0). One pick puts down the pair that spells a clock interrupt: acore.sendcarrying a generated deadline event and a delay, at the head of the enclosing group'sbody, and acore.on_eventnaming the same event on that group'sinterruptsrail. The pair compiles clean before the author types anything. Picked at a position whose enclosing block has no interrupts rail, the gesture is refused and nothing is written.StatifierBlocks.Edit.t()admits a composition,{:compound, [t()]}, carrying a non-empty list of the five commands.Edit.apply/2applies its members left to right and returns the compound of their inverses in reverse order, soStatifierBlocks.Edit.Historyremembers it as one undo entry: one gesture in, one gesture out. A member that refuses refuses the whole compound, with that member's own error term and no document at all. The set of edits is still five - a compound's leaves are drawn from it, and a list that is empty or holds a compound of its own is refused rather than flattened.A palette entry may declare
singleton: :head | :anywhere, saying how many blocks of its type a document may hold and, for:head, that the one it holds is first in the root's first declared slot. A document that does not comply draws one:configfinding per violating type, anchored on the root block, so a host gets "exactly one of this, at the top" without writing a validator of its own. Read it withStatifierBlocks.BlockType.singleton/1. Nothing is inserted, removed or moved on the author's behalf - the editor says what is wrong and the author acts. An entry that omits the key, or declares a value this package cannot read, is unconstrained exactly as it is today.
Changed
StatifierBlocks.Editor.Toolbar.toolbar/1's:selected?attribute is now:fittable?, because a selection is no longer the only thing that givesFit activesomething to fit. Rename the attribute at any direct call site; hosts rendering the editor pass nothing here.A
StatifierBlocks.ViewModel.PaletteGroupentry now carries:kind(:typeor:recipe) and:name, the name in whichever of the palette's two maps it came from.:type_nameis unchanged on a type entry and absent on a recipe entry, which has no type name at all.
Fixed
The editor toolbar's
Fit activefollows the run marks a host paints: it is enabled whenever there is a selection or an active mark, and with nothing selected it fits and reveals the first marked block.A document saved before the duration pivot opens clean instead of showing a refusal on every timer:
core.waitandcore.sendare attype_version2 and rewrite a stored duration written in the older spelling into the one the field reads. The rewrite happens in memory as the block resolves and nothing is written back, so persisting it stays the host's decision.The drawer's tab strip scrolls sideways at the narrow breakpoint instead of wrapping to a second row, so the bar stays one row tall however many tabs a host adds, and a fade marks the end the strip is clipped at.
0.17.0 2026-09-05
0.17.0 makes a stored duration mean one thing. A :duration field reads the
duration strings Predicator.Duration parses - 30s, 1h30m, 2d, 3d8h -
and refuses every other spelling, which is what finally lets 500ms and
1.5s through: the intermediate canonical form that stood between them and
the engine is gone, and with it two public functions. The editor surfaces
grow alongside it - a Fixtures tab on the inspector, a fixture-derived hint
beside a condition field, datamodel-derived value candidates, done-event
chips drawn as the block they name, and an on_select callback for a host
panel that follows the canvas.
Added
The inspector carries a fourth tab, Fixtures, about the selected block: one row per fixture row the block owns, with the slot the row expects, the slot the compiled chart took, and the verdict. It carries a count chip in the Findings tab's style, reading the selected block's row count.
StatifierBlocks.Shell.inspector_tabs/0now answers four tabs, with:fixtureslast, andStatifierBlocks.Shell.inspector_tab/1resolves the new name.The rows are the same
StatifierBlocks.Runtime.FixtureRunsresult the drawer's Fixtures tab renders - no second execution path - filtered to the selected block. Runs now refresh when the inspector's tab is picked and when the selection moves, as well as on the drawer's own tab, so switching blocks cannot leave the previous block's verdicts on screen.The drawer's Fixtures tab is unchanged: same rows, same verdicts, same count, same place in the tab order. With no selection, no fixtures source, or no rows for the block, the new tab reads its own empty state for that case; a document mid-edit reads as mid-edit rather than as a fixture failure.
The editor accepts an
on_selectfunction and calls it with a%{id:, type:, label:}descriptor for each new selection, ornilwhen nothing is selected, so a host panel can follow the canvas.Sub-second and fractional durations are expressible in a
:durationfield for the first time:500msand1.5sboth validate and both compile to adelaythe engine resolves. The recogniser that stood between them and the engine is gone.A condition's value picker now offers what the datamodel declares. A path whose ADR-0006 entry carries
one_ofgets those values by default, with novalue_candidatesmap supplied; a host entry for a path replaces the derived list for that path and leaves every other path's default in place.StatifierBlocks.Datamodel.value_candidates/2is the derivation, and nothing validates against the list -one_ofstays a hint.StatifierBlocks.ViewModel.summary_chip_titles/1gives the raw text behind each summary chip,nilwhere the chip is drawn as its type declared it.StatifierBlocks.Compiler.StateId.undone_event/1inverts a generateddone.stateordone.outcomeevent name back to the block it names, or answers:errorfor a name that does not invert unambiguously.A condition field now draws a hint beside its control, derived from the selected block's fixture rows. The exemplar is what the block's first fixture row in declaration order binds to the path the condition names, and every distinct value that path takes across the block's rows is listed on the hint's
title.StatifierBlocks.Shell.fixture_hint/3is the derivation, over thefixturesthe editor already holds.It is a hint and never an option: nothing reaches a picker, nothing is merged with
one_ofor with a host'svalue_candidates, and no value it shows can be selected. A block with no fixture rows, or a document with no fixtures source, renders exactly as it did before.
Changed
Breaking. A
:durationfield reads one grammar: the duration stringsPredicator.Durationparses, such as30s,1h30m,2dand3d8h. Any other spelling is a format finding.Migration: a
core.waitdurationor acore.senddelaystored in ISO-8601 -PT30S,PT1H30M,P1D- no longer validates. Rewrite the value in the unit spelling above;PT1H30Mbecomes1h30m,P1DT6Hbecomes1d6h, and the compiled chart is byte-identical either way.StatifierBlocks.Core.Duration.to_delay/1now takes the normalised durationparse/1returns rather than a canonical string.A summary chip whose text has the shape of a generated done-event name is drawn as the named block's label and the outcome, with the raw name on the chip's
titleattribute, so a card no longer shows the compiler's spelling of a fact the author stated (ADR-0005 decision 10w).The presentation cap measures the drawn chip rather than the generated one, so the cap lint no longer names a string the author cannot shorten. A translated chip that is still over the cap is refused exactly as before.
summary/2,summary_refusals/2andsummary_refusal_message/3take an optional trailing map of block labels. A call that passes none behaves exactly as it did: without labels nothing is translated.
Removed
StatifierBlocks.Core.Duration.to_iso/1andStatifierBlocks.Core.Duration.predicator?/1. With one grammar in and one attribute out there is no intermediate form to canonicalise to, and no narrower predicate to hold besideduration?/1. Callers that compiled a stored value should useparse/1and thento_delay/1.
0.16.0 2026-09-04
0.16.0 fills the expression seam. A condition's :expression field now
renders statifier-ui's expression editor - picklists of field, operator and
value over the source it can round-trip, and the plain source input over the
rest - so a signup-wizard condition like step in ['payment', 'review'] is
composed rather than typed, and a host can offer the values each datamodel
path accepts through a new value_candidates assign. statifier_ui joins
phoenix_live_view as an optional dependency: with it absent, nothing raises
and an :expression renders exactly what it rendered before.
Added
- An
:expressionfield renders statifier-ui's expression editor - picklists of field, operator and value over the source it can round-trip, a text input over the rest - whenstatifier_uiis on the load path, and the plain source input when it is not. A
value_candidateseditor assign,%{path => [%{label:, value:} | binary]}, carrying the values a host offers per datamodel path through to the expression control.
Changed
statifier_uiis a new optional dependency, resolved the wayphoenix_live_viewalready is: absent, nothing raises and an:expressionrenders exactly what it rendered before.
0.15.0 2026-09-02
0.15.0 is about seeing what a document actually declares and what its
fixtures actually do. The editor's drawer gains two more tabs: Fixtures,
which drives every attached fixture row through the compiled chart and
reports the outcome it took against the one it expected, and Datamodel, a
read-only grid of every declared path with the surfaces that declared it.
Compiling a core.resumable_group whose body opens with a delayed send now
warns that no deadline is re-armed after the first resume. The rest is
polish on the drawer and the truth table: keyboard reach, wrapping, pinned
case names, and refusals that read as sentences.
Added
- The editor's drawer gains a fourth tab, Fixtures, that drives every attached fixture row through the compiled chart and reports each one's expected outcome against the one actually taken.
- Compiling a
core.resumable_groupwhosebodyopens with a delayedcore.sendand whoseinterruptsrail carries aresumehandler now produces an advisory warning: no deadline is re-armed after the first resume, and the two escapes are arming the deadline outside the group or using acore.group. - The editor's drawer gains a fifth tab, Datamodel: a read-only grid of every declared datamodel path, with the surfaces that declared it and the type, scope and label the ADR-0006 projection carries. It shows exactly the vocabulary the undeclared-path advisories are decided against, and
StatifierBlocks.Datamodel.declared_view/3is that projection on its own.
Fixed
- The drawer's tab strip is now a single Tab stop instead of one per tab, matching the inspector's strip: Tab moves from the canvas to the active drawer tab and then on to the height slider, rather than stepping through all six tabs on the way.
- A drawer tab keeps its label and its count on one line at every width; a bar too narrow for all six tabs now wraps whole tabs onto a second row instead of breaking each tab into a three-line stack.
- A folded container carrying findings no longer draws its findings count underneath the fold control: the card reserves the corner the fold occupies whenever it renders a badge.
- A truth table wider than the drawer can now be scrolled from the keyboard. The table's scroll region is a Tab stop and a labelled
regionnamed for the table, so the arrow keys, Home and End reach the columns past the right edge; previously every cell in it was text and nothing in the panel was focusable, which left those columns reachable by pointer alone. - A mismatched truth-table cell now says what the disagreement was - "expected yes, selected no" beside the status word and on the cell's tooltip - instead of only the word
mismatch, and a row'snoterenders under the row name instead of being dropped. - A truth table's case column stays pinned to the left edge while the table is scrolled sideways, so a status word keeps the row name (and its note) beside it instead of losing its row on any table wider than the drawer.
- A truth-table row whose bindings fail to build a context now reads as a sentence naming the binding and what went wrong, instead of the inspected error tuple; an unrecognised reason still shows its term.
0.14.0 2026-09-01
0.14.0 is about a document declaring its own data, and about a subchart
that outlives the process running it. core.subchart gains a durable
runtime: the child runs as its own persisted run and re-enters the parent
through the driver's own door when it finishes. The editor's drawer gains
a third tab, Declarations, so the datamodel roots a document depends on
are authored in the document rather than supplied only by the host, and
every :expression field offers the declared paths as suggestions without
constraining what an author may type.
Added
StatifierBlocks.Runtime.DurableSubchartruns acore.subchartchild as its own persistedstatifier_persistencerun:dispatch_fun/1builds the fun the driver's:dispatchoption takes, and the child's completion re-enters the parent through the driver's own door.- The editor's drawer carries a third tab, Declarations, where an author
adds, edits, reorders and removes the document's own
datamodelroots (ADR-0001 decision 11's entries:id,expr,description). StatifierBlocks.Editgains a fifth command,{:set_datamodel, entries}, which replaces the document's whole declaration list and whose inverse is the list that was there before, so a declaration edit undoes and redoes like any other. It refuses a listStatifierBlocks.Document.validate/1would refuse, in the same{:malformed_envelope, {:datamodel, reason}}family.StatifierBlocks.Declarations, the pure list-to-list arithmetic behind the panel:add/1,remove/2,move/3,put/4,change/3,count/1andrefusal/1.StatifierBlocks.Datamodel.candidates/3returns the declared datamodel paths, unioned from the host's datamodel, the compile call's:declareroots and the document's owndatamodelkey, sorted and deduplicated.StatifierBlocks.Datamodel.candidates_under/2narrows those candidates to a prefix, in the datamodel document's own order.- An
:expressionconfig field offers the declared paths as a<datalist>, suggesting without constraining; supplying no datamodel renders the plain input unchanged.
Changed
- The
expression_componentoverride now receives a:candidateskey alongside:field,:id,:nameand:value. Existing overrides read the keys they know and are unaffected.
Fixed
- An arranged container whose slot ends in a drafts shelf drew its rejoin edge from the shelf's own outlet, asserting flow out of the one card nothing enters and nothing leaves. The rejoin now leaves the last step in the slot, and a slot holding nothing but a shelf rejoins from its header, the same as an empty one.
0.13.0 2026-09-01
0.13.0 is a small release about what an editor shows first. The drafts shelf 0.12.0 introduced opened expanded, so a document with parked work opened showing its shelf rather than its flow; now a stocked shelf opens folded and the flow is what the author sees. Nothing about the shelf's placement rules, its compile output or the fold control itself moves.
Changed
- The editor opens a non-empty drafts shelf folded, so a document with parked work opens showing its flow rather than its shelf. An empty shelf opens as before, the fold control is unchanged, and unfolding lasts for the session.
0.12.0 2026-09-01
0.12.0 is the release where a document can hold work that is not finished
yet. core.drafts is the document's shelf - one per document, a direct
child of the root, holding fragments an author has built but not placed -
and it compiles to nothing, so shelving work never moves a byte of the
emitted SCXML. core.placeholder is the other half: an in-flow marker for
a step left unwritten on purpose, compiling to a state that completes on
entry so a preview walks straight through the gap. Both are visible to a
host rather than silent - misplacement and duplication are Structure
errors, and a non-empty shelf or a placeholder raises an Emit warning on a
compile that still succeeds. The editor draws the shelf as a tray: a
detached row at the foot of the canvas with no boundary box and no
connectors, because parked work is not part of the flow.
Added
core.drafts, the document's shelf: a container whose onebodyslot holds fragments an author has built but not placed. It is admitted as a direct child of the root block'sbodyand nowhere else, a document carries at most one, and it compiles to nothing at all - a document with work on its shelf, the same document with the shelf emptied, and the same document with no shelf produce byte-identical SCXML.core.placeholder, an in-flow leaf marking a step left unwritten on purpose. One optionalnotefield, and it compiles to a state that completes on entry, so a preview of a half-built workflow walks straight through the gap.- Two Structure-stage errors:
:drafts_block_misplacedand:duplicate_drafts_block, the second naming the second and every later shelf in document order rather than the first. - Two Emit-stage warnings on a compile that succeeds:
:draft_blocks_present, once per document on a non-empty shelf, and:placeholder_block, once per marker, carrying the author's note. What a host does with either - a publish gate, say - is the host's. slot_style: :tray, a fourth value forpalette_entry/0'sslot_stylemap. A tray is a detached shelf: no boundary box, and no connector into it, out of it, or between one fragment and the next.StatifierBlocks.ViewModelgainstray?/1,shelf?/1,flow_children/1andshelf_children/1.StatifierBlocks.Shelf, the module owning the shelf's placement rules and the type-name predicates other layers ask about it.
0.11.0 2026-08-31
0.11.0 is the release where declaring a block type stops being boilerplate.
use StatifierBlocks.BlockType fills in every callback but emit/2, and
use StatifierBlocks.InvokeStep goes further for the shape that keeps
recurring - a leaf step naming one host invoke type - so such a block type
is a handful of options rather than a module of callbacks. Beside it, the
statifier_blocks:subchart invoke type gains a canonical handler: use StatifierBlocks.Runtime.Subchart generates the in-memory
Statifier.Session implementation a host would otherwise write by hand,
leaving the document resolver as the host's seam and putting a stated
reason on every refusal.
Added
use StatifierBlocks.BlockTypedeclares the behaviour and injects an overridable default for every callback butemit/2.use StatifierBlocks.InvokeStepdeclares a leaf step that names one host invoke type, filling in every callback from an invoke type, an optional produced type, extra config fields, and palette keys.use StatifierBlocks.Runtime.Subchartdeclares the canonicalstatifier_blocks:subchartinvoke handler for the in-memoryStatifier.Sessioncase: the host supplies a document resolver and a palette, and the generated module resolves the chart acore.subchartnames, compiles it as a child, and starts it as a child session. Refusals surface onerror.communication.invokewith a stated reason -unknown_document,child_compile_findings, orcycle_refused.StatifierBlocks.Runtime.Subchart.handlers/1builds the:invoke_handlersmap forStatifier.Session.start_link/2.
0.10.0 2026-08-31
0.10.0 is the release where a document declares its own data. A block
document carries a top-level datamodel key naming the <data> roots its
own guards and assigns read, so a document compiles on its own rather than
depending on whichever host happens to declare them; the compile call's
:declare option stays beside it as the host-wins surface, and the editor's
undeclared-path advisory reads both. Around that, core.on_event takes a
cond, so an interrupt rule fires only when its event arrives and the
condition holds; the done outcome of an invoke run mark gets theme tokens
of its own instead of borrowing the accent blue it was indistinguishable
from; and the canvas stops falling short of the tree it holds.
Added
- The block document gains a top-level
datamodelkey: a list ofStatifierBlocks.Document.DatamodelEntrystructs, each anidplus an optionalexprand an optionaldescription, naming the<data>roots the document's own guards and assigns read.Document.new/2takes a:datamodeloption (default[]); a document declaring none compiles and encodes byte-identically to one built before this key existed. The key is part of the document's canonical bytes, so it participates incontent_hash/1and in compile determinism. Compiler.compile/3's:declarecompile option now leads a second declaration surface rather than being the only one: the compile call's roots emit first, the document's owndatamodelroots follow, and block-declared roots follow those, all in one<datamodel>. A root both the compile call and the document declare is host-wins: the compile call's declaration is emitted, the document's is dropped, and the compile succeeds with a:shadowed_document_rootwarning onCompiled.warningsrather than refusing. A document root colliding with a block-declared root is still refused as:duplicate_binding.core.on_eventtakes an optionalcond: an:expressionconfig field that becomes the guard on the handler's transition, so an interrupt rule fires only when its event arrives and the condition holds. A handler whosecondis absent, empty, or whitespace emits exactly the bytes it emitted before the key existed, so the key is additive over every document authored without it. The condition is passed through to predicator verbatim -validate_config/1only asks that the stored value be a string - and the transition carries"cond"as its attribution key, so an upstream expression error lands on the field the author typed into. Recorded as ADR-0002's 2026-08-31 note.StatifierBlocks.Editortakes adeclareassign, the{id, expr}roots the host will passStatifierBlocks.Compiler.compile/3as:declare, defaulting to[].Editor.findings_count/3takes the matching:declareoption, so the host's number and the drawer's stay the same number. The document's own roots need no option - they are read off the document.StatifierBlocks.Datamodel.declared_roots/1, the total normalizer for a root set, besidedeclared_paths/1.- Two tier-2 theme tokens,
--sb-run-doneand--sb-run-done-bg, for thedoneoutcome of an invoke run mark. A host that themes the editor now sets the "came back" colour directly instead of inheriting whatever it set--sb-infoto.
Changed
Decoding a document now refuses an envelope object carrying a key outside
id,revision,root,schema_version,metadata,datamodel, and refuses adatamodelentry carrying a key outsideid,expr,description, or carrying an explicit JSONnullforexprordescription- matching the round-trip discipline already applied to unrecognized block keys.The family's two worked examples - the ADR-0001 card authorization document and the signup wizard - now declare the
<data>roots their own guards read through ADR-0001 decision 11's documentdatamodelkey, rather than leaving them to whichever host compiles them. Each declares exactly what it reads and no more:budget_remainingandamountfor the first,variantfor the second, none of them carrying anexpr, since all three are per-run values a host seeds or a step assigns. Either document now compiles on its own, with no:declareoption, without its guard raisingerror.executionover a root nothing declared.Both documents' canonical bytes move with the key, and so do their document hashes and the worked example's pinned chart identity. The identity move was verified rather than accepted: deleting the one
<datamodel>element from the new bytes reproduces the previously pinned hash exactly, so nothing else in either emission changed. Only the test fixtures ship these documents; no packaged code moved.The editor's undeclared-path advisory now reads three declaration sources rather than one. A config field a block type annotated
datamodel_path?: trueis declared when the host's datamodel holds its path, when the document's owndatamodelkey declares its root, or when the compile call's:declareoption declares its root. A declared root covers every path beneath it; the datamodel's own paths are still matched whole. ADR-0005's decision 11 is amended as 11k-11m, taking the open question ADR-0001's 2026-08-31 amendment left to it in its clause 11g.The check's precondition widens to match: it runs when a datamodel was supplied or when either surface declares a root, and still produces nothing at all when nothing anywhere was declared. A host that passes no datamodel and no roots sees exactly what it saw before; a document that declares its own roots now lints its own paths with no host involved.
An invoke mark whose outcome is
doneis drawn in--sb-run-donerather than--sb-info. In the shipped light palette--sb-infoand--sb-accentare the same blue, so a block that was active and a block that had come back differed only in treatment - a halo outside the border against a border and a fill - and read as one state at a glance. They are now different hues in every theme the package ships or documents.This is a visual change for a host that did not override
--sb-info, and a host that had retuned--sb-infospecifically to colour its done marks should move that value to--sb-run-done. Nothing else reads the new tokens, and theerroroutcome still takes--sb-error.The complete host theme in
docs/theming.mdrestates both tokens, as the theme audit requires of every colour token, and the accessibility discipline is unchanged:--sb-run-doneis held to the 3:1 non-text threshold as a border that carries information, and--sb-run-done-bgis recorded as translucent and held to no ratio.
Fixed
- A summary chip whose text is a single identifier -
signup.reminder_due- no longer breaks mid-token onto a second line. The chip keeps one line and clips at its own edge with an ellipsis, which is the treatment the invoke type on the line below it already used. The card's title keeps its wrap: a title is prose an author wrote, not an identifier.
- The canvas stage is now at least as wide as the tree it holds
(
min-width: min-content). Its box took the panel's width, while the tree has a floor of its own - nesting paddings, the interrupt channel, a card that will not shrink past its token width - so a panel narrower than that floor left the stage short of its own content, and everything read off that box, the measurement hook'soffsetWidthincluded, was short with it. Connectors.fan_path/3,Connectors.join_path/3andConnectors.interrupt_path/4now clamp an ascending edge level, asflow_path/3already did: the head is raised to the tail's ownyrather than routed upward. An arrowhead is oriented along its path, so an ascending arm rendered an arrow pointing back at the block the flow just left - a loop the document does not contain.
0.9.0 2026-08-31
0.9.0 is the release where the editor can watch a run. A host hands the editor the block ids a run has activated and the block it is calling out to, and the editor draws them on the cards themselves, in the accent and finding tokens a theme already retunes. The same host seam widens the drawer: a host contributes its own tabs beside the package's Truth tables and Findings, so a run feed the host is appending to lives in the drawer rather than beside the editor. Around that the chrome gives room back - the inspector folds to a rail the way the palette already does - the config form's fields are dressed from the same tokens as the palette's search box, and connectors stop overshooting the cards they point at.
Added
- The editor's inspector folds to a rail from a chevron on its own header, the way the palette already does, giving its width back to the canvas.
--sb-inspector-collapsed-width, the theme token for how wide that rail is.- The editor takes run marks from its host:
active_marks, the block ids a run has activated, andinvoke_mark, the block a run is calling out to together with how the call came back. Both are ordinary assigns, so a host pushes them withPhoenix.LiveView.send_update/3and needs no new API; both are held as editor state, so a re-render the host makes for its own reasons does not drop them; and both are cleared when the host opens a different document, because a mark addresses one block. - The marks reach the markup as
data-run-active,data-run-invokingand - only once a call has come back -data-invoke-outcomeon the block's.sb-node, and the stylesheet draws them in tokens a host theme already retunes: the accent family for the active mark, the finding severities for the outcomes. A mark on a folded container stays visible. - The editor accepts an optional
invoke_typesassign, and aninvoke_typefield then offers those types as a suggestion list; free text stays valid and an unknown type stays a lint rather than a refusal. StatifierBlocks.Connectors.slots_anchor/1, the anchor key for a container's body box. The editor stamps it on the element holding a container's slots, and an interrupt channel is now offset from that box rather than from the container's whole node box.- The editor takes drawer tabs from its host:
drawer_tabsis a list of%{id:, title:, content:}descriptors, with an optionalcount:, and each one is drawn beside the package's own Truth tables and Findings tabs and activates the same way.contentis a function component, the same seam shapeiconandexpression_componentuse, called when its tab is the active one. The descriptors are ordinary editor state, so a host pushes them withPhoenix.LiveView.send_update/3and a feed the host is appending to redraws as it grows - a tab whose content changes under the host is what the seam exists for. - The collapsed strip and the unchosen-tab resolution reach host tabs too: a
document with no truth tables and no findings and a running feed opens on
the feed rather than on an empty
Truth tables 0, and a collapsed drawer names it. StatifierBlocks.Shell.host_tabs/1is the admission rule, andStatifierBlocks.Shell.drawer_tab/2resolves a tab name against the package's tabs and the host's together. A host tab named for one of the package's own, or repeating an id already used, is not drawn: the id is stamped into the tab's DOM id and its panel's. No tab name is ever turned into an atom, so a craftedphx-value-tabreaches at worst a tab the host declared.
The package's own tabs, the drawer's five states, its resize and its collapse are unchanged, and a host that contributes no tabs gets the drawer it had.
Changed
- The config form's fields are dressed the way the palette's search box always
was - a token-built box with a border, a radius and the pane's own fill -
instead of being left to whatever the host's browser paints a bare
<input>or<select>as. The two are now one rule rather than two, so a host retuning--sb-border,--sb-radius-smor--sb-bgmoves every control in the editor together. - Placeholder text in those fields takes
--sb-fg-subtle, and a disabled field is muted with--sb-disabled-opacity, which is what a disabledsb-buttonhas always used. Boolean fields, which render a checkbox, are excluded from the box by selector and are unchanged.
Fixed
- Connector arrowheads keep one size at every stroke width and land their tip on the endpoint, rather than being scaled by the stroke of the path that references them and overshooting the card they point at.
- A flow edge whose head was measured above its tail is drawn level instead of ascending, so no arrowhead points back at the block the flow just left.
- An interrupt edge clears the container it exits instead of turning down inside it. A container is now as wide as what it holds rather than as wide as the box around it, so the box the routing is measured from means what it encloses.
- A gap's insertion marker masks the flow line it sits on, and its glyph stays legible when the canvas is zoomed out. The dashed placeholder ring stays on empty slots only.
0.8.0 2026-08-30
0.8.0 is the release where a container folds shut. A container in the
editor now carries a fold control on its own card, and folding it does not
hide what is inside: a folded container wears a ring badge counting the
findings under it, so a problem stays visible from the outside. Alongside
that, an editor whose host swaps one document out for another fits the new
document the way it fitted the first - the fit attr is spent once per
open document rather than once per editor, so a swap now behaves exactly as
a mount does. Hosts: see Removed - the --sb-fg-on-accent theme token is
gone, and a theme that still sets it can drop the declaration.
Added
- A container in the editor folds shut from a control on its own card, and a folded container carries a ring badge counting the findings inside it.
Changed
- The editor's
fitattr is now spent once per open document rather than once per editor: a document the host swaps in is armed from the attr passed in that same update and fitted by the next measurement, exactly as at mount. A host re-render carrying the document already open still never re-fits, and:manualor an absent attr still arms nothing.
Removed
- The
--sb-fg-on-accenttheme token, which no rule in the stylesheet reads any more. A host theme that sets it can drop the declaration.
0.7.0 2026-08-30
0.7.0 is the release where a finding looks like a finding wherever it is
read. One row anatomy - severity word, anchor tail, source chip, message -
now draws on the drawer's tab and on both inspector panels, which used to
show the bare message, and the two document-level surfaces carry a severity
pill row above the list. Alongside that, the 24-character presentation cap
on summary chips stops failing silently: a dropped chip raises a :lint
warning against its block and summary_refusals/2 says which chip and why,
so a card with no second line can be told apart from a type that declared
none. And an editor opened with a fit no longer flashes at 100% before
snapping to it. Hosts: see Removed - :no_presentation_source leaves
Finding.from_compiler/2's refusal type, and the canvas toolbar's dead
inserting? attribute is gone.
Added
StatifierBlocks.Shell.severity_counts/1cuts a findings list by severity, in:error,:warning,:infoorder and omitting any with nothing at them; it sums tofindings_count/1for the same list.StatifierBlocks.Editor.Findings.row/1andanchor_tag/1are public, so a host rendering findings of its own gets the editor's row anatomy rather than re-deriving it.StatifierBlocks.BlockType.summary_refusals/2reports the summary chips the 24-character presentation cap dropped, as{index, reason}withreasonin:too_long,:blank,:multilineor:not_a_string, andsummary_refusal_message/3puts one into the words an author reads.
Changed
- A finding renders the same way everywhere: severity word, anchor tail
(
config.duration,slot:body, nothing for a block anchor), source chip and message. The inspector's two findings panels showed only the message before. - Both document-level findings surfaces - the drawer's Findings tab and the inspector's with nothing selected - carry a severity pill row above the list. The list itself is still grouped by block.
- The view model raises a
:lintwarning against a block for every summary chip the presentation cap refused, so a card that draws no second line can be told apart from a type that declared none. A well-formed document gains no findings.
Removed
StatifierBlocks.Finding.from_compiler/2no longer declares the:no_presentation_sourcerefusal infrom_compiler_error/0; nothing has produced it since the adapter began mapping unplaced compiler findings to:compile, so a caller that matched on it can drop the clause and keep the{:unanchorable, finding}one.- The canvas toolbar's dead
inserting?attribute, left declared when 0.6.0 removed its "Cancel insert" button. A host still passing it toStatifierBlocks.Editor.Toolbargets an undefined-attribute warning; drop the assignment (statifier_blocks#172).
Fixed
- An editor opened with
fit: :widthorfit: :activeno longer paints its first frame at 100% and then snaps to the fit. While a fit is armed the root carriesdata-fit-pendingand the stylesheet keeps the stage unpainted under it, so the first frame an author sees is the fitted one; a host that never imported the measurement hook, and so never spends the fit, is revealed by a CSS-only fallback half a second in rather than left blank.
0.6.0 2026-08-30
0.6.0 is the release where the editor tells one story about findings. The
Findings tab reads the whole document when nothing is selected, the drawer's
strip and a host header can finally show the same number, and a container no
longer wears a badge on every uncollapsed face. Alongside that: a block type's
summary draws as a row of chips rather than one joined string, the palette's
search field wears the package's own chrome, and a host can open a document
already fitted to the canvas. Hosts: see Removed - source: :arity leaves
StatifierBlocks.Finding (pass :assignability) and :compile joins the enum;
ViewModel.subtitle/1 now answers only a type label, with the chips behind the
new summary_chips/1; .sb-badge is rendered by nothing; and the canvas
toolbar's "Cancel insert" is gone, its job done by the palette's Cancel.
Added
StatifierBlocks.Finding'ssourcegains:compile, andfrom_compiler/2maps any compiler finding its by-stage rule cannot place onto it instead of refusing, so a compile error raised against generated SCXML or against the document envelope can be rendered by the editor.StatifierBlocks.Shell.findings_groups/3groups a document's findings by the block each is anchored to, unanchored ones last, without dropping any - the grouping behind that panel, and headless like the rest ofShell.- The editor takes a
fitattr -:manual(the default, unchanged behaviour),:widthor:active- so a host can open a document already fitted to the canvas instead of leaving the author to pressFit widthon every document. The first measurement performs the fit once; after it the editor behaves exactly as if the author had pressed the button, and an unknown value is refused into:manual. StatifierBlocks.Editor.findings_count/3returns the number of findings the editor's Findings tab reports for a document, from the samedocument,palette,findingsanddatamodela host already passes the component, so a host header and the drawer cannot show two different numbers.
Changed
- A block type's
summary/1chips draw as a row of separate chips under the card's title (.sb-node__summary, one.sb-node__chipper chip) instead of one string joined with", "; the row wraps, and a type declaring no summary draws no row at all. StatifierBlocks.ViewModel.subtitle/1answers only the type label an author- named card carries, andnilotherwise. Read the chips from the newStatifierBlocks.ViewModel.summary_chips/1instead of the joined string.- The inspector's Findings tab reads the whole document when nothing is
selected: the count beside the tab is the document's findings number - the
same one the drawer's strip and
StatifierBlocks.Editor.findings_count/3report - and the panel lists those findings grouped by block, each row selecting its block. Findings anchored to a block the document no longer holds get anUnanchoredgroup of their own, since they are inside the count. With a block selected the tab is that block's findings, unchanged. A host styling the panel has three new classes:.sb-inspector__groups,.sb-inspector__group(withdata-block-idanddata-unanchored) and.sb-inspector__group-title, plus.sb-inspector__group-rowon the rows. - The palette's search field is drawn by the package - a border, a surface, padding and a radius, all from
--sb-*tokens - rather than left to whatever box the host's browser paints inside the pane. - A gap's "+" wears the editor's button chrome at rest, so an insertion point reads as a control without being hovered first. Its hover, armed and drag states are unchanged.
- The finding count badge no longer renders on a container's face. ADR-0005
places it on a collapsed subtree and the editor has no collapse command yet,
so a badge on every container read as an error on every card while the counts
multiplied up the tree. Every finding is still reachable: the node keeps its
subtree rollup in
data-findings-count, and the drawer's Findings tab and the inspector both list them. A host styling.sb-badgeshould know the class is now rendered by nothing.
Removed
:arityleavesStatifierBlocks.Finding'ssourcetype. Nothing ever produced it: slot arity and undeclared-slot violations arrive through the compiler's:structurestage and have always carried:assignability. Pass:assignabilitywhere you passedsource: :arity; the finding's{:slot, block_id, slot_name}anchor, and so where it renders, is unchanged.- The canvas toolbar's "Cancel insert" button. Leaving an insert is the palette's Cancel, beside the line that names the slot the next pick fills, or Escape.
0.5.0 2026-08-30
0.5.0 is the release where the editor behaves like the spike it was drawn
from. It bounds its own height and hands scrolling to the panes; every zoom
step and both fits scale the canvas for real; an arranged container's lanes
size to their own content, so connectors stop crossing sibling cards;
inserting visibly arms the gap the pick will land in, and a palette entry can
be dragged onto one; an unresolvable block's card is compact again, with its
findings and stored config moved to the inspector; the core block types
summarise themselves on a card's second line; nesting depth is banded across
the canvas; the plain controls render as buttons; and a host's icon
component is rendered as a function component. Hosts: see Changed -
.sb-node__raw-config is now .sb-inspector__raw-config, the editor's
buttons carry a new sb-button class, and two band tokens
(--sb-band-even, --sb-band-odd) join the tier-2 theming surface.
Added
- A palette entry can be dragged onto a gap on the canvas to insert a block of that type there. The slots that accept the dragged type highlight as soon as the drag starts, exactly as they do when a card is dragged, and the drop produces the same insert a "+" and a pick produce.
--sb-editor-heightbounds the editor: set it to a length and the panes scroll in their own boxes while the drawer stays pinned at the bottom, instead of the whole document growing the host page. The default isauto, so an editor nobody bounds is unchanged.- Two theming tokens for the editor's nesting bands,
--sb-band-evenand--sb-band-odd(tier 2). Both default to a surface the theme already carries, so a host that restates--sb-bgand--sb-bg-sunkenin its own palette bands the canvas without setting either one. - Block types may export an optional
summary/1, returningnil, a short string, or a list of chips, which the editor draws as a card's second line when the author has not named the block. It is read throughStatifierBlocks.BlockType.summary/2, which normalises every shape to a chip list and drops an over-long chip rather than truncating it. core.parallel,core.wait,core.on_event,core.sendandcore.branchsummarise themselves on the card: lane names,timer <duration>, the outcome and the event, the event, andN arms + otherwise.sb-button, one class carrying the editor's button look, so a host restyling the family changes one selector rather than seven.
Changed
- The measurement hook's payload carries the canvas panel's usable box under a
viewportkey, read from the element the editor stampsdata-sb-anchor="viewport". A host that registers the hooks from the package's default export needs no change; a host that reimplemented the hook against the documented payload should send the new key for the fits to resolve to a number. - Every slot on the canvas carries
data-sb-depth, its root-relative nesting depth, and the stylesheet paints a full-width band per nesting level, alternating by the depth's parity. Depth 0 is deliberately unbanded, so the canvas keeps its own dotted ground. - The interrupt-rules rail has a ground of its own, in the warning family its dashed edge already uses.
StatifierBlocks.ViewModel.Nodecarries asummaryfield, andViewModel.subtitle/1returns it for a block whose title is its type's. A block the author has named still reads its type's label there.- An unresolvable block's card reads its type and one short reason; its findings and its stored config moved to the inspector, so the card is the same width as its siblings.
- The inspector's Block section shows an unresolvable block's stored config as canonical JSON, wrapping mid-token rather than spilling past the pane.
- The stored-config
<pre>moved from the card to the inspector, and its class with it:.sb-node__raw-configis now.sb-inspector__raw-config. A host styling the old class should restyle the new one.
Fixed
- A slot that refuses the block being dragged no longer accepts a drop when it sits inside a slot that accepts it. The gaps in the refused slot were live targets, and dropping on one put the block in the slot that had said no.
- Every zoom step in the editor toolbar now scales the canvas, and the panel scrolls the scaled size rather than the unscaled one.
Fit widthresolves to the largest zoom step at which the chart fits the canvas panel, instead of only recording that the fit was asked for.Fit activeresolves to the largest zoom step at which the selected block fits, and scrolls that block to the centre of the panel.- A host's
iconcomponent is rendered as a function component rather than applied to a bare map, so it receives a tracked assigns map and may usePhoenix.Componenthelpers such asassign/3andassign_new/3. A host that worked around the old behavior by adding__changed__to the assigns itself no longer needs to; the component must still return a~Htemplate, which it always had to. - Connector edges no longer cross sibling cards: an arranged container's lanes size to their own content, so a nested arrangement wider than one lane no longer overflows into the lanes beside it (ADR-0005 decision 10b).
- Clicking a gap's "+" now visibly arms that gap, and the palette says which
slot of which block the pick will land in, with a Cancel beside it and
Escapeas the way out. - A palette pick made with nothing armed says why it did nothing instead of failing silently.
- A palette narrowed to the types a slot accepts now looks different from a palette that simply holds that many types.
- The editor's plain controls - Undo, Redo, Cancel insert, the zoom steps, Fit width, Fit active, and a list field's add/remove - render as buttons rather than as whatever the host's browser paints, with a hover, a muted disabled state and an accented pressed state.
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.