---
title: "Compositor"
description: "Compose layers of images, text, and video. A layer (including one inside a stack `itemTemplate`/`itemTemplates`) can take a WIRED asset with `\"url\": \"$port:<name>\"` — saving mints a real image input port called `<name>`; never bake a CDN URL into a layer. Full layout contract (sizing, anchors, stacks, variants, `$port:`): GET /api/v1/workflows/{id}/schema"
updated: 2026-10-03
---

# Compositor

**Node type:** `compv3`  
**Category:** `Editing`

## Description

Compose layers of images, text, and video. A layer (including one inside a stack `itemTemplate`/`itemTemplates`) can take a WIRED asset with `"url": "$port:<name>"` — saving mints a real image input port called `<name>`; never bake a CDN URL into a layer. Full layout contract (sizing, anchors, stacks, variants, `$port:`): GET /api/v1/workflows/{id}/schema

## Canvas ports

These appear as port handles on the left side of the node.

| ID           | Label             | Details |
| ------------ | ----------------- | ------- |
| `background` | **Background**    | `IMAGE` |
| `layer_1`    | **Layer 1**       | `IMAGE` |
| `data`       | **Data (batch)**  | `JSON`  |
| `layers`     | **Layers (JSON)** | `JSON`  |

## Sidebar config

These render as form fields in the right-side config panel when the node is selected.

_No sidebar config fields._

## Outputs

| ID      | Label      | Type    |
| ------- | ---------- | ------- |
| `image` | **Output** | `IMAGE` |

## Layers can size themselves to their content

Every layer is absolute x/y/width/height by default, which is why a chat bubble
hardcoded to 826x218 is correct for exactly one sentence. Three opt-in fields
let geometry follow content instead. A layer that declares none of them renders
exactly as it always did.

### 1. Hug the text

On a **text** layer:

```json
{ "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 700, "minWidth": 120 }
```

`hug` fits the BOX to the text. **autoFit is the opposite**: it shrinks the TEXT
to fit a box you fixed. Declaring `autoFit` and `sizing.height: "hug"` on the
same layer is refused, because there is no size that satisfies both.
`maxWidth` forces wrapping and `minWidth` is a floor; both work with or
without `sizing`.

#### Put the text on optical centre: `verticalAlign: "optical"`

A hugged text layer's box is `lines x fontSize x lineHeight`, and the leading
(that is `(lineHeight - 1) x fontSize`) is **split** half above the first line
and half below the last, on every surface. It is still DEAD SPACE inside the
box. A bubble with symmetric padding is padded around the line box rather than
around the glyphs, and that is what `optical` removes.

> **Corrected 2026-09-02.** This section used to say the leading piled up
> **below** the final baseline, and quoted 23px above the first caps against
> 39px below the last at fontSize 40 / lineHeight 1.3. That described the
> **bake** before `line0CentringShift`. Konva and CSS have always half-led, so
> the PNG disagreed with the canvas by `(lineHeight - 1) x fontSize / 2`
> (measured, export.y0 minus bake.y0 at fontSize 28: 5/8/14px at lineHeight
> 1.0/1.2/1.6, now 5/6/6). The old asymmetry is gone; the dead space is not.
>
> **Who moves, and by how much.** On the bake only: every plain text layer
> whose `lineHeight` is not exactly 1, by `(lineHeight - 1) x fontSize / 2`,
> SIGNED. Above 1 moves DOWN, below 1 moves UP. Single-line layers move too,
> because the shift is applied to line 0 and does not care how many lines
> follow: `ONE LINE` at fontSize 60 / lineHeight 1.6 moves 18px. `cap` and
> `optical` layers do not move, and `lineHeight` exactly 1 does not move. The
> three Konva surfaces and the HTML mirror are unaffected; they already
> half-led.

```json
{ "sizing": { "width": "hug", "height": "hug" }, "verticalAlign": "optical" }
```

`optical` switches the layer to **half-leading** metrics, the same thing CSS
does inside a line box: half the leading above the first line, half below the
last, and both halves trimmed off the measured box. The box is then exactly the
text block's em span, so a `fitTo` container with equal padding stops depending
on `lineHeight`, with no per-fontSize padding fudging.

**Use it for every container-around-text layout** (bubbles, badges, pills,
buttons). The `textBg` pill is trimmed the same way, so it stays centred on the
glyphs too. It is opt-in rather than the default only because templates already
exist whose asymmetric padding compensates for the old skew, and flipping the
default would double-correct them; making it the default is a follow-up.
`lineHeight: 1` has no leading, so `optical` is an exact no-op there, and the
legacy `top`/`middle`/`bottom` values of this field (block alignment inside a
fixed height) are unchanged (`center` is accepted as an alias for `middle`). It
is refused on a layer that does not render as text, and the refusal names the
layer, the value you wrote, and the values that would have worked.

#### What optical does NOT fix

It removes the **line-height-dependent** skew entirely, and nothing else. A
residual survives, because half-leading is an **em-box** construction and a line
of glyphs does not fill its em box symmetrically. It depends on the font and,
less obviously, on **the text itself**.

A line with **no descenders** (ALL-CAPS badges and buttons, "no time like now")
ends at the baseline instead of a descender bottom, so its ink sits high in the
em box and it reads at the **negative edge** of the range: at fontSize 40 a
descender-less line measures 6 to 8px more negative than a mixed-glyph one on
the same face.

Measured on real bakes at fontSize 40, across two faces (Inter Black, Impact)
and both content classes: **-13px to +1px** (mixed-glyph lines -5 to +1, descender-less lines -13
to -4), where a negative number means the glyphs sit that far above centre. It
scales with fontSize, roughly `-0.35x` to `+0.04x` of it, plus a pixel or two of
rasterisation quantisation that matters most below about 30px, where one whole
ink row is already 4% of the em.

It is **invariant to lineHeight**, and that invariance is the guarantee: equal
padding gives you a gap difference that stops changing when you change
`lineHeight`, not one that is pixel-zero.

How that is known, both legs, because neither carries it alone. **(1) Measured**
identical at lineHeight 1.0 and 1.3 across every face, size and content
combination, _including_ sizes whose `fontSize x lineHeight` product is
fractional (25 and 33 at lineHeight 1.3). A matrix of whole-number products only
would have made the rounding a no-op, so "no drift" there would have been
consistent with a rounding fault too, and proved nothing. **(2) Structurally**,
a single optical line measures exactly `fontSize`, so there is no rounded
quantity for a half-pixel to hide in.

> **Corrected 2026-09-02.** This leg used to add that the baked PNG applied no
> vertical paint shift at all, painting from the top of the em box. That was the
> defect, not the guarantee: every other surface half-leads, so the bake sat half
> a leading high. It now applies `line0CentringShift` and matches them. The
> invariance argument is unaffected, because the new shift is itself a function
> of the leading and is applied identically on every surface.

Zeroing it would mean
trimming to the font's own ascent/descent (CSS `text-box-trim`) rather than the
em box, which would stop `lineHeight: 1` being an identity, so it is out of
scope. If you need an ALL-CAPS badge centred to the pixel, nudge its container's
padding by the measured amount for that face and size; that nudge stays correct
however the text length or `lineHeight` changes.

### 2. Fit a container to another layer

On a **rectangle** or **image** layer:

```json
{ "fitTo": "message", "padding": [24, 32, 24, 32], "maxWidth": 760 }
```

The layer resolves to the measured box of the layer named `message`, grown by
the padding. `padding` is CSS order **[top, right, bottom, left]**, or a single
number for all four sides.

### 3. Anchor to an edge

On any layer:

```json
{ "anchor": { "to": "bubble", "edge": "bottom", "gap": 16, "align": "center" } }
```

The layer sits against the target's RESOLVED box, so a stack stays correct when
the layer above it grows a line. Chain them: text hugs, bubble fits the text,
timestamp anchors under the bubble.

`align` operates on the axis **perpendicular** to `edge`, the cross axis, the
same way flexbox `align-items` works. So `edge: "bottom"` + `align: "end"` is
bottom **and** right-aligned, not "further right along the bottom of a stack".

#### Inset from the aligned edge with `alignOffset`

`align` alone only reaches three positions on the cross axis: flush to the near
edge, centred, flush to the far edge. `alignOffset` (a number, default `0`)
insets from there, on the same cross axis:

```json
{
  "anchor": {
    "to": "$stage",
    "edge": "bottom",
    "gap": 76,
    "align": "end",
    "alignOffset": 68
  }
}
```

That is "76px up from the bottom of the canvas, 68px in from the right": a
corner inset, which before this field was not expressible at all (the workaround
was an invisible 1px guide rectangle to absorb the anchor).

**Three rules, and they are the whole field:**

1. `align: "start"` insets **inward** from the near edge: `start + alignOffset`.
2. `align: "end"` insets **inward** from the far edge: `end - alignOffset`.
3. `align: "center"` applies it as a **signed** offset from the centre;
   positive shifts toward `end`.

Start and end are both **inward** on purpose, so pinning a corner never needs a
negative number. Only `center`, which has no inward direction, spends the sign
on full freedom. The rule is identical for a layer target and for `$stage`
(only the main axis inverts between those two), and `alignOffset: 0` resolves
exactly as a scene authored before the field existed.

Negatives are legal on every `align`, not just `center`: they push the layer
**outward** past the reference edge, which is unusual but is a real choice, and
the overflow warning will tell you if it left the canvas. The value must be
finite and within **±16384**; outside that the whole scene is refused, so the
editor clamps to the same bound rather than saving a number the render rejects.

#### Split the anchor per axis with `anchorX` / `anchorY`

A whole `anchor` couples **both** axes to one target. `anchorX` and
`anchorY` split it per axis, like CSS position properties: each takes the same
`{ to, edge, gap }` shape and the same semantics, but governs one axis and
leaves the other free. `anchorY` takes a vertical edge (`top`/`bottom`),
`anchorX` a horizontal one (`left`/`right`); the wrong axis is refused by
name. They can point at **different** targets, which is the whole point.

`align`/`alignOffset` are refused on a per-axis anchor rather than ignored:
they act on the cross axis of a whole `anchor`, and here the cross axis is
free (or owned by the other per-axis anchor). Declaring `anchor` together with
`anchorX`/`anchorY` on the same layer is refused as a contradiction.

##### Worked example: a chat thread

```json
// WRONG: fixed slots
"msg1": { "x": 24, "y": 900 },
"msg2": { "x": 24, "y": 830 },
"msg3": { "x": 24, "y": 760 }
```

Each `y` was measured once, against one message length. Let msg2 wrap to three
lines and the gap above it becomes 26px while the gap below is 75px.
Content-dependent spacing is the clearest tell that a screenshot is fake. Worse,
once it wraps far enough msg2 prints on top of msg1 and nothing tells you.

If you are stuck with fixed slots, at least assert what you are relying on and
the render will tell you when it stops being true:

```json
"msg2": { "x": 24, "y": 830, "mustNotOverlap": ["msg1", "msg3"] }
```

That is the only check that can see this scene, because there is no anchor here
for anything else to verify against.

```json
// RIGHT: chained per-axis
"msg1": {
  "anchorY": { "to": "$stage", "edge": "bottom", "gap": 40 },
  "anchorX": { "to": "$stage", "edge": "left", "gap": 24 }
},
"msg2": {
  "anchorY": { "to": "msg1", "edge": "top", "gap": 8 },
  "anchorX": { "to": "$stage", "edge": "left", "gap": 24 }
},
"msg3": {
  "anchorY": { "to": "msg2", "edge": "top", "gap": 8 },
  "anchorX": { "to": "$stage", "edge": "left", "gap": 24 }
}
```

Every inter-bubble gap is exactly 8px however the messages wrap, because each
bubble is measured from the RESOLVED box of the one before it while its left
edge stays welded to the card.

#### Assert separation with `mustNotOverlap`

`mustNotOverlap: ["msg1"]` says "this layer stays clear of msg1". After layout
resolves, any named layer whose box intersects this one on both axes is reported
as `layout-collision` and the render still completes.

It is false-positive-free by construction rather than by heuristic: you declared
the separation, so nothing is inferred. A deliberate overlay simply never
declares the pair. Symmetric by declaration only (A naming B does not make B
name A), but the pair is reported once, so mutual declarations do not
double-report. An unknown name is refused like any other dangling reference, a
typo'd assertion that silently checks nothing being the worst outcome.

**Three layers of defence against the same defect**, so reach for the right one:

1. `mustNotOverlap` is the **authoring assertion**, and the only one that
   catches a fixed-slot layout, because such a scene declares no anchors.
2. The anchored-pair `layout-collision` is an **engine invariant**: it cannot
   fire on a layout the resolver can produce, so it only ever means we
   regressed.
3. **Chaining** with per-axis anchors makes the class **impossible by
   construction**, because the spacing is derived rather than measured once.
   That is the real fix; the other two are the net for layouts not yet
   converted.

### 4. Pin to the canvas with `$stage`

`anchor.to` also takes one reserved target, `"$stage"`, meaning the canvas
itself:

```json
{ "anchor": { "to": "$stage", "edge": "bottom", "gap": 60, "align": "end" } }
```

**The semantics invert, and this is the part to get right.** Anchoring to a
**layer** places your layer OUTSIDE-adjacent to that layer's edge:
`edge: "bottom"` means "sit BELOW it". Anchoring to `$stage` pins your layer
INSIDE the canvas against that edge, so `edge: "bottom", gap: 60` means your
layer's own BOTTOM edge sits 60px above the bottom of the stage.

The consequence is the reason it exists: a `$stage`-pinned layer **grows away
from the edge it is pinned to**. A bottom-anchored chat bubble that hugs its
text grows UPWARD from one line to six, and its bottom edge never moves, so it
cannot walk off the bottom of the canvas as the message gets longer. Same per
edge: `edge: "right"` pins the right edge and grows leftward.

`$stage` is an **anchor target only**. `fitTo: "$stage"` is refused (`fitTo`
grows a container around another layer's measured box; to cover the canvas, set
x/y/width/height directly), and a layer literally **named** `$stage` in the
layers map is refused rather than silently shadowing the reserved target.

### 🔴 Anchor the layer that HAS the content

This is the one that looks right and renders wrong. Layers resolve
**independently**, so anchoring a `fitTo` container does **not** drag its target
along. Write this and you get a correctly sized bubble, parked at the bottom of
the canvas, completely empty, with the message still sitting where it was
declared:

```json
// WRONG
"bubble": { "fitTo": "message", "anchor": { "to": "$stage", "edge": "bottom" } }
```

The correct form is the mirror image. Anchor the **text**, and let the container
merely fit it, so the bubble follows the message:

```json
// RIGHT
"message": {
  "sizing": { "width": "hug", "height": "hug" },
  "verticalAlign": "optical",
  "maxWidth": 200,
  "anchor": { "to": "$stage", "edge": "bottom", "gap": 70 }
},
"bubble": { "fitTo": "message", "padding": 12 }
```

`verticalAlign: "optical"` is what makes that `padding: 12` actually read as 12
above and 12 below. Without it the text sits high in the bubble by half the
leading, and the usual workaround is a hand-tuned asymmetric `padding` that
stops being right the moment the font size changes.

Rule of thumb: **anchor the layer that has the content; containers `fitTo` it.**
A container that ends up not overlapping its own target at all is reported as
`layout-detached-container` (a warning, not a refusal).

### Overflow is warned about, not refused

Any layer whose resolved box leaves the stage is reported on the render output:
`warnings` (one human sentence per layer) and `layoutWarnings` (structured:
`{ code: "layout-overflow", layer, box, stage, edges }`). The render still
completes, because bleeding off the edge is sometimes exactly what you wanted.
Running unattended? Assert `layoutWarnings.length === 0`. In batch mode each
entry also carries its `item` index, so you know which composite is wrong.

**Batches also get `layoutWarningsSummary`.** One mis-authored layer on a
30-item batch emits 30 near-identical entries, which is a channel nobody reads.
So a batch output carries a roll-up alongside the per-item list: one entry per
distinct `(layer, code)` pair, shaped
`{ code, layer, items: [<indices>], count, sample }`, where `sample` is one
representative structured warning with the full box/stage/edges detail. The
human `warnings` lines on a batch are the summary sentences
(`layout-overflow: layer "line" overflows right on 6 items (0-5)`) followed by
at most the first 3 per-item lines and a `… and K more` tail. Those 3 sample
lines are the first 3 in **item order**, not one per code, so with several bad
layers they can all belong to the loudest one. The summary sentences above them
are what guarantee every distinct problem is named.

Keep asserting on `layoutWarnings`, not on the summary: `layoutWarnings` is the
per-item channel and its shape is fixed, the summary is for reading.
`layoutWarningsSummary` is absent (not an empty array) when there is nothing to
summarize, and it never appears on a single render.

### `layout-collision`, and what it is honestly for

When layer B anchors to layer A (whole or per-axis) with a **non-negative**
`gap`, the layout has **declared** they sit apart. If their resolved boxes then
intersect on both axes you get
`{ code: "layout-collision", layer, target, box, targetBox, overlap }` and a
sentence naming both layers and the axis. The render still completes.

**It never fires on authored bleed.** A negative `gap` is legal, and it is the
only way to place a layer **inside** a layer target (only `$stage` is
inside-pinned), so `anchor: { to: "hero", edge: "top", gap: -60 }` is how you
lay a scrim over a hero. That overlap is declared, so it is never reported. A
plain absolutely-positioned overlay never warns either, because it declares no
anchor between the overlapping pair. It is not transitive: a chain msg1 to msg2
to msg3 is checked link by link.

**So this is not an authoring check, and you should not read it as one.** With
authored bleed excluded, the outside-adjacent formulas guarantee `gap` clearance
measured from the same box the check recomputes, so a non-negative gap cannot
produce an intersection. It is a **layout-engine tripwire**: if you ever see
one, our resolver regressed. Report it instead of editing your scene.

In particular it cannot catch the mistake people assume it catches, fixed x/y
slots that overlap once content grows, because those declare no anchor at all.
The fix for that is to chain the layers with per-axis anchors so the spacing is
derived rather than guessed.

Two carve-outs, so you do not over-trust that check:

- **Text is measured, images are not.** Text layers are measured for the
  overflow check even without `sizing`. An **image** layer that declares no
  width/height is unmeasurable at layout time (natural size is not known without
  fetching it), so it can only ever report a `top`/`left` overflow. Give image
  layers explicit width/height if you need them checked.
- **`template_api` callers do not see it.** That poll strips node output to an
  allowlist, so neither `layoutWarnings` nor `layoutWarningsSummary` is there.
  Both are present on the normal execution poll and on the executions GET.

### What it refuses

`fitTo` and `anchor.to` name layers by their key in the layers map. An unknown
name, a circular reference (A fits B fits A), the autoFit/hug contradiction,
`fitTo: "$stage"`, or a layer named `$stage` all fail the run with a message
naming the offending layer and listing the valid layer names.
Nothing is silently repaired, because a repaired layout is a paid render of a
guess.

Rotation is ignored by layout: an anchor uses the target's unrotated box.

### Author them in the editor, or in JSON

The visual compositor editor **does** resolve layout: its canvas paints the same
resolved geometry as the baked PNG, the node thumbnail and the HTML export,
through the one shared resolver. The **Layout** section of the properties panel
authors the same fields (hug, fit-to, anchor), so you no longer have to write
JSON for them. The node's `layers` input and config still accept them.

Authoring is narrower than resolving, so do not infer one from the other:
`anchorX`/`anchorY` are JSON/API-authored today. The panel resolves and paints
them correctly and shows a notice naming them, but has no controls for them,
and it disables the whole-`anchor` picker on such a layer so the contradiction
cannot be created by hand.

Resolved geometry is derived, never saved. That is why an axis the layout owns
is not draggable or resizable on the canvas: its handles are removed and the
selection outline turns amber, with a "sized by layout" badge saying which
field is deciding. Ownership is per axis: a layer carrying only `anchorY`
cannot be dragged vertically (the drag pins to the resolved y, and the up/down
nudge keys are inert) but still moves freely left and right.
Drag the layer it fits or anchors to instead. If a layout
refuses (unknown name, circular reference, autoFit vs hug), the canvas keeps
painting raw positions and shows the refusal rather than a guess.

## Gradient length: `span: "box"`

A linear fill (`{ "kind": "linear", "angle": 0, "stops": [...] }`) draws its ramp
along a line the length of the box's half-diagonal on each side of the centre.
On a wide, short box that is much longer than the box is tall, so a vertical
ramp never reaches its end colours inside the layer. That is the default and it
is kept so saved renders do not change.

Add `"span": "box"` to the fill to make the ramp run exactly across the box
along its angle (0 degrees is top edge to bottom edge, 90 is left to right, 45
reaches the corners). It is honoured by the editor, the PNG bake, the export and
the HTML export. Nodes without it render exactly as before.

Skew: `skewX` is in degrees and positive shifts the BOTTOM of the layer to the
right, so the top leans left. A forward (italic) lean is negative, for example
`skewX: -15`. `skewY` positive drops the right edge.

## Stacks: flow layout for a sequence

A chat thread is not three anchored layers, it is a LIST. `anchor` couples both
axes to one target, so a sequence of variable-height bubbles has to be
hand-positioned into fixed slots. Then the gaps vary with the content (26px
here, 75px there, which is the tell that a screenshot is fake) and you end up
capping message length to protect the layout.

A `type: "stack"` layer is flow layout: CSS block flow, SwiftUI VStack, Figma
Auto Layout. Children run one after another along `direction` with a
**constant** `gap`, each keeping its own content-driven size.

### Wrong vs right

**Wrong**: fixed slots. Correct for exactly one set of message lengths:

```json
"msg1": { "type": "text", "x": 60, "y": 200, "maxWidth": 600 },
"msg2": { "type": "text", "x": 60, "y": 340, "maxWidth": 600 },
"msg3": { "type": "text", "x": 60, "y": 480, "maxWidth": 600 }
```

**Right**: a stack. The gap is 18px whether a bubble is one line or four:

```json
"thread": {
  "type": "stack",
  "direction": "vertical",
  "gap": 18,
  "padding": [24, 24, 24, 24],
  "x": 60,
  "y": 200,
  "children": ["msg1", "msg2", "msg3"]
},
"msg1": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 600 },
"msg2": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 600 },
"msg3": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 600 }
```

`children` names **top-level layers**, flowed in list order. Each child may add
`alignSelf: "start" | "center" | "end"` (its cross-axis alignment) and
`sizing.width: "fill"` to stretch across the stack's content box.

**`alignSelf`, not `align`.** `align` is already a text layer's text
alignment, so `align: "center"` would mean two things on one layer. This is the
one place the stack vocabulary uses flexbox's longer spelling.

### The repeater: one template, N items

When the messages come from data, do not write N children. Write one template:

```json
"thread": {
  "type": "stack",
  "direction": "vertical",
  "gap": 18,
  "items": "$data.messages",
  "itemTemplates": {
    "them": {
      "bubble": { "type": "stack", "direction": "horizontal", "padding": 14, "fill": "#26262b", "cornerRadius": 18, "children": ["text"] },
      "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 620, "fontSize": 34 }
    },
    "me": {
      "bubble": { "type": "stack", "direction": "horizontal", "padding": 14, "fill": "#1f6feb", "cornerRadius": 18, "children": ["text"], "alignSelf": "end" },
      "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 620, "fontSize": 34 }
    }
  }
}
```

with `data` = `{ "messages": [ { "type": "them", "text": "you up?" }, { "type": "me", "text": "unfortunately" } ] }`.

Each item picks its template by `item.type`. The rest of its keys **bind exactly
like a batch `data` row**: a key names a layer inside that template, and a
string value sets a text layer's `text` or an image layer's `url`. There is no
`bind` field, and the only {{tokens}} anywhere in the compositor are the scope
tokens below, which live inside this template. Instantiated layers are named
`thread/0/bubble`, `thread/0/text`, `thread/1/bubble`, and so on.

`items` takes an array, or `"$data"` / `"$data.<path>"`. In **batch** mode
`$data` is the current row, so each output gets its own thread. For a **single**
render send the `data` port an **object**: a top-level array there is the batch
contract and renders one image per element. A path that resolves to nothing is
an empty stack, not an error; a path that resolves to a non-array is refused.

**The bubble is a stack, not a `fitTo` rectangle**, and that is the one thing to
carry over from the old way. A stack with a `fill` is a **frame**: it paints that
fill behind its own children, inset by its `padding`. `fitTo` is **refused** on a
stack child, because it sets position _and_ size from its target while the stack
also sets position, so it was a dead field. A stack with no `fill` paints nothing
at all and is pure layout.

Exactly one template? `item.type` is optional.

### Variants: one template, patched per value

Two whole templates for a **styling** delta is the wrong shape, and the shipped
DM thread is the proof: the two senders differ by a fill and an alignment, and
expressing that as `itemTemplates` duplicates every layer, font and padding.
The copies then drift, and both still render, so nothing tells you they drifted.

**Wrong**, two templates that are 95% the same:

```json
"itemTemplates": {
  "him": { "bubble": { "type": "stack", "padding": 14, "cornerRadius": 46, "fill": "#5B4AF0", "alignSelf": "end", "children": ["text"] }, "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 620, "fontSize": 34, "fill": "#ffffff" } },
  "her": { "bubble": { "type": "stack", "padding": 14, "cornerRadius": 46, "fill": "#25282E", "alignSelf": "start", "children": ["text"] }, "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 620, "fontSize": 34, "fill": "#ffffff" } }
}
```

**Right**, one base, and patches that are only the difference:

```json
"itemTemplate": {
  "bubble": { "type": "stack", "padding": 14, "cornerRadius": 46, "children": ["text"] },
  "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 620, "fontSize": 34, "fill": "#ffffff" }
},
"variants": {
  "key": "from",
  "map": {
    "him": { "bubble": { "fill": "#5B4AF0" }, "$align": "end" },
    "her": { "bubble": { "fill": "#25282E" }, "$align": "start" }
  }
}
```

with `data` = `{ "messages": [ { "from": "him", "text": "you up?" } ] }`.

**Patches deep-merge like a shallow theme override.** Objects merge key by key;
scalars **and arrays** replace wholesale, because an array here is one value (a
`cornerRadius` 4-tuple half-merged is nonsense). `$align` at the patch's top
level sets that instance's `alignSelf`. A patch key naming a layer the base
does not have is **refused**, naming the valid layers: a patch refines the base,
it cannot introduce a layer.

`variants.key` names the item field that picks the patch. It is **required**,
and like `type` it is consumed as a discriminator and never binds onto a layer.
An item whose key value has no `map` entry renders the **unpatched** base and
always warns `missing-variant` naming the value, an unmapped enum value is a
typo, never a deliberate "no styling".

Keep `itemTemplates` for items that are **structurally** different. A date
divider is not a bubble with another fill.

### Run selectors: position inside a streak

A **run** is a maximal streak of consecutive items whose `variants.key` value is
equal. `runs` patches by position inside it, think CSS sibling selectors, with
the streak in place of the parent:

```json
"runs": { "firstOfRun": {}, "midOfRun": {}, "lastOfRun": {}, "onlyOfRun": {} }
```

**`onlyOfRun` applies instead of first+last**, exactly like CSS
`:only-child`, and does not fall back to them. The merge order, least specific
first, is **base -> `map` patch -> shared `runs` -> that map entry's own
`$runs`**; each later step wins on a field both set.

**Grouped DM corners are the case this exists for.** Inside a streak of
same-sender bubbles the corner **facing the neighbour** goes tight, and since a
run never spans two key values, that is a different corner per sender, which one
shared `runs` map cannot say. So a map entry may carry its own `$runs`.
`cornerRadius` is `[topLeft, topRight, bottomRight, bottomLeft]`:

| position   | right-aligned sender | left-aligned sender |
| ---------- | -------------------- | ------------------- |
| firstOfRun | `[46,46,8,46]`       | `[46,46,46,8]`      |
| midOfRun   | `[46,8,8,46]`        | `[8,46,46,8]`       |
| lastOfRun  | `[46,8,46,46]`       | `[8,46,46,46]`      |
| onlyOfRun  | `[46,46,46,46]`      | `[46,46,46,46]`     |

```json
"variants": {
  "key": "from",
  "map": {
    "him": { "bubble": { "fill": "#5B4AF0" }, "$align": "end", "$runs": {
      "firstOfRun": { "bubble": { "cornerRadius": [46,46,8,46] } },
      "midOfRun":   { "bubble": { "cornerRadius": [46,8,8,46] } },
      "lastOfRun":  { "bubble": { "cornerRadius": [46,8,46,46] } }
    } },
    "her": { "bubble": { "fill": "#25282E" }, "$align": "start", "$runs": {
      "firstOfRun": { "bubble": { "cornerRadius": [46,46,46,8] } },
      "midOfRun":   { "bubble": { "cornerRadius": [8,46,46,8] } },
      "lastOfRun":  { "bubble": { "cornerRadius": [8,46,46,46] } }
    } }
  }
}
```

The 4-tuple `cornerRadius` form is **live**: it renders exactly as written,
in CSS `border-radius` order, on all four surfaces. A **scalar** still applies
to all four corners, and any other shape is refused by name rather than
silently rendering square. The patch mechanism itself is value-agnostic: it
merges whatever you put in it, and the radius is then validated by the one
shared normalizer.

**Run selectors drive spacing too, with `$gapBefore`.** Real DM rhythm is 8px
inside a same-sender run and 24px on a sender switch. That is a base `gap` of
8 plus 16 more before the first bubble of each run:

```json
"thread": { "type": "stack", "gap": 8, "items": "$data.messages", "itemTemplate": { }, "variants": {
  "key": "from",
  "runs": { "firstOfRun": { "$gapBefore": 16 }, "onlyOfRun": { "$gapBefore": 16 } }
} }
```

**Declare `onlyOfRun` as well as `firstOfRun`**, and this is the one thing
people get wrong here: a lone message is a run of **one**, so it is
`onlyOfRun` and `firstOfRun` never fires on it. A thread that alternates
sender every message would otherwise get no switch gap at all.

`$gapBefore` is a patch top-level key like `$align`: extra main-axis space
**before** that instance, **added** to the stack's own `gap`, and **ignored on
the first item that flows**, there is nothing to be spaced from, and a leading
gap would shift the whole stack off its own anchor. It **supersedes the
invisible-spacer-item pattern**: injecting `{"type":"break"}` rows put layout
knowledge into the data, and from there into an LLM prompt.

If you do still want a spacer layer, an **empty fixed-size stack** is legal: a
stack with no `children` and no `items` but an explicit `width` **and**
`height` is a zero-content flow participant of exactly that size. Both axes are
required, a stack sizes itself from its children on any axis it does not
declare, and with no children that is zero.

### `{{index}}` and `{{count}}`

Inside a mode-B template, and nowhere else in the compositor, any string may
carry `{{index}}` (0-based), `{{count}}` (how many items), and the item's own
fields by name, including dotted paths:

```json
"rank": { "type": "text", "text": "#{{index}} of {{count}}, {{author.name}}" }
```

**A data field wins over the meta-token of the same name.** An item carrying
`index` renders its value: the author's data beats our magic. A token that
resolves to nothing, or to an object, is left **literal** on the canvas and
reported as `unknown-scope-token`, substituting `""` would hide the mistake
inside a paid render.

`{{first}}` and `{{last}}` are deliberately **not** tokens. A boolean has no
useful text form, and what they are actually wanted for is conditional styling,
which run selectors already do without inventing a conditional syntax.

### `hideIfEmpty`: the cure for the blob

`hideIfEmpty: true` on a **text** layer, anywhere in the scene, stack or not ,
treats it as `visible: false` when its resolved text is empty or whitespace. It
paints nothing, its `textBg` paints nothing, and inside a stack it takes **no
flow space**, so the gap closes.

Without it, an empty text layer collapses to a zero-sized box but the **frame
around it still paints its padding**, which is how a missing field renders as a
small coloured lozenge nobody authored. With it, a frame whose children all end
up hidden this way is hidden too, so the bubble disappears with its text.

Behaviour without the flag is unchanged byte for byte. It is **refused** on a
non-text layer, naming the field: for an image with no url, or an empty frame,
set `visible` directly.

### Rows: an avatar beside a bubble

Nesting is capped at **two levels**, which is exactly what this shape needs:
column, then row, then the bubble frame.

```json
"itemTemplates": {
  "msg": {
    "line": { "type": "stack", "direction": "horizontal", "gap": 12, "children": ["avatar", "bubble"] },
    "avatar": { "type": "image", "width": 56, "height": 56, "cornerRadius": "50%" },
    "bubble": { "type": "stack", "direction": "horizontal", "padding": 14, "fill": "#26262b", "cornerRadius": 18, "children": ["text"] },
    "text": { "type": "text", "sizing": { "width": "hug", "height": "hug" }, "maxWidth": 560, "fontSize": 34 }
  }
}
```

The avatar declares `width` and `height` because it has to: an image has no
natural size at layout time, and a stack has to know how big every child is in
order to space them, so an unmeasurable child is **refused by name** rather than
silently contributing zero and letting its neighbour slide into it.

### The 30-slide serialized thread

The whole reason clipping exists. Pin the stack to the bottom of the stage, give
it a fixed height, and let the flow run off the top:

```json
"thread": {
  "type": "stack",
  "direction": "vertical",
  "gap": 18,
  "height": 1500,
  "overflow": "clip",
  "anchor": { "to": "$stage", "edge": "bottom", "gap": 120 },
  "items": "$data.messages",
  "itemTemplates": { "them": {}, "me": {} }
}
```

**The flow runs from the stack's anchored edge.** Anchored to `bottom` (or
`right`, horizontally), it lays out from the far end: the **last** item sits
against that edge and the **first** ones fall out. So the newest message is
always visible, and rendering the same stack over a growing `messages` array
gives you a thread that scrolls.

`overflow: "clip"` needs a fixed size on the flow axis (`height` here).
Declaring it on a hugging stack is refused: a hug box is exactly as big as its
content, so nothing could ever be clipped.

**Clipping reports as `layoutInfo`, not `layoutWarnings`.** Clipping is the
feature working, and putting it in the warning array would fire on every correct
render of this recipe. The entry is
`{ code: "layout-clipped", stack, clip, clipped: [...], partial: [...] }`, and
children under a clip are left out of the overflow check, so
`layoutWarnings.length === 0` still means what it always meant.

### Sizing, nesting, and what stacks refuse

A stack **hugs** its content per axis by default (children + gaps + padding
along the flow, widest child across it) and takes a declared `width`/`height`
instead when you give one. It positions itself with the ordinary `x`/`y` or
`anchor`, `$stage` included.

Nesting is capped at **two levels, inside an item template only** (either
form), because the
canonical shape needs both: column, then row, then a filled bubble frame.
Wrapping, `space-between` and grid are deliberately absent.

An invisible child takes **no flow space**: `visible: false` closes the gap, as in
Figma, rather than leaving a hole the size of a hidden layer.

Refused, never repaired, always naming layers: a `children` entry that is not a
layer; a layer claimed by two stacks; a `children` entry that is itself a
top-level stack; a child that also declares its own `anchor`, `anchorX`,
`anchorY` or `fitTo` (the stack owns its children's positions, and this is
refused identically in **both** binding modes); `items` without
`itemTemplates`; an `item.type` that is not a template key; a layer whose
name collides with the `<stack>/<index>/<layer>` namespace; a negative `gap`
(overlap inside a flow has no reading, use `anchor` for that); `rotation` on a
stack (layout works in unrotated stage space, so it is a dead field); a child
with no measurable size.

**Warned, not refused:** an item that omits a field every other item of its type
sets is reported as `missing-bound-field`, because those layers ship the template
placeholder. A template layer that no item ever binds is a static label and is
not reported. Under `variants` the variant value IS the item's type, so a
`number` divider binding `pre`/`post` is never compared against a `him` bubble
binding `txt`. A variant with only ONE item has no sibling to compare against,
so it is instead expected to bind the layers its own `variants.map` entry
styles, minus any layer no item anywhere binds, which is still a static label.

A stack paints nothing itself **unless it declares a `fill`** and then it is a
frame and paints that fill behind its own children. Either way its children take
its z-slot in flow order. The
editor canvas resolves and paints stacks **read-only**; visual stack editing is
phase 2, so author them as JSON for now.

---

_Auto-generated from the Wireflow node registry._

---

Documentation index: fetch https://www.wireflow.ai/llms.txt for the full list of Wireflow docs.
