Connector geometry: pure functions from measured rectangles to SVG path data (ADR-0005 decision 10, and the 2026-08-29 amendment to decision 7).
The amendment admits a second JavaScript hook, StatifierBlocksMeasure,
whose entire job is to read the boxes the browser laid out and push them.
Clause 7b.2 then says where the drawing happens: the connector geometry
itself is computed on the server, as pure functions from measured
rectangles to path data. That is this module. It is the graduation of the
geometry half of the campaign-012/013 spike's spike/js/layout.js, which
kept the same split for the same reason and which
spike/dev/selftest.html could only assert inside Chrome.
Here it runs in the gate. Nothing below reads the DOM, names a block type,
or reaches for the web framework: a rectangle is a rectangle, and that is
what keeps decision 13's promise that rendering is testable without a
browser. This module therefore lives outside StatifierBlocks.Editor.*
and loads in the headless tree, exactly as ViewModel and Shell do.
The three stages
measurement/1decodes the hook's push into%{anchor key => rect}. It is total: the payload arrives from the DOM, which is not a trusted source, and anything malformed is dropped rather than raised on.edges/2walks aViewModel.Nodetree and, for every edge adjacency and nesting imply, resolves the anchors it needs and emits oneEdge. An edge whose anchors were not measured is skipped, which is what makes the whole layer degrade to nothing when the hook is absent.- The path functions -
flow_path/3,fan_path/3,join_path/3,interrupt_path/4- turn two points into orthogonal path data with rounded corners. Orthogonal rather than bezier because at nesting depth 7 a curve that passes near a card reads as ambiguous and a right angle never does.
The 7d choices this module records
Clause 7d left four questions to the implementation, each to be answered "with a test rather than by guess". All four are written down here, because a wire has two ends and the record should be readable from the end that is testable in the gate.
Payload shape: whole stage, flat, keyed by the server's own string
One push carries every measured anchor, never a delta:
%{
"stage" => %{"w" => 1200.0, "h" => 2400.0},
"anchors" => [%{"k" => "card:blk_a", "x" => 8.0, "y" => 8.0,
"w" => 300.0, "h" => 32.0}, ...]
}A delta needs a shared memory of the previous measurement on both ends, and 7a forbids the hook holding "state that survives a re-render". A whole stage also satisfies 7c's plainest statement of what a measurement is - that the push be reconstructible from the rendering alone - which a delta by construction is not.
The key is the string the server stamped in data-sb-anchor. The hook
never composes one and this module never parses one: keys are built by
card_anchor/1 and its siblings and compared whole, so a block id may
contain any character an id may contain without the wire having an opinion
about it.
Coordinate space: the stage's own untransformed space
Every rectangle is relative to the stage's top-left corner, in the stage's
untransformed pixels. The hook subtracts the stage origin and divides
by the scale it reads off the stage, which is the single arithmetic step
the spike's render.js found necessary under zoom: the <svg> these
coordinates are written into is a child of the stage, drawn in the stage's
own space and sized from scrollWidth, so rendered coordinates would
scale twice and detach every line from the card it joins.
The alternative 7d names - push rendered coordinates and a scale, and let the server divide - was rejected because it would put the zoom into the server's geometry, and the property below is what it would have cost: nothing in this module knows a scale or an origin exists. Translating every measured rectangle translates every path by the same amount, and no other transform is applied anywhere.
Push cadence: mount, update, and a stage resize, over two frames
The hook measures on mount, on every updated(), and on a resize of the
stage, and each of those is scheduled through TWO animation frames and
coalesced into a single push. Two rather than one because the first frame
is merely when the DOM is live and the second is after a font swap or a
scrollbar has settled - the spike found exactly this, and routing against
a pre-swap measurement is the classic way connectors end up a few pixels
off the cards they join.
The cadence terminates without the hook remembering anything, which is
what 7a's "no state that survives a re-render" costs and why it costs
nothing: edges/2 is a pure function of the tree and the measurement, so
an unchanged measurement renders unchanged markup, LiveView computes an
empty diff, no patch is sent, and updated() is not called again. The
loop closes on this module's purity rather than on the client's memory.
Anchor attribute names: one attribute, one opaque value
Decision 7's DOM contract gains exactly one attribute, data-sb-anchor,
whose value is the anchor key. One attribute rather than a family of them
because the hook's read is then querySelectorAll("[data-sb-anchor]") -
one query, no knowledge of block structure, and no key composed on the
client. The seven kinds of anchor the shipped markup stamps:
stage- the canvas root, which is what makes the other boxes comparable to each other (7c names the stage box explicitly);node:<block id>- a block's whole box, which is where an interrupt channel is placed relative to;card:<block id>- a block's chrome, the box an edge arrives at;outlet:<block id>- a zero-height anchor at the bottom of a block, the box flow leaves from;slot:<block id>/<slot name>- a slot's header, which is where a fan edge lands. Not the first card inside it: the header carries the slot's name and its guard, and an edge that ran past it would cross the very condition it is subject to;fan:<block id>- theONE OF/ALL OFpill below an arranged container, the point its fan leaves from;join:<block id>- the join marker below that container's columns, the point their rejoins arrive at.
The last two are the only anchors that may be absent while the block they name is on the page: a stacked container renders no pill and a type that phrased no join renders no marker. Both fall back to the card and the outlet, which is where every fan left and arrived before campaign 016.
Summary
Types
Every measured anchor, keyed by the string the server stamped.
A point in the stage's untransformed coordinate space.
Functions
A block's chrome: the box an edge arrives at.
Every connector the tree implies, given what the browser measured.
The ONE OF / ALL OF pill below an arranged container: the point the
fan leaves from, when one is drawn.
A fan edge: from a hub down and out to one slot's inlet.
A flow edge from one point down to another, orthogonally.
The point flow enters a rectangle at.
An interrupt exit edge: from a rule on the interrupt rail, out past the right-hand side of its container, and down to the container's exit point.
The join marker below an arranged container's columns: the point the rejoins arrive at, when one is drawn.
A rejoin edge: from one slot's exit down and in to a join hub.
The hook's push, decoded into %{anchor key => Rect.t()}.
A block's whole box.
The point flow leaves a rectangle from.
The zero-height anchor at the bottom of a block: where flow leaves.
A slot's header: where a fan edge lands.
The stage's extent, or nil when nothing has been measured.
The canvas root's key: the origin every other box is relative to.
Types
@type measurement() :: %{optional(String.t()) => StatifierBlocks.Connectors.Rect.t()}
Every measured anchor, keyed by the string the server stamped.
A point in the stage's untransformed coordinate space.
Functions
@spec card_anchor(StatifierBlocks.Block.id()) :: String.t()
A block's chrome: the box an edge arrives at.
@spec edges(StatifierBlocks.ViewModel.Node.t(), measurement()) :: [ StatifierBlocks.Connectors.Edge.t() ]
Every connector the tree implies, given what the browser measured.
Adjacency inside a slot and the nesting of slots are the only sources of truth here, exactly as ADR-0005 decision 10a requires - connectors are rendered, never authored, and nothing below branches on a type name. The three derivations, all of them reading decision 10's presentation metadata:
flow between adjacent children of one slot, from the earlier block's outlet to the later block's card. Every slot, rails included: two rules attached to one rail still run in the order they are in.
the fan and the rejoin, for a container
ViewModel.arrangement/1says is arranged side by side -layout: :columns, or more than one body slot, which givescore.parallelandcore.branchthe same treatment without naming either. It is the same function the renderer read to lay those columns out, so the lines and the layout answer one question once. A fan edge runs from the container's hub to each slot's HEADER, and a rejoin runs from the last thing in that slot back to the container's join hub; the hubs are theONE OF/ALL OFpill and the join marker when those were measured, and the card and the outlet when they were not. A container with a single body slot instead gets one flow edge from its card into the first block of that slot.rail exits, one per attached rule, in the vocabulary
ViewModel.exit_edge/1derives from the slot's style: a:failurerail leaves by the ordinary flow edge, in the ordinary:flowvocabulary (thesb-67sruling), and an:secondaryrail leaves out of band, through a channel outside the container's own box so it crosses nothing at any depth.
An edge whose anchors are not in measurement is skipped rather than
guessed at. With an empty measurement - no hook imported, or a first
render that has not been measured yet - the result is [] and the editor
is the editor it was before, minus the drawn connectors. That is clause
7b.3's standing test, and it is a property of this function rather than of
a flag anywhere.
Idempotent, and a pure function of its two arguments: the same tree and the same measurement give the same list, which is what makes a re-push safe. A measurement that has not changed produces markup that has not changed, so the render loop the hook could otherwise drive terminates on its own without the hook remembering anything.
@spec fan_anchor(StatifierBlocks.Block.id()) :: String.t()
The ONE OF / ALL OF pill below an arranged container: the point the
fan leaves from, when one is drawn.
A hub anchor rather than a decoration the edges ignore. The pill sits on the flow line between the container's card and its columns, and the connector overlay paints ABOVE the tree - so a fan that left the card would draw a line straight through the pill's own words. Leaving from the pill instead makes it what it looks like: the point the flow divides at.
Absent from the measurement whenever no pill is rendered, and the fan falls back to the card. That is the same skip-when-unmeasured rule every other anchor here follows, so the pill is never a thing the geometry requires to exist.
A fan edge: from a hub down and out to one slot's inlet.
The same routing a flow edge takes - the difference is the class the renderer puts on it, not the geometry - but the elbow is pulled up close to the hub rather than sitting halfway, so every arm of one fan turns on the same line and the result reads as a distribution bar rather than as several unrelated edges.
A flow edge from one point down to another, orthogonally.
Straight when the two are vertically aligned; otherwise down to the
halfway line, across, and down again, with the corners rounded by radius
- clamped so a short edge cannot turn its own corners inside out.
@spec inlet(StatifierBlocks.Connectors.Rect.t()) :: point()
The point flow enters a rectangle at.
An interrupt exit edge: from a rule on the interrupt rail, out past the right-hand side of its container, and down to the container's exit point.
One corner rather than two, and it leaves the rule's card from its RIGHT
edge and travels down a channel to the right of everything the container
holds. channel_x is that channel, and placing it outside the container's
own box is the routing rule that keeps interrupt edges from crossing the
body of the container at any depth: there is nothing out there to cross.
@spec join_anchor(StatifierBlocks.Block.id()) :: String.t()
The join marker below an arranged container's columns: the point the rejoins arrive at, when one is drawn.
The mirror of fan_anchor/1, and there for the same reason - a rejoin
aimed past the marker crosses the word that says what the rejoin means.
Absent unless the container's type phrased a join (ADR-0002 amendment B),
and the rejoin falls back to the container's outlet.
A rejoin edge: from one slot's exit down and in to a join hub.
The mirror of fan_path/3 - the elbow sits close to the HUB, which here
is the lower end, so every arm turns on one line again.
@spec measurement(term()) :: measurement()
The hook's push, decoded into %{anchor key => Rect.t()}.
Total by construction. The payload crosses from the DOM, so every arm that
is not a well-formed anchor - a missing key, a non-numeric coordinate, a
shape that is not the one documented above - drops that anchor and keeps
the rest. A payload that is not a map at all decodes to %{}, which is
the same thing the editor holds before the first measurement arrives and
the same thing it holds when no hook is imported at all.
The stage's own extent decodes to stage_anchor/0 with its origin at
{0, 0}, because the stage IS the origin: the scroll extent is what the
<svg> is sized from, and it is a layout measurement no transform on the
stage touches.
@spec node_anchor(StatifierBlocks.Block.id()) :: String.t()
A block's whole box.
@spec outlet(StatifierBlocks.Connectors.Rect.t()) :: point()
The point flow leaves a rectangle from.
@spec outlet_anchor(StatifierBlocks.Block.id()) :: String.t()
The zero-height anchor at the bottom of a block: where flow leaves.
@spec slot_anchor(StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()) :: String.t()
A slot's header: where a fan edge lands.
@spec stage(measurement()) :: StatifierBlocks.Connectors.Rect.t() | nil
The stage's extent, or nil when nothing has been measured.
The <svg>'s own width, height and viewBox, which is why it is asked
for separately rather than looked up as one anchor among the rest: a
connector layer with no stage has no box to draw in and renders nothing.
@spec stage_anchor() :: String.t()
The canvas root's key: the origin every other box is relative to.