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 and the selection.
Truth tables a host supplies, keyed by the block they describe.
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.
What the drawer shows, from its own open flag, the fixtures source and the selection (2A).
The value type a field declares, as a stable data- string.
The height band the resize control offers: {min, max, default} in rem.
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.
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 [].
One step up the ladder, or the top of it.
One step down the ladder, or the bottom of it.
The zoom ladder the toolbar steps along, ascending.
Types
@type drawer() :: %{ open?: boolean(), status: :closed | :no_fixtures | :no_selection | :none_for_block | :ready, subject_id: StatifierBlocks.Block.id() | nil, tables: [StatifierBlocks.Predicates.TruthTable.t()], count: non_neg_integer(), jumps: [StatifierBlocks.Block.id()], title: String.t() }
What the drawer is showing, from its own flag and the selection.
@type fixtures() :: %{ optional(StatifierBlocks.Block.id()) => [ StatifierBlocks.Predicates.TruthTable.t() ] } | nil
Truth tables a host supplies, keyed by the block they describe.
@type inspector_tab() :: :config | :findings | :condition
Which of the inspector's three tabs is showing (3A).
Functions
@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.
@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.
@spec cell_word(StatifierBlocks.Predicates.TruthTable.Cell.t()) :: String.t()
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.
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.
@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.
@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.
@spec condition_source(StatifierBlocks.ViewModel.Field.t()) :: String.t()
The condition source a field carries, as a string a template can render.
@spec default_zoom() :: pos_integer()
100%, where the canvas opens.
@spec depth(StatifierBlocks.ViewModel.Node.t()) :: pos_integer()
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.
@spec drawer_view(%{ optional(:open?) => boolean(), optional(:fixtures) => fixtures(), optional(:selected_id) => StatifierBlocks.Block.id() | nil }) :: drawer()
What the drawer shows, from its own open flag, the fixtures source and the selection (2A).
Five states, and the four that are not :ready are exactly the ones a
screenshot cannot show:
:closed- the strip, with the document'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.
@spec field_type_tag(StatifierBlocks.BlockType.field_type()) :: String.t()
The value type a field declares, as a stable data- string.
The height band the resize control offers: {min, max, default} in rem.
@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.
@spec inspector_tabs() :: [inspector_tab()]
The inspector's three tabs, in the order 3A lists them.
@spec label_for(StatifierBlocks.ViewModel.Node.t(), StatifierBlocks.Block.id()) :: String.t()
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.
@spec table_block_ids(fixtures()) :: [StatifierBlocks.Block.id()]
Every block id the fixtures source has a table for, sorted.
@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.
@spec tables_for(fixtures(), StatifierBlocks.Block.id() | nil) :: [ StatifierBlocks.Predicates.TruthTable.t() ]
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.
@spec zoom_in(term()) :: pos_integer()
One step up the ladder, or the top of it.
@spec zoom_out(term()) :: pos_integer()
One step down the ladder, or the bottom of it.
@spec zoom_steps() :: [pos_integer()]
The zoom ladder the toolbar steps along, ascending.