StatifierBlocks.Editor (StatifierBlocks v0.31.0)

Copy Markdown View Source

The block editor: the only stateful module in the package's rendered half (ADR-0005 decisions 6, 8, 9, 13).

Everything this component does is translation. Every author gesture reduces to one of the four commands StatifierBlocks.Edit defines, and the mutation, its inverse and the set of places a block may be dropped are all pure functions of {document, palette} that were tested with LiveView absent from the dependency tree. What is left here is turning a phx- event into a command, offering it to StatifierBlocks.Edit.History, and re-rendering.

That split is the single most load-bearing constraint in ADR-0005, and the reason is drag-and-drop specifically: it is the interaction most likely to be tested by clicking around and declared fine, and the one most likely to corrupt a document when it is wrong. If the semantics of a drag lived here, they would be testable only through a browser driver.

The drag, in two round-trips

dragstart pushes one event. Edit.Targets.slot_verdicts/3 runs once, the result goes into the drag session, and the re-render stamps data-drop on every slot - so every valid target highlights before the pointer has moved, and hover costs nothing. drop pushes the position, one :move is built and applied, and the tree re-renders. dragend clears the session. There is no third round-trip and no client-side validity logic.

That one enumeration answers both halves of the drag. The accepting slots are data-drop; the refusing ones that have a data-flow reason to give (the 2026-08-29 ADR-0003 amendment's vocabulary) carry it as data-drop-reason beside it, so a later hover affordance has the reason already in the markup and still needs no round-trip and no JavaScript. The host's widening relation reaches all of this the way ADR-0003 decision 6 says it does and the way nothing else could: through palette.assignability, consulted by the one Assignability the compiler's validate/3 consults.

The other thing a drag can carry (sb-4nep)

A palette entry is a drag source too, and dragging one onto a gap inserts a block of that type there. It is the same two round-trips with the same one enumeration: insert-dragstart builds the session against a probe block of the dragged type instead of against a block the document holds, and insert-drop hands the gap's position to the same insert_from_palette/3 a pick uses. The drop therefore produces decision 2's :insert at a position the author named, exactly as the "+" and the pick do, and ADR-0005's command set is untouched - what is new is a gesture, not a command.

Which is also why the position comes from the gap rather than from palette_position. Arming is how the click path names a destination; a drag names one by landing on it. A drop made while some other gap is armed lands where it was dropped and clears the mode, because the gesture that just finished is the one that said where.

Config, and the gate that keeps the document sound

ADR-0002 decision 6 guarantees slots/1 returns without raising only for config validate_config/1 accepts - and an author halfway through typing an identifier has invalid config almost continuously. So an :update_config reaches the document only when it validates, which Edit.check_config/3 enforces inside History.commit/4.

In-progress form state that does not validate lives in this component's drafts assign: never in the document, never on the undo stack. It is overlaid onto the selected block's form at render time - values and findings both - so the author keeps their keystrokes and sees findings about the value they are currently typing, while the tree, the slot set and every consumer downstream still see the last config that validated. Crucially, the overlay never calls slots/1: it touches the form and nothing else, which is what keeps the draft from reaching a callback that was only promised valid config.

The datamodel, and why it is one assign and no logic

A host may hand the editor the datamodel paths it declares. The only thing that buys is the undeclared-path advisory ADR-0005 amendment 11e-11g specifies: a config field a block type annotated datamodel_path?: true whose value is not in that set gets an :info finding anchored on the field. The rule itself is StatifierBlocks.Datamodel's and is pure, so it is tested with LiveView absent; what happens here is one normalization on update/3 and one concatenation in rebuild/1, which is the same translation-only posture as everything else in this module.

nil is the default and means no datamodel supplied, which per 11f produces nothing anywhere - the check does not run at all. That is not the same as an empty set, which is a host declaring that its documents address nothing.

A datamodel is no longer the only thing that declares. ADR-0005 amendment 11k reads three sources, and two of them are roots rather than paths: the roots the document itself declares (ADR-0001 decision 11), which StatifierBlocks.Datamodel reads off the document with no help from here, and the roots the compile call names. The second is why there is a declare assign: this component has no compile call to read, so a host that will pass declare: to StatifierBlocks.Compiler passes the same list here. It is the same one normalization and one concatenation the datamodel gets - [] is the default and declares nothing, so a host that never passes it sees exactly what it saw before.

The rest of the host's compile call

declare is not the only option a host compiles with, and the ones it leaves out change the chart. A host that runs a document to completion compiles it with terminate: true; one that lints its invoke types passes known_invoke_types; one with declared-sensitive paths passes datamodel. Each of those changes the emitted SCXML, so each changes the provenance map that emission carries - terminate: true alone adds a top-level <final> per root-block outcome, owned by the root block in its root_<outcome> role.

This component compiles the open document three times over, and each compile is about the host's chart: refresh_run_provenance/1 resolves a run's state ids to blocks, refresh_source_view/1 builds the Source tab's listing of the generated chart, and refresh_fixture_runs/1 drives every fixture row through it. Compiling without the host's options makes each of those about a different chart than the one the host has: a configuration sitting on a state only the host's compile emits resolves to nothing and the Run pane marks nothing where it should mark the root block; the Source tab lists a chart the host does not run; a fixture row is driven through a chart nobody will execute. That is the whole of compile_options - the host hands over the option list it compiled with, and all three compiles use it. :declare is not read from it: the declare assign above is where that list already lives, and it is put on top of whatever this one carries so the two can never disagree.

[] is the default, which is today's behaviour exactly: the compiles pass declare: and nothing else, and a host that never sets the assign sees what it saw before. Nothing here validates the list - the compiler is the authority on its own options, and an option this component does not know about is one it must not swallow.

The assign is optional and it is not optional in the way a default usually is: a host compiling with terminate:, child_use:, known_invoke_types: or datamodel: and leaving it unset gets a run that is silently unmarked, a listing of a chart it does not run, and fixture verdicts from the same. Nothing fails; the surfaces just quietly describe a different chart.

The run marks a host paints

A host replaying or executing a document has two different things to say about it on the canvas: where the run is - the blocks a step has activated - and who it is waiting on - the block whose call out to a handler has not come back yet, and how it came back once it did. Those are active_marks and invoke_mark, and they are two assigns rather than one because a step can carry either without the other. One seam that took both would make every caller holding one of them pass a placeholder for the other.

They are assigns and not a new API, because every other thing a host tells this editor is an assign. A host that re-renders for its own reasons can pass them in the component call; a host reacting to a run event it received out of band pushes them, which is send_update/3 and needs nothing from this package:

Phoenix.LiveView.send_update(StatifierBlocks.Editor,
  id: "editor",
  active_marks: ["blk_capture"],
  invoke_mark: {"blk_authorize", "done"}
)

Which is exactly why a mark is held as editor state rather than read out of the assigns at render time. send_update/3 delivers only the keys it names, so a component whose marks lived only in the assigns the host most recently passed would drop them on the next unrelated re-render - an author moving a block, a header the host redrew for its own reasons. Each mark is written only by an update that carries it, the way datamodel and drawer_height already are, so a render that says nothing about the marks changes nothing about them.

A different document clears them, and that is the one place they part company with the pane folds. ADR-0005's 2026-08-30 amendment to decision 2 exempts a fold from the document-switch reset because a fold addresses no block; a mark addresses exactly one, so it stops being true the moment the block it names is gone. Marks reset in switch_document/2 beside selected_id and the collapsed set. A host that swaps a document and marks the new one in the same update is not fighting that reset: the reset runs first and the marks it passed are applied after.

The active marks are also what the toolbar's Fit active falls back on. An observer watching a run is not selecting anything - the marks are the run's own answer to "which block matters right now" - so the control is enabled whenever there is a selection or a mark, and with nothing selected it fits and reveals the first marked block. The order is the host's: the marks are kept as the list the host passed, deduplicated only where the canvas asks the question as a set. With no selection and no marks there is nothing to fit, and the control stays disabled.

A seated run marks blocks the same way and reaches the same control, which is the point of resolving the target out of the marks the canvas drew rather than out of the assign one of the two sources happens to use. A seated run decides its marks outright, so it also decides this: with a run seated the target comes from the resolved marks and not from an active_marks list a host is still carrying beside them. What a resolved run hands over is a set rather than a list, so "the first marked block" there is read off the document instead: the innermost ringed card, and the first of those in document order. Innermost, because a run standing on a leaf marks the containers it is inside as well, and fitting the outermost of those is Fit width under another name - the card a reader watching a run wants is the one the run is at.

What reaches the markup is data-run-active, data-run-invoking and - only for a call that has come back - data-invoke-outcome, on the block's .sb-node. The outcome is passed through rather than checked against a set: which outcomes a call can have is the block type's vocabulary, the one slot_outcome_key already reads, so a closed set here would be this package inventing a second one. The stylesheet tints the two the spike proved and leaves every other outcome the neutral treatment.

The Run pane's send control, and who moves the run (sb-j18n)

run_session is the other half of that seam, and it points the other way. run is what a host says about a run; run_session is a live Statifier.Session.server() the Run pane's send control puts an event into, drawn from the selected block type's fixtures/0 rows. The control writes to that session and to nothing else - never to the document, and never to run either - which is why ADR-0005 decision 15's deferral of a per-entry fixtures panel is untouched by it.

So the pane does not sync itself after a send, and that is a decision rather than an omission. This component holds no subscription to a session, does not know what a session announces its steps on, and cannot tell a step this control caused from one a timer or an invoke handler caused. The run assign is the host's - a host is the only party that already knows how its own runs are watched, and decision 15 leaves how a document is executed and stored outside this package entirely - so re-seating it after a send is the host's too. The answer is a documented host recipe, not an editor-owned subscriber.

It is the loop the marks above already describe, with the session added. A host that passes run_session:

  1. subscribes to whatever its own runs announce on - a Phoenix.PubSub topic keyed on the run, a trace subscriber, a monitor on the session process;
  2. passes the live session as run_session, and the run it is watching as run (with active_marks and invoke_mark beside it);
  3. on each message saying the run moved, re-reads the run and pushes the new reading back with send_update/3.

Step 3 is the whole of it, and it is the same send_update/3 the marks section shows. The reference embedder's StatifierExamplesWeb.EditorLive is that shape already: it subscribes per run id and its private push_run/1 pushes the marks and the drawer tab descriptors from every handler that could have moved the run, including the announcement of a step nothing on the page pressed. A send through the pane is exactly that case - an advance the page did not initiate, arriving on the subscription it already holds - so a host wiring the pane up adds run and run_session to what it passes and nothing at all to what it does when a message arrives.

Pushing rather than passing matters here for the reason the drawer tabs give: a host tab's content is a closure this package calls while rendering the panel, and an ordinary parent re-render does not re-enter that subtree.

Without a subscription the send still lands. The session advances, the canvas does not, and the author sees an event that went somewhere they cannot see - the honest reading for an editor mounted without a host watching its runs, and the reason the control is enabled only once a session is supplied and a live run is seated beside it - a persisted run reads as not sendable, because there is no session for it to send to.

Opening at a fit (sb-ehqn)

A document wider than the canvas opens with its right-hand columns off the edge, and the only remedy the editor had was the author pressing Fit width on every document they opened. fit is the host's opt-in to having that press made for them: :manual (the default) is exactly today's behaviour, and :width or :active makes the editor open the way it looks after that button.

It is an opening state and not a control, which is the whole of the care in it. The fit needs numbers only the browser has, so it cannot happen at mount; what happens at mount is that the mode is set and a fit is armed, and the first measurement payload spends it - the same computation handle_event("fit", ...) runs, on the same ladder, against the same measured scroller. It is spent once per open document, and re-armed only when the host swaps a different document in: a host re-renders for reasons of its own, and an attr that re-fitted on each of them would throw an author back to the fit every time their own header changed. What the attr opens is a document, though, so a different document is another opening and is armed from the attr the host passes in that same update - :manual or an absent attr arming nothing, exactly as at mount. Between one document's measurement and the next document's arrival the attr is inert, zoom -/+ return the canvas to :manual as they always have, and the editor is in the state it would have been in had the author pressed the button themselves.

A host that never imports the measurement hook measures nothing, so the fit is never armed away and never spent: the mode is set, the canvas is at 100%, and that is decision 7's absent-hook test holding here too.

Between the mount and that first payload the canvas is laid out at 100%, which is a frame the author should not be shown: it paints the whole chart at full size and then snaps to the fit. So for exactly as long as a fit is armed the root carries data-fit-pending, and the stylesheet keeps the stage unpainted under it - the layout still happens, because the layout is what the hook has to measure; only the ink waits. The stylesheet also carries a delayed reveal so the wait cannot outlive the frame it exists for: a hook-less host never spends the fit, and a blank canvas forever would be a worse defect than the flash. Nothing about this is the hook's - it writes no attribute, no style and no class (decision 7a), and the attr is server-stamped like every other.

What stays the host's

Which palette entries a tenant may use, who may edit or publish a document, where it is stored, and what publishing means are all outside this package (decision 15).

The 2026-08-29 shell amendment's ruling 8A adds two more, and names the seam for each: slots for markup, events for actions. The outer header - document identity, the document switcher, the theme control, compile and publish - is the :header slot, because markup is exactly the thing a host wants to own and a slot costs this package no API surface. The drawer's height is on_drawer_resize out and drawer_height back in, because the height is remembered per viewer and this package has no viewer. A host that renders its own publish button into the slot and receives the press as its own event is the intended shape; the editor draws no header of its own and is not waiting for permission to. So is concurrency: this is a single-session component, it surfaces the revision it loaded so a host can do optimistic concurrency on save, and it does not merge, rebase or resolve anything.

The drawer tabs a host contributes

8A's split gives the drawer to the package, and 1A's test - tabular, and about the whole document - governs what the package puts in it. Neither anticipated a host with content that passes that test: a host executing the open document has a run feed, and a feed of steps is a grid of rows about the whole document. The 2026-08-30 amendment recording the drawer's tab strip as a host seam transfers 1A to the host for the tabs it contributes, and drawer_tabs is where they go.

Each entry is %{id:, title:, content:} with an optional count:. id is the host's own name for the tab and is what its DOM id and its panel's are built from; title and count are what the tab and the collapsed strip draw; content is a function component - the same seam shape icon and expression_component already use, for the same reason ADR-0005 decision 9's does: HEEx has no dynamic-component tag, and a one-argument function returning a rendered struct is the whole of the contract. It is called with the tab's id and count, and with change tracking on those two keys only - which is why the example reaches for assign/2 rather than Map.put/3. StatifierBlocks.Editor.Drawer's moduledoc has what goes wrong when a host merges its own values in instead.

Phoenix.LiveView.send_update(StatifierBlocks.Editor,
  id: "editor",
  drawer_tabs: [
    %{id: "runs", title: "Runs", count: length(events),
      content: fn assigns -> MyApp.run_feed(assign(assigns, :events, events)) end}
  ]
)

A function and not a slot, and the reason is the live feed the seam exists for. A slot's body is a closure over the host's assigns, which sounds like the shorter path to live content and is not: a LiveComponent is re-rendered when the assigns it was passed change, and a host assign read only inside a slot body is not one of them, so the appended step never reaches the screen. Pushing the descriptors is how every other thing a host tells this editor arrives, send_update/3 included, and it is live for the same reason the run marks are.

Held as editor state, like the marks and for the same reason: an update that says nothing about the tabs changes nothing about them. A host tab named for one of the package's own tabs, or repeating an id already used, is not drawn - StatifierBlocks.Shell.host_tabs/1 says why.

The findings number a host may show (sb-ukgu)

A host that draws its own header usually wants to say how many findings the open document has, and the obvious way to get that number - counting whatever list the host itself passed in, or counting the compiler's raw output - produces a different number from the one the drawer's Findings tab reports. It has to: the drawer counts the caller's findings plus the :resolution and :config findings ViewModel derives plus the undeclared-path advisories, and the host's own list is only the first of those three. Two numbers for one document, side by side on the same screen, is the defect this seam closes.

So there is one number, the drawer's, and findings_count/3 is how a host reads it:

StatifierBlocks.Editor.findings_count(document, palette,
  findings: findings,
  datamodel: datamodel
)

Its arguments are deliberately the assigns the host already holds and already passes to the component, not the editor's internal state. That is what makes it usable: the editor is a LiveComponent, so a host has no handle on its socket, and a number that could only be read out of that socket would have to be pushed back through on_change - late by one render on mount, and absent entirely for a document nobody has edited yet. A pure function of the same inputs is available on the host's first render, needs no round-trip, and cannot drift: the option keys are the assign names, and the component's own rebuild/1 builds its view model through the very same private function this one calls.

What comes back is Shell.findings_count/1 over ViewModel.findings - orphans included, for the reason recorded there. A host showing a different number than the drawer is a bug in the host; a host showing none is fine.

The insert mode, and the pick that lands nowhere (sb-dfyk)

palette_position is a mode, and every visible part of it hangs off that one assign: the armed gap on the canvas, the instruction naming where the pick will land, the Cancel beside it, and the Escape binding - which is on the root only while the mode is open, so a resting editor holds no window listener for a mode nobody opened. Arming also un-collapses the palette, because a mode whose only instruction is inside a folded pane is the same defect in a different place.

A pick made with nothing armed stays a no-op, and that is a ruling rather than an omission. The alternative on the table was appending to the selected container's default slot, and "default slot" is a rule no accepted ADR states: decision 8 ties a pick to a position the author named at a gap, and inventing a destination on their behalf would be a new contract written into a handler rather than into the record. What the no-op was missing was not a destination but a reason, so it now gives one - palette_unarmed_pick is that sentence, and it renders in the same region the armed case uses for its instruction.

The selection a host can follow (on_select)

The selection is editor state: it is produced by a gesture on the canvas and only the component knows it, so - unlike the findings number above - there is no pure function of the host's own assigns that answers it. What cannot be read has to be pushed, and on_select is the push.

It is a one-argument function, called with each new selection and never for its return value, and it sits beside on_change rather than inside it: a document and a selection are different subjects, and a host that wants one should not have to receive the other. What arrives is a descriptor rather than a block -

KeyValue
idthe selected block's id
typethe block's type name, as the document stores it
labelthe card's first line: the author's title where they gave one, the type's label otherwise
  • because the host already holds the document it passed in, so returning the block's config would be a second channel for something the host can already read, and one that goes stale the moment the two disagree.

Deselection calls on_select with nil, because a panel that follows the canvas has to be able to empty itself; a callback that only ever fired on a new block would leave the panel showing the last one forever. It fires when the selection changes and not otherwise - not on every render, not on an edit to the selected block, not on a mount with nothing selected, and not on re-selecting the block that is already selected.

Nothing about it is a document edit. There is no :select command, and nothing here is serialized, stored, undone or redone. The package's own inspector still reads the selection out of component state: this is a seam out, not a rewiring of what is already inside. See ADR-0005's 2026-09-05 amendment, the host seams, on_select and a selection descriptor.

The seam the other way is the selected_id assign, which a host with a selection surface of its own writes to move the selection the callback reports back. It is an input and not a command: the sentence above stands exactly as written, because a documented assign is not a :select command and nothing about a selection is serialized, stored, undone or redone. An id the open document does not hold clears the selection rather than naming a block that is not there. See ADR-0005's 2026-09-07 amendment, a selected_id a host may write, honoured in update/2 through rebuild/1.

"Save as a step", and the declaration a host receives (on_collapse)

The other seam out, and the one that had to be built without becoming a write. StatifierBlocks.Composite.Collapse.propose/3 reads a selection back as the Composite.Data declaration that stands for it; the gesture calls it and hands the result to on_collapse, a one-argument function beside on_change and on_select, called for its effect and never for its return value.

The gesture edits no document and this package persists nothing. No command is committed, no StatifierBlocks.Edit is built, on_change never fires, and the undo history is not touched: the document after the gesture is byte-identical to the document before it. What the host receives is a map, and which table it goes in, which tenant it belongs to, and whether it is saved at all are the host's - ADR-0005's 2026-09-07 amendment, clauses 15E to 20E, and epic ruling R5 behind them.

A refusal is delivered too, or it is not delivered at all: propose/3 answering {:error, reason} is a refused gesture in the sense Expand's three refusals already are, reported in the editor's own chrome, and on_collapse fires only on the {:ok, _}, so a host callback never has to match a failure it did not ask for.

The marking step is the smallest presentation that is honest about what 18E decides. 18E says the proposed params are the values the author marks, and every value differing from its field's default where they mark none - so the gesture cannot be a single button, and it does not need to be more than a list. "Save as a step" on the selected card opens a tray naming every block in the selected subtree and every config field on it, each a checkbox; Save proposes, Cancel closes, and a tray with nothing ticked is 18E's unmarked reading rather than a proposal with no params. There is no preview of the declaration, no naming step and no type-name field, because naming is the host's act (15E) and a package that drew a name field would be drawing a decision it cannot make.

It is offered on a read-only mount as well, and that is deliberate rather than an oversight: it is not on @read_only_refused because it reaches nothing a read-only mount withholds. Reading a document is the whole point of such a mount, and this gesture is a read.

With no on_collapse registered, neither the control nor the tray is drawn (ADR-0005's Note of 2026-09-08, item 1). A gesture whose only outcome is a callback nobody registered offers the author a marking step, a Save, and then silence; withholding it is the honest answer, and it is the same answer read_only? clause 1 gives about the palette column - not rendered, rather than rendered inert. The four events the gesture is made of are answered with the socket unchanged on such a mount, the way @read_only_refused's are, so a crafted payload opens no tray. The condition is notify_collapse/2's: a one-arity function. "Replace with its steps" is untouched in both directions - it commits an StatifierBlocks.Edit the editor makes itself and needs no host.

Collapse.replacement/4 - the compound that swaps the arrangement for a composite of the name the host registered - is a separate public function, and nothing in this component calls it (17E). A host that wants the swap commits it itself through StatifierBlocks.Edit.Session.commit/2, after it has stored the declaration, named it, and rebuilt its palette with it.

Profiles, and a read-only mount

A host that mounts this editor for two audiences out of one codebase says so with the profile assign: a plain map naming which of the editor's surfaces this mount draws, and whether this mount edits at all. It is ADR-0005's 2026-09-07 amendment, and docs/profiles.md is the page that works it through - the default, the ids each list draws from, a minimal mount and a read-only one.

Two properties are worth having here rather than only there. A host that passes no profile gets the editor it had before the assign existed, because every key is optional and every list defaults to :all; there is no arrangement of the map, %{} included, that removes a surface a host did not name. And an id a list names that the package cannot resolve is dropped, never raised - a profile is written once against the tab set of the version it was written for, and an id that outlives its tab has to leave a surface missing rather than a mount that crashes at render.

run?: false mounts an editor that watches no run: it seats none whatever the host passes in run and run_session, so there is no run pane, no run marks on the canvas and no Held here column in the Datamodel tab. It addresses the run rather than the pane because a seated run decides the canvas's marks outright, so hiding the pane alone would ring cards out of a stream the reader has been given no surface to read (ADR-0005's Note of 2026-09-12). It does not reach active_marks and invoke_mark: those are marks a host paints itself, and a mount that asks for no run and then names blocks by hand still draws the blocks it named.

read_only?: true renders the document without offering any way to change it: no palette column, no drag hook on the canvas, no "+" button on the gaps between blocks, config fields drawn as values rather than controls, Undo and Redo hidden rather than disabled, and every gesture that would reach the document answered with the socket unchanged - so on_change never fires. Selection and findings are untouched, because reading the document is the whole point of the mount. A document is never refused for being read-only.

Assigns

AssignRequiredMeaning
idyesthe LiveComponent id
documentyesthe document being edited
paletteyesthe host's palette
findingsnocaller-supplied findings, merged with the two ViewModel derives
datamodelnothe paths the host declares; drives the undeclared-path advisories, and nil (the default) turns them off entirely
declarenothe {id, expr} roots the host will pass the compiler as :declare; declared roots count as declared for the advisories (11k), and [] (the default) declares none
compile_optionsnothe rest of the option list the host compiles this document with - terminate:, child_use:, known_invoke_types:, datamodel: - forwarded to every compile this component runs: the provenance recompile behind the Run pane's marks, the Source tab's listing, and the fixture runs. Without it those three describe a chart the host does not have, silently. :declare is taken from the declare assign whatever this list says, and [] (the default) compiles exactly as it did before
on_changenoone-argument function called with each new document
on_selectnoone-argument function called with each new selection: a %{id:, type:, label:} descriptor, or nil for no selection
on_collapsenoone-argument function called with each declaration the "Save as a step" gesture proposes, in on_select's shape. The gesture edits no document and this package persists nothing: what the host does with the map - which table, which tenant, whether it is saved at all - is the host's. It is also what draws the gesture: unset (the default), no card carries the "Save as a step" control, no tray is drawn, and the gesture's four events are answered with the socket unchanged (ADR-0005's Note of 2026-09-08, item 1)
selected_idnothe block the editor is about, written by a host that has a selection surface of its own; honoured only on an update that carries it, and an id the open document does not hold clears the selection instead of naming it. Held as editor state, and cleared when the host opens a different document. Not a command: it moves the selection, it does not edit the document
iconnofunction component resolving an icon name to markup
expression_componentnooverride for :expression fields (sui-bob's seam); with it unset, an :expression renders statifier-ui's own expression editor when that package is on the host's load path, and the package's plain source input when it is not
debouncenowhat phx-debounce the inspector's config controls carry - nil (the default) renders no attribute anywhere, which is byte for byte what this component rendered before the assign existed. The form posts phx-change on each change event and this component offers an :update_config for each one it decodes, so a host persisting on its own on_change writes once per keystroke unless it asks for something slower. StatifierBlocks.Editor.ConfigForm's own attr, which a host composing that component reached directly, documents the accepted values and why every control means every control; this is the same attr, reachable from the mount

| value_candidates | no | the values offered per datamodel path, %{path => [%{label:, value:} | binary]}; merged over the datamodel's own one_of enumerations, per path, so a path this map names uses this map's list and a path it does not name keeps what the datamodel declares. Read only by an expression editor that draws value pickers; %{} (the default) now means nothing beyond what the datamodel declares rather than nothing at all | | field_candidates | no | the values a host offers for one field, keyed {type_name, field_key}: [{value, label}] for a closed list, which a :string field draws as a <select>, or {:open, [{value, label}]} for an open one, drawn as a <datalist>. A {:path, opts} and an :expression field read it too and draw either spelling as a <datalist>, ahead of the declared datamodel paths, because the value stays typed by the control. %{} (the default) offers none, and a field it does not name renders exactly as it did. It draws a control and decides nothing: validate_config/1 is still the only authority on a value, and a stored value a closed list does not offer is drawn rather than rewritten | | invoke_types | no | the invoke types the host is prepared to answer; suggestions on an invoke_type field, never a constraint, and [] (the default) means no list supplied | | chart_outcomes | no | what the host says each of its stored documents finishes with, %{document id => [outcome]}; offered as candidates on a core.subchart's outcomes field and compared against what that block declares (StatifierBlocks.ViewModel.outcome_findings/3). %{} (the default) says nothing about any chart, and a chart the map does not name is unknown, which is not disagreement | | active_marks | no | the block ids a run has activated; held as editor state, and cleared when the host opens a different document | | invoke_mark | no | the block a run is calling out to and how the call came back - {block_id, outcome}, a bare block_id for no answer yet, or nil for no call at all | | run | no | a run to watch over this document: statifier-ui's StatifierUI.Live.State, live or persisted, or nil (the default) for no run. It seats the canvas in a run pane, decides the marks outright while it is there, and puts the run's held values beside the declared ones in the Datamodel tab. Held as editor state behind the same guard the marks use, and cleared when the host opens a different document | | run_session | no | the live session the Run pane's send control puts events into: a Statifier.Session.server(), or nil (the default) for none. Held as editor state behind the same guard run uses, and cleared when the host opens a different document. The send writes to the session only: re-seating run afterwards is the host's, per The Run pane's send control above | | theme | no | --sb-* custom properties for the canvas root | | fit | no | the fit the editor opens in: :manual (the default), :width or :active; the first measurement performs it once, and an unknown value is refused into :manual | | fixtures | no | %{block_id => [TruthTable.t()]}, read by both the drawer's truth-table tab and, as of sb-4yze, its Fixtures tab (refresh_fixture_runs/1 drives each row through the compiled chart), and, as of sb-e30x, by the inspector's config form for the fixture hint beside an :expression control, and, as of sb-0l36, by the inspector's own Fixtures tab, which shows the selected block's runs out of the same result; nil (the default) means no fixtures source, and the drawer is still there with a count of 0 | | drawer_tabs | no | tabs the host contributes to the drawer, each %{id:, title:, content:} with an optional count:; drawn beside the package's own and rendered by calling content | | drawer_height | no | the drawer's height in rem, remembered by the host per viewer (2A); bounded on the way in | | on_drawer_resize | no | one-argument function called with each new drawer height, which is how the host comes to have one to remember | | class | no | appended to the root element's own classes | | history_limit | no | bound on the undo stack; :infinity by default | | profile | no | which surfaces this mount draws, and whether it edits: %{drawer_tabs:, inspector_tabs:, palette_groups:, toolbar:, read_only?:, run?:}, every key optional and every list :all by default. An id a list names that the package cannot resolve is dropped. See Profiles, and a read-only mount above and docs/profiles.md |

Summary

Types

Which surfaces a mount renders, whether it edits, and whether it watches a run (ADR-0005's 2026-09-07 amendment, and its 2026-09-12 Note for run?). Every key is optional and every list may be :all.

A toolbar item a profile may list.

Functions

How many findings the document has, from the assigns a host already holds.

Types

profile()

@type profile() :: %{
  optional(:drawer_tabs) => [StatifierBlocks.Shell.tab_id()] | :all,
  optional(:inspector_tabs) => [StatifierBlocks.Shell.inspector_tab()] | :all,
  optional(:palette_groups) => [String.t()] | :all,
  optional(:toolbar) => [toolbar_chip()] | :all,
  optional(:read_only?) => boolean(),
  optional(:run?) => boolean()
}

Which surfaces a mount renders, whether it edits, and whether it watches a run (ADR-0005's 2026-09-07 amendment, and its 2026-09-12 Note for run?). Every key is optional and every list may be :all.

toolbar_chip()

@type toolbar_chip() :: :history | :zoom | :fits | :metrics

A toolbar item a profile may list.

Wider than Editor.Toolbar's own word for a chip - two of the four are groups of buttons - and named for the shape ruling RQ-SF036-1 spells. :metrics is the two right-aligned read chips and nothing else; the Canvas heading and the nested tree chip are not addressable, because a profile that could remove them could leave the middle pane unnamed.

Functions

findings_count(document, palette, opts \\ [])

How many findings the document has, from the assigns a host already holds.

This is the same number the drawer's Findings tab reports, computed by the same code - see the moduledoc's findings number a host may show for why the seam takes inputs rather than reading component state, and StatifierBlocks.Shell.findings_count/1 for what is inside the number.

opts mirrors the assigns of the same name, and defaults to the component's defaults:

  • :findings - the caller-supplied findings, [] by default.
  • :datamodel - the paths the host declares, nil by default, which per ADR-0005 amendment 11f turns the undeclared-path advisories off entirely rather than declaring that the document addresses nothing.
  • :declare - the roots the host will pass the compiler, [] by default, which per amendment 11m declares none. The document's own roots need no option: they are read off document.
  • :chart_outcomes - what the host says its stored documents finish with, %{} by default, which says nothing about any chart and so reports no subchart disagreement (sb-r4w7).

render(assigns)

Slots

  • header - The host's outer header (8A): document identity, the document switcher, the theme control, compile and publish. The package renders the slot's markup and none of its own, and a host that fills nothing gets no header element at all.