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.
Three 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 still have a reserved place and so does the
read-only declared-path view, and neither is drafted here.
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.
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.height(:float) (required)root(StatifierBlocks.ViewModel.Node) (required)target(:any) (required)class(:string) - Defaults tonil.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.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
[].