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.
The derived finding sources, and the compiler adapter
build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()].
This module derives sources of its own, because decision 13
puts resolution, migration and validation inside ViewModel rather than
upstream of it. There are five of them, listed below. The count grew
with ADR-0005 clause 11o, which superseded the "exactly two" this
moduledoc used to say, and again with clauses 11p to 11t, which gave a
host a whole-document rule of its own - so the number is stated here
rather than left to a reader to count, and the list beneath it is what it
counts:
:resolution-Palette.resolve/2failing on a block (:unknown_block_type,:block_type_too_new,:migration_failed), anchored{:block, id}, severity:error.:config-validate_config/1on a resolved block, one per{key, message}pair, anchored{:config, id, key}, severity:error.:configagain, this time about the whole document - a palette entry declaringsingleton(ADR-0005 clause 10z) that the document does not satisfy, anchored{:block, root_id}, severity:error. See "d10's cardinality declaration" below.:lint- a summary chip the presentation cap refused, anchored{:block, id}, severity:warning.:lintagain, this time from a host's own whole-document rule - aStatifierBlocks.DocumentValidatorin the palette'svalidators(ADR-0005 clauses 11p to 11t) - anchored wherever the rule says, severity:warningunless the rule says otherwise. The rule says where and what; this module stamps the source, which is what keeps a host's rule distinguishable from a declared shape's:config.
:assignability findings are never produced here, and the two :lint
producers above are the only ones that are; the rest 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 :assignability, the
compiler's invoke-type lint for a :lint this module has never derived -
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:
| Anchor | Position in the view model |
|---|---|
| any anchor naming a block id not in the document | t().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 field | that field's findings |
{:config, id, key}, key matches no field | that 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).
d10's cardinality declaration
A palette entry may declare singleton: :head | :anywhere (ADR-0005
clause 10z), and build/3 answers it with one :config finding per
violating type - never with a repair. Both values mean "the document
holds exactly one block of this type"; :head additionally means it is
the first child of the root's first slot.
What "the root's first slot" is, because a root type declaring more
than one slot leaves the phrase undefined otherwise: it is the first
slot the root's type declares - slots/1's list is ordered and that
order is load-bearing everywhere else here, so the head is index 0 of
that slot's children. A root whose type declares no slots, or does not
resolve at all, falls back to the alphabetically first slot name the root
block actually carries, which is the order build_resolved_node/4
already puts undeclared slots in. Every :head finding names the slot it
measured against, so an author with a multi-slot root is never left
guessing which one the rule meant.
The count is over the document's blocks by type and the declarations
come from the palette, which is what lets the zero case exist at all:
a type nothing in the document uses still draws its finding, because the
palette is where the host said the document needs one. One finding per
violating type, not one per surplus block. The anchor is {:block, root_id} - decision 11's anchor enum has no document member, this clause
does not widen it, and the root is the one block every document has.
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.
A field declared hidden?: true (ADR-0002 decision 7, amended
2026-09-07) is still listed in form.fields, carrying the flag. The
projection is the whole schema, and hiding is a rendering claim rather
than a filter: a host reads the list and filters by the flag to draw its
own surface, and StatifierBlocks.Editor.ConfigForm - the package's own
form - is the surface that skips them. Keeping them in the list is also
what lets ConfigForm.decode/3 preserve a hidden value, since that
function is keyed off the fields it is handed.
Summary
Types
What a host says each of its stored documents finishes with: the finals a
compile with StatifierBlocks.Compiler's :child_use option emits for
that document, keyed by the document id an author types into a
core.subchart's chart field.
Threaded through the recursive walk instead of two positional arguments.
How a block in outline/1's list is reached from the block above it
(ADR-0005's 2026-09-07 amendment).
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.
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.
The disagreements between what a core.subchart declares in outcomes
and what the host says the chart it names actually finishes with
(sb-r4w7).
The document in reading order: one {node, depth, kind} per block,
pre-order (ADR-0005's 2026-09-07 amendment).
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 raw text behind each chip summary_chips/1 draws, nil where the
chip is drawn as its type declared it, and always exactly as long as
summary_chips/1.
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
What a host says each of its stored documents finishes with: the finals a
compile with StatifierBlocks.Compiler's :child_use option emits for
that document, keyed by the document id an author types into a
core.subchart's chart field.
@type ctx() :: {StatifierBlocks.Palette.t(), %{optional(StatifierBlocks.Block.id()) => [StatifierBlocks.Finding.t()]}, StatifierBlocks.BlockType.chip_labels()}
Threaded through the recursive walk instead of two positional arguments.
@type kind() :: :step | :arm | :rail | :tray
How a block in outline/1's list is reached from the block above it
(ADR-0005's 2026-09-07 amendment).
A partition of how a block is reached, never a filter on which blocks are listed: arms, rails and trays are kinds, not omissions.
@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.
@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
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
@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.paralleldeclares it.- More than one body slot is
:fan, the exclusive one.core.branchreaches 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.
@spec body_slots(StatifierBlocks.ViewModel.Node.t()) :: [ StatifierBlocks.ViewModel.Slot.t() ]
Every slot placed in the body flow, in order: the arrangement's columns.
@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.
@spec build(StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), [ StatifierBlocks.Finding.t() ]) :: t()
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, and whatever the palette's validators return - the derived
findings that are not errors.
The document-level rules run after the per-block ones and before the
findings argument (ADR-0005 clause 11t): the palette's own singleton
declarations first, then each StatifierBlocks.DocumentValidator in the
palette's list order. A palette declaring neither has nothing to run and
builds exactly the view model it built before either existed.
@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.
@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.
@spec flow_children(StatifierBlocks.ViewModel.Slot.t()) :: [ StatifierBlocks.ViewModel.Node.t() ]
A slot's children that are in the flow: everything but a shelf.
@spec outcome_findings( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), chart_outcomes() ) :: [ StatifierBlocks.Finding.t() ]
The disagreements between what a core.subchart declares in outcomes
and what the host says the chart it names actually finishes with
(sb-r4w7).
It is a separate pass rather than part of build/3 for the reason
StatifierBlocks.Datamodel.findings/4 is: chart_outcomes is the
host's input, not the document's, and the projection stays a function of
the document plus the palette. The result goes in through the same
caller-findings seam every other supplied finding uses, so there is one
routing path and one place it is tested.
Three rules, and the first is the one that keeps it quiet:
- Unknown is not disagreement. A
chartthe map says nothing about produces nothing, and so does an entry holding an empty list - an empty list is the absence of knowledge about a chart's finals, not the claim that it has none. ADR-0005 amendment11ftakes the same posture for anildatamodel, for the same reason: a host that has not answered has not disagreed. - The author's own list is what is compared.
child_outcomes/1, notoutcome_names/1: the appendederroris ADR-0068's failure event rather than a<final>the child reports. - It is a
:warning, never an error. The document compiles either way. What a mismatch costs is a conditioneddone.invoketransition that can never match - a dead routing arm - which is exactly the "compiles, and may not behave as intended":warningnames. The source is:lintfor the reasonsummary_findings/4above is one: the rule is this package's reading of a host value, not the block type'svalidate_config/1, which cannot read the chart at all.
Anchored {:config, block_id, "outcomes"}, so it renders under the
field the author would fix it in.
@spec outline(t()) :: [ {StatifierBlocks.ViewModel.Node.t(), non_neg_integer(), kind()} ]
The document in reading order: one {node, depth, kind} per block,
pre-order (ADR-0005's 2026-09-07 amendment).
Pure, and a pure function of the view model alone - it reads root and
walks the tree build/3 already put in the struct, resolving nothing,
calling no callback, consulting no palette and reading no findings.
Calling it twice on one view model returns two identical lists.
It exists so that the outline pane this package may grow, a host's own
list view and a test asserting what a document says read one walk rather
than three re-derivations of it. Node.sentence is the line each entry
draws; depth is how far it is indented.
Every block appears exactly once. The first entry is always
{root, 0, :step}. A consumer wanting only the flow filters the list it
was given: the walk hides nothing, because a walk that hides a failure
rail is a walk a reviewer cannot trust to be the document.
kind | The slot the node's parent holds it in |
|---|---|
:step | the parent's body, where arrangement/1 is :stack |
:arm | one of the parent's body slots, where arrangement/1 is :fan or :lanes |
:rail | a slot rail?/1 accepts |
:tray | a slot tray?/1 accepts |
Depth is block nesting depth and nothing else. Every child is
exactly one deeper than the node whose slot holds it, in all four rows:
a slot is not an entry in the list and never consumes a level, so an
arm's blocks are one deeper than their container and a rail's blocks sit
at the same depth as that container's body blocks. The slot's identity
is carried by kind instead, which is why kind exists rather than a
second numeric column.
:step versus :arm is arrangement/1's question, asked once here
rather than re-derived from a slot count, so a type declaring
layout: :columns reads as arms for the same reason its slots sit side
by side on the canvas and the two surfaces cannot drift apart.
Slot order is the canvas's order: body_slots/1 first, then the
rails, then the trays, each group in node.slots order. Within a slot
the order is flow_children/1 then shelf_children/1, so a drafts
shelf sits at the foot of its slot and takes that slot's kind like any
other child - it is visited rather than skipped, because
flow_children/1 exists so a renderer can draw connectors past the
shelf, not so a reader can be told the shelf is not in the document.
shelf?/1 and this list are what a consumer wanting the flow alone
reads.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{
...> "body" => [
...> Block.new("core.wait", id: "wait", config: %{"duration" => "30s"}),
...> Block.new("core.send", id: "send", config: %{"event" => "order.paid"})
...> ]
...> }
...> )
iex> root |> Document.new() |> ViewModel.build(Palette.core(), []) |> ViewModel.outline()
...> |> Enum.map(fn {node, depth, kind} -> {node.block_id, depth, kind, node.sentence} end)
[
{"root", 0, :step, "Sequence"},
{"wait", 1, :step, "Wait 30s"},
{"send", 1, :step, "Send order.paid"}
]
@spec rail?(StatifierBlocks.ViewModel.Slot.t()) :: boolean()
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.
@spec shelf?(StatifierBlocks.ViewModel.Node.t()) :: boolean()
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.
@spec shelf_children(StatifierBlocks.ViewModel.Slot.t()) :: [ StatifierBlocks.ViewModel.Node.t() ]
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.
@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"
@spec summary_chip_titles(StatifierBlocks.ViewModel.Node.t()) :: [String.t() | nil]
The raw text behind each chip summary_chips/1 draws, nil where the
chip is drawn as its type declared it, and always exactly as long as
summary_chips/1.
ADR-0005 decision 10w's other half. A chip whose text has the shape of a
generated done-event name is drawn as <block label> and the outcome,
and the raw name goes on the chip's title attribute - verbatim and
untruncated, because that is what keeps the translation lossless for an
author reading a screenshot beside generated SCXML.
The length is realigned against summary_chips/1 rather than trusted,
so a Node built by hand - a doctest, a host's fixture - answers one
nil per chip instead of an empty list the caller would zip away.
iex> ViewModel.summary_chip_titles(%ViewModel.Node{
...> block_id: "blk_ON", type: "core.on_event", type_version: 1, status: :ok,
...> entry: %{label: "On event"}, summary: ["Abandon", "fraud.aborted"]
...> })
[nil, nil]
@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"]
...> })
[]
@spec title(StatifierBlocks.ViewModel.Node.t()) :: String.t()
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"
@spec tray?(StatifierBlocks.ViewModel.Slot.t()) :: boolean()
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.