StatifierBlocks.Connectors (StatifierBlocks v0.10.0)

Copy Markdown View Source

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

  1. measurement/1 decodes 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.
  2. edges/2 walks a ViewModel.Node tree and, for every edge adjacency and nesting imply, resolves the anchors it needs and emits one Edge. An edge whose anchors were not measured is skipped, which is what makes the whole layer degrade to nothing when the hook is absent.
  3. 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. All four clamp an ascending edge level: the head is raised to the tail's own y rather than routed upward, because every arrowhead is oriented along its path and an ascending one points back at the block the flow just left. flow_path/3 carried that clamp alone until campaign-022 ruling R8d; the other three drew the arrow the document does not contain.

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 eight 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, the fallback an interrupt channel is placed relative to;
  • slots:<block id> - a container's BODY box, the one an interrupt channel is placed relative to when it was measured. A container's node box is only as wide as the width its parent handed down, which is not the width of what it holds: the body overflows it to the right. A channel offset from that box is a channel drawn through the container's own contents, which is the one thing the channel exists to avoid;
  • 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> - the ONE OF / ALL OF pill 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.

A container's body: the box its slots occupy, and the box an interrupt channel is offset from.

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

measurement()

@type measurement() :: %{optional(String.t()) => StatifierBlocks.Connectors.Rect.t()}

Every measured anchor, keyed by the string the server stamped.

point()

@type point() :: %{x: float(), y: float()}

A point in the stage's untransformed coordinate space.

Functions

card_anchor(block_id)

@spec card_anchor(StatifierBlocks.Block.id()) :: String.t()

A block's chrome: the box an edge arrives at.

edges(root, measurement)

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/1 says is arranged side by side - layout: :columns, or more than one body slot, which gives core.parallel and core.branch the 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 the ONE OF / ALL OF pill 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/1 derives from the slot's style: a :failure rail leaves by the ordinary flow edge, in the ordinary :flow vocabulary (the sb-67s ruling), and an :secondary rail 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.

fan_anchor(block_id)

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

fan_path(hub, to, radius \\ 10)

@spec fan_path(point(), point(), number()) :: String.t()

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.

Down, or level, and never up, on flow_path/3's own terms: a slot whose inlet was measured above the hub that feeds it is clamped to the hub's y and the arm is drawn level. A fan reaches that case more easily than a flow does - one short lane beside a tall one puts an inlet above the bar - and without the clamp that lane's arm carried the arrowhead backwards.

flow_path(from, to, radius \\ 10)

@spec flow_path(point(), point(), number()) :: String.t()

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.

Down, or level, and never up. A head above its tail is clamped to the tail's own y and the edge is drawn level rather than ascending. The vocabulary has no upward flow: every arrowhead on the canvas is oriented along its path, so a path that climbed would render an arrow pointing back at the block the flow just left, and an author reads that as a loop the document does not contain. Clamping rather than routing around is the honest rendering, because the input is not a real upward edge - it is two boxes that were measured against different layout passes, or a slot whose header sits above the hub that feeds it. Level says "these two are at the same height", which is what the numbers actually said; a detour would draw a descent nothing in the tree asked for.

Two coincident points therefore draw a zero-length path and no visible edge, which is the same sentence: there is no distance between them to draw an edge along.

inlet(rect)

The point flow enters a rectangle at.

interrupt_path(from, exit_point, channel_x, radius \\ 10)

@spec interrupt_path(point(), point(), number(), number()) :: String.t()

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.

The same clamp again, on the exit rather than on an inlet: a container whose exit point was measured above the rule on its rail - a rule near the bottom of a tall region, or two boxes measured against different layout passes - is clamped to the rule's own y, so the channel leg is level and the edge still ends pointing into the exit rather than away from it.

join_anchor(block_id)

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

join_path(from, hub, radius \\ 10)

@spec join_path(point(), point(), number()) :: String.t()

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.

The clamp is the same one, and mirrored with it: the HUB is the head here, so a hub measured above the slot exit that feeds it is raised to that exit's own y and the arm is drawn level rather than climbing back into the region it is leaving.

measurement(params)

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

node_anchor(block_id)

@spec node_anchor(StatifierBlocks.Block.id()) :: String.t()

A block's whole box.

outlet(rect)

The point flow leaves a rectangle from.

outlet_anchor(block_id)

@spec outlet_anchor(StatifierBlocks.Block.id()) :: String.t()

The zero-height anchor at the bottom of a block: where flow leaves.

slot_anchor(parent_id, slot_name)

A slot's header: where a fan edge lands.

slots_anchor(block_id)

@spec slots_anchor(StatifierBlocks.Block.id()) :: String.t()

A container's body: the box its slots occupy, and the box an interrupt channel is offset from.

Separate from node_anchor/1 because the two boxes are not the same box. A container's node box takes the width its own parent handed down, and the body inside it is as wide as the work it holds - so the body OVERFLOWS the node box rather than fitting inside it. Measured on the card_processing fixture, one container's node box was 386px wide around 811px of content. A channel offset from the node box is therefore drawn straight through the contents it is supposed to be escaping. The body box is the container's real extent, so a channel offset from it clears everything the container holds - the routing rule, restored to the box it was written for.

Absent on a leaf and on a collapsed container - neither renders a body - which is why the interrupt walk falls back to node_anchor/1 rather than skipping the edge.

stage(measurement)

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.

stage_anchor()

@spec stage_anchor() :: String.t()

The canvas root's key: the origin every other box is relative to.