Example: Bonus Cashback, full stack¶
Manifest row 05 · built today as betta-bonus-cashback-stack
Three tilted panels stacked and centred on one another: Bonus in blue on a yellow panel tipped
5° clockwise, a white panel tipped the other way carrying a small two-line UP TO beside a big
$300, then Cashback on a wide yellow panel. Under them, two lines of white small print. Every
panel casts a soft shadow and wipes on from the left in a stagger; the three panels breathe together
on a shared pulse once settled; the small print slides down and fades in separately.
flowchart TB
subgraph scene["scene-cashback (one motion graph per ratio)"]
ROOT["stack-root<br/>flowBox · column · absolute (120,120)"]
PANELS["stack-panels<br/>flowBox · column · flow · pulse"]
FOOT["stack-footnotes<br/>flowBox · column · flow"]
BONUS["bonus<br/>text on yellow pill · flow · wipe"]
AMT["amount-row<br/>flowBox · row · flow · wipe"]
PRE["amount-prefix<br/>text 'UP TO' · flow"]
HERO["amount-hero<br/>text price run · flow"]
AMTBG["amount-bg<br/>background · derived"]
CASH["cashback<br/>text on yellow pill · flow · wipe"]
FN["footnote · text · flow · slideFade"]
TERMS["terms · text · flow · slideFade"]
PANELS -- flowIn --> ROOT
FOOT -- flowIn --> ROOT
BONUS -- flowIn --> PANELS
AMT -- flowIn --> PANELS
CASH -- flowIn --> PANELS
PRE -- flowIn --> AMT
HERO -- flowIn --> AMT
AMTBG -- of --> AMT
FN -- flowIn --> FOOT
TERMS -- flowIn --> FOOT
end
The document¶
{
"id": "scene-cashback",
"durationInFrames": 75,
"layers": [
// ── Outer column: the panel group over the small-print group. Content-sized both ways,
// so its box IS the lockup. x/y place its top-left where the library template puts it.
{
"id": "stack-root",
"type": "flowBox",
"zIndex": 0,
"layout": {
"mode": "absolute",
"16:9": { "x": 120, "y": 120, "w": "content", "h": "content", "anchor": "top-left" },
"9:16": { /* own position */ },
"1:1": { /* own position */ }
},
"props": { "direction": "column", "alignAcross": "centre", "gap": 19 }
},
// ── The three panels. This group carries the pulse: every member inherits it through flow
// membership, so the panels breathe about this group's centre, in time.
{
"id": "stack-panels",
"type": "flowBox",
"zIndex": 0,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-root", "w": "content", "h": "content" },
"9:16": { "flowIn": "stack-root", "w": "content", "h": "content" },
"1:1": { "flowIn": "stack-root", "w": "content", "h": "content" }
},
"props": { "direction": "column", "alignAcross": "centre", "gap": 34 },
"animations": [
{ "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40, "delay": 30 } }
]
},
// ── "Bonus": one text layer; the yellow pill is its own paint-only background, so it tilts
// and shadows with the glyphs. Painted above the amount panel where they overlap.
{
"id": "bonus",
"type": "text",
"zIndex": 3,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 },
"9:16": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 },
"1:1": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 700, "size": 158, "color": "#054f8e", "horizontalAlign": "center" }
},
"props": {
"content": [ { "value": "Bonus" } ],
"background": {
"fill": "#ffd100", "radius": 4,
"padding": { "top": 25, "right": 37, "bottom": 27, "left": 32 }
}
},
"effects": [ { "type": "dropShadow", "color": "#000000", "opacity": 0.26, "blurPx": 4, "offsetX": 5, "offsetY": 6 } ],
"animations": [
{ "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
]
},
// ── The amount panel is a row of two texts with a background derived around them.
// The row carries the tilt and the wipe; both members inherit them.
{
"id": "amount-row",
"type": "flowBox",
"zIndex": 2,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 },
"9:16": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 },
"1:1": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 }
},
// "bottom" lines the ink bottom of "TO" up with the ink bottom of the digits — the
// reference's own alignment. "baseline" would take UP's baseline, the first line.
"props": { "direction": "row", "alignAcross": "bottom", "gap": 5 },
"animations": [
{ "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
]
},
{
"id": "amount-prefix",
"type": "text",
"zIndex": 2,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "amount-row", "w": "content", "h": "content" },
"9:16": { "flowIn": "amount-row", "w": "content", "h": "content" },
"1:1": { "flowIn": "amount-row", "w": "content", "h": "content" }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 700, "size": 53, "color": "#054f8e",
"case": "uppercase", "lineHeight": 1.2, "multiline": true, "horizontalAlign": "center" }
},
"props": { "content": [ { "value": "up\nto" } ] }
},
{
"id": "amount-hero",
"type": "text",
"zIndex": 2,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "amount-row", "w": "content", "h": "content" },
"9:16": { "flowIn": "amount-row", "w": "content", "h": "content" },
"1:1": { "flowIn": "amount-row", "w": "content", "h": "content" }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 700, "size": 166, "color": "#054f8e" }
},
"props": {
"content": [
// A bindable number, never a typed "$300". The symbol sits smaller and lifted.
{ "price": { "amount": 300, "currency": "AUD", "locale": "en-AU" },
"typography": { "currencySymbol": { "size": { "ofRoot": 0.72 }, "baseline": 0.12 } } }
]
}
},
{
"id": "amount-bg",
"type": "background",
"zIndex": 1,
"layout": {
"mode": "derived",
"16:9": { "of": ["amount-row"], "ofBounds": "extents" },
"9:16": { "of": ["amount-row"], "ofBounds": "extents" },
"1:1": { "of": ["amount-row"], "ofBounds": "extents" }
},
"props": {
"fill": "#ffffff", "radius": 4,
"padding": { "top": 15, "right": 11, "bottom": 8, "left": 24 }
},
"animations": [
// The wipe on the row doesn't reach a derived layer's own opacity/clip, so it is repeated.
{ "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
]
},
// ── "Cashback": same shape as "Bonus", untilted, wider padding.
{
"id": "cashback",
"type": "text",
"zIndex": 3,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-panels", "w": "content", "h": "content" },
"9:16": { "flowIn": "stack-panels", "w": "content", "h": "content" },
"1:1": { "flowIn": "stack-panels", "w": "content", "h": "content" }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 700, "size": 154, "color": "#054f8e" }
},
"props": {
"content": [ { "value": "Cashback" } ],
"background": {
"fill": "#ffd100", "radius": 5,
"padding": { "top": 26, "right": 12, "bottom": 27, "left": 12 }
}
},
"animations": [
{ "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 8 } }
]
},
// ── The small print, in its own column so it sits outside the pulse.
{
"id": "stack-footnotes",
"type": "flowBox",
"zIndex": 0,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-root", "w": "content", "h": "content" },
"9:16": { "flowIn": "stack-root", "w": "content", "h": "content" },
"1:1": { "flowIn": "stack-root", "w": "content", "h": "content" }
},
"props": { "direction": "column", "alignAcross": "centre", "gap": 3 }
},
{
"id": "footnote",
"type": "text",
"zIndex": 4,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
"9:16": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
"1:1": { "flowIn": "stack-footnotes", "w": "content", "h": "content" }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 400, "size": 61, "color": "#ffffff", "tracking": -0.6 }
},
"props": {
"content": [ { "value": "with selected products*" } ],
"patterns": [ { "expression": "\\*$", "typography": { "size": { "ofRoot": 0.58 }, "baseline": 0.45 } } ]
},
"animations": [
{ "animation": "slideFade", "params": { "from": "top", "distance": 24, "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 16 } }
]
},
{
"id": "terms",
"type": "text",
"zIndex": 4,
"layout": {
"mode": "flow",
"16:9": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
"9:16": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
"1:1": { "flowIn": "stack-footnotes", "w": "content", "h": "content" }
},
"typography": {
"16:9": { "family": "GT Eesti Display", "weight": 400, "size": 26, "color": "#ffffff", "tracking": -0.3 }
},
"props": { "content": [ { "value": "*Via Digital Visa Gift Card. Via redemption. T&Cs apply." } ] },
"animations": [
{ "animation": "slideFade", "params": { "from": "top", "distance": 10, "durationInFrames": 12 },
"at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 18 } }
]
}
]
}
What the notes ask an author to reach, and where it binds:
| Note | Binding target | Control |
|---|---|---|
| Editable text | bonus.props.content[0].value, cashback.props.content[0].value, amount-prefix.props.content[0].value, footnote…, terms… |
text |
| Editable amount | amount-hero.props.content[0].price.amount |
number |
| Change colours | *.props.background.fill, *.typography[ratio].color |
colour |
| Pulse toggle per panel | no numeric leaf to bind — see stress points | — |
| Remove "UP TO" and re-centre | structural: removing amount-prefix from the row — see stress points |
— |
Panel shadows are layer effects
Each panel above carries a drop shadow in the source artwork. A shadow is a per-layer
effect, applied to the whole layer (glyphs and pill together), not a field on
background — see Coordinate System & Sizing.
Where text and its panel need different shadows, they become two layers: a text layer
and a background layer spanning it, each with its own
effects.
What the engine does with it¶
Resolve. The price run becomes $ and 300 parts through Intl.NumberFormat("en-AU"); every
{ ofRoot } multiplies out against its layer's root size; each at resolves against this scene's
start. The pulse, wipe, and slideFade names expand into keyframe tracks — wipe into
whatever it animates (see stress points), pulse into a continuous scale track, slideFade into
y and opacity tracks.
Solve. Topological order runs leaves first: the five texts measure themselves with no width
constraint (all "content"), the font-table reader supplying GT Eesti's cap-height and baseline at
each size. amount-row packs prefix and hero left to right on their resting boxes, bottoms aligned;
amount-bg takes the row's extents and grows by its padding; stack-panels stacks bonus, row,
cashback centred with a 34px gap; stack-footnotes stacks the two lines; stack-root stacks the
two groups and lands its top-left at (120, 120). Rotation never enters packing.
Animate. Frame 40: stack-panels is at scale 1.025 on its pulse; bonus, the amount row, and
cashback inherit that through flow membership and compose it with their own resting tilts. The
prefix and hero inherit the row's tilt in turn. amount-bg re-derives from the row's animated
extents. The footnotes, outside the pulsing group, sit still.
Place. Eleven positioned elements, each with a matrix3d and an opacity; text runs as
absolutely positioned spans; pills and the derived panel as fills. The browser paints, lays out
nothing.
What this shows¶
- A tilted text panel is one layer.
bonusandcashbackare each a single text with a paint-only pill, so tilt, shadow, wipe, and pulse apply once and everything moves together. - A two-text panel needs three layers. The amount panel is a row, two texts, and a derived background — and that's where the encoding starts to strain (below).
- Pulse is authored once and inherited. No part carries its own pulse; the group does, and the footnotes stay still simply by living in a sibling group.
Stress points¶
- Panels and text "wipe on", but only transform and opacity animate. A wipe is a clip reveal,
not a transform. Under Motion Graph → What animates
a
wipenamed animation has nothing legal to expand into. Every row of this catalogue uses it. The brief needs either a clip/mask property on the node (paint-only, in the layer's local frame, never affecting layout) or a statement that "wipe" is implemented as an opacity/position pair instead. - A derived background cannot tilt with a tilted group.
amount-bgtakes the union ofamount-row's bounds "pushed through the matrix into canvas space" (Motion Graph → Internal representation), so behind a row rotated -5° it is an axis-aligned box around a tilted row, not a tilted panel. Three of five components in this family need a tilted multi-text panel. Either the union is taken in the members' common parent frame and the parent's matrix applied to the result, or a container gets a paint-onlybackgroundof its own like text already has (Positioning → flowBox). - No per-member spacing in a flow. The built preset has four different vertical gaps (32, 37,
19, 3) and a 16px vertical nudge on the prefix;
flowBoxhas onegap(Positioning → flowBox's layout vocabulary). Encoded here by splitting into three nested columns and rounding 32/37 to 34, which is a lossy workaround. A per-membermarginonFlowMember(per-ratio px, affects packing) would remove it. - Baseline alignment is first-line only. The
UP / TOstack beside a hero wants its last line's baseline on the hero's baseline (Coordinate System → anchor grid).alignAcross: "bottom"on content bounds approximates it for digits, but breaks the moment the hero has a descender ($already dips). AlastBaselinetier, or a per-layer choice, is missing. - Per-part pulse toggle has no binding. The note wants a pulse on/off per panel. An animation's
presence isn't a numeric leaf (Positioning → Bindings target numeric leaves only),
and inherited animations can't be declined by one member. Needs either an
enabledboolean on an animation entry that bindings may target, or a per-layer opt-out of an inherited animation. - "Remove the UP TO and re-centre" is structural.
prefix.enabled: falsetoday removes the stack and the hero re-centres. In v2 that's deleting a layer from a flow, which a binding cannot do. A layer-levelhiddenthat also removes the layer from packing (distinct from opacity 0) would cover it and is bindable as a boolean. - The row's wipe doesn't reach the derived background.
ofedges carry geometry only (Motion Graph → Opacity along edges), so the wipe is duplicated onamount-bgwith identical timing. Correct per the rules, but it's the one place an author has to keep two animations in sync by hand. background.shadowdoes not exist yet.backgroundSchemahas fill, opacity, radius, padding; every panel in this family carries a Photoshop drop shadow. Used here as a paint-only extents contributor per Coordinate System → Bounds.