Skip to content

Foundations: Coordinate System & Sizing

Shared vocabulary every layout mode (Positioning) and Background read from: real coordinates, how a layer expresses its own size, where it rotates and scales around, and which box a pin or background actually targets. None of it is owned by one feature.

Real pixel coordinates, not a normalised grid

Today's schema positions everything on a normalised 01000 grid (LAYOUT_COORDINATE_SPACE), independent of a document's actual output resolution. This reshape drops that grid entirely — every coordinate is a real pixel value in the per-ratio canvas the document already declares (canvasSchema, a perAspectRatio of { width, height }). That canvas is the render format — the composition renders at exactly those dimensions — so a w: 150 is 150 output pixels with no second scaling step. LAYOUT_COORDINATE_SPACE and every grid-relative calculation built on it (scaleLayoutToCanvas included) go away with schemaVersion: 2.

Uniform w/h sizing

Every layer's width and height are expressed the same way, regardless of layer type:

w: number | "content"
h: number | "content"
Value Meaning
number An explicit size in real pixels.
"content" Sized to the layer's own intrinsic content.

This generalises cleanly across layer types, not just text:

  • Text — this is what Text Foundation's point/box consolidation is built on. Today's two hardcoded text layer types (pointTextLayerSchema, boxTextLayerSchema) collapse into this one rule — w/h mode plus a multiline flag reproduces both.
  • Imagesw: 150, h: "content" means aspect-ratio-preserving auto-height, a genuinely different (and often more useful) intent than fit: cover/contain/fill, and complements it rather than replacing it. An image's intrinsic size comes from its file's real pixel dimensions, the same natural fallback text gets from measuring its own glyphs — no new algorithm, just the sizing rule's own definition applied to a layer type that already carries a natural size.

What "content" resolves to is the layer's intrinsic size, and that has rules of its own — the next section.

Intrinsic size

Every "content" axis, every content-sized container, and every fraction that can't be resolved bottoms out at the same thing: a primitive's intrinsic size. That makes it load-bearing, not a rendering convenience — every primitive must define a real, always-available intrinsic size, because it is the point every fallback chain below is guaranteed to terminate at.

Layer type Intrinsic size comes from
text Measuring its own glyphs — the ink extent of its runs after wrapping against whatever width constraint it has (Text Foundation).
image, product The file's real pixel dimensions.
video The video's frame dimensions.
lottie The composition's declared w/h in the Lottie JSON.
sticker The SVG's viewBox.
shape Declared: props.intrinsicSize: { width, height }, in absolute px. Pure geometry has no natural size — a rectangle is whatever size it's told to be — so it has to be told once, in its own properties.
component Its own bounds hook (see Rasterization and bounds ownership) — a bespoke renderer must say how big it is.
flowBox, pinPoint, background None of their own. A container gets an intrinsic size for free by recursing into its children until it reaches a real primitive; pinPoint is a point; background takes its members' union.

Why shape's declared size is absolute, never a fraction of its container

shape was the one content-bearing primitive that turned out to lack a natural size — unlike flowBox/pinPoint/background, which carry no size at all by design, a shape paints something, it just has no inherent extent to measure. Declaring it relative to the container was considered and rejected: a fraction against an indeterminate container already falls back to content sizing (below), and content sizing for a shape is its declared size, so a relative declaration would never let the chain terminate — the exact circularity the collapse rules exist to avoid. When a shape sits in a box that disagrees with its declared size, it reuses image's existing fit: cover | contain | fill rather than inventing new vocabulary for the identical problem: contain shrinks to fit inside, cover fills and crops with proportions kept, fill stretches non-uniformly to match.

The two sizing-collapse cases

Combining content sizing with container-relative sizing produces exactly two cases, and only one is hard. Both were worked through directly rather than assumed.

  1. A content-sized layer inside a content-sized container. Resolves cleanly with no circularity: the layer resolves first against its own intrinsic size, and the container's content size wraps whatever its children resolved to — the same upstream-first dependency order measure already has on layout generally (Pipeline). SAVE in a content-sized flowBox is this case.
  2. A layer sized as a fraction of its container, inside a container sized to content. A genuine circular dependency — the same one CSS has with a percentage width inside an auto-sized flex container — and it resolves the same way CSS resolves it: a fraction against an indeterminate container is defined to behave as content sizing, falling back to the layer's own intrinsic size. That reduces the hard case to the first one rather than needing a second algorithm, and it's why the intrinsic-size requirement above is a requirement — this fallback has to land on something real.
  3. A stretch member inside a container sized to content on that axis. Not circular, but a two-pass resolve rather than one: the container first takes its content size from the members' own intrinsic sizes, then every stretch member is widened to that size. The container's size is therefore the widest member's, never a stretched member's, so the pass terminates. stretch only ever applies to a member whose own size on that axis is "content" — an authored number wins, the same way CSS align-items: stretch leaves an explicit width alone — and it sizes the member's extents to the container, not its content: two pills with different paddings under stretch end up the same visible width, which is what the equal-width column a designer drew actually means.

The empty-container rule

The fallback chain makes an empty, or effectively empty, container a real, representable invalid state, so it gets an explicit rule: any container, at any depth, must have at least one descendant — found by walking its full tree — that resolves to a real intrinsic size. A flowBox whose only member is another empty flowBox, a pinPoint with nothing pinned that has size, a background whose of list names only sizeless layers: none of these satisfy it, and each is rejected at validation with a message naming the container, never silently collapsed to a 0 × 0 box. It's a whole-document check, and it's the same graph walk that already does dangling reference and cycle detection (the motion graph) — not new validation machinery. It runs on every mutation, not only at authoring time, because a mutation that removes a layer can strip a container, or a background's member list, down to nothing intrinsic after the fact.

Container-relative sizing, and what happens on resize

targetFraction on an absolute layer (table below) is container-relative sizing: the same scalar-of-a-box mechanism pin has, with the target implicit — whichever container the layer's chain starts at, today the Scene's canvas[ratio], later a component's own box — rather than an explicit pinTo. It is not a new positioning mode.

Overflow is a property of being a container, not of flowBox. flowBox's overflow: show | hide (Positioning) is the one field, reused by every container type, and it applies regardless of which mode placed the overflowing content. Scenes need it too, not only future components — the case that proved it: during a scene transition, an edge-positioned layer can extend past its Scene's own boundary well before it reaches the render frame's edge, and whether that spillover shows or clips changes what the transition looks like.

A layer's own mode and unit choice is its complete response to its container resizing. There is no separate, container-level resize policy: a targetFraction value scales with the container, a plain authored number never moves, a pin follows its target, a flow member reflows within its flowBox. When a container is placed at a size other than the one its contents were authored against, each layer already answers for itself by whichever of these it was authored with, and nothing is left to decide at the container level. Worth stating plainly because it keeps getting re-asked as if it were open.

Measure needs a constraint from layout, and that's not circular

The moment w/h can independently be "content" per axis, measure needs a width constraint before it can run — a fixed width in, a wrapped or intrinsic height out — the same way box text already implicitly works today. That constraint is sometimes a static schema read (w: 320) and sometimes another layer's resolved box (a stretch flow member takes its container's width; a w: { unit: "targetFraction" } layer takes a fraction of its pin target's). That's why measure is a service solve calls from inside its topological walk rather than a stage that finishes first: by the time solve reaches a layer, everything upstream of it has a box, so the constraint always exists. It isn't circular because the dependency only ever points upstream, and a cycle is rejected at validation before solve runs.

Rotation and scale, unified

rotationX/rotationY/rotation collapse into one field, and scale gains the same scalar-or-structured shape:

rotation: number | { x: number; y: number; z: number }
scale: number | { x: number; y: number }
skew: { x: number; y: number }      // degrees; absent means none

A bare number is shorthand for the common 2D case — rotation: 45 means { x: 0, y: 0, z: 45 }, scale: 1.2 means { x: 1.2, y: 1.2 } — identical to today's separate flat fields, just expressed through the same scalar-or-structured pattern as w/h sizing and typography size (number | { ofRoot }, see Text Foundation). One pattern, three places it applies.

skew slants a layer without rotating it: skew: { x: 12, y: 0 } pushes the top edge right relative to the bottom, turning a rectangle into a parallelogram. It costs nothing to carry, because a matrix already holds it — rotation, scale and skew all live in the same four numbers of the transform, and skew is simply the pair of terms that let a point's x depend on its y (and the reverse). Composing one more matrix is the same multiplication the graph already does (the motion graph), so the only real cost is the authoring field. It pivots on the layer's content centre like every other transform, and it never affects layout.

rotation, scale, skew, position, and opacity are the complete set of animatable properties — w/h are solve inputs and never animate (the motion graph).

The anchor grid

One vocabulary, universal across every layout mode (including absolute, not just pin), optional, defaulting to top-left for ordinary layers: a horizontal tier crossed with a vertical tier, eighteen combined values.

left centre right
top top-left top-centre top-right
capHeight capHeight-left capHeight-centre capHeight-right
centre centre-left centre-centre centre-right
baseline baseline-left baseline-centre baseline-right
lastBaseline lastBaseline-left lastBaseline-centre lastBaseline-right
bottom bottom-left bottom-centre bottom-right

baseline, lastBaseline and capHeight are the typographic tiers: text-only, validated against the owning layer's type, derived from font metrics at measure time and never an authored offset. baseline and capHeight read the first line; lastBaseline reads the last, which is what a two-line lead-in stacked beside a single-line hero needs to sit on. baseline is the line the glyphs sit on; capHeight is the top of a capital letter above it, read from the font's own tables (Text Foundation). capHeight exists because top is not it: a text box's top includes the font's ascender space above the caps, which differs per font, so two fonts aligned by top don't share a cap line, and two aligned by baseline share a floor, not a ceiling. The discount lockup's small prefix sitting level with the top of the big digits is capHeight, and nothing else in the grid expresses it. For text layers specifically, an unset anchor infers from the authored typography alignment instead of defaulting to top-left, so the common case never needs both fields kept in sync by hand: horizontalAlign: left|center|right gives the horizontal tier, verticalAlign: top|middle|bottom gives top|centre|bottom (an unset verticalAlign is top). The typographic tiers are never inferred; they are always authored.

An arbitrary line is an expression, not another tier

The grid names the first line and the last, because alignment never wants anything else. The general form — the third line of five, the second from the end — is point('baseline', n) in an expression, where n counts from the top and a negative counts from the bottom. The two named tiers are sugar over it: baseline is point('baseline', 0) and lastBaseline is point('baseline', -1), the same relationship targetFraction has to the expression it stands for. Putting the index in the tier name instead would mean parsing a parameter out of an enum token, which is a parser inside a string that currently needs none.

Anchor positions the box; horizontalAlign/verticalAlign position text within it

anchor is distinct from text's own horizontalAlign/verticalAlign — anchor positions the resolved box itself, externally; align governs where a line sits within that box. These only become vacuously redundant in one narrow case (single-line, both axes content — no slack left for align to act on); in every other case, including multiline and content-sized, they're fully independent and both meaningful.

A third, unrelated meaning of 'anchor' already exists in the schema

audioStartSchema's anchor: start|middle|end (Cues) is a temporal anchor — a point in another animation's timeline — not a spatial one at all. Same word, a third distinct concept from this section's anchor and from the bounds/pivot discussion below. Flagging the collision so it isn't mistaken for this section's vocabulary.

Bounds: content, extents, raster

Bounds What it is Used for
content The tight, ink-or-pixel-only box. Always the rotation/scale pivot.
extents content expanded by declared, static, paint-only additions (padding, stroke, shadow spread). What a pin or background should target for the visual edge, not just the ink.
raster The motion-blur capture margin, sized from the worst-case motion vector, held constant for the duration of a motion-blur window (not recomputed per frame). Purely internal; never a pin/anchor/background target.

Two things expand content into extents, and they sit at different levels.

A painted panel, the pill a text layer draws behind its glyphs (props.background) or the box a background layer paints. One vocabulary either way:

background: {
  fill: Colour,
  opacity?: number,
  padding?: number | { top, right, bottom, left },
  radius?: number | { topLeft, topRight, bottomRight, bottomLeft },
}

That is today's backgroundSchema with radius widened from one number to an optional per-corner object, and nothing else added.

Per-layer effects — drop shadow, glow, blur, the existing effects library — apply to the whole layer, glyphs and panel together. They run at place on the layer's painted output, grow extents by their declared spread, and never touch content or the motion graph.

An effect entry carries enabled: boolean, default true, for exactly the reason an animation entry does (Keyframe Animation): a manifest binding writes leaf values and cannot add or remove an array member, so "let the client turn this glow off" only works if the effect is pre-authored and the switch is a leaf. A disabled effect contributes nothing to extents, so turning one off doesn't leave a hole in the layout.

Why a shadow is an effect on the layer, not a field on the background

A drop shadow under a lockup panel is a layer-level effect in Photoshop and it should be one here. Putting shadow and stroke on background would fork the vocabulary: a text layer would gain a way to shadow its pill that an image layer has no equivalent of, and the same concept would exist twice with different spellings depending on which layer type you were looking at. It also would not actually buy the flexibility it appears to — a shadow under the pill and a different shadow under the glyphs is still two shadows on one layer, which one field can't express either.

When text and its panel genuinely need different effects, they are two layers: a text layer with its own effects, and a background layer spanning it with its own. That reuses a primitive that has to exist anyway, keeps one effects mechanism for every layer type, and leaves the pill as pure paint on the layer it belongs to. The alternative considered and rejected was resolving a text layer's background into a hidden background layer under the hood, giving it its own effects and rasterisation pass: it adds a layer the author never wrote for the Builder to explain in selection and z-order, and a hidden derived layer would not inherit the text's opacity or tilt, since of edges carry geometry only (the motion graph) — which is exactly what a pill must do.

Rasterization and bounds ownership

Every primitive gets a pre-defined, optimized, accurate rasterization method plus content/ extents bounds computation built in. A composite component that's purely a wrapper around primitives (in effect returning a snippet of template JSON) inherits both for free from its constituent primitives. A component that does genuinely bespoke rendering — not just composing primitives — must supply its own bounds/rasterization via hooks; it doesn't get the free ride.

raster bounds formalise as a direct extension of the same point: a primitive's built-in rasterization method computes its own worst-case-motion-vector raster bounds (the motion graph already produces the per-layer motion vector as a byproduct of transform composition); a primitives-only composite combines its constituents' raster bounds the same way it inherits their content/extents; a bespoke-rendering component supplies its own via hooks, same as the other two bounds variants. One mechanism, three bounds types, no motion-blur-specific special case left over.

Pivot, and the pinPoint escape hatch

There is no authored pivot field. Rotation and scale always happen around the centre of a layer's own resolved content bounds — never extents or raster — a fixed resolver convention, never a per-layer value. Pinning content specifically matters: paint-only additions (padding, stroke, shadow spread) are asymmetric more often than not, and letting them shift the rotation centre would make it a function of decoration choices rather than a stable convention.

A dedicated pivot field was considered and rejected: it would be a second mechanism achieving what the motion graph already achieves via pin, and it would reopen the exact bug that motivated consolidating this in the first place — pivot/anchor computed independently in several places, a rarely-exercised per-layer field being the shape of thing most likely to drift out of sync again.

The one real need for a custom rotate/scale origin — a layer or group wanting an off-centre or shared reference point — is served by pinning to a pinPoint instead: a minimal, non-visual layer, sibling to flowBox (see Positioning), a bare positional reference other layers can pin to, with none of flowBox's container fields — just layout, animations, and an id to be pinned to. Animate the pinPoint's own rotation/scale instead of the real layer's, and the real layer inherits the composed transform around the pinPoint's position — the same effect a custom pivot would have bought, using the mechanism that already has to exist for pin/flow. Its w/h resolve to a 0 × 0 box under the uniform sizing rule above rather than being exempt from it — a point is a box with no extent, not a layer with no size.

targetFraction: sizing relative to a reference box

One spelling of a general mechanism

targetFraction is a structured shorthand for the expression = target.<bounds>.w * value, kept because a Builder can offer it as a slider and because it is the case common enough to deserve its own shape. Anything it can't express — a fraction plus a pixel term, a value derived from two layers, a clamp — is written as an expression in the same field. They are one mechanism with two spellings, not two mechanisms.

A value that needs to track an arbitrary point or span along a resizing target — a decorative notch at 75% along a variable-width price block, an underline under the first 40% of a variable-length headline — isn't on any named anchor grid point, and a fixed-px offset provably doesn't scale when the target reflows. Rather than reopening the anchor/pinToAnchor vocabulary with a numeric escape hatch, the fix lives one level down: offsetX, offsetY, w, and h can each individually take, instead of a plain number, { value: number, unit: "targetFraction" } — a scalar multiple of a reference box's dimension (width for offsetX/w, height for offsetY/h), applied on top of wherever the anchor already resolved to. Same origin convention as px offsets (X rightward, Y downward from the resolved anchor point); unbounded, not clamped to 0–1, so a negative value reaches back past the anchor point and a value past 1 reaches beyond the reference box's far edge.

// Notch at 75% along a variable-width price block, a fixed 4×12px tick mark
{
  "mode": "pin",
  "offsetX": { "value": 0.75, "unit": "targetFraction" },
  "offsetY": 0,
  "w": 4, "h": 12,
  "anchor": "top-left",
  "pinTo": "price-block", "pinToBounds": "content", "pinToAnchor": "top-left",
  "pinType": "position"
}
// Underline spanning the first 40% of a variable-length headline, a fixed 3px thickness
{
  "mode": "pin",
  "offsetX": 0, "offsetY": 4,
  "w": { "value": 0.4, "unit": "targetFraction" }, "h": 3,
  "anchor": "top-left",
  "pinTo": "headline", "pinToBounds": "content", "pinToAnchor": "bottom-left",
  "pinType": "position"
}

Not called "fraction": rs.fraction() already means something narrower and bounded elsewhere in this schema (a 0–1 manifest-control type), and reusing that name here for an unbounded scalar multiplier would make the same word mean two different things depending on context.

Where it applies:

Mode Reference box Status
pin The pinTo target's resolved pinToBounds box Built alongside pin — the target's box is already resolved by the topological solve before this layer resolves, so no new ordering concern.
absolute The Scene's own canvas box for that aspect ratio Rides the same schemaVersion bump, deliberately built alongside pin's — needed now so a future component/element system can size a layer relative to its own containing box without a second mechanism later.
flow The member's own flowBox container A fraction of an axis the flowBox sizes with a number resolves directly. A fraction of an axis the flowBox sizes as "content" is the second sizing-collapse case and falls back to the member's content sizing — the CSS percentage-in-an-auto-container rule, not a rejection.

On an absolute layout the fields are x/y rather than offsetX/offsetY, and they take targetFraction the same way: x: { value: 1, unit: "targetFraction" } with anchor: "top-right" is the canvas's right edge, whatever the ratio. targetFraction values resolve away before the transform math runs, not a new runtime concept: once the reference box is known, each targetFraction value is multiplied by its resolved width or height and replaced with a plain px number, then enters the exact same pipeline a px-authored value always has. w/h and offsetX/offsetY are therefore a two- or three-way union — plain number (px), "content" (where applicable), or { value, unit: "targetFraction" } — fully backward-compatible, since every currently-authored value is already a plain number and stays one.

Migrating existing values

A pure, standalone upgradeV1ToV2 transformer (packages/retail-studio-schema/src/migrations/v1-to-v2.ts) rescales every grid-unit value — layer layout, animation transforms, scene enter/exit, manifest layout-typed field defaults — from the old 01000 grid to real pixels, and rewrites pattern typography size from a multiplier into the unified { ofRoot } shape (Text Foundation) in the same pass — that rewrite is a step of this transformer, never run on its own against a document that stays on schemaVersion: 1. It's invoked manually, per document, for the one mandatory migration this delivery makes (betta-red-hot-sale and betta-eofy, see Text Foundation) — not wired into any live load path. Every other existing template stays on schemaVersion: 1, unmigrated, reading the old grid values through the legacy pipeline untouched.