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 input — w, h, gap, padding, a price amount |
once, in solve |
canvas, manifest, self, and other layers' resting boxes |
A node value — x, 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.