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 |
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:
{
"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:
align: "start"insets inward from the near edge:start + alignOffset.align: "end"insets inward from the far edge:end - alignOffset.align: "center"applies it as a signed offset from the centre; positive shifts towardend.
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:
mustNotOverlapis the authoring assertion, and the only one that catches a fixed-slot layout, because such a scene declares no anchors.- The anchored-pair
layout-collisionis an engine invariant: it cannot fire on a layout the resolver can produce, so it only ever means we regressed. - 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 atop/leftoverflow. Give image layers explicit width/height if you need them checked. template_apicallers do not see it. That poll strips node output to an allowlist, so neitherlayoutWarningsnorlayoutWarningsSummaryis 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.