StatifierBlocks.Editor.BlockNode (StatifierBlocks v0.20.0)

Copy Markdown View Source

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.

The unresolvable card's face (campaign-017 ruling D4)

Decision 12's card is the one exception to "the face is four lines", and since ruling D4 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 D4 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 24-character chip) refuses rather than truncates under ADR-0002 amendment B3. Fed a finding, it would answer nil every time and leave the face blank; truncating one here instead would be a second, contradictory presentation policy 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 D4 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 R2, campaign 016)

"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 R2 now ships too, on container chrome only, as .sb-node__fold. It is not a fifth command: ADR-0005's command set is still :insert, :move, :remove, :update_config, 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 - campaign-017 ruling D4 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 sb-vamn 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; sb-vamn removed it and left the class 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.

Summary

Functions

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

Functions

block_node(assigns)

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.

  • 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 (sb-d7g).

    Defaults to 0.