Skip to content

Positioning — Layout Modes (Absolute, Flow, Pin)

Every layer is positioned exactly one of four ways: absolute (a fixed per-ratio box), flow (a member of a flowBox container), pin (relative to another layer), or derived (a box computed from other layers, owned by Background). This page owns the mechanism for the first three; derived is listed here so the union is complete in one place. Coordinate units, sizing, the anchor grid, and the bounds vocabulary every mode reads from live in Foundations: Coordinate System & Sizing, not here.

layout becomes a discriminated union on mode

Today layout is a per-ratio map of {x,y,w,h,scale,rotation,rotationX,rotationY,opacity} (layoutStateSchema), with no mode field — every layer is implicitly absolute. The reshape makes positioning mode an explicit discriminant:

layout: z.discriminatedUnion('mode', [
  z.object({ mode: z.literal('absolute'), ...perRatioLayoutSchema }),
  z.object({ mode: z.literal('flow'), ...perRatioFlowMemberSchema }),
  z.object({ mode: z.literal('pin'), ...perRatioPinBoxSchema }),
  z.object({ mode: z.literal('derived'), ...perRatioDerivedSchema }),   // `of` + `ofBounds`; the box is never authored
])

The mode discriminant can't live inside the per-ratio envelope itself — perAspectRatio is a strict three-ratio-key object, and the resolver's per-ratio collapse step would replace the whole envelope with value[ratio], stripping mode before the renderer ever sees it. So ratio keys spread flat as siblings of mode, and the per-ratio type is today's existing per-ratio layout type, extended with the mode literal plus Coordinate System & Sizing's anchor, uniform w/h, and unified rotation/scale fields — never hand-enumerated as a field subset, so nothing is silently dropped by construction:

layout: {
  mode: "absolute",
  "16:9": { x: 960, y: 900, w: "content", h: "content", anchor: "bottom-centre" },
  "9:16": { x: 540, y: 1500, w: "content", h: "content", anchor: "bottom-centre" },
  "1:1":  { x: 540, y: 960,  w: "content", h: "content", anchor: "bottom-centre" },
}

x/y place the layer's anchor point, not its top-left corner — so the block above pins the bottom-centre of a content-sized layer to a fixed spot, and the layer grows upward and outward from there as its content changes. An absent anchor is top-left (text infers from its alignment), which reads exactly like today's x/y.

Mode What it means Who may use it
absolute A fixed per-ratio box. Any layer.
flow A member of a flowBox container. Any layer.
pin Relative to another layer. Any layer.
derived The box is computed from other layers, never authored. background only — validated against layer type.

ALL_LAYOUT_MODES names all four; ADMITTED_LAYOUT_MODES is the subset the schema accepts at any given time, so a mode ships with its solver (Reserved-but-unbuilt variant gating).

Every field but mode is per-ratio

mode is the one ratio-invariant field on layout, forced by the shape above: it's the union discriminant and sits beside the ratio keys, not inside them. Everything else is per-ratio, including the referencespinTo, pinToAnchor, pinToBounds, pinType, flowIn, a derived layer's of and ofBounds, alignAcrossSelf, grow, and every number. A layer is therefore pinned in every ratio or in none, but it may pin to a different target, flow in a different flowBox, or span a different member set, per ratio — the same layer-hidden-in-portrait reshuffles that already make per-ratio absolute boxes necessary apply to relationships too. The consequence for the motion graph is that topology is per (scene, ratio), and validation's reference, cycle, and depth checks run once per ratio.

main's layoutStateSchema is still the plain, mode-less object this reshape replaces — this reshape ships only as part of schemaVersion: 2 (see Schema-Version Gating). What exists today on feature/neo-1253-retail-studio-text-engine implements absolute but nests the per-ratio fields under box ({mode: 'absolute', box: {...perRatio}}) instead of spreading them flat as siblings of mode — that nested shape needs correcting to the flat form above; the underlying resolver and measure/wrap logic are independent of which shape wraps them, so this is a schema-and-call-site change, not a re-litigation of that logic.

The schemaVersion: 1 compat shim

Reading a legacy (mode-less) layout value is the one-time conversion a document goes through when someone explicitly migrates it from schemaVersion: 1 to 2 — never a live, ongoing tolerance:

// legacy input: { "16:9": {x, y, w, h, ...}, "9:16": {...}, "1:1": {...} }
// upgraded to:  { mode: "absolute", "16:9": {x, y, w, h, ...}, "9:16": {...}, "1:1": {...} }

structurally derived from today's schema type, never hand-enumerated. The tested worktree already has a working version of this shim (isLegacyLayoutValue + a z.preprocess upgrading a bare mode-less layout value), built against its nested-box shape — it needs the same flat-spread correction as the reshape itself, not a rewrite from scratch.

Because the ratio keys stay flat, layer.layout[ratio] keeps resolving for an absolute layer and no read site changes. What does change: anything that writes a whole layout value must carry mode through, and anything that iterates layout's keys as ratios must skip mode — at minimum apps/web/src/components/TemplateBuilder/editOps.ts, EditStep.tsx, and whatever CanvasLayoutManipulator/CanvasSelectionOverlay write or iterate directly.

Also worth preserving exactly: the tested worktree's legacyLayoutBindingCompat fix in resolve.ts. Every whole-layout manifest binding published before the reshape carries the pre-reshape path — a bare ['layout'], confirmed across every template in the corpus, hundreds of sites — and silently clobbering layer.layout with a bound value would destroy mode if this rewrite (['layout'] → the corrected post-reshape path) isn't carried forward untouched.

Bindings target numeric leaves only. Manifest bindings are applied by applyBindings after validateTemplate has run, so a binding that could rewrite mode, pinTo, flowIn, or a background's of would be a way to create the cycle, dangling reference, or shape mismatch validation just ruled out. validate.ts keeps a binding-path allow-list: a binding may write a leaf value — a per-ratio number (x, offsetX, w, gap, grow, …), a text run's value, a colour, a boolean, an asset reference (an image's src), or a whole per-ratio box under an absolute layout — and nothing structural. A binding is the degenerate expression = manifest.<field>, so the two share one allow-list rather than each keeping its own.

Several bindings may share one fieldId, and that is how one control drives many layers: each binding is looked up and written independently, so five bindings on five layers' pulse switches, all carrying fieldId: "pulse", give a campaign operator one toggle (Keyframe Animation). It needs one validation rule to be safe: every binding sharing a fieldId must resolve to the same expected type. Without it, the value is written to the paths whose type it matches and skipped with a console warning on the rest, so a toggle silently works on four panels and not the fifth. Checked in validateTemplate, where the types of all the target paths are already known. A template whose manifestBindings point anywhere else fails validation with the same friendly-message treatment a reserved mode gets.

hidden: removed from layout, not painted at zero

Every layer carries an optional per-ratio hidden: boolean. A hidden layer is not solved, not packed, not painted, and contributes nothing to a container's content size or to a derived background's union. It is not opacity: 0, which paints nothing but still occupies its slot in a flow and still widens the box around it.

layout: {
  mode: "flow",
  "16:9": { flowIn: "offer-row", w: "content", h: "content" },
  "9:16": { flowIn: "offer-row", w: "content", h: "content", hidden: true },
  "1:1":  { flowIn: "offer-row", w: "content", h: "content" },
}

Per-ratio because that is what it is for: a lockup that carries a lead-in stack in landscape and drops it in portrait, where the remaining members re-centre on their own. It is a plain boolean, so it is a bindable leaf, which is what makes "turn this part off" a control a campaign editor can offer without the binding rules having to admit anything structural.

A hidden layer is still a real layer for validation. Its references are still checked, it still counts in the cycle and depth checks, and it still satisfies the empty-container rule for its container, so hiding the last visible member of a flowBox in one ratio is a document that fails validation rather than one that renders an empty box.

editorModeSchema rename

The existing, unrelated layoutModeSchema (an editor-capability-tier enum, basic|intermediate|advanced|author) is renamed to editorModeSchema — it describes an editing-capability tier, not layout, and sharing the word "layout" with the new positioning mode is a real comprehension cost for zero benefit. The schema declaration is renamed; the consumer sweep in apps/web is the other half and still carries the old name: at minimum useMaxLayoutMode.ts, useEffectiveLayoutMode.ts, RetailStudioLayoutModeSwitcher.tsx.

flowBox — a real layer type

flowBox is a non-visual layer type (sibling to pinPoint — see Coordinate System & Sizing); a member points at it via layout.mode: 'flow' + a flowIn reference, the same addressing mechanism pin uses. Because it's a layer, it has an id to be referenced by, a layout of its own (so a row can itself be pinned or flowed), and group-level animations via commonLayerFields — its members inherit them through the motion graph, and a props.background of its own — the same field, the same schema, the same resolve as a text layer's pill (Coordinate System & Sizing). A panel behind a row is that row's own paint, so it tilts, fades and wipes with the row rather than being a second node kept in sync by hand, and padding gives a container the inset it otherwise has no field for. Two layer types, one background mechanism, nothing special-cased for either.

Use a background layer instead only when the thing being spanned is not a group: a panel behind two layers that are not members of the same container is what derived is for. A panel behind a flowBox's members is the flowBox's own background.

A member belongs to exactly one flowBox by construction. A flowBox can itself be a flow member of another flowBox — nested flow is explicitly allowed, nothing in the shape prevents it, and it's completely ordinary to want (nested flex containers are routine in CSS). The failure case (a cycle) is already covered by the general motion-graph-wide cycle rejection above.

flowchart LR
    FB["flowBox layer<br/>(non-visual, direction/align/gap/wrap in props)"]
    M1["member<br/>layout.mode: 'flow'<br/>flowIn: FB.id"]
    M2["member<br/>layout.mode: 'flow'<br/>flowIn: FB.id"]
    M1 -. flowIn .-> FB
    M2 -. flowIn .-> FB

A flow member's layout, one block per ratio the document's canvas declares:

layout: {
  mode: "flow",
  "16:9": FlowMember, "9:16": FlowMember, "1:1": FlowMember,
}

type FlowMember = {
  flowIn: LayerId,                 // the flowBox this member belongs to
  w: number | "content" | TargetFraction,   // a fraction of the flowBox's own box
  h: number | "content" | TargetFraction,
  alignAcrossSelf?: AlignAcross,   // per-member cross-axis override
  grow?: number,                   // default 0
  margin?: number | { top, right, bottom, left },   // per-member spacing; negatives allowed
  rotation?: Rotation,             // paint-level: never reflows siblings
  scale?: Scale,
}

margin is what gap cannot express. A container's gap is one number applied between every pair of members, and a real lockup almost never has one: the source artwork for these components runs gaps like 32 then 37 then 19 down a single column, plus per-panel nudges of a few pixels that exist purely because a designer moved something. margin adds to the space on that member's side of the gap, so a column with gap: 19 and one member carrying margin: { top: 18 } puts 37 above that member and 19 everywhere else. Negatives are allowed and pull a member toward its neighbour, which is how the overlapping panels in the cashback lockups are drawn. It is per-ratio like every other flow-member field, and it contributes to the member's own extents, so a derived background spanning the row clears the nudge rather than cutting through it.

rotation and scale are present on every mode's per-ratio block, not only absolute — a tilted panel is usually a flow member. They never affect packing (above).

Position along the main axis is not a field — it's scene layer-array order (below).

flowBox's layout vocabulary

The container fields — direction/alignAcross/alignAlong/gap/wrapLayers/alignWrap/ overflow — live in flowBox's own props (flowBoxLayerPropsSchema), matching the established per-layer-type props convention. The two per-member fields, alignAcrossSelf and grow, live on the member's own layout next to flowIn, since they describe that member, not the container:

Field Values Meaning
direction row \| column No reversed variants — reordering is already fully achievable by reordering members in the scene's layer list, and CSS's reverse variants mostly solve RTL/authoring-convenience problems that don't apply here.
alignAcross / alignAcrossSelf Cross-axis tier (top\|capHeight\|centre\|baseline\|lastBaseline\|bottom for row, left\|centre\|right for column) + stretch Always the axis perpendicular to direction. alignAcrossSelf is the per-member override. stretch means the member's cross-axis extents fill the container; it applies only to a member whose size on that axis is "content" (Coordinate System & Sizing). capHeight, baseline and lastBaseline are the anchor grid's typographic tiers: members line up on a cap line or a baseline, the first line's or the last's.
alignAlong Main-axis tier (left\|right\|centre for row, top\|bottom\|centre for column) + space-between\|space-around\|space-evenly Always the axis along direction. Container-only — no alignAlongSelf, matching real CSS flexbox precedent that main-axis distribution has no per-item override.
gap number Space between same-line members.
grow number (default 0) CSS flex-grow, adapted: once a flowBox computes how much main-axis space its members need at base size plus gaps, any leftover space distributes proportionally by grow value (grow: 2 claims twice the leftover of a sibling's grow: 1; grow: 0 never expands past base size). alignAlong's spacing rules only ever act on space no member's grow already claimed.
wrapLayers boolean Wraps members onto multiple rows/columns once they exceed the container's cross-axis extent.
alignWrap Same tiers as alignAcross CSS align-content's job — once wrapping produces more than one row/column, governs how those lines distribute across the container's cross-axis extent. Only has an effect when wrapLayers is on and wrapping actually produced more than one line.
overflow show \| hide A property of being a container, not of flowBox specifically — Scenes carry the same field (Coordinate System & Sizing). Governs the two real behaviors when a fixed-size, non-wrapping flowBox is smaller than its members' combined need: members extend past the bounds (CSS overflow: visible) or get clipped at them (overflow: hidden). There is deliberately no flex-shrink-equivalent — too much added complexity for a case that's usually an authoring mistake, especially once wrapLayers already covers "content doesn't fit, handle it gracefully" for anyone who wants that. "Warn" is not a third overflow value — whether an author gets alerted is a separate, orthogonal concern from what the render actually does, and is a Builder/QC-tooling concern, not a schema field.

alignAcross: "baseline" for a non-text member adopts the same fallback real CSS align-items: baseline already uses: the member's own bottom edge (whichever bounds the flow is resolving against) stands in for its baseline; "capHeight" on a non-text member uses its top edge the same way. Neither is restricted to text-only members, so the common icon-next-to-price-text case isn't rejected outright — the icon just aligns by its edge while the text aligns by its type.

"content" sizing on flowBox's main axis sizes to the no-wrap size (sum of members' base sizes plus gaps). Combining that with wrapLayers: true on the same axis is a validation error, not a silent no-op — wrapping exists to handle "not enough room," and a content-sized main axis is defined to always have exactly enough room by construction, so the combination can never mean anything. The cross axis was never in tension: its content-size is just the sum of each wrapped line's own extent plus inter-line gaps, well-defined after wrapping runs, never an input to it.

Member position along the main axis is scene layer-array order, not a separate concept — since flowBox deliberately has no members list, the only source of "which member comes first" is the order members appear in the scene's layers array, filtered to whichever reference this flowBox via flowIn. This is fully decoupled from zIndex, which governs paint order independently and needs no new mechanism — a member later in flow order can still paint under an earlier one if zIndex says so. The schema already treats the two as separate fields; it's only the Builder's layer panel that today restamps zIndex from array position when it reorders. Letting an author sort that panel by array order or by zIndex is a Builder concern outside this brief.

A flow member's own animations apply on top of its flow-resolved position with no new design needed — flowBox membership is just another motion-graph parent relationship, so a member's own enter/exit motion composes against its flow-resolved resting position exactly the way it already would under absolute or pin. The corollary is the same rule CSS applies to transform: a flowBox packs its members on their resting, untransformed boxes, so a member's rotation or scale — resting or animated — never reflows its siblings. A spinning icon in a row stays in its slot; the row doesn't breathe around it.

Referencing a whole group from background needs no schema change

of stays LayerId[]-only permanently (see Background), and a background that wants to span an entire flowBox's membership just includes the flowBox's own id in that list, since it's a real layer with a resolved box. For a non-visual layer the two bounds (Coordinate System & Sizing) are: content is the union of its members' extents (what's actually painted inside it), and extents is its own authored box — so pinToBounds: "content" on a flowBox hugs the members and "extents" hugs the container, and overflow: show is exactly the case where the two differ.

Not shared code with text's line-breaker, but the same algorithmic shape

flowBox's row/column packing is a self-computed greedy first-fit packer — the same shape of algorithm as Text Foundation's line-breaker, but genuinely not shared code: the text engine's functions are built specifically around re-measuring each candidate line as one whole-string call, because word widths are not additive (kerning shifts a word's measured width depending on what's next to it). A flowBox member's width is simply additive — a box's width doesn't change depending on its neighbour — which is the exact case text wrapping was deliberately built to not assume. So flowBox wrapping needs its own small (~20-line) greedy row-packing implementation, not a function call into the text engine's code. Worth remembering as precedent for why this is safe to build rather than a red flag: the algorithm shape isn't a novel invention, it's already proven out once in this codebase.

flow's own dangling-flowIn rejection shares validate.ts's checkContainerPinReferences/ checkContainerPinCycles, which pin builds below — flowBox depends on pin landing first for this specifically, since pin and flow share the same base addressing/validation mechanism, only the resulting layout math differs.

Non-visual layer types today are ad hoc, not one clean list

Today's only non-visual layer (audio) is excluded via a type-level split (VisualLayer = Exclude<Layer, AudioLayer>, types.ts) plus roughly eight separate inline !== 'audio' filters scattered across the interpreter and validator — not one shared exception point. flowBox/pinPoint are a new third category (positioned but never painted) the current binary split can't express as-is. Consolidating those scattered filters into one shared "non-painted types" predicate is real work flowBox's own implementation needs to do, not something it gets for free — pinPoint's own marginal cost over flowBox is close to zero, but only once that consolidation exists.

pin — relative positioning, superseding attachTo

The shape below is the intended design, not a description of existing code. Rides the v1→v2 schemaVersion bump alongside Coordinate System & Sizing's real px, uniform sizing, and anchor grid.

layout: {
  mode: "pin",
  "16:9": PinBox, "9:16": PinBox, "1:1": PinBox,   // one per ratio the document's canvas declares
}

type TargetFraction = { value: number; unit: "targetFraction" }

type PinBox = {
  offsetX: number | TargetFraction,
  offsetY: number | TargetFraction,
  w: number | "content" | TargetFraction,
  h: number | "content" | TargetFraction,
  anchor?: Anchor,       // this layer's own anchor point
  rotation?: Rotation,   // the pinned layer's own, composed after the target's
  scale?: Scale,
  pinTo: LayerId,        // the target layer
  pinToBounds: "content" | "extents",   // never "raster" — internal-only
  pinToAnchor: Anchor,   // the target's reference point
  pinType: "full" | "position",
}

w: "content" is the common case, not an afterthought — a pinned label sized to its own text is what most pins are.

Field Meaning
pinTo The target layer's id.
pinToAnchor The target's reference point — the same anchor grid, baseline-* text-only.
pinToBounds Which of the target's bounds to resolve against.
pinType: "position" Tracks the target's chosen pinToAnchor point frame-by-frame — moves if that point moves, but the pinned layer's own scale/rotation/opacity stay untouched.
pinType: "full" Composes the target's live, per-frame scale/rotation onto the pinned layer, and multiplies in the target's opacity — the pinned layer is a child of the target in every sense, an edge in the motion graph like every other relationship (opacity along edges).

Only two pinType values, deliberately — a third, "fixed regardless of target scale/rotation" behavior doesn't need its own value. Since rotate/scale always happens around the centre of a layer's own content bounds (Coordinate System & Sizing), pinType: "position" with pinToAnchor: "centre" and pinToBounds: "content" already produces exactly that "fixed" behavior for free — the target's content-centre is invariant under its own rotate/scale by construction. This equivalence is specifically a content-bounds equivalence: it does not carry over to pinToBounds: "extents" or "raster", since those boxes are content expanded by paint-only additions or the motion-blur capture margin, and neither is guaranteed to share content's centre once those additions are asymmetric. Pinning position-mode to an extents/raster "centre" is a different, legitimate reference (the pinned layer tracks that box's actual centre, which does move in a small arc as the target rotates/scales) — not a bug, not "almost fixed."

offsetX/offsetY/w/h each admit { value, unit: "targetFraction" } alongside a plain px number — a scalar multiple of the pinTo target's resolved pinToBounds box (width for offsetX/w, height for offsetY/h), applied on top of wherever pinToAnchor already resolved to. See Coordinate System & Sizing for the full mechanism, the two motivating cases, and why it's targetFraction and not simply "fraction".

Not glyph-relative — the discount lockup's suffix is an inline text run instead, see Text Foundation.

Solve: the packages/retail-studio-compositions/src/interpreter/solve/ module (today resolving absolute only, one layer at a time) grows topological resolution — a pin target may itself be pinned — cycle-safe by construction (fall back to a stable finite box rather than recursing forever, defensive; validateTemplate should already reject a cyclic document by the time this runs). pinToBounds distinguishing content from extents needs the rasterization/bounds ownership model (Coordinate System & Sizing) to actually exist for non-text layer types — resolving both against the target's plain box until that exists is an acceptable documented simplification. targetFraction values resolve away before the transform math runs, not a new runtime concept: once the pinTo target's box is known (already guaranteed by the topological solve before this layer resolves), each targetFraction value is multiplied by the target's resolved width or height and replaced with a plain px number, then enters the exact same pin/transform-composition pipeline a px-authored value always has.

Validation: dangling-pinTo/self-pin/cycle/depth-cap in validate.ts; baseline-* anchor values checked against the owning layer's type (for anchor) and the target layer's type (for pinToAnchor).

Depth cap: one combined budget across pin, flow and expression links

There is a cap, and it is one shared budget across a chain that mixes link kinds — a chain running pin → flow → pin → flow → pin back to a plain absolute layer counts the same as five pin links or five flow links alone; the kinds do not get separate budgets that add up. A link is one pinTo, one flowIn, or one layer("id") reference in an expression — one hop from a layer to the layer it depends on. The containing scene is the root, not a link, so an ordinary pinned layer is 1 deep.

The number is deliberately not fixed yet. Picking one now would be guessing: the real constraint is whatever depth starts to cost render time, editor responsiveness, or debuggability, and none of that is measurable until the solver and the graph exist and real documents run through them. The component examples already show the natural encoding of a lockup sitting around four links deep, which is itself evidence that a number chosen from intuition would have been wrong. Set it from measurement, once there is something to measure.

The rationale for having one at all doesn't depend on the number: a chain deep enough to hit it is a sign the structure wants to be a bespoke component instead, and failing loudly at validation beats letting an unbounded chain quietly degrade the editor or the render.

Unblocked once pin exists: the sticker system (attachTo's only real consumer today) migrating onto layout.mode: "pin", and attachTo's eventual retirement below.

attachTo as a reference, not necessarily reusable code

attachTo (layer-attachment.ts) already does single-level relative positioning with continuous-drift compensation, which makes it worth reading for gotchas before building pin — notably that it deliberately excludes the host's scale/rotation/opacity from inheritance, and that its cycle-safety is structural (single-level, no chaining, so a cycle can't form) rather than a real graph algorithm. Whether any of its actual code is reusable for pin's multi-level topological resolution is a separate question the motion graph's own shape will answer — worth checking once that shape is settled, not assumed either way going in.

attachTo retirement

Named here so it has an explicit home rather than floating: migrate the sticker system (attachTo's only real consumer today) onto layout.mode: "pin", then retire attachTo itself. Real, necessary, unblocked once pin exists, and low-stakes given minimal production sticker usage today.

Frontend impact

  • New: group-flow solve and pin solve modules within interpreter/solve/ (Foundations → Pipeline & Architecture).
  • Flow/pin layers have no Builder authoring support. The authoring GUI is out of scope — flow/pin layers are hand-authored via document JSON, same as other non-GUI-authored capabilities. The Builder must render them correctly and not misbehave when one is selected; full drag/manipulation is deferred.

Alternatives considered

Alternative Why rejected
Nested frames for grouping More Figma-faithful but a structural (tree) change; rejected in favour of flat groups-by-reference
Glyph-relative pin anchors (for the discount lockup's suffix, which needs to start at the hero number's last-glyph edge) Rejected in favour of expressing the suffix as an inline run within the hero's own text content (reusing Price's inline-price run machinery) — keeps pin scoped to whole-layer positioning, a materially smaller feature
Reconciling attachTo and pin as parallel mechanisms attachTo lives outside layout entirely, which is exactly the contradictory positioning state the layout union exists to make unrepresentable — rejected in favour of superseding it: pin replaces attachTo, and the sticker system migrates onto pin once it ships

Risks

  • The attachTopin migration is a real refactor, not risk-free, but low-stakes given minimal production sticker usage today.

Open questions

  • Correcting the built nested-box shape to the decided flat-sibling shape is scoped work, not yet estimated. Every call site reading layer.layout.box[ratio] in the tested worktree moves back to layer.layout[ratio] — the same path main's consumers already use — so the correction shrinks the reshape's apps/web footprint rather than adding to it.

Done when

  • The layout-shape correction is done when every call site reading layer.layout.box[ratio] has moved to the flat-spread form and is tested against it (see Text Foundation → Done when for the pairing with that feature's own done-when).
  • flowBox is done when the discount-lockup row lays out via a flowBox with alignAcross: "capHeight", the prefix's cap line level with the number's.
  • pin is done when the full discount-lockup hard case passes end-to-end (prefix + number in a flow group aligned by cap-height, suffix as an inline run, one spanning background over the assembly — see Background).