/* T-AV — Home3DPin (home-3d Task 4: pin + master timeline + overlays).
   Boots the Stage3D engine when the quality gate allows it, renders the
   3D wave field behind the whole home route on a fixed full-viewport
   canvas (FIXED-CANVAS amendment), and pins a 1000vh/800vh scroll
   narrative (lengthened from 850/700 on 2026-06-12 — Rajai: "most of
   beats need a slightly longer scroll"): one ScrollTrigger
   scrub-linked (animation:) to one master
   GSAP timeline whose onUpdate drives the engine, the story rail and
   ALL overlay visibility. On any failure it warns and the page stays 2D.

   CANVAS LIFETIME RULE: the engine binds permanently to ONE canvas
   element (engine.attach throws on any other), so the canvas is NEVER
   rendered via JSX — React would recreate it on route re-entry. A
   file-scoped element survives unmounts and is re-appended into the
   host div by ref callback. Likewise the waves module is created once
   and survives route changes inside the engine singleton.

   LIFECYCLE RULES (spec §5.8 — the v2 ghost):
   1. The pin DOM renders once per homepage mount, unconditionally in
      3D mode. No conditional mounting inside the pin, no key churn.
   2. Effect cleanup kills + reverts the ScrollTrigger FIRST, before
      anything else lets go (st.kill(true) → tl.kill() → eng.release →
      delete __tavPin).
   3. ScrollTrigger creation is deferred a double rAF past boot, after
      the App route effect's double scrollTo(0,0); the first tl.progress
      snap seeds engine + rail + offstage (engine has no smoothing).
   4. Engine is a singleton; route re-entry reuses the renderer.
   NO REACT STATE IN THE SCROLL PATH: overlay visibility comes only
   from the master timeline; the story rail updates via classList. */

let keptCanvas = null;
let keptWaves = null;        // waves module — keyed to the engine instance below
let keptWavesEngine = null;  // the engine instance keptWaves was built for
let keptSketch = null;       // sketch module — same kept-identity pattern as waves
let keptSketchEngine = null; // the engine instance keptSketch was built for
let keptStage = null;        // Task 6+7+8+9 modules {grass, assembly, cameraRig, annos, showPrep, term, walls, show} — one kept set per engine
let keptStageEngine = null;

// Memoised GSAP loader — prevents a double-evaluation race if two mounts
// fire before the first loadScript resolves.
let gsapLoading = null;
function ensureGsap() {
  if (window.gsap && window.ScrollTrigger) return Promise.resolve();
  if (!gsapLoading)
    gsapLoading = loadScript('/assets/vendor/gsap/gsap.min.js')
      .then(() => loadScript('/assets/vendor/gsap/ScrollTrigger.min.js'));
  return gsapLoading;
}

// Interim bfcache guard (Task 11 will implement a full reboot path).
// On back-forward restore, if the engine was destroyed during pagehide
// the 3D canvas host would stay marked ready but be blank.  Strip the
// data attribute so the 2D waves become visible again.
(function () {
  let _registered = false;
  function _registerPageshow() {
    if (_registered) return;
    _registered = true;
    window.addEventListener('pageshow', function (ev) {
      if (!ev.persisted) return;
      const eng = window.__tavEngine;
      if (!eng || eng.destroyed) {
        document.querySelectorAll('.home-hero').forEach(function (el) {
          el.removeAttribute('data-stage3d');
        });
        document.querySelectorAll('.stage3d-canvas-host').forEach(function (el) {
          el.classList.remove('is-ready');
        });
      }
    });
  }
  _registerPageshow();
}());

function getStageCanvas(cls) {
  if (!keptCanvas) {
    keptCanvas = document.createElement('canvas');
    keptCanvas.setAttribute('aria-hidden', 'true');
  }
  keptCanvas.className = cls;
  return keptCanvas;
}

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.onload = resolve;
    s.onerror = () => reject(new Error('Failed to load ' + src));
    document.head.appendChild(s);
  });
}

/* Native dynamic import, hidden from Babel standalone — in text/babel
   scripts Babel rewrites bare `import()` into `require()` (which doesn't
   exist in the browser). The Function constructor defers parsing to the
   engine, so this stays a real ESM dynamic import with import-map
   resolution intact. */
const nativeImport = new Function('p', 'return import(p)');

/* =====================================================================
   BEAT_PANELS — the shared window.BEAT_COPY (components/BeatCopy.jsx,
   loaded before this file; single source of truth for the 8-beat
   num/tag/title/accent/copy/meta/slug) plus the 3D-only `side` field:
   which side the copy panel occupies ('r' = right); the 3D visual gets
   the other side. 01 r, 02 l, 03 r ... alternating by index.
   Beats 07/08 have no slug → no CTA (same as the 2D story).
   ===================================================================== */
const BEAT_PANELS = window.BEAT_COPY.map((b, i) => ({ ...b, side: i % 2 === 0 ? 'r' : 'l' }));

/* Collect the overlay elements the timeline animates. querySelectorAll
   returns document order, which is BEAT_COPY order (one map render). */
function collectOverlayEls(root) {
  const panels = Array.prototype.slice.call(root.querySelectorAll('.stage3d-panel')).map((el) => ({
    el,
    head: Array.prototype.slice.call(el.querySelectorAll('.beat__num, .beat__title')),
    body: Array.prototype.slice.call(el.querySelectorAll('.beat__copy, .beat__meta, .btn-link')),
  }));
  const quiet = root.querySelector('.stage3d-quiet');
  return {
    panels,
    photo: root.querySelector('.stage3d-photo'),
    photoWrap: root.querySelector('.stage3d-photo-wrap'),
    backplate: root.querySelector('.stage3d-backplate'),
    frame: root.querySelector('.stage3d-frame'),
    quiet,
    // §K: beat-09 is now a framed beat panel — stage its head/body with y
    // like the other beats (head = num+title, body = copy).
    quietHead: quiet ? Array.prototype.slice.call(quiet.querySelectorAll('.beat__num, .beat__title')) : [],
    quietBody: quiet ? Array.prototype.slice.call(quiet.querySelectorAll('.beat__copy')) : [],
    caption: root.querySelector('.stage3d-caption'),
  };
}

/* §F plate geometry — shared by the shrink tween's transformOrigin and
   layoutEnding()'s frame/caption rects (one source of truth). */
const PLATE_SCALE = 0.64;
const PLATE_ORIGIN_Y = 0.30; // plate settles slightly above center

/* §N CUE DWELL/FREEZE — story-time warp. Both the 3D (eng.setProgress) and
   the overlay panel tweens ride ONE scrubbed playhead = tl.progress() =
   SCROLL progress. warp(scrollP)->storyP holds story FLAT on a plateau at
   each cue (the freeze: scroll keeps moving, the story stops, NO auto-move);
   unwarp(storyP)->scrollP repositions the timeline tweens so they fire at the
   right scroll moment and HOLD across the plateau. Move segments keep slope 1
   (the exact old identity pacing) and the pin grows x(1+H) to fund the holds,
   so moves keep their pixel pace. Pure functions of progress; reverse re-
   derives; tl duration stays exactly 1 (unwarp(1)===1). DWELL_STORY is the
   per-cue freeze width in story-equivalent units — THE taste dial (short 0.03
   / default 0.05 / long 0.08); per-cue tuning = make `dwell` a literal array. */
function buildWarp(BEATS) {
  const beatTarget = ([a, b]) => Math.max((a + b) / 2, a + 0.06);
  const cues = [
    0.072,                                                 // b01 completed CONCERT brief (= seekBeat b01)
    beatTarget(BEATS.b02), beatTarget(BEATS.b03), beatTarget(BEATS.b04),
    beatTarget(BEATS.b05), beatTarget(BEATS.b06), beatTarget(BEATS.b07),
    beatTarget(BEATS.b08),                                 // 0.1375 0.27 0.40 0.49 0.58 0.68 0.7975
  ];
  const DWELL_STORY = 0.05;
  const dwell = cues.map(() => DWELL_STORY);
  const H = dwell.reduce((s, d) => s + d, 0);
  const span = 1 + H;
  const below = [];                                        // cumulative dwell strictly below cue i
  let acc = 0;
  for (let i = 0; i < cues.length; i++) { below.push(acc); acc += dwell[i]; }
  function warp(scrollP) {                                 // SCROLL -> STORY (per-frame; 8-iter loop)
    const u = scrollP * span;
    if (u <= 0) return 0;
    if (u >= span) return 1;
    for (let i = 0; i < cues.length; i++) {
      const enter = cues[i] + below[i];                    // u where story first reaches cue i
      if (u < enter) return u - below[i];                  // move segment leading to cue i (slope 1)
      if (u <= enter + dwell[i]) return cues[i];           // PLATEAU: story frozen at the cue
    }
    return u - H;                                          // final move after the last cue
  }
  function unwarp(storyP) {                                // STORY -> SCROLL (plateau-START edge at a cue)
    const s = Math.max(0, Math.min(1, storyP));
    let b = 0;
    for (let i = 0; i < cues.length; i++) { if (cues[i] < s) b += dwell[i]; else break; }
    return (s + b) / span;
  }
  return { warp, unwarp };
}

/* §F rects: the 1px frame sits at the FINAL plate rect; the caption
   docks below-left of the plate. Pure layout from viewport dims — called
   at pin creation and on every ScrollTrigger refresh.
   §K: the beat-09 STAGE QUIET panel is no longer docked at the plate (it
   now plays as a framed beat BEFORE the photo, positioned by CSS like the
   other beat panels), so layoutEnding only owns the frame + caption.
   Coarse-viewport branch (<= 720px): the caption is stacked BELOW the
   plate, full plate-width — no left docking. This mirrors the
   .stage3d-panel mobile reflow in home3d.css and prevents collision on
   ~390px viewports. Desktop branch (> 720) is unchanged. Inline styles
   are set explicitly (clearing opposing edges) so the branch is safe on
   ScrollTrigger refresh/resize. */
function layoutEnding(els, viewportEl) {
  if (!els.frame || !viewportEl) return;
  const W = viewportEl.clientWidth, H = viewportEl.clientHeight;
  if (!W || !H) return;
  const w = W * PLATE_SCALE, h = H * PLATE_SCALE;
  const left = (W - w) / 2;
  const top = PLATE_ORIGIN_Y * H * (1 - PLATE_SCALE);
  Object.assign(els.frame.style, { left: left + 'px', top: top + 'px', width: w + 'px', height: h + 'px' });
  const belowPx = Math.round(top + h + 18);
  if (W <= 720) {
    /* Mobile: caption stacked below the plate, aligned to plate edges. */
    const plateRight = (W - left - w) + 'px';
    Object.assign(els.caption.style, { left: left + 'px', right: plateRight, top: belowPx + 'px', bottom: 'auto', width: 'auto' });
  } else {
    /* Desktop: caption docked below-left of the plate. */
    Object.assign(els.caption.style, { left: left + 'px', right: 'auto', top: belowPx + 'px', bottom: 'auto', width: '' });
  }
}

/* Master timeline — total duration 1. §N (2026-06-12): every numeric
   position below is STORY-space; buildOverlayTimeline remaps each one to
   the SCROLL playhead via U = warp.unwarp (the timeline is scroll-indexed,
   the story freezes on a plateau at each cue). So e.g. the photo crossfade
   authored at story [0.945, 0.975] actually sits at U(0.945)..U(0.975) on
   the playhead. Read the numbers below as STORY positions (they track the
   beats); U handles the scroll mapping.
   Everything is a pure function of progress (scrub reversal re-derives
   state; nothing is threshold-fired). Per beat [a,b]:
   - container: snap-in at the title position, fade-out at b-0.02
     (children keep their own staged states inside it — reverse scrub
     restores every layer from the same timeline).
   - title+num in at a+0.01 (autoAlpha + y 14→0, 0.02 wide);
   - body+chips+CTA in at mid = a + 0.35*(b-a);
   Beat 01 is special: its panel enters at 0.067, AFTER the form is
   fully gone (term.js FADE_OUT ends 0.0655) — Rajai 2026-06-12: the
   form and the beat-01 panel must never co-exist either; the stamped
   CONCERT holds [0.044, 0.060] so it can actually be read. The range
   [0, 0.075] is owned by the typewriter, so the in/mid/out positions
   are compressed (fade-out starts 0.0875, completes 0.1075).
   Beat 02 is special too: ONE PANEL AT A TIME (Rajai, 2026-06-11 —
   "never show two beat panels at once"). Its generic entry (a + 0.01 =
   0.085) would double-stack with the exiting beat-01 panel, so it waits
   for the TRACE beat instead (0.125 = BEATS.trace[0] + 0.005, after the
   beat-01 panel is fully gone) — the sketch drawing itself IS beat 02's
   content, so the panel rides the trace, not the gather.
   §K ending order: beat-09 framed panel [0.895, 0.94] (enter/hold/exit)
   BEFORE the photo; photo in over BEATS.crossfade [0.945, 0.975]; abyss
   backplate seals late (second half of that band); shrink over
   BEATS.shrink [0.975, 1.0]; caption + frame in during the shrink. */
function buildOverlayTimeline(tl, BEATS, els, warp) {
  /* §N: the timeline is SCROLL-indexed, so remap every STORY position
     through U (= unwarp). Beat-panel transition DURATIONS stay LITERAL — a
     fixed crisp scroll-fade that completes just inside its freeze and never
     stretches across a plateau. ENDING-block durations use Udur (story span
     -> scroll span): that region is above every cue, so Udur is a pure affine
     compression that keeps each tween inside [.., 1.0] (literal durations
     there overflow past 1.0 and overlap the beat-09 card with the photo). */
  const U = (s) => warp.unwarp(s);
  const Udur = (x, y) => warp.unwarp(y) - warp.unwarp(x);
  els.panels.forEach((p, i) => {
    const range = BEATS['b0' + (i + 1)];
    const a = range[0], b = range[1];
    const first = i === 0;
    const second = i === 1;
    const tIn = first ? 0.067 : second ? 0.125 : a + 0.01;
    const tMid = first ? 0.070 : second ? 0.133 : a + 0.35 * (b - a);
    const tOut = first ? 0.0875 : b - 0.02;
    const dIn = first ? 0.012 : 0.02;
    tl.fromTo(p.el, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.002, ease: 'none' }, U(tIn));
    tl.fromTo(p.head, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, duration: dIn, ease: 'power2.out' }, U(tIn));
    if (p.body.length) {
      tl.fromTo(p.body, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, duration: dIn, ease: 'power2.out' }, U(tMid));
    }
    tl.to(p.el, { autoAlpha: 0, duration: 0.02, ease: 'power2.in' }, U(tOut));
  });
  const cw = BEATS.crossfade[1] - BEATS.crossfade[0]; // crossfade band width (0.03)
  const sw = BEATS.shrink[1] - BEATS.shrink[0];       // shrink band width (0.025)
  /* ENDING BLOCK (story >= 0.87, above every cue): every duration below MUST
     use Udur, never a literal — a literal scroll-duration here overflows the
     playhead past 1.0 (trips the duration!=1 guard) and overlaps the beat-09
     card with the incoming photo (the two bugs the §N critique caught). */
  if (els.quiet) {
    const qIn = 0.892;
    const qOut = BEATS.crossfade[0] - 0.017; // clears before the crossfade (§K invariant)
    tl.fromTo(els.quiet, { autoAlpha: 0 }, { autoAlpha: 1, duration: Udur(qIn, qIn + 0.002), ease: 'none' }, U(qIn));
    if (els.quietHead.length) {
      tl.fromTo(els.quietHead, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, duration: Udur(qIn, qIn + 0.014), ease: 'power2.out' }, U(qIn));
    }
    if (els.quietBody.length) {
      tl.fromTo(els.quietBody, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, duration: Udur(qIn + 0.006, qIn + 0.006 + 0.016), ease: 'power2.out' }, U(qIn + 0.006));
    }
    tl.to(els.quiet, { autoAlpha: 0, duration: Udur(qOut, qOut + 0.014), ease: 'power2.in' }, U(qOut));
  }
  if (els.photo) {
    tl.fromTo(els.photo, { autoAlpha: 0 }, { autoAlpha: 1, duration: Udur(BEATS.crossfade[0], BEATS.crossfade[1]), ease: 'power2.out' }, U(BEATS.crossfade[0]));
  }
  if (els.backplate) {
    tl.fromTo(els.backplate, { autoAlpha: 0 }, { autoAlpha: 1, duration: Udur(BEATS.crossfade[0] + cw * 0.5, BEATS.crossfade[1]), ease: 'power2.in' }, U(BEATS.crossfade[0] + cw * 0.5));
  }
  if (els.photoWrap) {
    tl.fromTo(els.photoWrap,
      { scale: 1, transformOrigin: '50% ' + PLATE_ORIGIN_Y * 100 + '%' },
      { scale: PLATE_SCALE, duration: Udur(BEATS.shrink[0], BEATS.shrink[1]), ease: 'power2.inOut' },
      U(BEATS.shrink[0]));
  }
  if (els.caption) {
    tl.fromTo(els.caption, { autoAlpha: 0, y: 10 }, { autoAlpha: 1, y: 0, duration: Udur(BEATS.shrink[0] + sw * 0.4, BEATS.shrink[0] + sw * 0.4 + 0.012), ease: 'power2.out' }, U(BEATS.shrink[0] + sw * 0.4));
  }
  if (els.frame) {
    tl.fromTo(els.frame, { autoAlpha: 0 }, { autoAlpha: 1, duration: Udur(BEATS.shrink[0] + sw * 0.52, BEATS.shrink[0] + sw * 0.52 + 0.012), ease: 'none' }, U(BEATS.shrink[0] + sw * 0.52));
  }
  // Anchor: force timeline duration to exactly 1 (U(1.0)===1.0).
  if (els.photo) tl.set(els.photo, {}, U(1.0));
}

/* Story-rail updater — zero React state. Queries the rail DOM once per
   pin creation, then toggles classes / writes the number text directly
   from the scrub callback. Beat index = which BEATS range contains p
   (resolve band 0.87..1 stays on beat 08, index 7). */
function makeRailTick(BEATS) {
  const rail = document.querySelector('.story__rail');
  const num = rail ? rail.querySelector('.story__rail-num') : null;
  const ticks = rail ? Array.prototype.slice.call(rail.querySelectorAll('.tick')) : [];
  const ends = ['b01', 'b02', 'b03', 'b04', 'b05', 'b06', 'b07', 'b08'].map((k) => BEATS[k][1]);
  let last = -1;
  return function railTick(p) {
    let idx = 0;
    while (idx < 7 && p >= ends[idx]) idx++;
    // §K: the beat-09 STAGE QUIET panel + the ending live in resolve —
    // light tick 09 (index 8) from the resolve/outro start, so 09 is lit
    // while the beat-9 panel shows (it enters ~0.895, inside outro) and
    // through the photo/plate.
    if (p >= BEATS.outro[0]) idx = 8;
    if (idx === last) return;
    last = idx;
    if (rail) rail.classList.toggle('is-visible', idx >= 1);
    if (num) num.textContent = String(idx + 1).padStart(2, '0') + '/12';
    for (let i = 0; i < ticks.length; i++) ticks[i].classList.toggle('is-active', i === idx);
  };
}

function Home3DPin({ navigate }) {
  const q = window.TAVQuality || { mode: '2d' };
  const pinWrapRef = React.useRef(null);
  const viewportRef = React.useRef(null);
  const canvasHostRef = React.useRef(null);
  const termRef = React.useRef(null);
  const annosRef = React.useRef(null);
  const loadingRef = React.useRef(null);

  /* Single code path: whenever the host div mounts, ensure the kept
     canvas is its child. appendChild is a no-op move if already there. */
  const hostRefCb = React.useCallback((el) => {
    canvasHostRef.current = el;
    if (el) {
      const c = getStageCanvas('stage3d-hero-canvas');
      if (!el.contains(c)) el.appendChild(c);
    }
  }, []);

  React.useEffect(() => {
    if (q.mode !== '3d') return;
    let dead = false;
    let heroEl = null;          // captured for cleanup (refs are detached before effect cleanup)
    let resizeObserver = null;
    let removePointer = null;
    let st = null;              // the one ScrollTrigger
    let tl = null;              // the one master timeline
    let raf1 = 0, raf2 = 0;
    let prevScrollRestoration;  // M1: captured before forcing 'manual'

    (async () => {
      try {
        await ensureGsap();
        const engineMod = await nativeImport('/components/Stage3D/engine.js');
        if (dead) return;
        const eng = engineMod.mount(getStageCanvas('stage3d-hero-canvas'), { tier: q.tier });
        if (!keptWaves || keptWavesEngine !== eng) {
          const { createWaves } = await nativeImport('/components/Stage3D/waves3d.js');
          if (dead) return;
          keptWaves = createWaves(eng, q.tier);
          keptWavesEngine = eng;
          eng.addModule(keptWaves);
        }
        /* Sketch module (Task 5) — created at boot so the ten
           lines/*.glb fetches are tier 0 (kicked inside createSketch,
           before any scroll). Same kept-identity pattern as waves: one
           instance per engine, registered exactly once. */
        if (!keptSketch || keptSketchEngine !== eng) {
          const { createSketch } = await nativeImport('/components/Stage3D/sketch.js');
          if (dead) return;
          keptSketch = createSketch(eng);
          keptSketchEngine = eng;
          eng.addModule(keptSketch);
        }
        /* Task 6+7+8 modules: grass floor, assembly choreography (kicks
           the eager GLB loads inside createAssembly), camera rig (through
           the T8 FOH pull), lock-home annotations, walls (single wall-
           emissive writer, T8 extraction), show-prep effects (T7 focus
           pulse; T8 arm phase for walls + blackout) and the T8
           beat-01 typewriter. Same kept-identity pattern; the annotations
           AND term containers are re-bound EVERY mount below (React
           recreates the .stage3d-annos / .stage3d-term elements per
           route entry). */
        if (!keptStage || keptStageEngine !== eng) {
          const [{ createGrass }, { createAssembly }, { createCameraRig }, { createAnnotations }, { createShowPrep }, { createTerm }, { createWalls }, { createShow }] = await Promise.all([
            nativeImport('/components/Stage3D/grass.js'),
            nativeImport('/components/Stage3D/assembly.js'),
            nativeImport('/components/Stage3D/camera.js'),
            nativeImport('/components/Stage3D/annotations.js'),
            nativeImport('/components/Stage3D/show-prep.js'),
            nativeImport('/components/Stage3D/term.js'),
            nativeImport('/components/Stage3D/walls.js'),
            nativeImport('/components/Stage3D/show.js'),
          ]);
          if (dead) return;
          const grass = createGrass(eng);
          const assembly = createAssembly(eng, { sketch: keptSketch, tier: q.tier });
          const cameraRig = createCameraRig(eng);
          const annos = createAnnotations(eng, { assembly, cameraRig });
          // T8: show-prep drives the blackout's base-light dim through
          // grass.setBaseLevel (grass owns its lights; single caller).
          const showPrep = createShowPrep(eng, { assembly, grass });
          const term = createTerm(); // beat-01 typewriter (pure DOM, T8)
          // walls: single writer of wall emissive (test-grid + arm lift +
          // blackout zero + T9 LED show content). Registration order is
          // FLEXIBLE — it is order-free w.r.t. assembly because wall
          // meshes are always at rest before any wall writes (no clone-
          // lifecycle constraint).
          const walls = createWalls(eng, { assembly });
          // T9 show: beams + dj silhouette + teal spill. Registered AFTER
          // assembly (reversed iteration → show runs BEFORE assembly each
          // frame: the dj restore-before-ensureClones discipline).
          const show = createShow(eng, { assembly });
          keptStage = { grass, assembly, cameraRig, annos, showPrep, term, walls, show };
          keptStageEngine = eng;
          eng.addModule(grass);
          eng.addModule(assembly);
          eng.addModule(cameraRig);
          eng.addModule(annos);
          eng.addModule(term);
          eng.addModule(walls); // order-free; registered before showPrep is fine
          eng.addModule(show);  // after walls, BEFORE showPrep
          // showPrep MUST be added LAST: the engine iterates modules in
          // reversed registration order, so showPrep runs FIRST each frame
          // (before assembly). This is the restore-before-ensureClones
          // discipline the assembly seam requires.
          eng.addModule(showPrep);
        }
        if (dead) return;
        if (annosRef.current) keptStage.annos.setContainer(annosRef.current);
        if (termRef.current) keptStage.term.setContainer(termRef.current);
        keptWaves.setSketchSource(keptSketch); // idempotent (same-source guard)
        keptWaves.setMode('hero');
        window.gsap.registerPlugin(window.ScrollTrigger);
        eng.start(window.gsap);
        eng.resize(); // canvas was appended before boot; size it now

        if (typeof ResizeObserver === 'function' && canvasHostRef.current) {
          resizeObserver = new ResizeObserver(() => eng.resize());
          resizeObserver.observe(canvasHostRef.current);
        }

        /* Pointer → engine (cursor-modulated wave speed). Fine pointers
           only; passive; normalized -1..1 against the (now full-viewport)
           canvas. */
        if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
          /* M3: the host is fixed inset:0, so the canvas rect IS the
             viewport — innerWidth/innerHeight math, no layout read. */
          const onMove = (e) => {
            const w = window.innerWidth, h = window.innerHeight;
            if (!w || !h) return;
            eng.setHeroPointer(
              (e.clientX / w) * 2 - 1,
              (e.clientY / h) * 2 - 1
            );
          };
          window.addEventListener('pointermove', onMove, { passive: true });
          removePointer = () => window.removeEventListener('pointermove', onMove);
        }

        /* Coarse one-shot boot state via classList — NOT React state, so
           the pin subtree never re-renders after mount (§5.8 rule 1). */
        if (canvasHostRef.current) canvasHostRef.current.classList.add('is-ready');
        heroEl = document.querySelector('.home-hero');
        if (heroEl) heroEl.setAttribute('data-stage3d', 'ready'); // CSS fades the 2D waves

        /* Deferred ScrollTrigger creation (§5.8 rule 3): a double rAF
           lands after the App route effect's double scrollTo(0,0), so
           the pin measures a settled, top-scrolled document. */
        raf1 = requestAnimationFrame(() => {
          raf2 = requestAnimationFrame(() => {
            if (dead || !pinWrapRef.current || !viewportRef.current) return;
            /* M1: capture-then-restore around the pin's lifetime. NOTE:
               the vendored ScrollTrigger co-manages this flag at runtime
               (refresh() re-applies its eval-time capture, ST-managed
               scrolls force 'manual'), so this set is advisory while the
               pin lives — the cleanup restore is the part that matters:
               it stops 'manual' leaking to other routes after ST's last
               scroll forced it. */
            prevScrollRestoration = history.scrollRestoration;
            history.scrollRestoration = 'manual';
            window.ScrollTrigger.config({ ignoreMobileResize: true });

            const railTick = makeRailTick(eng.BEATS);
            const warp = buildWarp(eng.BEATS);
            const hostEl = canvasHostRef.current;

            /* C1: the timeline IS the ScrollTrigger animation (linked via
               `animation:` below) — the link is what makes scrub:0.15 real
               smoothing; without it ST creates no scrub tween and progress
               is raw. Engine, rail and offstage gating all hang off the
               timeline's own onUpdate, so they read the SCRUBBED playhead:
               one scroll-driven path for everything. */
            tl = window.gsap.timeline({
              paused: true,
              onUpdate() {
                const p = tl.progress();          // SCROLL progress (scrubbed playhead)
                const sp = warp.warp(p);          // §N STORY progress (frozen flat at each cue plateau)
                eng.setProgress(sp);
                railTick(sp);
                /* C1b: past the pin end the live canvas hides so beats
                   09-12 and the footer never sit on a 3D world. Gated by
                   scrubbed progress (replaces the raw onLeave/onEnterBack
                   flips) so the hide lands exactly when the world
                   visually finishes, reversal-safe by construction. */
                if (hostEl) hostEl.classList.toggle('is-offstage', p >= 1);
              },
            });
            const els = collectOverlayEls(pinWrapRef.current);
            buildOverlayTimeline(tl, eng.BEATS, els, warp);
            layoutEnding(els, viewportRef.current); // §F frame/caption/quiet rects
            /* I3: timeline positions are pin-progress units ONLY while the
               total duration is exactly 1 (buildOverlayTimeline contract). */
            if (Math.abs(tl.duration() - 1) > 1e-9) console.warn('stage3d: timeline duration != 1', tl.duration());

            /* Soft beat-settle anchors (Rajai 2026-06-12: "a pause at
               beats when fast scrolling"): the seekBeat landing points
               for beats 02-08 — same derivation, keep in sync with
               __tavPin.seekBeat below. Beat 01 is excluded: its CONCERT
               hold [0.044, 0.060] must stay freely scrubbable.
               §N: this snap is the accepted pre-§M subtle settle (NOT the
               rejected auto-jump), now remapped through unwarp so its
               targets are correct under the story-time warp — a snap target
               is a plateau START; a stop inside a plateau nudges the scroll
               but not the frozen story (story is flat across the plateau). */
            const snapPts = [eng.BEATS.b02, eng.BEATS.b03, eng.BEATS.b04, eng.BEATS.b05, eng.BEATS.b06, eng.BEATS.b07, eng.BEATS.b08]
              .map(([a, b]) => warp.unwarp(Math.max((a + b) / 2, a + 0.06)));

            st = window.ScrollTrigger.create({
              trigger: pinWrapRef.current,
              /* 10/8 viewport-heights (was 8.5/7) — 2026-06-12 pacing
                 note: every beat gets ~18% more scroll travel. */
              start: 'top 64px',
              end: () => '+=' + window.innerHeight * (q.tier === 'desktop' ? 10 : 8),
              pin: viewportRef.current,
              anticipatePin: 1,
              animation: tl,
              scrub: 0.15,
              snap: {
                /* Settles the scroll onto the nearest beat reading
                   position after a fast fling — ONLY within a small
                   radius (mid-beat positions stay free) and never in
                   the resolve/ending band. Pure scroll mechanics: the
                   scrub re-derives everything on the way, no element
                   tracking, no state.
                   inertia:false is REQUIRED: with velocity projection
                   on, programmatic jumps (rail seeks, __tavPin.seek)
                   read as enormous flings and the projected landing
                   clamps to 0/1 — the snap then yanked the scroll to
                   the pin extremes (caught in headless verify). */
                inertia: false,
                snapTo(value) {
                  if (value > warp.unwarp(0.87)) return value;
                  let best = value;
                  let dMin = 0.025;
                  for (const t of snapPts) {
                    const d = Math.abs(value - t);
                    if (d < dMin) { dMin = d; best = t; }
                  }
                  return best;
                },
                duration: { min: 0.2, max: 0.6 },
                delay: 0.08,
                ease: 'power1.inOut',
              },
              onRefresh() { eng.resize(); layoutEnding(els, viewportRef.current); },
            });

            /* First seek snaps (engine has no smoothing) — no stale
               mid-narrative flash when navigating back to home. Setting
               tl.progress fires tl's own onUpdate (the designed path),
               which drives engine + rail + offstage in one go. NOTE: with
               the animation link, ScrollTrigger's scrub tween owns the
               playhead during normal scrolling — never call tl.progress()
               anywhere else (this boot snap and __tavPin.seek, which goes
               through window.scrollTo, are the sanctioned exceptions). */
            tl.progress(st.progress);

            window.__tavPin = {
              st,
              tl, // §K: exposed for the headless kit's duration assertion
              seek(f) {
                /* 'instant', NOT 'auto': site.css sets scroll-behavior:smooth;
                   'auto' would smooth-scroll and scrub the narrative on the way.
                   §N: f is a STORY position; unwarp maps it to the scroll position. */
                window.scrollTo({ top: st.start + warp.unwarp(f) * (st.end - st.start), behavior: 'instant' });
              },
              /* I2: beat-indexed seek (0-7) sourced from the engine's own
                 BEATS geometry — lands mid-beat, clamped past the head
                 stagger so the panel body copy is always staged in. */
              seekBeat(i) {
                const ranges = [eng.BEATS.b01, eng.BEATS.b02, eng.BEATS.b03, eng.BEATS.b04, eng.BEATS.b05, eng.BEATS.b06, eng.BEATS.b07, eng.BEATS.b08];
                const [a, b] = ranges[i];
                // b01 special case: the generic formula would land mid-form;
                // 0.072 lands inside the panel window (panel tIn 0.067, body
                // staged at 0.070) with the form already gone (0.0655).
                const target = i === 0 ? 0.072 : Math.max((a + b) / 2, a + 0.06);
                this.seek(target);
              },
            };

            document.fonts?.ready?.then(() => { if (!dead) window.ScrollTrigger.refresh(); });
          });
        });
      } catch (e) {
        console.warn('stage3d boot failed -> staying 2d', e);
      }
    })();

    return () => {
      dead = true;
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
      /* Cleanup ORDER (§5.8 rule 2): kill+revert the ScrollTrigger first
         (unwinds the pin spacer), then the timeline, then release the
         engine ticker, then drop the debug handle. */
      if (st) { st.kill(true); st = null; }
      if (tl) { tl.kill(); tl = null; }
      if (resizeObserver) resizeObserver.disconnect();
      if (removePointer) removePointer();
      if (heroEl) heroEl.removeAttribute('data-stage3d');
      if (keptWaves) keptWaves.setMode('off');
      if (window.__tavEngine && window.__tavEngine.release) {
        window.__tavEngine.release(window.gsap);
      }
      delete window.__tavPin;
      /* M1: hand scroll restoration back to the browser (only if the
         deferred ST creation actually ran and captured the previous value). */
      if (prevScrollRestoration !== undefined) history.scrollRestoration = prevScrollRestoration;
    };
  }, []);

  if (q.mode !== '3d') return null;

  /* The pin DOM below renders once per homepage mount and never changes
     shape: no conditional children, no keys that churn. The only
     conditional ({b.slug !== undefined}) is over the constant
     BEAT_PANELS, identical every render. Overlays start hidden via CSS;
     ONLY the master timeline changes them. */
  /* BOOT WINDOW (M4 — known, accepted): until the ScrollTrigger lands
     its pin spacer (double rAF + dynamic module fetch after mount), the
     document is ~8.5 viewports SHORT of its final height. A fast
     pre-boot scroll can therefore land deep in the tail sections; when
     ST creation then grows the document, the browser keeps the pixel
     offset and the view settles mid-narrative. Accepted: the loading
     readout (Task 11) will cover the boot window. */
  return (
    <>
      <div className="stage3d-canvas-host" ref={hostRefCb} />
      <section className="stage3d" ref={pinWrapRef}>
        <div className="stage3d__viewport" ref={viewportRef}>
          <div className="stage3d__overlays">
            {BEAT_PANELS.map((b) => (
              <div key={b.num} className={'stage3d-panel' + (b.side === 'r' ? ' is-right' : '')} data-beat={b.num}>
                <div className="beat__num">{`Beat ${b.num} · ${b.tag}`}</div>
                <h2 className="beat__title">{b.title} <span className="accent">{b.accent}</span></h2>
                <p className="beat__copy">{b.copy}</p>
                <div className="beat__meta">{b.meta.map((m) => <span key={m} className="chip">{m}</span>)}</div>
                {b.slug !== undefined && (
                  <a className="btn-link"
                    href={b.slug ? '/what-we-do/' + b.slug : '/what-we-do'}
                    onClick={(e) => { e.preventDefault(); if (navigate) navigate(b.slug ? 'what-we-do/' + b.slug : 'what-we-do'); }}>
                    Open the spec sheet {window.ArrowRight ? <window.ArrowRight /> : null}
                  </a>
                )}
              </div>
            ))}
            <div className="stage3d-term" ref={termRef} />        {/* typewriter (Task 8) */}
            <div className="stage3d-annos" ref={annosRef} />      {/* annotations (Task 6) */}
            <div className="stage3d-loading" ref={loadingRef} />  {/* outrun readout (Task 11) */}
            {/* ===== §F ending stack (§J/§K) =====
                backplate UNDER the photo wrap (guarantees the canvas is
                occluded before the shrink reveals margins); the IMG's
                autoAlpha does the crossfade, the WRAP's transform does
                the shrink-to-plate; frame + caption sit on top. §K: the
                beat-09 STAGE QUIET panel is now a FRAMED beat panel that
                plays BEFORE the photo (carbon card, CSS-positioned like
                the other beats) — it is no longer docked at the plate.
                Rects for frame + caption are computed in layoutEnding()
                on every ScrollTrigger refresh. */}
            <div className="stage3d-backplate" />
            <div className="stage3d-photo-wrap">
              <img className="stage3d-photo" src="/assets/stage3d/surf-abu-dhabi-pro.avif" alt="Surf Abu Dhabi Pro 2024, stage built by T-AV" />
            </div>
            <div className="stage3d-frame" />
            <div className="stage3d-quiet">
              {/* NOTE: beat-09 copy is intentionally duplicated verbatim in
                  pages/Home.jsx (2D fallback beat-09 section). Beat 09 does
                  not live in BEAT_COPY (which covers beats 01-08 only);
                  adding it there would require restructuring the 2D story
                  section. Update both sites together when copy changes. */}
              <div className="beat__num">Beat 09 · THE STAGE GOES QUIET</div>
              <h2 className="beat__title">The stage goes <span className="accent">quiet again.</span></h2>
              <p className="beat__copy">Cases packed by 04:00. Site is clean. The rig is back in the warehouse before the city wakes up. The next floor plan is already on the desk.</p>
            </div>
            <div className="stage3d-caption">
              <div className="stage3d-caption__label">SURF ABU DHABI PRO · 2024</div>
              {/* §K: beat-font caption — white title + teal accent, mirroring
                  the beat panels' .beat__title + .accent split. */}
              <div className="stage3d-caption__line"><span>The same stage.</span> <span className="accent">As built.</span></div>
            </div>
          </div>
        </div>
      </section>
    </>
  );
}

window.Home3DPin = Home3DPin;
