Skip to content

Foundations: Expressions

Four things in this brief are already the same idea wearing different clothes: a number computed from other numbers. targetFraction is a number times a reference box's dimension. A manifest binding is a number from campaign data. A named animation's slide distance is a multiple of the layer's own width. A "you save $223" is one bound price minus another. Given as four bespoke shapes they are four mechanisms computing the same class of fact, which is the thing the motion graph exists to stop. One small primitive underneath them is more consistent with the rest of the architecture, not less.

What an expression is

Any field that admits a number also admits a string beginning with =.

"x":     "= canvas.w - 120",
"w":     "= target.extents.w * 0.75 + 12",
"value": "= manifest.priceWas - manifest.priceNow",
"y":     "= layer('headline').content.point('bottom-left').y + 24"

It is a little bit of maths. It is deliberately not a program: no variables, no statements, no branches, no iteration, no user-defined functions. An author reading one should be able to say what it evaluates to without knowing anything about evaluation order.

The grammar, closed on purpose

The whole language:

Values number literals
Operators + - * /, unary -, parentheses
Functions min(a, b), max(a, b), clamp(value, lo, hi), round(value)
References the table below
Strings single-quoted, and only ever a layer id or an anchor name

Nothing else, and in particular no strings, no booleans, no comparison, no conditional, and no frame or time. Each exclusion is load-bearing rather than a v1 trim:

  • No frame. A time-aware expression is a second animation system, and motion belongs in keyframes (Keyframe Animation). The day an expression can read the clock, "solve builds the topology, animate walks it" stops being true.
  • No conditionals. A branch means the reference set depends on a value, which kills the static extraction the next section relies on. The day someone needs if, that is the signal to stop and design the thing they actually want, not to widen the grammar.
  • No strings. Text composition is runs, and a formatted price is a price run (Price). Expressions produce numbers.

Single quotes inside, so an expression never needs escaping

A layer id or anchor name inside an expression is written layer('price'), not layer("price"). An expression lives inside a JSON string, so double quotes would have to be escaped at every call site — "= layer(\"price\").x" — which is unreadable, easy to get wrong by hand, and a reliable source of corruption when a document is round-tripped through a tool or a model. Single quotes cost nothing and the nesting problem disappears.

A string, not a JSON syntax tree

{ "op": "+", "left": { "op": "*", … } } would need no parser and would be trivially machine-checkable. It is also unreadable: a three-term formula becomes a screenful of nesting that neither a person nor a model can scan, and every example in this brief would double in length. The grammar is small enough that a tokenizer and a Pratt parser are a few hundred lines with no dependency, so the cost of the readable form is one parser we own and the error messages it emits. That trade is worth taking; the mitigation is the failure-mode table below, not a different encoding.

What an expression may reference

The rule behind this table: an expression may read anything the graph already publishes, and may never make the graph publish something new.

Reference Is
canvas.w, canvas.h the ratio's own canvas box (Coordinate System & Sizing)
manifest.<field> a bound numeric campaign value
self.<bounds>.<w\|h> the layer's own resting box on the other axis
target.<bounds>.<w\|h> the pinTo target's box, or for an absolute layer the containing box
layer('id').<bounds>.<w\|h> another layer's resting box
layer('id').<bounds>.point('<anchor>'[, line]).<x\|y> a point on another layer's resting box; the optional line selects which line a typographic anchor reads, counting from the top, negative from the bottom, clamped to the lines that exist
layer('id').<x\|y\|scale.x\|scale.y\|rotation.z\|opacity> another layer's live value this frame

<bounds> is content or extents, never raster. <anchor> is any value from the anchor grid. So the reference surface is exactly the vocabulary the bounds and anchor sections already define, and widening one widens the other for free.

Positions read in the container's own coordinate space. Because references are container-local (the motion graph), both layers are always in the same container, so there is one unambiguous space and no conversion to reason about.

Glyph geometry is deliberately not on this list

"The last line of that text layer", "the right edge of its last glyph" — tempting, and it is the glyph-relative anchor already rejected for the discount lockup arriving through a side door. Making line and glyph geometry a public reference surface turns every text-engine change into a breaking change, and a function over glyphs is unbounded in a way a tier list is not. When a real case needs one, it arrives as a named anchor tier, validated and visible in the Builder, and expressions read it through the same point() call as every other anchor.

When an expression evaluates

The property an expression feeds decides when it runs. Not the expression, and not a flag on it.

Feeds Evaluates May reference
A solve inputw, h, gap, padding, a price amount once, in solve canvas, manifest, self, and other layers' resting boxes
A node valuex, y, scale, rotation, opacity every frame, during the animate walk, at that node all of the above, plus other layers' live values

So x: "= layer('price').content.point('centre-right').x + 10" does track price as price animates. It compiles into the graph as an instruction at that node and is evaluated on every frame, in topological order, exactly like every other per-frame value.

The one rule: a solve-time value may never depend on a per-frame value. w: "= layer('a').x" is rejected at validation, because w is an input to solve and x does not exist until animate. This is not a new constraint, it is w/h don't animate stated once at the level where it belongs. It is also what keeps measurement campaign-scoped: solve-time expressions cannot reach a per-frame value, so text cannot re-wrap frame to frame.

An expression that feeds a node value but references only frame-invariant things is folded once rather than re-evaluated. Constant folding, not a rule authors need to know.

Expressions are edges in the graph

Because the grammar has no dynamic lookup, every layer('id') in a document is extractable by parsing, before anything is evaluated. That is what lets an expression reference become an ordinary edge in the motion graph, established when the topology is built, alongside pinTo, flowIn and a derived layer's of. One topological sort, one cycle check, one message, no new machinery. It is also the concrete reason the grammar stays closed: the moment an expression can compute which layer it references, the graph cannot be built before the graph is evaluated.

Two properties follow, and both are worth stating rather than leaving to be discovered.

Cycle detection stays at layer granularity, and that is correct rather than conservative. solve resolves a layer's whole box as a unit and animate computes a node's whole state as a unit, so "A depends on B" is a fact about layers even when the expressions involved touch one field of each. Finer granularity would require resolving a layer's fields independently, which buys nothing and costs the ordering guarantee.

Expression references count toward the same depth budget as pinTo and flowIn (Positioning), for the same reason: a chain of layers each computed from the last is exactly the structure that wants to be one component.

Failure modes, all loud

An expression is powerful enough that its failures have to be unmissable. Every one below names the layer and the field.

Failure Caught Result
Syntax error validation rejected, with the offending token's position
Unknown reference (no such layer, no such field, a layer in another container) validation rejected, same treatment a dangling pinTo gets
Tier violation (a solve input reading a per-frame value) validation rejected, naming both fields
self referencing its own axis validation rejected
Cycle validation rejected by the shared whole-graph check
Divide by zero, or a non-finite result evaluation a render error carrying the layer id (Pipeline); never a silent NaN reaching the DOM

A Builder shows a computed field read-only with its expression behind it, the way After Effects does, so an author can always see that a value is derived rather than typed.

What this absorbs

Was going to be Is
{ value, unit: "targetFraction" } sugar for = target.extents.w * value, kept as a structured form for the Builder and defined as the expression it means
"fraction plus a pixel term", which the structured form could not express = target.extents.w * 0.75 + 12
A computed-binding kind, for "you save $223" = manifest.priceWas - manifest.priceNow
A selfFraction unit for named-animation distances = -self.extents.w
A follow edge, for "this layer's opacity is that layer's opacity" = layer('lockup-row').opacity
A plain manifest binding the degenerate case, = manifest.price

The last row is the tell that this is the right level: a binding was always an expression with no arithmetic in it.