StatifierBlocks.ViewModel (StatifierBlocks v0.2.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.

: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:

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).

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

ctx()

Threaded through the recursive walk instead of two positional arguments.

slot_presentation()

@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.

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

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 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.

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.