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/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.
:arity, :assignability and :lint findings are never produced here;
their producers live elsewhere - sb-da9 (palette-aware arity and
undeclared-slot checks, not yet built), Assignability.validate/3, and
the compiler's invoke-type lint respectively - 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).
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.
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).
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 and :config 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.
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).
Types
@type ctx() :: {StatifierBlocks.Palette.t(), %{optional(StatifierBlocks.Block.id()) => [StatifierBlocks.Finding.t()]}}
Threaded through the recursive walk instead of two positional arguments.
@type slot_presentation() :: {:primary | :secondary | :failure, 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 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 and :config 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.
@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.