Compositor

Compose layers of images, text, and video

Compositor

Node type: compv3
Category: Editing

Description

Compose layers of images, text, and video

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

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:

{
  "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 all of the leading (that is (lineHeight - 1) x fontSize) piles up below the final baseline instead of being split around the block. So a bubble with symmetric padding renders its glyphs high: measured on a real composite at fontSize 40 / lineHeight 1.3, 23px above the first line's caps against 39px below the last baseline, roughly 0.2 x fontSize off centre, and it gets worse as either number goes up.

{ "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 three faces 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, the baked PNG applies no vertical paint shift at all (it draws from ty=0 with textBaseline: "top") and a single optical line measures exactly fontSize, so on the surface that ships there is no rounded quantity for a half-pixel to hide in. 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:

{ "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:

{ "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:

{
  "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
// 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:

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

// 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:

{ "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:

// 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:

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

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:

"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:

"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:

"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:

"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:

"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:

"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]
"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:

"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:

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

"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:

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

© 2026 Wireflow. All rights reserved.

Compositor | Wireflow Docs