# `StatifierBlocks.Shell`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/shell.ex#L1)

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

# `drawer`

```elixir
@type drawer() :: %{
  open?: boolean(),
  tab: tab_id(),
  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`

```elixir
@type drawer_tab() ::
  :tables | :findings | :declarations | :fixtures | :datamodel | :source
```

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

The package's tabs are atoms and a host's are strings, which is what keeps
the two namespaces apart without a registry: `drawer_tab/2` never turns an
incoming tab name into an atom, so a crafted `phx-value-tab` reaches at
worst a host tab the host itself declared.

# `drawer_tab_entry`

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

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

# `findings_group`

```elixir
@type findings_group() :: %{
  block_id: StatifierBlocks.Block.id() | nil,
  label: String.t(),
  findings: [StatifierBlocks.Finding.t()],
  document?: boolean()
}
```

One block's findings, as the inspector's unselected Findings tab lists them.

`block_id` is `nil` for the two groups that are not about a block - the
document's own findings (the `:document` anchor), and the unanchored
findings, whose anchors name ids the document does not hold. `document?`
tells those two apart: it is `true` on the document's group only
(ADR-0005's Amendment of 2026-09-22, `11w`).

# `fit_mode`

```elixir
@type fit_mode() :: :manual | :width | :active
```

Which fit the canvas is in: `:manual` is every zoom an author stepped to
themselves, and the other two are the two toolbar buttons.

# `fixture_hint`

```elixir
@type fixture_hint() :: %{path: String.t(), value: String.t(), values: [String.t()]}
```

One fixture-derived hint: the path it is about, the exemplar value drawn
beside the control, and every distinct value that path takes across the
block's rows in first-appearance order.

# `fixtures`

```elixir
@type fixtures() ::
  %{
    optional(StatifierBlocks.Block.id()) =&gt; [
      StatifierBlocks.Predicates.TruthTable.t()
    ]
  }
  | nil
```

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

# `host_tab`

```elixir
@type host_tab() :: %{
  :id =&gt; host_tab_id(),
  :title =&gt; String.t(),
  optional(:count) =&gt; non_neg_integer(),
  optional(:content) =&gt; (map() -&gt; term()),
  optional(any()) =&gt; any()
}
```

A tab a host contributes, as the editor's `drawer_tabs` assign carries it.

`content` is a function component and is the editor's to call, not this
module's - nothing here renders anything. What the shell reads is the
descriptor: what the tab is called, and how much it holds. `count` is
optional and absent means none.

The editor calls `content` with `%{id: ..., count: ...}`, and a host value
the function adds for its own markup is added with an `assign/2` call rather
than merged into that map: a merged key is invisible to change tracking, and
the panel then draws once and never again. `StatifierBlocks.Editor.Drawer`'s
moduledoc carries the whole of that, and the naming rule decision 1 puts on
this half of the package is why it is stated there and only pointed at here.

# `host_tab_id`

```elixir
@type host_tab_id() :: String.t()
```

A host tab's id: its own name for it, and the DOM id it is stamped into.

# `insert_target`

```elixir
@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`

```elixir
@type inspector_tab() :: :config | :findings | :condition | :fixtures
```

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

# `severity_count`

```elixir
@type severity_count() :: %{
  severity: StatifierBlocks.Finding.severity(),
  count: pos_integer()
}
```

One severity's share of a findings list, as the pill row above the list
reports it.

`count` is always positive: a severity with nothing at it has no pill, on
the same reasoning the inspector's tab chip is absent at zero rather than
reading `0` - a row of pills where two of the three say nothing is a row an
author learns to stop reading.

# `tab_id`

```elixir
@type tab_id() :: drawer_tab() | host_tab_id()
```

Either kind of drawer tab, as `drawer_view/1` reports the active one.

# `block_count`

```elixir
@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`

```elixir
@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`

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

# `clamp_height`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `declared_by`

```elixir
@spec declared_by(%{sources: [atom()]}) :: String.t()
```

The "Declared by" cell for one row of the read-only declared-path view:
every surface that declared the path, in `Datamodel.declared_view/3`'s
order, in one string.

    iex> StatifierBlocks.Shell.declared_by(%{sources: [:datamodel, :document]})
    "Datamodel, Document"

# `declared_shape`

```elixir
@spec declared_shape(%{
  type: StatifierDatamodel.Index.entry_type() | nil,
  item_type: StatifierDatamodel.Index.entry_type() | nil
}) :: String.t()
```

The "Type" cell for one row of the same view, as the ADR-0006 projection
carries it.

A `list` says what it holds when the entry named an `item_type`, because
"list" alone is the one type in decision 4's set that does not describe a
value on its own. Everything else is its own name, and a path no entry
describes is `unspecified` rather than blank - a blank cell reads as a
rendering gap, and this is a fact about the declaration.

An entry whose `type` names a declaration carries `{:declared, name}`
(`sd-ADR-0001`'s amendment of 2026-09-06, in `statifier_datamodel` 0.3.0),
and the cell is that name. It reads as one of the nine does on purpose:
what the author wrote in the `type` key is what the column says, and the
declaration's own fields are rows of their own beneath it, because the
index projects them there exactly as it projects an inlined `object`'s
`fields`.

    iex> StatifierBlocks.Shell.declared_shape(%{type: :integer, item_type: nil})
    "integer"

    iex> StatifierBlocks.Shell.declared_shape(%{type: :list, item_type: :string})
    "list of string"

    iex> StatifierBlocks.Shell.declared_shape(%{type: {:declared, "authorization"}, item_type: nil})
    "authorization"

    iex> StatifierBlocks.Shell.declared_shape(%{type: :list, item_type: {:declared, "authorization"}})
    "list of authorization"

    iex> StatifierBlocks.Shell.declared_shape(%{type: nil, item_type: nil})
    "unspecified"

# `default_zoom`

```elixir
@spec default_zoom() :: pos_integer()
```

100%, where the canvas opens.

# `depth`

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

# `drawer_tab`

```elixir
@spec drawer_tab(term(), [host_tab_id()]) :: tab_id()
```

The drawer tab `value` names, out of the package's tabs and `host_ids`, 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.

`host_ids` are the ids of the tabs the host is currently contributing, and
they stay strings on the way through. A tab name is never turned into an
atom here: the package's tabs are matched by comparing *their* names to the
payload, and a host tab is matched by string equality against a list the
host itself declared, so no payload can grow the atom table.

# `drawer_tabs`

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

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

The package's, not the strip's whole set: a host's tabs follow these, and
`drawer_view/1` is where the two are put together.

# `drawer_tabs`

```elixir
@spec drawer_tabs([tab_id()] | :all) :: [drawer_tab()]
```

The package's drawer tabs a profile leaves, in this module's order.

This is `drawer_view/1`'s own half of the strip: the host's tabs are
filtered against the same list there, where the two halves are put
together. Same rule as `inspector_tabs/1` - the intersection in this
module's order, and a listed id the package does not know is dropped
rather than raised (ADR-0005's 2026-09-07 profile amendment). A host tab
id is a string, so it passes through this function without matching
anything, which is why the filter for those runs where the host's
descriptors are.

    iex> StatifierBlocks.Shell.drawer_tabs([:source, "runs", :findings])
    [:findings, :source]

# `drawer_title`

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

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

# `drawer_view`

```elixir
@spec drawer_view(%{
  optional(:open?) =&gt; boolean(),
  optional(:tab) =&gt; tab_id() | nil,
  optional(:fixtures) =&gt; fixtures(),
  optional(:findings) =&gt; [StatifierBlocks.Finding.t()],
  optional(:orphan_findings) =&gt; [StatifierBlocks.Finding.t()],
  optional(:host_tabs) =&gt; [host_tab()],
  optional(:declarations) =&gt; [StatifierBlocks.Document.DatamodelEntry.t()],
  optional(:accepts) =&gt; [String.t()],
  optional(:declared_view) =&gt; [StatifierBlocks.Datamodel.declared_row()],
  optional(:source_view) =&gt; StatifierBlocks.SourceView.t() | nil,
  optional(:selected_id) =&gt; StatifierBlocks.Block.id() | nil,
  optional(:profile) =&gt; [tab_id()] | :all
}) :: drawer()
```

What the drawer shows, from its own open flag, its tab, the fixtures source,
the document's findings and the selection (2A, and the ruling of 2026-08-29).

Two tabs since the operator's ruling of 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.

## A host's own tabs

`:host_tabs` are the descriptors of the tabs the host is contributing, and
they join the strip after the package's, in the order the host declared
them. They are ordinary entries from there on: the strip resolves an
unchosen tab through them on the same rule, so a document with no tables and
no findings and a running feed opens on the feed rather than on an empty
`Truth tables 0` - which is 2A's reasoning about the strip, not an exception
to it.

Only the descriptor is read here. A host tab's `content` is a function the
editor calls when that tab is active, so nothing about what it draws is
decided in this module.

# `field_type_tag`

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

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

# `findings_count`

```elixir
@spec findings_count([StatifierBlocks.Finding.t()]) :: non_neg_integer()
```

How many findings the document has - the number the Findings tab reports,
and the one number this package means by "the document's findings".

There is exactly one such number and this is where it is defined, so that
the tab chip, the collapsed strip and the count a host reads through
`StatifierBlocks.Editor.findings_count/3` cannot say three different things
about one document. That was the defect: three call sites each counted a
list of their own, and the lists were not the same list.

The argument is `ViewModel.findings` - every finding the view model holds,
derived and caller-supplied alike. **Orphans are inside it.** A finding
anchored on a block the document no longer holds renders nowhere on the
canvas, but it is still something wrong with this document, and a number
that dropped it would report a document as clean while its findings list
sat under the drawer saying otherwise.

# `findings_groups`

```elixir
@spec findings_groups(
  StatifierBlocks.ViewModel.Node.t() | nil,
  [StatifierBlocks.Finding.t()],
  Enumerable.t()
) :: [findings_group()]
```

The document's findings grouped by the block each one is anchored to, for
the inspector's Findings tab when nothing is selected.

The grouping is the only thing this adds to `findings_count/1`'s list: the
same findings, in the same order, cut into runs by their anchor's block id.
It does not filter, and it does not re-derive - a group's findings are the
ones handed in, so `Enum.map(groups, & &1.findings) |> List.flatten() |>
length()` is `findings_count/1` again by construction. That is deliberate:
a panel that showed fewer findings than the chip beside it counts is the
same two-numbers defect `findings_count/1` exists to close.

Group order is first appearance in `findings`, which is document order for
everything the view model derives, and the **unanchored group is last**. An
unanchored finding is one of `ViewModel.orphan_findings` - its anchor names
a block id the document does not hold - and it gets a group rather than a
filter because it is inside the number and an author who cannot see it
reads the chip as wrong. It carries `block_id: nil`, which is what tells a
caller there is nothing to select.

The findings anchored on the document itself (`:document`, ADR-0005's
Amendment of 2026-09-22, `11w`) form one group, placed **first** and
labelled `Document`. It too carries `block_id: nil`, so every group carries
`document?`, `true` on that group only - the key that tells a caller which
of the two block-less groups it holds.

`root` is only ever read for a label; a `nil` root labels every group with
its block id, which is `label_for/2`'s own fallback for an id the tree does
not hold.

# `fit_mode`

```elixir
@spec fit_mode(term()) :: fit_mode()
```

The fit `value` names, or `:manual`.

Total for `inspector_tab/1`'s reason with the source changed: this value
arrives from a **host's** `fit` attr rather than from the DOM, and a host
templating it from a stored preference or a query string is as likely to
spell it wrongly as an author is to craft a payload. A fit the editor does
not have is refused into the one every editor opens in, so the canvas is
never stamped with a mode no stylesheet rule and no button can leave.

# `fit_zoom`

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

# `fixture_hint`

```elixir
@spec fixture_hint(fixtures(), StatifierBlocks.Block.id() | nil, String.t() | nil) ::
  fixture_hint() | nil
```

The fixture-derived hint for one block's condition source, or `nil`.

ADR-0005's 2026-09-05 note, "The hint: a fixture value, drawn beside the
field, never an option". The exemplar is what the block's **first fixture
row in declaration order** binds to the path being edited; the whole set is
every distinct value that path takes across the block's rows, in
first-appearance order. Both halves come out of `tables_for/2` - the same
reader the drawer's truth-table tab uses - so nothing is stored and nothing
new crosses the `fixtures` seam.

**Which path is "the path being edited"** is the one thing the record left
to the implementing bead, because this package draws the hint beside the
control and never sees inside it. The answer here is the path the source
currently names, longest first so `user.age_group` is not answered by
`user.age`, falling back to the first declared path when the source names
none - a block whose condition is still empty gets the hint for the path its
fixtures lead with rather than no hint at all.

A path's declaration order is the fixture source's own `paths` list where it
declares one; `TruthTable` derives and sorts one only when it does not.
A row that does not bind the path contributes nothing, and a block with no
rows - or no fixtures source at all - answers `nil`, which is what makes the
hint absent rather than empty.

This is a hint and never an option: nothing here reaches a picker, nothing
is merged with `one_of` or with a host's `value_candidates`, and no value it
answers can be selected.

# `fixture_row_count`

```elixir
@spec fixture_row_count(fixtures()) :: non_neg_integer()
```

How many fixture rows the whole source holds - the number the Fixtures
tab's strip carries.

Rows and not failures, and the reason is both editorial and mechanical. The
strip counts CONTENT, the way the tables tab counts tables and the findings
tab counts findings; a chip reading `Fixtures 0` over forty passing rows
says the opposite of what 2A's strip is for. And `drawer_view/1` runs on
every render: a row count is a sum over the assign, while a failure count
would put a compile plus one chart run per row inside every keystroke.

# `height_band`

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

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

# `host_tabs`

```elixir
@spec host_tabs([host_tab()]) :: [host_tab()]
```

The host tabs that may join the strip, in the order the host declared them.

Two rules, and both are about the strip staying readable rather than about
what a host is allowed to want. A host tab named for one of the package's
own tabs is dropped, because the package's tab is the one `drawer_tab/2`
resolves that name to and a strip with two "Findings" on it is a strip an
author cannot use. A repeated id is dropped after its first appearance,
because the id is stamped into the tab's DOM id and its panel's, and a
duplicate there breaks the `aria-controls` pairing for both.

Nothing else is filtered. Which content belongs in the drawer is 1A's test -
tabular, and about the whole document - and the host applies it to its own
content the same way this package applies it to its own.

The reserved names now include `"fixtures"` (added 2026-09-02): a host tab with
that id is dropped the same way one named `"declarations"` already is.

# `insert_target`

```elixir
@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`

```elixir
@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`

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

The inspector's four tabs, in the order 3A and its amendment list them.

# `inspector_tabs`

```elixir
@spec inspector_tabs([inspector_tab()] | :all) :: [inspector_tab()]
```

The inspector tabs a profile leaves, in this module's order.

`listed` is a profile's `inspector_tabs` value: `:all`, or the ids it names
(ADR-0005's 2026-09-07 profile amendment). The result is the intersection,
and it keeps **this module's** order rather than the profile's, so two hosts
that name the same tabs in different orders draw the same strip.

A listed id the package does not know is dropped and the mount renders -
the amendment's own rule, on the same reasoning as `inspector_tab/1`: a
profile is written once against the tab set of the version it was written
for, and an id that outlives its tab must leave a surface missing rather
than a crash at render.

    iex> StatifierBlocks.Shell.inspector_tabs(:all)
    [:config, :findings, :condition, :fixtures]

    iex> StatifierBlocks.Shell.inspector_tabs([:fixtures, :nope, :config])
    [:config, :fixtures]

# `label_for`

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

# `severity_counts`

```elixir
@spec severity_counts([StatifierBlocks.Finding.t()]) :: [severity_count()]
```

The same findings cut by severity, for the pill row above the list.

It is `findings_count/1` again, told a second way, and the two are pinned
to each other by construction: summing every entry's `count` is
`findings_count/1` again, for every list. That is the property
`findings_groups/3` documents about its own grouping, and it is here for the
same reason - a summary line that disagrees with the list beneath it is the
two-numbers defect `findings_count/1` exists to close, and a pill row is a
second place for it to reappear.

Order is `:error`, `:warning`, `:info` - most to least urgent, which is the
order the severities are declared in and the order an author triages in.
A severity with nothing at it is **omitted** rather than rendered as zero,
so the row's width is what the document actually holds.

The argument is the same list `findings_count/1` takes: `ViewModel.findings`
for the document, a group's or a block's list for a narrower surface.
Orphans are inside it wherever they are inside the list handed in.

# `slot_label`

```elixir
@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`

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

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

# `table_count`

```elixir
@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`

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

# `viewport`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `zoom_out`

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

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

# `zoom_stage_width`

```elixir
@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`

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

The zoom ladder the toolbar steps along, ascending.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
