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 references — pinTo, 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
attachTo→pinmigration is a real refactor, not risk-free, but low-stakes given minimal production sticker usage today.
Open questions¶
- Correcting the built nested-
boxshape to the decided flat-sibling shape is scoped work, not yet estimated. Every call site readinglayer.layout.box[ratio]in the tested worktree moves back tolayer.layout[ratio]— the same pathmain's consumers already use — so the correction shrinks the reshape'sapps/webfootprint 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). flowBoxis done when the discount-lockup row lays out via aflowBoxwithalignAcross: "capHeight", the prefix's cap line level with the number's.pinis 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).