StatifierBlocks.Shell (StatifierBlocks v0.5.0)

Copy Markdown View Source

The shell's arrangement, as pure functions (ADR-0005, the 2026-08-29 shell amendment: rulings 1A, 2A, 3A, 7A, 8A).

The amendment records where each region of the editor goes and what kind of content it holds. Almost none of that is markup. Which zoom step a "+" reaches, how deep the tree is, which of five states the drawer is in, which block ids the drawer's index page offers, and which of a block's fields are conditions are all questions with answers, and every one of them is decided here rather than in a ~H template - the same split decision 13 draws and the same reason: a template is testable only with LiveView present, and these answers are the part worth testing.

So this module is lib/statifier_blocks/, not lib/statifier_blocks/editor/. Nothing in it sits behind the LiveView-presence guard the editor's own modules are wrapped in, and the headless suite exercises all of it (ADR-0005 decision 1).

The drawer is five states, not a boolean

drawer_view/1 is the whole of 2A that is not CSS. A drawer is never open-or-gone: collapsed it is a strip carrying a title and a count, and open with nothing to show it is an index page listing the blocks that do own a table. Both of those are states an author reaches through a sequence of gestures rather than by looking at a screenshot, which is why they are computed by a function with a return value instead of by three :if attributes.

The drawer does not own a selection. Its subject is the selected block and it follows the canvas, because a drawer that pinned its own subject would be a second cursor in the editor.

Fixtures arrive built

fixtures is %{block_id => [TruthTable.t()]}, or nil for no fixtures source - which is a different state from a source that holds nothing, and the strip says (0) for both while the drawer still exists. This package deliberately does not invent a fixture-bundle format: ADR-0002 decision 9 puts that convention in statifier-ui, so what crosses this seam is tables a host already built with StatifierBlocks.Predicates.TruthTable.build/2.

Summary

Types

What the drawer is showing, from its own flag, its tab and the selection.

Which of the drawer's tabs is showing (1A, and the R4 ruling of 2026-08-29 that put document findings here).

One entry in the drawer's tab strip: what it is called and how much it holds.

Truth tables a host supplies, keyed by the block they describe.

Where an armed insertion would land, in the two names the author already reads off the canvas: the slot's label and the holding block's title.

Which of the inspector's three tabs is showing (3A).

Functions

How many blocks the tree holds, the root included.

Every finding about the selected block, in one list (3A).

A cell's status as one word an author reads, per StatifierBlocks.Predicates.TruthTable.Cell's five values.

The drawer's height in rem, clamped to the band the layout can hold.

The nearest ladder step to percent.

The selected block's condition-bearing fields, in form order.

The condition source a field carries, as a string a template can render.

100%, where the canvas opens.

How deep the tree nests, the root counting as 1.

The drawer tab value names, or :tables.

The drawer's tabs, in the order the strip lists them.

The title a drawer tab carries, on the strip and on the tab itself.

What the drawer shows, from its own open flag, its tab, the fixtures source, the document's findings and the selection (2A, and R4).

The value type a field declares, as a stable data- string.

The largest ladder step at which content wide fits available wide.

The height band the resize control offers: {min, max, default} in rem.

The names of the position a palette pick would insert at, or nil.

The tab value names, or :config.

The inspector's three tabs, in the order 3A lists them.

A label for a block id, for the index page's jump list.

The label of the slot the block carrying block_id sits in.

Every block id the fixtures source has a table for, sorted.

How many tables the whole source holds - the number the collapsed strip carries.

The tables fixtures holds for one block, or [].

The scroller's usable box out of a measurement payload, or nil.

The anchor key the canvas scroller is stamped with.

The scaled extent of a measured box, or nil at 100% and unmeasured.

One step up the ladder, or the top of it.

One step down the ladder, or the bottom of it.

The width the stage is LAID OUT at while a zoom is applied, or nil.

The zoom ladder the toolbar steps along, ascending.

Types

drawer()

@type drawer() :: %{
  open?: boolean(),
  tab: drawer_tab(),
  tabs: [drawer_tab_entry()],
  status: :closed | :no_fixtures | :no_selection | :none_for_block | :ready,
  subject_id: StatifierBlocks.Block.id() | nil,
  tables: [StatifierBlocks.Predicates.TruthTable.t()],
  findings: [StatifierBlocks.Finding.t()],
  orphans: MapSet.t(StatifierBlocks.Finding.t()),
  count: non_neg_integer(),
  jumps: [StatifierBlocks.Block.id()],
  title: String.t()
}

What the drawer is showing, from its own flag, its tab and the selection.

drawer_tab()

@type drawer_tab() :: :tables | :findings

Which of the drawer's tabs is showing (1A, and the R4 ruling of 2026-08-29 that put document findings here).

drawer_tab_entry()

@type drawer_tab_entry() :: %{
  id: drawer_tab(),
  title: String.t(),
  count: non_neg_integer()
}

One entry in the drawer's tab strip: what it is called and how much it holds.

fixtures()

@type fixtures() ::
  %{
    optional(StatifierBlocks.Block.id()) => [
      StatifierBlocks.Predicates.TruthTable.t()
    ]
  }
  | nil

Truth tables a host supplies, keyed by the block they describe.

insert_target()

@type insert_target() :: %{slot: String.t(), parent: String.t()}

Where an armed insertion would land, in the two names the author already reads off the canvas: the slot's label and the holding block's title.

inspector_tab()

@type inspector_tab() :: :config | :findings | :condition

Which of the inspector's three tabs is showing (3A).

Functions

block_count(node)

@spec block_count(StatifierBlocks.ViewModel.Node.t()) :: pos_integer()

How many blocks the tree holds, the root included.

The toolbar's count, and the reason it is here rather than inline: it is a fold over the same recursion depth/1 walks, and both are read once per render over a tree that can be thousands of nodes.

block_findings(arg1)

@spec block_findings(StatifierBlocks.ViewModel.Node.t() | nil) :: [
  StatifierBlocks.Finding.t()
]

Every finding about the selected block, in one list (3A).

3A's rule is that the inspector is about the selected block and the document goes to the drawer, so this is deliberately narrower than ViewModel.findings - it is the block's own findings, its slots' findings, and the findings routed to its form's fields. The document-level panel decision 13 names is untouched and still shows everything.

Not the subtree: a container whose child has a finding is not itself the thing to fix, and ViewModel.Node.findings_count already carries the subtree number for the badge that wants it.

cell_word(cell)

A cell's status as one word an author reads, per StatifierBlocks.Predicates.TruthTable.Cell's five values.

Words rather than colour: the five statuses are not a severity ramp - an :undecidable cell is not a worse :mismatch - and a reader who cannot tell two hues apart gets the same table as everyone else.

clamp_height(rem)

@spec clamp_height(term()) :: float()

The drawer's height in rem, clamped to the band the layout can hold.

Total for clamp_zoom/1's reason and one more: this value round-trips through a host. 2A puts the remembered height on the host's side, so the number arriving on mount is whatever the host stored last time, and a host that stored nil, a string, or a value from an older band gets a drawer rather than a crash.

clamp_zoom(percent)

@spec clamp_zoom(term()) :: pos_integer()

The nearest ladder step to percent.

Total, because the value can arrive from a phx-value- attribute and the DOM is not a trusted source: anything unreadable resolves to the default rather than raising or leaving the canvas at a size no control can undo.

condition_fields(arg1)

@spec condition_fields(StatifierBlocks.ViewModel.Form.t() | nil) :: [
  StatifierBlocks.ViewModel.Field.t()
]

The selected block's condition-bearing fields, in form order.

A condition is an :expression field and nothing else identifies one: core.branch keys one per arm by the arm's slot name and reads it through value_path: ["arms", i, "cond"] (ADR-0002 decision 10), and a host type declaring :expression gets the same treatment without the editor learning its name. That is decision 2's rule - the editor never branches on a type - applied to the Condition tab.

condition_source(field)

@spec condition_source(StatifierBlocks.ViewModel.Field.t()) :: String.t()

The condition source a field carries, as a string a template can render.

default_zoom()

@spec default_zoom() :: pos_integer()

100%, where the canvas opens.

depth(node)

How deep the tree nests, the root counting as 1.

Depth is a document metric and not a layout one - the canvas draws columns from slot metadata rather than from depth (decision 10) - so this is what the toolbar reports and nothing reads it to decide a style.

drawer_tab(value)

@spec drawer_tab(term()) :: drawer_tab()

The drawer tab value names, or :tables.

The same shape as inspector_tab/1 and for the same reason: the tab arrives from a phx-value-tab attribute, so an unknown one is a crafted payload rather than a bug, and the answer to it is the first tab.

drawer_tabs()

@spec drawer_tabs() :: [drawer_tab()]

The drawer's tabs, in the order the strip lists them.

drawer_title(tab)

@spec drawer_title(drawer_tab()) :: String.t()

The title a drawer tab carries, on the strip and on the tab itself.

drawer_view(state)

@spec drawer_view(%{
  optional(:open?) => boolean(),
  optional(:tab) => drawer_tab() | nil,
  optional(:fixtures) => fixtures(),
  optional(:findings) => [StatifierBlocks.Finding.t()],
  optional(:orphan_findings) => [StatifierBlocks.Finding.t()],
  optional(:selected_id) => StatifierBlocks.Block.id() | nil
}) :: drawer()

What the drawer shows, from its own open flag, its tab, the fixtures source, the document's findings and the selection (2A, and R4).

Two tabs since R4 (operator, 2026-08-29): truth tables, and the document-level findings that used to render as a block under the canvas. Both are tabular and about the whole document, which is 1A's admission test. The tab decides the title and the count the strip carries, so a collapsed drawer says what it is holding rather than naming one tab forever.

The truth-table statuses are unchanged and stay on status, because they describe that tab's content and nothing about the findings tab depends on them:

  • :closed - the strip, with the active tab's count.
  • :no_fixtures - open, with no fixtures source at all. Nothing to index.
  • :no_selection - open, nothing selected: the index page, listing every block that owns a table.
  • :none_for_block - open, a block selected that owns none: the index page again, which is 2A's answer to the spike's cold-start gap.
  • :ready - the selected block's tables.

The unchosen tab

:tab is nil until an author picks one, and an unchosen drawer resolves to the first tab in drawer_tabs/0 order with a non-zero count, or :tables when every count is zero. The rule is 2A's own: the strip exists so the drawer's content is discoverable from any state, and a strip reading Truth tables 0 on a document with four findings in it hides the only thing the drawer currently holds. Ties never arise - the order is the tie-break - and once the author picks a tab the pick stands, empty or not, because at that point the strip is reporting a choice rather than making one.

field_type_tag(type)

@spec field_type_tag(StatifierBlocks.BlockType.field_type()) :: String.t()

The value type a field declares, as a stable data- string.

fit_zoom(content, available, current)

@spec fit_zoom(term(), term(), term()) :: pos_integer()

The largest ladder step at which content wide fits available wide.

This is the whole of what a fit computes, and both fits compute it: Fit width passes the stage's measured extent, Fit active passes the selected card's. Neither measures anything itself - the numbers arrive from the measurement hook - and neither lays anything out, which is the constraint the 2026-08-30 ruling put on this: the client measures, this picks a step off the same ladder the two buttons step along, and the stylesheet scales.

With nothing measured yet, or a content box wider than any step can shrink to fit, the answer is the step the author is already on and the bottom of the ladder respectively. A fit that cannot be computed leaves the canvas where it is rather than jumping to a guess.

height_band()

@spec height_band() :: {float(), float(), float()}

The height band the resize control offers: {min, max, default} in rem.

insert_target(root, arg2)

@spec insert_target(
  StatifierBlocks.ViewModel.Node.t(),
  StatifierBlocks.Edit.target() | nil
) ::
  insert_target() | nil

The names of the position a palette pick would insert at, or nil.

nil for no armed position at all, and nil again for a position naming a block or a slot this view model does not hold - a gap armed just before an edit removed the block under it reaches the second, and there is nothing truthful to say about it. Both answers are the same to a caller, because both mean "there is no destination to name".

The labels rather than the raw ids for the reason slot_label/2 gives: arm_approved under blk_wizard is the editor naming, in an instruction meant to orient someone, two things the canvas never showed them.

Pure, and here rather than in the component, so the sentence the palette prints is asserted directly instead of through markup.

inspector_tab(value)

@spec inspector_tab(term()) :: inspector_tab()

The tab value names, or :config.

The tab arrives from a phx-value-tab attribute, so an unknown one is a crafted payload rather than a bug, and the answer to it is the first tab.

inspector_tabs()

@spec inspector_tabs() :: [inspector_tab()]

The inspector's three tabs, in the order 3A lists them.

label_for(root, id)

A label for a block id, for the index page's jump list.

The type name, because the index page is read before anything is selected and a block's label is its type's - ViewModel has already resolved that, so this walks the rendered tree rather than the document and gets the unresolvable case (decision 12) right for free.

slot_label(root, id)

@spec slot_label(StatifierBlocks.ViewModel.Node.t(), StatifierBlocks.Block.id() | nil) ::
  String.t() | nil

The label of the slot the block carrying block_id sits in.

"root" for the document's own root, which sits in no slot, and nil for an id no node in root carries - a selection that went away between two builds reaches that, and it is the same answer as no selection at all.

It is the slot's label, not its name, for the reason the inspector's header status reads a type's label: both are the vocabulary the canvas already shows the author, and a raw arm_approved beside a card reading When approved is the editor naming one thing two ways.

Here rather than on ViewModel.Node because a node does not know where it sits - the containment is the parent's, and a field duplicating it on every child is a second copy of the tree's shape that a re-parenting edit has to remember to update.

table_block_ids(fixtures)

@spec table_block_ids(fixtures()) :: [StatifierBlocks.Block.id()]

Every block id the fixtures source has a table for, sorted.

table_count(fixtures)

@spec table_count(fixtures()) :: non_neg_integer()

How many tables the whole source holds - the number the collapsed strip carries.

The count is the document's and not the selection's on purpose (2A): the strip is what makes the drawer's content discoverable from any state, and a strip reading (0) because nothing is selected would teach an author the document has no tables.

tables_for(fixtures, block_id)

The tables fixtures holds for one block, or [].

nil fixtures - no source at all - answers [] like a source that holds nothing for the block. The two are told apart by drawer_view/1, which is the only caller that has anything different to say about them.

viewport(arg1)

@spec viewport(term()) :: %{width: float(), height: float()} | nil

The scroller's usable box out of a measurement payload, or nil.

Total for clamp_zoom/1's reason: the payload is a wire value and an editor whose viewport is unreadable is an editor whose fits decline to move rather than one that raises. nil is also what the editor holds before the first measurement and what it holds forever with no hook imported, which is why the fits below all take it as a term().

viewport_anchor()

@spec viewport_anchor() :: String.t()

The anchor key the canvas scroller is stamped with.

A second reserved key beside Connectors.stage_anchor/0, and the reason it is reserved rather than derived from a block id is the same one: the stage is where the boxes are measured from and this is what they have to fit into, so neither is a block's anchor and neither can collide with one.

It lives here rather than beside the connector anchors because nothing routes through it. The fits are the only thing that reads it, the fits are a shell arrangement, and a connector never asks how wide the scroller is.

zoom_extent(arg1, zoom)

@spec zoom_extent(term(), term()) :: {float(), float()} | nil

The scaled extent of a measured box, or nil at 100% and unmeasured.

A CSS transform is drawn after layout and takes no space, so a scaled stage alone leaves the scroller sized to the unscaled tree: zoomed in, the bottom right of the chart is unreachable; zoomed out, the panel scrolls over empty space. The wrapper around the stage is what carries the scaled size, and this is that size.

nil at 100% is deliberate rather than an optimisation: an unzoomed editor then renders exactly the markup it rendered before there was a zoom at all, so the default case has no inline geometry to be wrong.

zoom_in(percent)

@spec zoom_in(term()) :: pos_integer()

One step up the ladder, or the top of it.

zoom_out(percent)

@spec zoom_out(term()) :: pos_integer()

One step down the ladder, or the bottom of it.

zoom_stage_width(arg1, zoom)

@spec zoom_stage_width(term(), term()) :: float() | nil

The width the stage is LAID OUT at while a zoom is applied, or nil.

The wrapper zoom_extent/2 sizes is the stage's parent, so its width is also the width the stage is laid out into - and that is a loop: at any step above 100% the wrapper is wider than the panel, the stage fills it, the stage's scroll extent grows, the next measurement makes the wrapper wider again. The live check at 150% found it immediately, at 33,554,428 pixels of wrapper, which is where the run stops rather than where it converges.

Pinning the stage to the scroller's own width breaks it: the number the stage lays out at then comes from the panel, which no zoom moves, so the measurement that sizes the wrapper is the same at every step. It is the width the stage already had before there was a zoom - a block filling its scroller - said out loud because the wrapper is now between the two.

nil at 100% and unmeasured, for zoom_extent/2's reason: an unzoomed editor carries no inline geometry at all.

zoom_steps()

@spec zoom_steps() :: [pos_integer()]

The zoom ladder the toolbar steps along, ascending.