The full-width drawer row (ADR-0005, the 2026-08-29 shell amendment, rulings 1A and 2A).
1A says what belongs here and the test is two words: tabular and document-level. Content that is a grid of rows about the whole document goes in the drawer; content about one block does not, whatever its shape.
Six tabs ship. Truth tables were first. The document-level findings list
joined them under operator ruling R4 (2026-08-29), which retired the text
block that used to sit under the canvas: a list of findings is a grid of
rows about the whole document, so 1A's test admits it and the canvas gets
its height back. Declarations joined them under the 2026-09-01 amendment
(clause 2i), which took ADR-0001 11i's named door: the document's own
datamodel roots are a grid of rows about the envelope, which is 1A's
test again. Fixture runs joined under sb-4yze: one row per fixture row
in the document, each carrying the outcome slot it expected against the
one the compiled chart actually took (StatifierBlocks.Runtime.FixtureRuns
does the driving; this module only draws the table). The read-only
declared-path view took the last reserved place: one row per path the
three declaring surfaces name, with the surfaces that named it and the
shape the ADR-0006 projection carries, which is a grid of rows about the
whole document and admitted by 1A for the same reason the four before it
were. It is read-only on purpose - the editable half is the Declarations
tab beside it, over the document's own roots - and no reserved place
remains behind it. A path whose ADR-0006 entry carries a one_of draws
that enumeration in a Values column beside its shape, cut at eight
values with the remainder counted ("+3 more") so that a path declaring
forty does not turn one row into a paragraph. It is the same enumeration
a value picker is fed from, drawn here for the same reason the shape is:
"where did this picklist come from" is then answered by a row in this
table rather than by opening a condition.
The measurable reason the drawer exists at all: a truth table for a branch in a credit-card processing document is one row per case and one column per bound input plus the verdicts, and at the inspector's width it either scrolls sideways or inverts its column order to keep the answers on screen. The spike did the second and filed the inversion as a readability defect. A drawer is as wide as the editor is, which is the axis the table needs.
Never open-or-gone
2A: collapsed, the drawer is a strip carrying a title and a count - a small-caps label beside a chip, "Truth tables 3" - and that is what makes the content discoverable from any state rather than only from the affordance that opens it. Opening it with no table on the selected block shows the index page: the blocks that do own one, each of them a jump. That is the whole of the spike's cold-start gap, closed.
The strip reports the active tab, and an author who has not picked one
gets the first tab that actually holds something
(StatifierBlocks.Shell.drawer_view/1 resolves it). That is 2A's own
reasoning applied to a second tab: a strip is worth having because it says
what is in the drawer, and one that names an empty tab while the other has
four rows in it says the opposite.
The count is the document's, not the selection's. A strip reading 0
because nothing happens to be selected would tell an author something false
about their document.
A host's own tab
8A's split is that the package ships the editing surface and the host
ships what surrounds it. A host tab is that reaching the drawer: the
editor's drawer_tabs assign carries a label, a count and a function
component, the function is called here when its tab is the active one,
and the package learns nothing about what it draws.
This admits no new package tab. 1A's test - tabular, and about the whole document - governs the tabs shipped here, unchanged and unweakened; for a tab the host contributes, that test transfers to the host as its own obligation. The 2026-08-30 amendment recording the drawer's tab strip as a host seam is where that split is written down.
It is called the way HEEx calls <.tab /> rather than by applying it to a
bare map, which is StatifierBlocks.Editor.Icons' rule (sb-b8g) and the
same one for the same reason: a host component that derives a value with
assign/3, which is what an ordinary component does, raises on an assigns
map with no change-tracking key in it.
A function rather than a slot, for the reason StatifierBlocks.Editor's
own moduledoc gives: the first thing a host wants a tab for is a feed of
something happening now, and a slot body reading a host assign does not
redraw when that assign moves, because the editor is a LiveComponent and
a slot body is not one of the assigns it was passed. A pushed descriptor
is.
Host data enters the panel through assign/2
The content function is called with the two assigns this seam has -
%{id: ..., count: ...} - and everything else the panel draws comes from
the host, out of the closure the descriptor was built with. Getting it
from the closure into the markup is one line, and which line it is
matters:
content: fn assigns -> MyApp.run_feed(assign(assigns, :events, events)) endMap.merge/2 and Map.put/3 write the same key and are the trap. The
assigns map that arrives carries a __changed__ naming the two keys
this call passed, id and count, because the call is change-tracked
like any other component call. A merged key is absent from that map, so
every part of the template that reads it is treated as unchanged and never
reaches the diff: the panel draws once, holding whatever the closure held
when the tab was opened, and then stands still while the count on the tab
beside it keeps climbing. Nothing raises and nothing warns, because
nothing is missing - the value is in the assigns, and change tracking was
only never told about it.
Phoenix.Component.assign/2 is what tells it. It writes the key and
records the key in __changed__, and that record is the whole of the
difference between the two lines.
The resize is a command, not a hook
Decision 7 ships exactly one JavaScript hook and this section adds none.
The resize is a native range control inside a phx-change form: the new
height crosses as an ordinary event, StatifierBlocks.Shell.clamp_height/1
bounds it, and the host stores it - one round trip, and the state that
matters lives where state already lives. 2A is explicit that the height is
remembered per viewer and that the package has no viewer, so nothing
here persists anything.
The arrow keys are the server's
The strip is a role="tablist" with a roving tabindex, so the tabs
are one stop on the Tab sequence and all but the active one are reached
with the arrow keys or not at all - and once sb-mtak made the strip scroll at the
narrow breakpoint with its scrollbar hidden, "not at all" also meant not
visible. WAI-ARIA's pattern closes that: Left and Right move one tab and
wrap, Home and End go to the ends.
The server owns the movement, and nothing here is a JavaScript hook.
Decision 7 caps the package at two hooks and says in as many words that
adding another requires amending that record, which is a deliberately high
bar and the wrong bar to clear for a keystroke that already has a
server-side route: phx-keydown on the tablist carries the key, the
editor's "drawer-tab-key" handler picks the neighbour out of the same
ordered tab list the strip draws from, and the pick is stored in the same
assign a click stores. So the tab order lives in one place, the arrow keys
and the pointer reach it the same way, and ExUnit can drive the whole
behaviour.
Activation is automatic - moving focus picks the tab - which is what
lets the server know which tab is focused without being told: under a
roving tabindex the focused tab is the active one, so phx-value-tab
on the tablist is enough and no per-tab binding is needed. WAI-ARIA
recommends automatic activation when showing a panel is cheap, and these
panels are already re-rendered on every document change.
Moving DOM focus is the one part a re-render cannot do by itself, and it
is done without a hook either. The @focus_tab span carries the newly
active tab in its id, so a pick that moves the tab replaces the
element rather than patching it, and the phx-mounted on the replacement
runs JS.focus/1 at the new tab. focus() scrolls its element into view,
which is what makes a tab past the clipped edge reachable at the narrow
breakpoint. The editor clears focus_tab on every other route into the
drawer - a click, an open, a close, a document switch - so the span is
absent unless a key press just asked for the focus to move, and opening
the drawer never steals it.
Summary
Functions
The drawer row: a strip when collapsed, tabs and a table when open.
Functions
The drawer row: a strip when collapsed, tabs and a table when open.
Attributes
view(:map) (required) -StatifierBlocks.Shell.drawer_view/1's value.read_only(:boolean) - Whether this mount edits. The only panel it changes is the declarations list, which is the drawer's one editing surface; every other tab here is a reading already (ADR-0005's 2026-09-07 profile amendment).Defaults to
false.height(:float) (required)root(StatifierBlocks.ViewModel.Node) (required)target(:any) (required)class(:string) - Defaults tonil.focus_tab(:any) - The tab the strip must put DOM focus on, ornilfor "leave focus alone". The editor sets it when an arrow key moved the active tab and clears it on every other route into the drawer - see the moduledoc's "The arrow keys are the server's".Defaults to
nil.declarations(:list) - the entries the Declarations tab draws - the document's, or an author's draft. Defaults to[].declaration_refusal(:string) - the sentence for a refused declaration edit, ornil. Defaults tonil.fixture_runs(:any) -StatifierBlocks.Runtime.FixtureRuns.t()for the Fixtures tab, ornil. Defaults tonil.declared_view(:list) -StatifierBlocks.Datamodel.declared_view/3's rows for the Datamodel tab. Defaults to[].declared_values(:map) - The enumerations the datamodel document declares per path, asStatifierBlocks.Datamodel.value_candidates/1projects them - the declaration's own values, with no host map merged over them. Drawn beside the declared path they belong to; a path absent from the map declares none.Defaults to
%{}.declared_types(:list) -StatifierBlocks.Datamodel.declared_types/1's rows for the Datamodel tab. Defaults to[].environment_view(:any) - What the environment holds at the selected block's position, as%{path:, type:}rows, ornilwhen nothing is selected - ADR-0011 decision 9's "what is known here".niland[]say different things: nothing selected, and nothing known there.Defaults to
nil.run?(:boolean) - Whether a run is seated in the editor. The Datamodel tab's "what is known here" table draws its held-value column only while one is - seeStatifierBlocks.Runtime.RunValues.Defaults to
false.source_view(:any) -StatifierBlocks.SourceView.t()for the Source tab, ornilbefore the editor has compiled anything. The editor refreshes it only while the drawer is open on that tab.Defaults to
nil.selected_id(:any) - the selected block's id, which is what the Source tab highlights by. Defaults tonil.host_tabs(:list) - The host's tab descriptors, already throughStatifierBlocks.Shell.host_tabs/1. The active one'scontentis called for the panel; the strip draws them all from@view.tabs, where their labels and counts already are.contentis called with%{id: ..., count: ...}and with change tracking on those two keys only, so a host value the function adds for its own markup is added withPhoenix.Component.assign/2and never merged in - see the moduledoc.Defaults to
[].