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

One block's chrome, dispatching to its slots (ADR-0005 decisions 10, 11,
12, 13).

## No branch on a type name, ever

Decision 10's `layout` and `slot_style` are how nested groups, lanes and
interrupt rails render distinctly **without the editor branching on a type
name**. `core.parallel` declares `layout: :columns`, so its lane slots sit
side by side and the absence of ordering between them is visible;
`core.resumable_group` declares `slot_style: %{"interrupts" => :secondary}`,
so its interrupt rules render as an attached rail rather than a second
body. A host block type with the same structural shape gets the same
rendering by declaring the same thing, which is the property that matters.
There is no branch anywhere in this module on `"core.parallel"` or on any
other type string, and the operator pre-decision behind ADR-0005 is that
there never will be.

`icon` is a **name**, never markup. This component takes an icon component
as an assign and passes the name to it; a host that ships heroicons
renders heroicons. Accepting raw SVG from a callback would be injecting
host-authored markup into this package's own render tree - an injection
surface, and a guarantee that the icon set fragments across palettes.

A host that ships nothing gets `StatifierBlocks.Editor.Icons`, this
package's own set for the names its own palette emits, and an entry that
names no icon at all gets no tile. Neither is a hole in the paragraph
above: the default is markup this package wrote, resolved from a name by
the same seam, and the host's `icon` still wins wherever it is passed.

## The card face

Four lines at most, and every one of them is a different question:

  * the icon tile, which says what KIND of thing this is before anything
    is read;
  * the title, the most specific name available for this step - the
    author's own when the block carries one, and the type's label
    otherwise (`ViewModel.title/1`);
  * the second line, which is one of two facts and never both: the
    type's label when the title above is the author's
    (`ViewModel.subtitle/1`), so a named block never hides what type it
    is; otherwise the type's summary of this block's config, drawn as a
    row of chips (`ViewModel.summary_chips/1`, ADR-0005's 2026-08-30
    amendment) and absent entirely when the type declares none, so an
    unnamed block never says its type twice;
  * the invoke type in mono, when the block's config carries one - the
    one fact about a step that calls out to a handler that an author
    checks most and a label cannot carry.

None of the four is a branch on a type name. The title and the subtitle
read a declared field and a palette label; the invoke line reads a
config KEY, so a host type that calls a handler gets the same line
`core.invoke` does by carrying the same key.

## A finding on the card face (ADR-0005's Note of 2026-09-08, item 2)

A finding that draws on a face draws **inside the card's own box** -
it is a child of `.sb-node__chrome`, a row below the title and the chip
row that spans the chrome grid, rather than a sibling of the chrome under it.
On a leaf the two positions look alike; on a CONTAINER they do not. A
container's node box is as wide as everything it holds and its card is
the chrome centred inside that box, so a finding placed under the
chrome drew as a full-width line between the card and the first slot
label beneath it, reading as the slot's rather than the card's. The
Note rules that a face finding is contained by the card it is about,
and being a child of the card is what makes that true at every width.

The presentation diagnostics do not draw here at all - see
`face_findings/1`.

## The unresolvable card's face

Decision 12's card is the one exception to "the face is four lines", and
since the operator's ruling of 2026-08-29 it is a *smaller* exception
than it was: a type name and **one** short reason, and nothing else.
Its findings and its raw config are the inspector's - the Block section
holds the bytes, the Findings tab holds every finding - which is what
keeps the card the same width as the siblings it sits beside.

Before that ruling this face carried both, and the pair is what made the
card misread. The findings are sentences, the config is a canonical JSON
object, and a lane is as wide as the widest thing in it: one unresolvable
block pushed its whole column out and buried the shape of the document
under the detail of its one broken part. Detail an author reads once, on
demand, is what a selection is for.

The reason is read off `status` - `Palette.resolve/2`'s own error term,
which has three shapes - and phrased here, rather than taken from the
first finding. Two reasons, and neither is about length alone:

  * a finding's message is a sentence written for a list, and the only
    presentation cap this package has (`BlockType`'s 32-character chip)
    exists precisely to refuse one: under ADR-0002 amendment B3 it
    **refuses rather than truncates**, and the summary-chip carve-out
    ADR-0005's Note of 2026-09-08 item 2 records clips a chip to a
    prefix that still names a declaration. A sentence has no such
    prefix, so neither treatment leaves anything worth reading, and a
    third policy invented here would be a contradictory one on the same
    card.
  * a phrase chosen from a closed set is bounded by construction. No
    host-authored string reaches this line, so no host can widen the
    card by writing a longer finding - which is the failure that ruling
    is undoing.

The chrome stays dashed and the badge still counts, so the card says it
is broken and how much is wrong with it; what it no longer does is say
all of it at once.

## The container box

A container draws a box around its body only when it is a BOUNDARY, and
`ViewModel.boundary?/1` is the whole of that question: the node has a slot
in the rail partition (ADR-0005 decision 10c, as amended by 10h, which
widened the partition from `:secondary` to any rail style and left 10c's
reason - an attached rule is about a region, so the region needs a visible
edge - unchanged). This module stamps `sb-node--boundary` from that
predicate and decides nothing else about it.

Every other container draws no box, which is the rest of 10c: a box around
each of them turns a deep document into nested rectangles that read as
noise, and what an author needs to see there is the cards and the edges
between them. The container's own card stays at the head of its body, so
the block is still a thing on the canvas without its subtree being fenced.

None of that is in this file beyond the class and `data-container`: the
box is paint, the stylesheet owns paint, and a host restyling the editor
reads the same two hooks the package's own stylesheet does.

## The delete affordance (operator ruling, 2026-08-29)

"`x` on hover, `-` + `x` on the selected card, nothing at rest." A
delete control on every one of forty cards at rest is noise competing
with the workflow itself.

Hidden by opacity and by nothing else. `display: none` and
`visibility: hidden` both take the button out of the tab order, which
would make deleting a block a pointer-only gesture - so the rest state
is a control that is present, focusable, and revealed by `:focus-visible`
the moment a keyboard reaches it. `data-reveal` on the button is what the
stylesheet's rest rule selects and what the presentation test asserts,
so the contract is one string rather than a computed style nothing can
check.

The `-` half of that ruling now ships too, on container chrome only, as
`.sb-node__fold`. It is not a command: no member of ADR-0005's command
set (`StatifierBlocks.Edit.t()`) folds a container, and which
containers are folded shut is editor state the shell holds beside the
selection (the amendment to decision 2, 2026-08-30). The fold is a native
`<button>` carrying `aria-expanded`, so Enter and Space are the browser's
and no window key binding exists. Its rest state differs from the `x` in
one way the stylesheet reads off `data-reveal`: revealed on hover or
selection while the container is open, and **always** visible while it is
shut, because a control that hides a region has to be the way back.

## Unresolvable blocks (decision 12)

A block whose type does not resolve renders rather than vanishing: its
type name, unavailable chrome, one reason line, and **its existing
children rendered normally, recursively** - the document's `slots` map
preserved every one of them, decoding never having consulted a registry.

Its findings and its config read-only as canonical JSON (there is no
`config_schema/1` to drive a form and inventing one would be guessing)
are still rendered, and still nowhere else in the editor - an operator
ruling moved them from this card to the inspector's Block and Findings
sections, for the reason the card-face section above gives. Decision 12's
"nothing is lost" is unchanged by that; what changed is which surface
shows it.

It may be selected, moved and deleted. Its config may not be edited. It is
never a drop target, because decision 5's first rule needs `slots/1` and
there is none - `Edit.Targets` already leaves it out of the droppable set,
so nothing here has to re-derive that.

The acceptance property is preservation: open a document containing a
block type the host does not have, edit an unrelated part of the tree,
save, and the unresolvable block's bytes are unchanged. An editor that
quietly dropped it would turn a missing palette entry into silent data
loss.

## The accent, and why the stylesheet still names no type

A palette entry may declare `accent_token`, the NAME of a `--sb-*`
custom property (ADR-0005 decision 14's amendment). This component
stamps it on the card and rebinds `--sb-block-accent` there; the
stylesheet reads that property in exactly two rules, an icon tile and a
card stripe, so adding a block type with its own identity adds no CSS
and no branch. The value is the theme's - a descriptor carries a name,
never a colour - and a name that does not validate resolves to the
editor's accent rather than to a broken card.

## The measurement anchors

Three of them per block, stamped as `data-sb-anchor` and read by nothing
in this module: the node's own box, its card, and a zero-height outlet at
the very bottom of everything it contains. The outlet exists so that flow
leaves a container from BELOW its children rather than from its header -
a sequence's last block and the sequence itself have to leave from the
same line, or every edge out of a nested container starts in the wrong
place.

They are markup, and the amendment to decision 7 is why that is all they
are: the measurement hook reads these boxes and pushes them, and
`StatifierBlocks.Connectors` decides what to draw from the numbers. A host
that never imports the hook has three inert attributes and an empty div.

## The join marker

A container whose slots sit side by side draws a marker under them saying
what happens when they are done - `core.parallel` completing on its first
lane says "continue at first". The words are the block type's, resolved by
`StatifierBlocks.BlockType.join_label/2` and carried on the view model, so
this component draws a string and never learns that a completion rule
exists. A type that declares none gets the editor's own word, which is what
ADR-0002 amendment B's `nil` means and what the spike drew before any type
could carry a completion rule: the marker is about the arrangement, so an
arrangement that fans out still says where it comes back together.

A node whose slots stack draws no marker at all. There is nothing to
rejoin, and a word under a single column reads as a rendering bug.

## The badge, and the one face that carries it

ADR-0005 puts the count badge on a **collapsed** subtree (:461, :1457):
`findings_count` covers a whole subtree precisely so that a node folded
shut can still say something inside it needs attention. Decision 11's last
sentence is what that is for - a finding must never hide inside something
folded shut, which is the failure mode that makes tree editors feel
unreliable.

So `.sb-badge` renders on a **collapsed** container whose subtree rollup is
greater than zero, and nowhere else: never on an expanded face, and never
at a count of zero. What shipped before 2026-08-30 was a badge on every
container whenever its rollup was non-zero, which is not what the record
says and read as an error on every container face as the counts multiplied
up the tree; that badge was removed and the class left as the seam this
renders into.

The rollup itself is unchanged and is still on every node as
`data-findings-count`, expanded or not, so the drawer's Findings tab and
the inspector's grouping read exactly what they read before. The badge is
a **ring** rather than a fill - the treatment :1233-1235 argues for, and
the reason `--sb-fg-on-accent` had no consumer left to justify it.

# `block_node`

One block: chrome, findings, and its slots, recursively.
## Attributes

* `node` (`StatifierBlocks.ViewModel.Node`) (required)
* `drag` (`:any`) - Defaults to `nil`.
* `selected_id` (`:string`) - Defaults to `nil`.
* `collapsed` (`:any`) - The `MapSet` of block ids the author has folded shut, threaded down the
  recursion the way `selected_id` is. A node reads its own membership and
  passes the whole set on; nothing here holds state.

  Defaults to `nil`.
* `armed` (`:any`) - The `{parent_id, slot, index}` the palette is armed at, passed through to `Slot`. 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 this canvas is holding - `%{block_id:, ids:}`, or
  `nil` when none is open (ADR-0005's 2026-09-07 amendment, clause 3D).
  Threaded the way `marks` is and for the same reason: it is editor
  state that addresses a block, and nothing in the view model carries
  it. Only the card whose `block_id` it names draws anything.

  Defaults to `nil`.
* `expandable` (`:any`) - The block ids in this document that are composite block types - a
  `MapSet`, or `nil` when none is threaded. Threaded the way
  `pending_remove` is and for the reason ADR-0005's 2026-09-07 amendment
  clause 7E gives: a composite draws as the ordinary leaf card it is and
  `ViewModel.Node` gains no field for it, so nothing in the drawing code
  learns the word "composite" and only the Expand affordance (clause 1E)
  reads this.

  Defaults to `nil`.
* `collapsible` (`:boolean`) - Whether this mount registered an `on_collapse` callback - a one-arity
  function - and therefore whether the "Save as a step" control is drawn
  (ADR-0005's Note of 2026-09-08, item 1). Threaded rather than derived
  because the callback is the editor's assign and a node has no way of
  asking what the mount was given. `false` (the default) draws no Save
  control on any card: the gesture's only outcome is a callback nobody
  registered, so offering it would offer the author a marking step, a
  Save, and then silence. "Replace with its steps" is unaffected in
  both directions - it commits an `Edit.t()` the editor makes itself.

  A mount that registered the callback draws the Save control's BOX on
  every card that could ever carry it, selected or not: selected, the
  control itself; unselected, an `aria-hidden` placeholder of the same
  box (`.sb-node__strip-reserve`). The strip is a reserved grid column
  sized by its own children (ADR-0005's Note of 2026-09-08, item 4), and
  the reveal the other controls use - `opacity`, which changes no box -
  holds that reservation across a REVEAL but not across a change in
  WHICH controls are PRESENT. Without the placeholder, selecting a card
  added a member to its strip, the strip's column grew, the title's
  column shrank and the title re-wrapped: the reservation held for hover
  and broke for selection. The placeholder is `visibility: hidden`
  rather than `opacity: 0` because unlike the controls it must leave the
  tab order and the accessibility tree as well as the paint, while
  keeping the box that is the whole point of it.

  Defaults to `false`.
* `read_only` (`:boolean`) - Whether this mount edits (ADR-0005's 2026-09-07 profile amendment,
  `read_only?`), threaded on to this node's slots so their gaps draw no
  "+" button.

  `true` also withholds the two controls on the card's own strip whose
  events such a mount refuses: "Replace with its steps"
  (`.sb-node__expand`) and Delete (`.sb-node__remove`), both members of
  the editor's `@read_only_refused`. Withholding, rather than drawing the
  control and rendering a refusal, is the operator ruling of 2026-09-12,
  and it follows the precedent already set by clause 1's palette
  column and by the Note of 2026-09-08 item 7c's gap "+": never draw a
  gesture the mount refuses. The fold toggle and "Save as a step" are
  untouched - folding changes no document, and Save is conditioned on the
  `on_collapse` callback, not on `read_only`.

  Defaults to `false`.
* `target` (`:any`) (required)
* `icon` (`:any`) - Defaults to `nil`.
* `class` (`:string`) - Defaults to `nil`.
* `root?` (`:boolean`) - The document's root. It is neither draggable nor deletable, because
  `Edit.apply/2` refuses both for it (`check_not_root/2`) - rendering the
  affordances anyway would be an interface that lies about what it can do.

  Defaults to `false`.
* `depth` (`:integer`) - How many slots this node sits inside, counted from the root. The
  recursion carries it because nothing else can: `Shell.depth/1` is a
  subtree MAXIMUM for the toolbar, and a node has no way of asking where
  it is from inside its own render. `Slot` stamps it and the stylesheet
  bands on it.

  Defaults to `0`.

---

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