# `StatifierBlocks.Editor.Slot`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/editor/slot.ex#L2)

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 on an editing mount

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 one mount that draws no "+" is the read-only one (ADR-0005's
2026-09-07 profile amendment, `read_only?`). Its `palette-open` was
already on the editor's refused list, so the click was inert - but an
inert control is still a control, and the editor's own comment beside
that list says a read-only mount draws none. `read_only` is threaded here
for that sentence, from the canvas through the node, and the gap draws
its `div` and no button: the gap is the flow marker and the empty arm's
position, which a reader needs, and the button is the affordance, which
a reader does not.

An EMPTY slot is the case where withholding the button withheld more than
the affordance. The dashed ring that says "nothing is here yet" was styled
on the button, and an empty slot has no other content to carry it, so a
read-only mount drew a header and then nothing at all - which reads as a
rendering that ran out rather than as an arm with nothing in it, and an
empty arm is a real arm (ADR-0002's `10d` draws a fan edge into one). So
the gap draws a `.sb-gap__placeholder` span when the mount is read-only
AND its slot is empty: a mark, not a control that refuses (ADR-0005's Note
of 2026-09-08, item 7b). It carries no event, no `phx-` attribute and no
tab stop, and `empty?` is threaded to it from the slot rather than derived
in CSS for the same reason `data-empty` is stamped rather than derived -
the view model already knows.

The mark is also what SAYS the arm is empty, not only what draws it (the
operator's ruling of 2026-09-12). An editing mount announces the gap's
"+", so the read-only mount that draws no "+" was announcing a slot header
and then nothing, and a reader who cannot see the ring could not tell an
empty arm from an arm the rendering had cut short. So the span is a
labelled `role="img"` - the arm's own label followed by "is empty" -
rather than an `aria-hidden` decoration, since an `aria-hidden` element
cannot carry a name at all. The label names WHICH arm, which is the half a
header two lines up does not supply once a reader has moved past it.
`role="img"` and not a `button`, because the name is all that is
restored - the mark still carries no event, no `phx-` attribute and no tab
stop, which is item 7b's other half.

## The gap IS the insertion marker (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

A gap is also where an insertion is *aimed*, and until 2026-08-29 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
the editor spike'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 2026-08-29: 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

`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.

# `slot`

One slot: header, findings, and alternating gaps and children.
## Attributes

* `slot` (`StatifierBlocks.ViewModel.Slot`) (required)
* `parent_id` (`:string`) (required)
* `drag` (`:any`) - Defaults to `nil`.
* `selected_id` (`:string`) - Defaults to `nil`.
* `collapsed` (`:any`) - The folded-shut block ids, threaded to the children. Defaults to `nil`.
* `armed` (`:any`) - The `{parent_id, slot, index}` the palette is armed at, or nil. Threaded
  the way `drag` and `selected_id` are, 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}`, or `nil` when nothing is marked - threaded the way `collapsed`
  is 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`.
* `pending_remove` (`:any`) - The delete offer the canvas is holding, threaded to `BlockNode`
  (ADR-0005's 2026-09-07 amendment, clause 3D).

  Defaults to `nil`.
* `expandable` (`:any`) - The document's composite block ids, threaded to `BlockNode`
  (ADR-0005's 2026-09-07 amendment, clause 1E).

  Defaults to `nil`.
* `collapsible` (`:boolean`) - Whether the mount registered an `on_collapse` callback, threaded to
  `BlockNode` so a card draws the "Save as a step" control only where
  there is a host to hand the declaration to (ADR-0005's Note of
  2026-09-08, item 1).

  Defaults to `false`.
* `read_only` (`:boolean`) - Whether this mount edits (ADR-0005's 2026-09-07 profile amendment,
  `read_only?`). `true` draws the gaps without their "+" buttons, the way
  the palette column is not drawn at all: the position stays legible, the
  affordance goes. An empty slot's gap draws a non-interactive placeholder
  in the button's place, so the mark the ring carried is not lost with it
  (ADR-0005's Note of 2026-09-08, item 7b).

  Defaults to `false`.
* `target` (`:any`) (required)
* `icon` (`:any`) - Defaults to `nil`.
* `class` (`:string`) - Defaults to `nil`.
* `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 as
  `data-sb-depth` and banded on by the stylesheet.

  Defaults to `0`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
