<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"theme",    "type":"string", "label":"Palette theme — aurora | ember | mono | slate",     "default":"aurora"},
    {"id":"bgColor",  "type":"color",  "label":"Stage background override (empty = theme bg)",       "default":""},
    {"id":"cards",    "type":"string", "label":"Deck JSON — array of {tag,title,meta,color?}",        "default":""},
    {"id":"landings", "type":"string", "label":"Landing beats JSON — abs seconds, one per card",     "default":""},
    {"id":"yaw",      "type":"number", "label":"Scene yaw (deg, side tilt of the whole deck)",        "default":-12}
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Card Flyby · accelerating 3D deck cascade — reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 1920px;
        height: 1080px;
        background: #06070d;
        overflow: hidden;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        background: radial-gradient(1300px 820px at 50% 52%, #0d1018 0%, #06070d 70%, #04050a 100%);
        overflow: hidden;
        font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
      }
      /* .stage carries the perspective; .scene carries the static yaw. The cards' own
         rotateX drives the tumble-forward — the scene only holds the side tilt. */
      .stage {
        position: absolute;
        left: 0;
        top: 0;
        width: 1920px;
        height: 1080px;
        perspective: 2400px;
        perspective-origin: 50% 55%;
      }
      .scene {
        position: absolute;
        left: 0;
        top: 0;
        width: 1920px;
        height: 1080px;
        transform-style: preserve-3d;
        transform: rotateY(-12deg);
      }
      .card {
        position: absolute;
        left: calc(50% - 700px);
        top: calc(50% - 395px);
        width: 1400px;
        height: 790px;
        border-radius: 32px;
        will-change: transform, opacity;
        --wipe: 0;
        --color: #444;
        transform-origin: 50% 50%;
      }
      /* Wireframe (always present, opacity tracks the slot). */
      .wire {
        position: absolute;
        inset: 0;
        border-radius: 28px;
        border: 1.5px solid rgba(255, 255, 255, 0.42);
        box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.18);
        background: rgba(255, 255, 255, 0.012);
        overflow: hidden;
      }
      .wire .wire-label {
        position: absolute;
        left: 68px;
        top: 60px;
        font-size: 26px;
        font-weight: 500;
        letter-spacing: 0.18em;
        text-transform: uppercase;
        color: rgba(255, 255, 255, 0.5);
      }
      .wire .wire-corner {
        position: absolute;
        right: 68px;
        top: 60px;
        width: 34px;
        height: 34px;
        border: 1.5px solid rgba(255, 255, 255, 0.32);
        border-radius: 50%;
      }
      .wire .wire-bar {
        position: absolute;
        left: 68px;
        right: 68px;
        bottom: 68px;
        height: 1.5px;
        background: rgba(255, 255, 255, 0.22);
      }
      /* Solid colored face — revealed via an angled clip-path wipe driven by --wipe.
         Polygon sweeps upper-left → lower-right at ~72°. wipe=0 collapsed off-element,
         wipe=1 covers the full card. */
      .solid {
        position: absolute;
        inset: 0;
        border-radius: 28px;
        /* gentle top→bottom tonal gradient (theme-derived shades) for depth */
        background: linear-gradient(
          158deg,
          var(--c1, var(--color)) 0%,
          var(--c2, var(--color)) 100%
        );
        box-shadow:
          0 34px 100px rgba(0, 0, 0, 0.55),
          inset 0 1px 0 rgba(255, 255, 255, 0.14);
        overflow: hidden;
        /* the title is the hero — centered in the card */
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 90px 104px;
        clip-path: polygon(
          0% 0%,
          calc(var(--wipe) * 200% - 30%) 0%,
          calc(var(--wipe) * 200% - 80%) 100%,
          0% 100%
        );
      }
      .solid::before {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: 28px;
        background: linear-gradient(
          135deg,
          rgba(255, 255, 255, 0.13) 0%,
          rgba(255, 255, 255, 0.03) 40%,
          rgba(0, 0, 0, 0.14) 100%
        );
        pointer-events: none;
      }
      .solid .title {
        font-size: 184px;
        font-weight: 800;
        letter-spacing: -0.035em;
        line-height: 0.92;
        color: var(--ink, #ffffff);
        text-align: center;
        max-width: 100%;
        overflow-wrap: break-word;
        text-wrap: balance;
      }
      .solid .play {
        position: absolute;
        right: 68px;
        bottom: 70px;
        width: 80px;
        height: 80px;
        border-radius: 50%;
        background: rgba(0, 0, 0, 0.28);
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .solid .play::after {
        content: "";
        width: 0;
        height: 0;
        border-left: 22px solid #ffffff;
        border-top: 14px solid transparent;
        border-bottom: 14px solid transparent;
        margin-left: 5px;
      }
    </style>
  </head>
  <body>
    <!-- duration 6.0s = a 6-card accelerating cascade (1 card lands per beat, dwells shrinking) -->
    <div
      data-hf-id="hf-cf-root"
      id="root"
      data-composition-id="card-flyby"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="6.0"
      data-root="true"
    >
      <div data-hf-id="hf-cf-stage" class="stage">
        <div data-hf-id="hf-cf-scene" class="scene" id="scene"></div>
      </div>

      <script>
        (function () {
          // ════════════════════════════════════════════════════════════════════
          //  CARD FLYBY  ·  accelerating 3D deck cascade
          //  ────────────────────────────────────────────────────────────────────
          //  A depth column of N cards rolls FORWARD. On each landing beat the next
          //  card tumbles up into the front slot (s0) and WIPES in its solid colored
          //  face (angled clip-path), the previous front card tumbles forward + falls
          //  down toward the camera on exit, and every card behind advances one slot.
          //  Dwells shrink card-to-card → the deck reads as an accelerating rush into
          //  a held final card. Asset-free: wireframe + solid CSS cards, system fonts.
          //
          //  COMPOSE BY DATA — `cards` (the deck) and `landings` (one beat per card)
          //  are the two data surfaces. Card count is data: 3/6/9 cards all "just
          //  work"; landings default to an accelerating schedule derived from count.
          // ════════════════════════════════════════════════════════════════════

          window.__timelines = window.__timelines || {};
          // force3D is set per card tween/set below (NOT a global default — a global
          // would leak onto the plain-object padding tween and warn). It keeps a 3D
          // matrix on every transform so cards never collapse to 2D: a card whose
          // rotX/Y/Z all hit 0 (the front s0 pose) would otherwise drop out of the 3D
          // stacking context and paint in DOM order, letting back cards render on top.

          // ── 1. Variables ──────────────────────────────────────────────────────
          var defaults = { theme: "aurora", bgColor: "", cards: "", landings: "", yaw: -12 };
          var vars = Object.assign(
            {},
            defaults,
            window.__hyperframes && window.__hyperframes.getVariables
              ? window.__hyperframes.getVariables()
              : {},
          );

          // ── Palette themes — each is a COHESIVE ramp (one temperature/saturation
          //    family) so any card count reads as a designed set. card.color still
          //    overrides per card. White-on-card stays AA at the card type sizes. ──
          var THEMES = {
            // cool jewel arc: teal → ocean → indigo → violet → plum
            aurora: {
              bg: "#06060e",
              ink: "#ffffff",
              colors: ["#0e5f63", "#15517e", "#283f86", "#4a3a83", "#6a3380", "#8a2e63"],
            },
            // warm sunset arc: wine → brick → rust → amber → ochre → bronze
            ember: {
              bg: "#0e0805",
              ink: "#fff6ee",
              colors: ["#5c2030", "#7e2828", "#9c3b1c", "#a9551a", "#8a5a1c", "#5f4626"],
            },
            // sleek monochrome blue staircase (dark → mid)
            mono: {
              bg: "#060912",
              ink: "#ffffff",
              colors: ["#11243f", "#16314f", "#1b3d63", "#214a78", "#27568d", "#2d63a2"],
            },
            // muted cool slate — understated / editorial
            slate: {
              bg: "#0b0e12",
              ink: "#eef2f6",
              colors: ["#232a33", "#2b3540", "#33414e", "#3b4c5c", "#43576a", "#4b6278"],
            },
          };
          var TH = THEMES[String(vars.theme || "").trim()] || THEMES.aurora;

          // tiny hex helpers (deterministic, no deps) — derive shades + soft ink.
          function parseHex(h) {
            h = String(h || "")
              .trim()
              .replace(/^#/, "");
            if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
            if (h.length !== 6) return null;
            var n = parseInt(h, 16);
            return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
          }
          function clamp8(v) {
            return Math.max(0, Math.min(255, Math.round(v)));
          }
          function toHex(c) {
            return (
              "#" +
              [c.r, c.g, c.b]
                .map(function (v) {
                  return clamp8(v).toString(16).padStart(2, "0");
                })
                .join("")
            );
          }
          function mix(hex, t, amt) {
            var c = parseHex(hex);
            if (!c) return hex;
            return toHex({
              r: c.r + (t.r - c.r) * amt,
              g: c.g + (t.g - c.g) * amt,
              b: c.b + (t.b - c.b) * amt,
            });
          }
          var BLACK = { r: 0, g: 0, b: 0 },
            WHITE = { r: 255, g: 255, b: 255 };
          function darken(hex, amt) {
            return mix(hex, BLACK, amt);
          }
          function lighten(hex, amt) {
            return mix(hex, WHITE, amt);
          }

          var DEFAULT_CARDS = [
            { title: "FADEGLOW" },
            { title: "SAUL BASS" },
            { title: "EULER" },
            { title: "HERMÈS" },
            { title: "DOSSIER" },
            { title: "BLACK HOLES" },
          ];
          // landings tuned by hand on the sandbox: accelerating dwells, final card holds ~1.6s.
          var DEFAULT_LANDINGS = [0.4, 1.7, 2.7, 3.45, 4.0, 4.4];

          var DUR = 6.0; // matches data-duration
          var YAW = Number(vars.yaw);
          if (!isFinite(YAW)) YAW = -12;

          function parseJSON(v) {
            if (!v) return null;
            if (typeof v !== "string") return v;
            try {
              return JSON.parse(v);
            } catch (e) {
              return null;
            }
          }

          var CARDS = parseJSON(vars.cards);
          if (!CARDS || !CARDS.length) CARDS = DEFAULT_CARDS;
          var N = CARDS.length;

          // ── 2. Landing schedule (accelerating cascade) ─────────────────────────
          // gaps between landings shrink geometrically; the final card lands early
          // enough to hold a beat before the cut.
          function genLandings(n, total) {
            var start = 0.4;
            var hold = Math.min(1.6, total * 0.27);
            var end = Math.max(start + 0.3, total - hold);
            if (n <= 1) return [start];
            var ratio = 0.78,
              weights = [],
              sum = 0,
              i;
            for (i = 0; i < n - 1; i++) {
              var w = Math.pow(ratio, i);
              weights.push(w);
              sum += w;
            }
            var span = end - start,
              acc = start,
              times = [start];
            for (i = 0; i < n - 1; i++) {
              acc += (span * weights[i]) / sum;
              times.push(acc);
            }
            return times;
          }
          var LANDED = parseJSON(vars.landings);
          if (!LANDED || LANDED.length !== N) {
            LANDED = N === DEFAULT_CARDS.length ? DEFAULT_LANDINGS.slice() : genLandings(N, DUR);
          }
          // per-card transition duration derived from the gap into it (faster as we accelerate).
          function transDur(i) {
            var gap = i === 0 ? (N > 1 ? LANDED[1] - LANDED[0] : 0.4) : LANDED[i] - LANDED[i - 1];
            return Math.max(0.12, Math.min(0.42, gap * 0.32));
          }

          // ── 3. Build the deck DOM from data ────────────────────────────────────
          var scene = document.getElementById("scene");
          scene.style.transform = "rotateY(" + YAW + "deg)";
          // bg: explicit bgColor override wins; else the theme's field color. The radial
          // lifts the centre slightly and sinks the edges, both derived from the bg.
          var BG = vars.bgColor && String(vars.bgColor).trim() ? vars.bgColor : TH.bg;
          document.documentElement.style.background = BG;
          document.body.style.background = BG;
          var rootEl = document.getElementById("root");
          rootEl.style.background =
            "radial-gradient(1300px 820px at 50% 52%, " +
            lighten(BG, 0.1) +
            " 0%, " +
            BG +
            " 70%, " +
            darken(BG, 0.4) +
            " 100%)";

          function el(cls, parent) {
            var d = document.createElement("div");
            d.className = cls;
            if (parent) parent.appendChild(d);
            return d;
          }
          var cardEls = [];
          for (var i = 0; i < N; i++) {
            var c = CARDS[i] || {};
            var card = el("card", scene);
            card.id = "card-" + i;
            // color: per-card override, else cycle the cohesive theme ramp. A gentle
            // top→bottom shade pair (--c1/--c2) gives each face tonal depth.
            var col = c.color || TH.colors[i % TH.colors.length];
            card.style.setProperty("--color", col);
            card.style.setProperty("--c1", lighten(col, 0.05));
            card.style.setProperty("--c2", darken(col, 0.22));
            card.style.setProperty("--ink", TH.ink);

            var wire = el("wire", card);
            var label = el("wire-label", wire);
            label.textContent = String(i + 1).padStart(3, "0");
            el("wire-corner", wire);
            el("wire-bar", wire);

            var solid = el("solid", card);
            var title = el("title", solid);
            title.textContent = c.title || "";
            el("play", solid);

            cardEls.push(card);
          }

          // ── 4. SLOT poses ───────────────────────────────────────────────────────
          // rotX is the key. Cards behind tilt BACK (top edge away, +rotX); the front
          // is upright (rotX 0); on exit a card continues forward into a face-down
          // tumble (-rotX) while dropping down + toward camera. Depth from perspective
          // (Z) + the cards' rotateX, not a camera move.
          var SLOT = {
            far: { x: 360, y: 380, z: -1080, rotX: 84, scale: 0.74, opacity: 0, zi: 60 },
            s3: { x: 280, y: 290, z: -820, rotX: 66, scale: 0.8, opacity: 0.32, zi: 70 },
            s2: { x: 195, y: 200, z: -560, rotX: 46, scale: 0.86, opacity: 0.58, zi: 80 },
            s1: { x: 105, y: 105, z: -290, rotX: 22, scale: 0.93, opacity: 0.85, zi: 90 },
            s0: { x: 0, y: 0, z: 0, rotX: 0, scale: 1.0, opacity: 1.0, zi: 100 },
            exit: { x: -90, y: 620, z: 420, rotX: -88, scale: 1.1, opacity: 0, zi: 110 },
          };
          // pose for a card whose index is `rel` slots behind the current front card
          // (rel 0 = front, +ve = behind, -ve = already exiting / gone).
          function poseForRel(rel) {
            if (rel <= -1) return SLOT.exit;
            if (rel === 0) return SLOT.s0;
            if (rel === 1) return SLOT.s1;
            if (rel === 2) return SLOT.s2;
            if (rel === 3) return SLOT.s3;
            return SLOT.far;
          }

          var tl = gsap.timeline({ paused: true });

          function poseTween(idx, slot, t, dur, ease) {
            // z-index is set instantly at the swap start so paint order matches the
            // slot — CSS can't reliably depth-sort non-intersecting planes.
            tl.set("#card-" + idx, { zIndex: slot.zi }, t);
            tl.to(
              "#card-" + idx,
              {
                x: slot.x,
                y: slot.y,
                z: slot.z,
                rotationX: slot.rotX,
                scale: slot.scale,
                opacity: slot.opacity,
                force3D: true,
                duration: dur,
                ease: ease || "power2.inOut",
              },
              t,
            );
          }

          // ── 5. Initial pose (front index = -1: nothing landed yet) ─────────────
          // card j sits at rel = j + 1, so card0 starts at s1, card1 at s2, etc. Apply
          // via gsap.set (so the paused render at t=0 shows the staged stack) AND via
          // tl.set at 0 (so scrubbing back to 0 restores it).
          for (var j = 0; j < N; j++) {
            var s = poseForRel(j + 1);
            var props = {
              x: s.x,
              y: s.y,
              z: s.z,
              rotationX: s.rotX,
              scale: s.scale,
              opacity: s.opacity,
              zIndex: s.zi,
              force3D: true,
              "--wipe": 0,
            };
            gsap.set("#card-" + j, props);
            tl.set("#card-" + j, props, 0);
          }

          // ── 6. Per-landing orchestration ───────────────────────────────────────
          // On landing i, card i becomes front. Tween the live window [i-1 .. i+4]:
          // i-1 tumbles to exit, i rolls into s0 (+ wipe), the rest advance toward
          // the front. Cards outside the window are already at rest (far / gone).
          for (var L = 0; L < N; L++) {
            var dur = transDur(L);
            var t = Math.max(0, LANDED[L] - dur);

            for (var k = L - 1; k <= L + 4; k++) {
              if (k < 0 || k >= N) continue;
              var rel = k - L;
              var ease = rel === 0 ? "power3.out" : rel < 0 ? "power2.in" : "power2.inOut";
              poseTween(k, poseForRel(rel), t, dur, ease);
            }

            // the landing card wipes in its solid face as it rolls up.
            tl.to(
              "#card-" + L,
              {
                "--wipe": 1,
                duration: dur * 0.85,
                ease: "power2.inOut",
              },
              t + dur * 0.15,
            );
          }

          // pad the timeline so duration() >= data-duration.
          tl.to({}, { duration: DUR }, 0);
          tl.seek(0);

          window.__timelines["card-flyby"] = tl;
        })();
      </script>
    </div>
  </body>
</html>
