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 0–1000 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:
| 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/hmode plus amultilineflag reproduces both. - Images —
w: 150, h: "content"means aspect-ratio-preserving auto-height, a genuinely different (and often more useful) intent thanfit: 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.
- 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).
SAVEin a content-sizedflowBoxis this case. - 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. - A
stretchmember 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 everystretchmember is widened to that size. The container's size is therefore the widest member's, never a stretched member's, so the pass terminates.stretchonly ever applies to a member whose own size on that axis is"content"— an authored number wins, the same way CSSalign-items: stretchleaves an explicitwidthalone — and it sizes the member'sextentsto the container, not itscontent: two pills with different paddings understretchend 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 0–1000 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.