One named slot: its header, its children, its gaps, and its "+" buttons (ADR-0005 decisions 5, 6, 8, 11, 13).
Validity is a property of the slot, and it arrives as markup
Decision 5 makes drop-target validity per-slot rather than per-gap, and
decision 6 makes it reach the client as markup. So during a drag session
this component stamps data-drop="ok" on the slot when the server's
one-shot Edit.Targets.droppable_slots/3 accepted it and data-drop="no"
when it did not, and hover highlighting is CSS on that attribute. There is
zero round-trip per hover, the client holds no validity logic to fall out
of sync, and the highlight cannot disagree with the server because it is
the server's answer.
Outside a drag session the attribute is absent entirely, which is what distinguishes "not a target for the block being dragged" from "no drag is happening".
A refused slot that has a data-flow reason to give also carries
data-drop-reason - not_assignable, or fixable_by:<block id> naming
the block whose declaration an author would change (ADR-0003's 2026-08-29
amendment). It is absent when the refusal was structural, when it was for
room or for the dragged block's own subtree, or when different gaps in
the slot refused for different reasons: those are refusals with no single
honest sentence to show, and an attribute is not the place to guess one.
Nothing reads it yet; it is markup so that the hover affordance that
eventually does needs no round-trip and no JavaScript, exactly as
data-drop needs none.
The gaps, and why "+" is always there
A slot with n children has n+1 gaps, each carrying data-parent-id,
data-slot and data-index - the DOM contract decision 7's hook reads.
Each also carries a "+" button, and it is rendered whether or not a drag
is in progress: decision 8 wants every drop target reachable without
dragging, and an affordance that only appears mid-drag would be reachable
only by dragging. The palette that opens is filtered by the same predicate
the drag uses, not a parallel implementation of it.
The gap IS the insertion marker (R3, operator ruling 2026-08-29)
The ruling asks for "a marker on the edge between siblings, subtle at rest, highlighted on hover and during drag; empty slots keep a placeholder". A gap already sits between two siblings, and the flow edge between them already runs through it - the marker is therefore the gap, restyled, rather than a second thing drawn on the overlay beside it.
That is the whole reason it is not on the connector layer. The overlay is
aria-hidden, carries pointer-events: none, and is redrawn from a
measurement that does not exist until a host imports the measure hook; a
marker living there would be an insertion affordance that is invisible to
a screen reader, unreachable by keyboard, and absent entirely from the
hook-absent editor. Riding the gap keeps every server event, the
phx-click palette path and the keyboard path exactly as they shipped,
and the marker degrades to the affordance it already was.
data-empty is the placeholder half: a slot with no children has one gap
and nothing else, so it is the gap that has to say an empty arm is a real
arm you can drop into. It is stamped on the slot rather than derived in
CSS from the absence of a child, because "this slot is empty" is a fact
the view model already has and :has() would be re-deriving.
The armed gap (sb-dfyk)
A gap is also where an insertion is aimed, and until sb-dfyk it said so nowhere. Clicking a "+" opened the palette against that one position and left forty other plus signs looking exactly like the one that had just become the destination of the next pick, so the mode the editor was now in was legible only to the server.
armed is that position, threaded down here the way drag and
selected_id already are, and for the same reason: which gap is armed is
a fact about the whole editor, and a component that decided it locally
would be a second answer to a question that has one. The gap compares the
tuple to its own three coordinates and says data-armed and
aria-pressed - the attribute for the stylesheet, the ARIA state for
everyone the stylesheet cannot reach, since a "+" that has become a live
toggle is exactly the case aria-pressed exists for.
The rail partition
:secondary and :failure are both attached rails rather than body
slots (amendment 10h's placement row), and ViewModel.rail?/1 is that
partition. The container's boundary box is derived from the same
partition, one level up in BlockNode.
What tells the two apart is a class each and nothing else:
sb-slot--secondary for the interrupt's dashed warning edge, and
sb-slot--failure for the failure path's solid error-family one. The
vocabularies say opposite things about the same placement - an interrupt
fires whether or not you get there, a failure path is the continuation
this step takes when it goes badly - so a shared placement with no
distinction read as a second set of interrupt rules, which is what
campaign 013's screens recorded.
The exit edge
A rail's exit is the second question 10h asks of the style, and the
operator ruled it on sb-67s: a failure rail leaves by the ordinary
flow edge, and the dashed exit channel stays interrupt vocabulary.
ViewModel.exit_edge/1 is that derivation and data-exit-edge is it in
the markup, stamped on the rails and nowhere else: a rail is the only
slot whose exit rejoins a flow the reader can see, and the stylesheet
draws the mark off the attribute's presence, so an attribute on a body
slot would be a claim about a mark that is not there. The function is
total over the three styles all the same - the rail test belongs here,
not in every caller. Nothing here reads a type name to reach either
answer: core.invoke gets the failure vocabulary by declaring
slot_style: %{"on_error" => :failure}, and a host type declaring the
same gets the same.
The fan's landing point
The header carries data-sb-anchor, which is where a fan edge lands when
the container above arranges more than one slot (the amendment to decision
7, and StatifierBlocks.Connectors). The header rather than the first
card inside it: the header is what carries the slot's name and its guard,
and an edge that ran past it to the card would cross the very condition
the edge is subject to. An empty slot's header is also its exit anchor -
an arm with nothing in it yet is a real arm, and a fan that silently
skipped it would tell an author their empty arm does not exist.
The label, and the condition under it
A slot's name and the condition it is subject to are ONE unit in the
header, wrapped in sb-slot__name, and the findings sit beside that unit
rather than beside the label. The wrapper is what lets the chip sit
under the words it qualifies while the header stays the baseline row
the fan lands on and the column layout centres: a header that stacked its
own children would have to be a column, and the arrangement rules above
it place a row.
The chip carries ViewModel.Slot.condition - the expression source, in
the author's own text, read-only. Read-only because this is the canvas:
the condition is editable in the inspector's form, where it is a field
with a schema, a finding anchor and a value path, and a second editable
spelling of one value on a surface with none of those is two controls
that can disagree about what was typed.
A slot with no condition source renders NO chip - not an empty one. Every slot in a stacked group would otherwise grow a blank box, and a blank box under a label reads as a condition that evaluated to nothing rather than as a slot that is not subject to one.
Nothing here reads a type name to decide any of it. core.branch gets
chips because its arms declare an :expression field keyed by the arm's
slot, and a host type that declares the same gets the same.
Depth, and why it is threaded (sb-d7g)
data-sb-depth is this slot's ROOT-RELATIVE nesting depth, and it is the
counter the recursion carries down rather than a number looked up. The
root block's own slots are 0, and each BlockNode a slot renders is one
deeper than the slot that rendered it.
Shell.depth/1 does NOT answer this. It is the subtree MAXIMUM the
toolbar reports - how deep the document goes - so every slot of one
document would get one number from it and the bands would be flat.
The attribute is the whole of the markup contract: the stylesheet bands on
its parity, alternating a ground per nesting level, and there is no class
and no wrapper element beside it. Depth 0 is deliberately unbanded, so
the canvas keeps its own dotted ground and the first band is the first
level of nesting.
Recursion
Slot renders children via BlockNode and BlockNode renders slots via
Slot, and that is the whole tree. Groups, lanes, branch arms and
interrupt rails are these same two components differing only by the
layout and slot_style metadata decision 10 put on palette_entry/0.
There is no Group component and no Parallel component, and decision 13
says there must never be one.
Summary
Functions
One slot: header, findings, and alternating gaps and children.
Functions
One slot: header, findings, and alternating gaps and children.
Attributes
slot(StatifierBlocks.ViewModel.Slot) (required)parent_id(:string) (required)drag(:any) - Defaults tonil.selected_id(:string) - Defaults tonil.collapsed(:any) - The folded-shut block ids, threaded to the children. Defaults tonil.armed(:any) - The{parent_id, slot, index}the palette is armed at, or nil. Threaded the waydragandselected_idare, and for the same reason: the gap that was clicked is the one that has to look different, and only the editor knows which one that is.Defaults to
nil.marks(:any) - The host's run marks -%{active: MapSet.t(), invoke: {block_id, outcome} | nil}, ornilwhen nothing is marked - threaded the waycollapsedis and for the same reason: a mark is editor state that addresses a block, and nothing in the view model carries it.Defaults to
nil.target(:any) (required)icon(:any) - Defaults tonil.class(:string) - Defaults tonil.depth(:integer) - This slot's ROOT-RELATIVE nesting depth: the root block's own slots are 0, the slots of a block inside one of those are 1, and so on. Stamped asdata-sb-depthand banded on by the stylesheet (sb-d7g).Defaults to
0.