Skip to content

Example: Black Friday banner

Manifest row 01 · built today as betta-black-friday-banner

Two black panels, BLACK tilted 5° one way and FRIDAY 5° the other, sitting shoulder to shoulder over a red offer panel that reads UP TO as a small two-line stack beside a huge 50% OFF*. Under that, a bare black tagline SALE ON NOW with the first word bold. Every panel hugs its own copy, so a longer headline widens its panel and the rows below re-centre under it. Panels wipe on from the left in a stagger, the tagline fades up into place, and the three panels breathe on a shared beat.

flowchart TB
    subgraph scene["scene-black-friday"]
        ROOT["bf-lockup<br/>flowBox column · absolute (120,120)"]
        TITLE["bf-title<br/>flowBox row · flow"]
        BLACK["bf-black<br/>text · flow · rotation −5 · pulse"]
        FRIDAY["bf-friday<br/>text · flow · rotation 5 · pulse"]
        OFFER["bf-offer<br/>flowBox row · flow · pulse"]
        UPTO["bf-offer-prefix<br/>text 'UP TO' · flow"]
        HERO["bf-offer-hero<br/>text '50% OFF*' · flow"]
        OFFERBG["bf-offer-bg<br/>background · derived"]
        TAG["bf-tagline<br/>text · flow · slideFade"]
        BLACK -- flowIn --> TITLE
        FRIDAY -- flowIn --> TITLE
        TITLE -- flowIn --> ROOT
        UPTO -- flowIn --> OFFER
        HERO -- flowIn --> OFFER
        OFFER -- flowIn --> ROOT
        OFFERBG -- of --> OFFER
        TAG -- flowIn --> ROOT
    end

The document

{
  "id": "scene-black-friday",
  "durationInFrames": 75,
  "layers": [
    // ── Root column. Content-sized both ways: its box IS the lockup. Yellow canvas background
    //    from the reference is the scene's own backgroundColor, not part of this component.
    {
      "id": "bf-lockup",
      "type": "flowBox",
      "zIndex": 0,
      "layout": {
        "mode": "absolute",
        "16:9": { "x": 120, "y": 120, "w": "content", "h": "content", "anchor": "top-left" },
        "9:16": { /* own x/y */ }, "1:1": { /* own x/y */ }
      },
      // One column gap. The source has three different gaps (6 / −11 / 20) — see Stress points.
      "props": { "direction": "column", "alignAcross": "centre", "gap": 20 }
    },

    // ── Title row: BLACK + FRIDAY side by side, tops aligned.
    {
      "id": "bf-title",
      "type": "flowBox",
      "zIndex": 1,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "bf-lockup", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "props": { "direction": "row", "alignAcross": "top", "gap": 6 }
    },
    {
      "id": "bf-black",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "bf-title", "w": "content", "h": "content", "rotation": -5 },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 197, "color": "#ffffff",
                  "case": "uppercase", "tracking": 2, "horizontalAlign": "center" }
      },
      "props": {
        "content": [ { "value": "Black" } ],
        "background": { "fill": "#000000", "radius": 9,
                        "padding": { "top": 49, "right": 37, "bottom": 49, "left": 36 } }
      },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } },
        { "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40, "delay": 30 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },
    {
      "id": "bf-friday",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        // The source nudges FRIDAY 8px down off the row's top edge — see Stress points.
        "16:9": { "flowIn": "bf-title", "w": "content", "h": "content", "rotation": 5 },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 196, "color": "#ffffff",
                  "case": "uppercase", "tracking": 2, "horizontalAlign": "center" }
      },
      "props": {
        "content": [ { "value": "Friday" } ],
        "background": { "fill": "#000000", "radius": 6,
                        "padding": { "top": 48, "right": 39, "bottom": 57, "left": 39 } }
      },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } },
        { "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40, "delay": 30 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },

    // ── Offer panel: a row of two texts with one red background spanning both. The panel is
    //    pulled up 11px under the title row in the source (negative gap) — see Stress points.
    {
      "id": "bf-offer",
      "type": "flowBox",
      "zIndex": 1,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "bf-lockup", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "props": { "direction": "row", "alignAcross": "centre", "gap": 6 },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 8 } },
        { "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40, "delay": 30 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },
    {
      "id": "bf-offer-bg",
      "type": "background",
      "zIndex": 1,
      "layout": {
        "mode": "derived",
        "16:9": { "of": ["bf-offer"], "ofBounds": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "props": { "fill": "#ed1c24", "radius": 5,
                 "padding": { "top": 29, "right": 35, "bottom": 42, "left": 27 } }
    },
    {
      "id": "bf-offer-prefix",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        // Source nudges this stack 23px up off the hero's ink centre — see Stress points.
        "16:9": { "flowIn": "bf-offer", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 42, "color": "#ffffff",
                  "case": "uppercase", "tracking": 1, "lineHeight": 1, "multiline": true,
                  "horizontalAlign": "right" }
      },
      "props": { "content": [ { "value": "Up\nTo" } ] }
    },
    {
      "id": "bf-offer-hero",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "bf-offer", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 201, "color": "#ffffff",
                  "case": "uppercase", "tracking": 2 }
      },
      "props": {
        "content": [ { "value": "50% off*" } ],
        "patterns": [
          { "expression": "%",    "typography": { "tracking": -36 } },
          { "expression": "\\*$", "typography": { "size": { "ofRoot": 0.58 }, "baseline": 0.45 } }
        ]
      }
    },

    // ── Tagline. Bare text, two runs so the first word is bold without a pattern. Fades up into
    //    place instead of wiping; deliberately no pulse.
    {
      "id": "bf-tagline",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "bf-lockup", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 400, "size": 150, "color": "#000000",
                  "case": "uppercase", "tracking": 0 }
      },
      "props": {
        "content": [ { "value": "Sale ", "typography": { "weight": 700 } }, { "value": "on now" } ]
      },
      "animations": [
        { "animation": "slideFade", "params": { "from": "bottom", "distance": 90, "durationInFrames": 14 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 14 } }
      ]
    }
  ]
}

What the notes say a client changes, and where it would bind:

Note Field path Control
Editable text bf-black.props.content[0].value, bf-friday…, bf-offer-prefix…, bf-offer-hero…, bf-tagline.props.content[*].value text
Change colours *.props.background.fill, bf-offer-bg.props.fill, *.typography[ratio].color colour
Pulse toggle per panel bf-black.animations[1], bf-friday.animations[1], bf-offer.animations[1] — on/off no bindable leaf exists — see Stress points
Tilt *.layout[ratio].rotation degrees

What the engine does with it

Resolve. Every at resolves against this scene's start; the four wipes land at frames 0, 4, 8 and the tagline's slide at 14. Each named animation expands to keyframe tracks — pulse to a continuous scale track, slideFade to y and opacity tracks — and { ofRoot: 0.58 } on the asterisk multiplies out against the hero's 201.

Solve. Topological order puts the six text layers first, then bf-title and bf-offer, then bf-lockup, then bf-offer-bg. Every text is content-sized, so solve calls measure for each with no width constraint; bf-offer-prefix wraps at its \n. The rows pack on the texts' resting extents — the tilt is paint, it doesn't widen the slot — and the column centres the three rows. bf-offer-bg takes the union of its one member's content box, which for a flowBox is its members' extents, and pads it.

Animate. Each panel's pulse scales it about its own content centre (the built component breathes about the lockup's shared centre — see Stress points). bf-offer's pulse and wipe reach bf-offer-prefix and bf-offer-hero through flow membership, and bf-offer-bg re-derives from the row's animated extents every frame, so the panel breathes with its text. The tagline rides its own y/opacity tracks.

Place. Nine positioned elements, each with a matrix3d and an opacity; the tilted panels are rotated text layers whose pills paint around already-placed spans.

What this shows

  • Panels are text layers with a pill, groups are flowBoxes with a background. A single word on a panel needs no container at all; a two-text panel becomes a row plus a derived background.
  • Nested flow does the two-axis layout. A row inside a column with no absolute coordinate anywhere; a longer headline re-centres everything beneath it for free.
  • Animation inheritance is structural. The offer panel's wipe and pulse are authored once on the row; both texts and the background follow without an animation of their own.

Stress points

  1. Pulse pivot. Every pulsing part in the built component scales about the lockup's centre (useSharedPulseOrigin), so BLACK and FRIDAY breathe toward one point. Under the brief, rotation and scale always pivot on the layer's own content centre (Coordinate System → Pivot) and the pinPoint escape hatch is unavailable to a flow member, because a layer has exactly one layout.mode. The nearest encoding — one pulse on the root flowBox — loses the per-panel toggle. Neither the reference's per-panel toggles nor the shared pivot can be had together.
  2. Per-member margin / nudge. The source needs three different column gaps (6, −11, 20), an 8px drop on FRIDAY, and a 23px lift on the UP TO stack. flowBox has one gap (Positioning → flowBox vocabulary) and a flow member has no margin/offset. The built code has lockupOffsetSchema for exactly this reason. Negative gap isn't defined either.
  3. Wipe is a clip animation. wipe reveals with a clip-path, not a transform or opacity. The Motion Graph → What animates admits only position/scale/rotation/opacity as node values, so a named animation can't expand to it. Nearly every note in the manifest says "panels wipe on".
  4. Pulse toggle isn't bindable. Turning an animation on or off per part is neither a numeric leaf nor a text value (Positioning → Bindings target numeric leaves only). Binding params.scale to 1 as "off" is a hack. An enabled boolean on an animation entry, on the binding allow-list, is the missing piece.
  5. rotation on a flow/pin member. The brief puts rotation/scale in the absolute block (Positioning → discriminated union) and the FlowMember/PinBox types omit them. Three of this component's tilted panels are flow members. Assumed allowed here; needs stating.
  6. No shared typography base. The built component has one base typography that every part overrides field by field. Five sibling text layers each carry a full typography block; a client changing the family or colour changes it five times. A component/element system would own this, but the brief has no layer-level inheritance today.
  7. Derived background of a rotated row. If bf-offer were tilted (it is, in Boxing Day), its background's union is taken from members' bounds pushed through their matrices (The Motion Graph → Internal representation), which yields an axis-aligned box around a rotated row, not a rotated panel. The background can't inherit the row's rotation because of edges carry geometry only. Untilted here, so it only surfaces on 02.