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

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 editor 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 an operator ruling; 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 the fan markers.

# `measurement`

```elixir
@type measurement() :: %{optional(String.t()) =&gt; StatifierBlocks.Connectors.Rect.t()}
```

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

# `point`

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

A point in the stage's untransformed coordinate space.

# `card_anchor`

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

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

# `edges`

```elixir
@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/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 operator's ruling of 2026-08-29), 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`

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

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

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

```elixir
@spec inlet(StatifierBlocks.Connectors.Rect.t()) :: point()
```

The point flow enters a rectangle at.

# `interrupt_path`

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

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

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

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

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

A block's whole box.

# `outlet`

```elixir
@spec outlet(StatifierBlocks.Connectors.Rect.t()) :: point()
```

The point flow leaves a rectangle from.

# `outlet_anchor`

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

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

# `slot_anchor`

```elixir
@spec slot_anchor(StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()) ::
  String.t()
```

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

# `slots_anchor`

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

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

# `stage_anchor`

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

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

---

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