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 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.
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.
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.
Assigns
| Assign | Required | Meaning |
|---|---|---|
id | yes | the LiveComponent id |
document | yes | the document being edited |
palette | yes | the host's palette |
findings | no | caller-supplied findings, merged with the two ViewModel derives |
datamodel | no | the paths the host declares; drives the undeclared-path advisories, and nil (the default) turns them off entirely |
declare | no | the {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 |
on_change | no | one-argument function called with each new document |
icon | no | function component resolving an icon name to markup |
expression_component | no | override for :expression fields (sui-bob's seam) |
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 |
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 |
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()]} the drawer's truth-table tab reads; 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 |
Summary
Functions
How many findings the document has, from the assigns a host already holds.
Functions
@spec findings_count( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), keyword() ) :: non_neg_integer()
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,nilby 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 offdocument.
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.