StatifierBlocks.ViewModel (StatifierBlocks v0.13.0)

Copy Markdown View Source

Everything the editor renders, derived from {document, palette, findings} (ADR-0005 decisions 9, 10, 11, 12, 13).

This is the load-bearing module decision 13 names: it is where resolution, migration, validation, and palette_entry/0 lookup happen, and it produces a structure in which every block already carries its declared slots, its form fields, its presentation metadata, and its findings routed to the position that renders them. It lives outside StatifierBlocks.Editor.* despite being an editor concern, and it names no LiveView module - the components downstream of it are close to mechanical, reading a view model and emitting markup with no palette lookups and no callback invocations of their own.

Two derived finding sources, and the compiler adapter

build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()]. This module derives exactly two sources of its own, because decision 13 puts resolution, migration and validation inside ViewModel rather than upstream of it:

  • :resolution - Palette.resolve/2 failing on a block (:unknown_block_type, :block_type_too_new, :migration_failed), anchored {:block, id}, severity :error.
  • :config - validate_config/1 on a resolved block, one per {key, message} pair, anchored {:config, id, key}, severity :error.

:assignability and :lint findings are never produced here; their producers live elsewhere - StatifierBlocks.SlotValidation (palette-aware slot arity and undeclared-slot checks; landed under sb-da9, was described here as "not yet built") and Assignability.validate/3 for the first, the compiler's invoke-type lint for the second - and this module does not adapt StatifierBlocks.Compiler.Finding into StatifierBlocks.Finding to manufacture them. That adapter is a real, mechanical possibility (Compiler.Finding carries block_id and config_key, which map onto {:config, id, key} / {:block, id} cleanly), and it landed instead as StatifierBlocks.Finding.from_compiler/2 (sb-kmk) - ViewModel still derives no findings from it; callers adapt compiler findings themselves and pass the result to build/3 as caller-supplied findings. Derived and caller-supplied findings are concatenated - derived first - into one list, which is both t().findings and the document-level panel's source.

Routing, and the case that must not vanish

Each finding in the concatenated list is placed by its anchor:

AnchorPosition in the view model
any anchor naming a block id not in the documentt().orphan_findings
{:block, id}that node's findings
{:slot, id, name}that slot's findings (a slot name the node does not carry falls back to the node's findings)
{:config, id, key}, key matches a config_schema/1 fieldthat field's findings
{:config, id, key}, key matches no fieldthat node's form.unrouted

The fourth row exists because Core.Branch.config_schema/1 keys one field per arm by the arm's own slot name, but validate_config/1 also emits findings keyed "arms" - a key that matches no field, because adding or removing an arm is a document edit, not a form value (core/branch.ex). form.unrouted is rendered at the head of the config form for exactly this case, and an unresolvable node - which has no form at all - folds the same case into its own findings instead of discarding it.

No route drops a finding: every arm of the table above lands somewhere, and t().findings_count accumulates the same placements. A node's findings_count covers its whole subtree - its own findings, its slots' findings, its form's field and unrouted findings, plus every child's own findings_count - so a collapsed subtree can carry a count badge (decision 11's last sentence) without walking back down into it.

d10's defaults

palette_entry/0 is optional, and every one of its keys has a default (decision 10) so a block type that implements none of it still renders: label defaults to the type name, group to "Other", description to "", icon to nil, keywords to [], order to 0, layout to :stack, slot_style to %{}, and slot_outcome_key (decision 10's proposed 10f) to %{}.

d10's outcome declaration

A container whose statically-named slot holds blocks that finish it in more than one way may declare, per slot, the config key those blocks carry the answer under - core.group says slot_outcome_key: %{"interrupts" => "outcome"}. Two things come out of it here, both read through BlockType's total normalizers: the slot carries the declared key as Slot.outcome_key, and every child in that slot carries the resolved value as Node.outcome. A malformed declaration, or a child whose config holds no well-formed outcome name, is nil in both places - the uniform rendering, never a broken one (ADR-0002 amendment B3).

d12: unresolvable nodes

A block whose type does not resolve renders with its type name, a status: {:unresolvable, reason} carrying Palette.resolve/2's own error term, form: nil, its config as canonical-JSON text in raw_config_json (there is no config_schema/1 to drive a form and inventing one would be guessing), a :resolution finding, and its existing children rendered normally, recursively with raw slot names - the document's slots map preserved every one of them, decoding never having consulted a registry.

d9: the form is a projection, never cached

A resolved node's form.fields come from module.config_schema/1 called against the block's current config, every time build/3 runs. Nothing here memoizes a schema across an edit: a branch that gains an arm gains a field the very next time build/3 is called, because the schema is a function of config (ADR-0002 decision 7), not a cache of one.

Summary

Types

Threaded through the recursive walk instead of two positional arguments.

What the parent's palette_entry/0 says about ONE slot: how it is placed (decision 10's slot_style) and where its children carry their outcome (decision 10's slot_outcome_key, proposed as 10f). Passed as a pair rather than as two arguments because build_slot/8 is already at the arity the style guide allows, and because the two are read together.

t()

Functions

The custom-property NAME a palette entry declares as its block type's accent, or nil when it declared none or declared one this package will not put in a style attribute (ADR-0005 decision 14's accent_token).

How a container arranges its body slots: :lanes, :fan or :stack (ADR-0005 amendment 10b, and the campaign-012 spike's arrangementOf).

Every slot placed in the body flow, in order: the arrangement's columns.

Whether a container draws as a boundary box: true when ANY of its slots declares a rail style (ADR-0005 amendment 10c, as amended by 10h).

Builds the view model. Derives :resolution, :config and :lint findings from {document, palette}, concatenates findings after them, routes every one of the combined list per the moduledoc's table, and groups palette's types into palette_groups.

Which edge vocabulary a slot's exit is drawn in (amendment 10h's exit-edge row, as ruled on sb-67s, 2026-08-29).

The words on the pill drawn on the edge below an arranged container, or nil when nothing is arranged (ADR-0005 amendment 10b, campaign 016).

A slot's children that are in the flow: everything but a shelf.

Whether one slot is placed as an attached rail rather than in the body flow: :secondary and :failure, and nothing else (amendment 10h's placement row).

Whether this node is the drafts shelf itself.

A slot's children that are shelves - at most one, by ADR-0002 G12b, and drawn after the flow children so the shelf sits at the foot of the canvas.

The block type's own label, drawn under the title when the title is the author's - the one thing a renamed card says nowhere else, and nil when the author's name and the type's label are the same word.

The chips on the card's second line: the type's summary of this block's config, one element per chip, and [] when there is no row to draw.

The name on the face of a card: the author's own, when this block carries one, and the block type's palette label otherwise.

Whether one slot is a detached shelf rather than either a body slot or an attached rail (ADR-0005's amendment of 2026-08-31, section 10s).

Types

ctx()

Threaded through the recursive walk instead of two positional arguments.

slot_presentation()

@type slot_presentation() ::
  {:primary | :secondary | :failure | :tray, String.t() | nil}

What the parent's palette_entry/0 says about ONE slot: how it is placed (decision 10's slot_style) and where its children carry their outcome (decision 10's slot_outcome_key, proposed as 10f). Passed as a pair rather than as two arguments because build_slot/8 is already at the arity the style guide allows, and because the two are read together.

t()

@type t() :: %StatifierBlocks.ViewModel{
  document_id: StatifierBlocks.Document.id(),
  findings: [StatifierBlocks.Finding.t()],
  orphan_findings: [StatifierBlocks.Finding.t()],
  palette_groups: [StatifierBlocks.ViewModel.PaletteGroup.t()],
  revision: non_neg_integer(),
  root: StatifierBlocks.ViewModel.Node.t()
}

Functions

accent_token(entry)

@spec accent_token(map()) :: String.t() | nil

The custom-property NAME a palette entry declares as its block type's accent, or nil when it declared none or declared one this package will not put in a style attribute (ADR-0005 decision 14's accent_token).

This is the consumption half of that seam. The editor stamps the name on the block's card and its palette row and rebinds --sb-block-accent there; two rules in the stylesheet read that property - an icon tile and a card stripe - and they are the only two, which is what keeps a block type's identity from becoming a rule per type. The editor never learns a type name at any point in that path.

A descriptor carries a name and never a colour, on the same discipline icon is under: a block type naming a hex value would be deciding what it looks like in themes it has never seen. The value is the theme's.

Total, and validating for the reason the spike's theme.js was: the return value is interpolated into a style attribute, so anything but an anchored --sb-* name resolves to nil and the card falls back to the editor's accent. A typo in a host's registry degrades to the default rather than producing a broken card or an injection point - ADR-0002 amendment B3's discipline, arriving at one more key.

iex> StatifierBlocks.ViewModel.accent_token(%{accent_token: "--sb-accent-invoke"})
"--sb-accent-invoke"

iex> StatifierBlocks.ViewModel.accent_token(%{accent_token: "red; background: url(x)"})
nil

iex> StatifierBlocks.ViewModel.accent_token(%{})
nil

arrangement(node)

@spec arrangement(StatifierBlocks.ViewModel.Node.t()) :: :lanes | :fan | :stack

How a container arranges its body slots: :lanes, :fan or :stack (ADR-0005 amendment 10b, and the campaign-012 spike's arrangementOf).

One derivation, read by three consumers that would otherwise each grow their own: the class BlockNode puts on the slot box, the words the ONE OF / ALL OF pill reads, and Connectors' decision between a fan and a single entry edge. Three answers derived separately from the same two facts is how they drift, and the pill disagreeing with the layout is the drift a reader would see first.

The two facts, and neither of them a type name:

  • layout: :columns - decision 10's own metadata - is :lanes, the concurrent arrangement. core.parallel declares it.
  • More than one body slot is :fan, the exclusive one. core.branch reaches it by declaring one slot per arm, and a host type of the same shape reaches it the same way.

Rails are excluded from the count for the same reason Connectors excludes them: a rail is attached beside the body, so it is not one of the things the body fans into.

A container with no body slot at all arranges nothing and is :stack, whatever it declares - there is no second column for a marker to sit over.

body_slots(node)

Every slot placed in the body flow, in order: the arrangement's columns.

boundary?(node)

@spec boundary?(StatifierBlocks.ViewModel.Node.t()) :: boolean()

Whether a container draws as a boundary box: true when ANY of its slots declares a rail style (ADR-0005 amendment 10c, as amended by 10h).

The partition is the rail partition, :secondary and :failure alike, not the :secondary partition. 10c's stated reason - an attached rule is about a region, so the region needs a visible edge - is as true of a failure path as of an interrupt, and deriving both the rail placement and the boundary from one partition is what kept decision 13's recursion from acquiring a branch.

Drawing a box around every container instead turns a deeply nested document into nested rectangles that read as noise, which is why this reads metadata rather than depth.

build(document, palette, findings)

Builds the view model. Derives :resolution, :config and :lint findings from {document, palette}, concatenates findings after them, routes every one of the combined list per the moduledoc's table, and groups palette's types into palette_groups.

The :lint half is the summary chips the presentation cap refused (StatifierBlocks.BlockType.summary_refusals/2), one :warning per refusal - the only derived finding here that is not an error.

exit_edge(slot)

@spec exit_edge(StatifierBlocks.ViewModel.Slot.t()) :: :flow | :interrupt

Which edge vocabulary a slot's exit is drawn in (amendment 10h's exit-edge row, as ruled on sb-67s, 2026-08-29).

:interrupt for the interrupt rail alone. A :failure rail's exit is :flow, the same edge an ordinary body slot leaves by: ADR-0004's amendment makes a failure path end in an error-outcome final whose completion event the PARENT continues on, so it leaves in-band, and the dashed exit channel with the interrupt arrowhead stays exclusively interrupt vocabulary. Before the ruling the spike drew a failure rail in-band and drew it leaving out-of-band in the same picture.

It is total over the three styles rather than defined on rails only: the answer for a body slot is the same :flow its children already leave by, and a partial function here would make every caller re-derive the rail test this module already owns.

fan_label(node)

@spec fan_label(StatifierBlocks.ViewModel.Node.t()) :: String.t() | nil

The words on the pill drawn on the edge below an arranged container, or nil when nothing is arranged (ADR-0005 amendment 10b, campaign 016).

The distinction the pill states is the one the arrangement already makes and nothing else in the picture does: a fan's columns are alternatives and a parallel's lanes are concurrent, and side-by-side columns look identical either way. The spike drew the same two words off the same derivation, and its note is the reason this is here rather than in the renderer: the words are the only place the exclusive/concurrent distinction is stated.

It is the editor's own vocabulary rather than a type's, which is what separates it from join_label - a type phrases what its columns come back together as, because only the type knows its completion rule, but whether its columns are alternatives is a fact about the arrangement this module already derived.

flow_children(slot)

A slot's children that are in the flow: everything but a shelf.

rail?(slot)

Whether one slot is placed as an attached rail rather than in the body flow: :secondary and :failure, and nothing else (amendment 10h's placement row).

10i's posture applies above this: a slot_style value this editor does not know resolves to :primary before it ever reaches here, so a host declaring against a newer record gets an ordinary body slot rather than a raise or a dropped slot.

shelf?(node)

Whether this node is the drafts shelf itself.

A shelf is a child of the root's body like any other block, so the slot holding it is an ordinary :primary one and tray?/1 says nothing about it. What has to be true of the node is 10u's other half: no connector enters the shelf and none leaves it, so it is not in its own parent's chain either. flow_children/1 and shelf_children/1 are that partition, and they are the rendering counterpart of ADR-0002's G9a - the sibling before the shelf is adjacent to the sibling after it, on the canvas for the same reason it is in the compiler.

shelf_children(slot)

A slot's children that are shelves - at most one, by ADR-0002 G12b, and drawn after the flow children so the shelf sits at the foot of the canvas.

subtitle(node)

@spec subtitle(StatifierBlocks.ViewModel.Node.t()) :: String.t() | nil

The block type's own label, drawn under the title when the title is the author's - the one thing a renamed card says nowhere else, and nil when the author's name and the type's label are the same word.

nil for every block the author has not named, because there the second line is the type's summary of this block's config and that line is a row of chips rather than a string: it is summary_chips/1, drawn as its own markup (ADR-0005's 2026-08-30 amendment, decision 10, the summary chip row). This function and that one are the two arms ADR-0002 amendment H5 describes, and exactly one of them answers for any card.

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...>   block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...>   entry: %{label: "Wait"}
...> })
nil

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...>   block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...>   entry: %{label: "Wait"}, summary: ["timer 30s"]
...> })
nil

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...>   block_id: "b", type: "host.step", type_version: 1, status: :ok,
...>   entry: %{label: "Intake"}, title: "Collect the details"
...> })
"Intake"

summary_chips(node)

@spec summary_chips(StatifierBlocks.ViewModel.Node.t()) :: [String.t()]

The chips on the card's second line: the type's summary of this block's config, one element per chip, and [] when there is no row to draw.

The other arm of ADR-0002 amendment H5, and the reader ADR-0005's 2026-08-30 amendment (decision 10, the summary chip row) describes. [] means no row at all rather than an empty one, which is the card every type had before it declared a summary: summary/1 is optional and eight of the thirteen core types declare none.

Empty for a block the author has named, because that card's second line is already the type's label (subtitle/1) and a card has one second line. A string summary arrives here as a one-element list, so the one-chip case draws one chip and no separator of any kind.

Nothing is refused here. An over-long or newline-carrying chip was already dropped where the node was built (StatifierBlocks.BlockType.summary/2, under ADR-0002 B3's refuse-never-truncate discipline), so this reads what survived. What did not survive is not silent: build/3 raises a :lint warning against the block for each refused chip, so the difference between "declared nothing" and "declared something too long" is readable.

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...>   block_id: "b", type: "core.on_event", type_version: 1, status: :ok,
...>   entry: %{label: "On event"}, summary: ["Abandon", "fraud.aborted"]
...> })
["Abandon", "fraud.aborted"]

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...>   block_id: "b", type: "core.sequence", type_version: 1, status: :ok,
...>   entry: %{label: "Sequence"}
...> })
[]

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...>   block_id: "b", type: "host.step", type_version: 1, status: :ok,
...>   entry: %{label: "Intake"}, title: "Collect the details",
...>   summary: ["from the type"]
...> })
[]

title(node)

The name on the face of a card: the author's own, when this block carries one, and the block type's palette label otherwise.

Two questions, not one, which is why this and subtitle/1 are a pair rather than one field. A card answers "what is this step" with the most specific name available, and "what kind of step is it" underneath - and when the only name available IS the type's, there is nothing for the second line to add.

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.title(%ViewModel.Node{
...>   block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...>   entry: %{label: "Wait"}
...> })
"Wait"

iex> alias StatifierBlocks.ViewModel
iex> ViewModel.title(%ViewModel.Node{
...>   block_id: "b", type: "host.step", type_version: 1, status: :ok,
...>   entry: %{label: "Intake"}, title: "Collect the details"
...> })
"Collect the details"

tray?(slot)

Whether one slot is a detached shelf rather than either a body slot or an attached rail (ADR-0005's amendment of 2026-08-31, section 10s).

:tray is deliberately not in the rail partition. 10h made "is this container a boundary box" a question asked of that partition, on 10c's grounds that an attached rule is about a region and a region needs a visible edge. A tray is not attached to a region; it is beside the document. Folding it in would put a boundary box around the root block of every document that has a shelf - a frame drawn around the entire workflow to say something about a shelf beside it (10t).

It is not in the body partition either, which is what body_slots/1 spells: a tray is not one of the things a container fans into, and its contents take no entry edge.