// ─────────────────────────────────────────────────────────────
// protected/bifrost.js — Project Bifrost scenes inside the
// Overview page of the timeline.
//
// This file:
//   1. Wraps site-2's six scroll-bound scenes (hero → architecture
//      stack → words → aurora arc → treasure-map → join CTA) so
//      they run inside the Overview page, not as a standalone site.
//   2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
//      scroller is the Overview's internal scrolling container —
//      never the window — so the three-page Timeline/Overview/
//      Timeline model (each fixed-viewport) keeps working.
//   3. Drives the Europe map's opacity from the scroller's scroll
//      position: fully visible at the top, fades to 0 as the user
//      scrolls into the hero, fades back in on scrolling up.
//   4. Is a lazy-init module. Nothing happens at page load; the
//      dot-nav handler in timeline.js calls window.__bifrost.init()
//      the first time the Overview page becomes active.
//
// CSP: 'script-src self'. No inline scripts anywhere.
// ─────────────────────────────────────────────────────────────

(function () {
  'use strict';

  // Lazy single-shot init flag — nav can click the Overview pip
  // multiple times; we only wire everything up once.
  let initialized = false;
  let refreshScheduled = false;

  // Shared state between init() and the public surface (scrollTo etc).
  // These are assigned once during init(); scrollTo reads them to drive
  // the Overview's internal scroller instead of window scroll.
  let lenisInstance = null;
  let scrollerEl = null;

  // A tiny helper: schedule a ScrollTrigger.refresh() on the next
  // animation frame, de-duplicating calls within the same frame.
  function scheduleRefresh() {
    if (refreshScheduled || !window.ScrollTrigger) return;
    refreshScheduled = true;
    requestAnimationFrame(() => {
      refreshScheduled = false;
      window.ScrollTrigger.refresh();
    });
  }

  function init() {
    if (initialized) {
      // Already booted — just re-measure, in case layout shifted while
      // the page was inactive (e.g. user resized the window on Timeline).
      scheduleRefresh();
      return;
    }
    initialized = true;

    // Guard: vendor libs must have loaded.
    if (typeof window.gsap === 'undefined' ||
        typeof window.ScrollTrigger === 'undefined' ||
        typeof window.Lenis === 'undefined') {
      console.warn('[bifrost] Vendor libraries (gsap/ScrollTrigger/Lenis) missing; skipping init.');
      return;
    }

    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    // Reveal-on-load elements: if reduced motion, just make them visible
    // and bail before registering any ScrollTrigger-bound tweens.
    if (reduceMotion) {
      document.querySelectorAll('#page-overview [data-reveal], #page-overview [data-reveal-lines]').forEach(el => {
        el.style.opacity = '1';
        el.style.transform = 'none';
      });
      // The hero-wrap is hidden by CSS (`.js .hero-wrap { opacity: 0 }`)
      // to prevent a flash during page activation — unhide it here since
      // we're skipping the fade-in tween.
      const heroWrap = document.querySelector('.hero-wrap');
      if (heroWrap) heroWrap.style.opacity = '1';
      // Still fade the Europe map fully in — it's the scene background.
      const mapEl = document.getElementById('overview-globe');
      if (mapEl) mapEl.style.opacity = '1';
      return;
    }

    // ─── Scroller setup ──────────────────────────────────────────
    //
    // The Overview is a fixed-position .page that contains one
    // scrollable child: `#overview-scroll`. All six scenes live
    // inside it. Lenis drives wheel input on that element;
    // ScrollTrigger reads scroll from the same element.
    const scroller = document.getElementById('overview-scroll');
    if (!scroller) {
      console.error('[bifrost] #overview-scroll not found');
      return;
    }
    scrollerEl = scroller;

    const gsap = window.gsap;
    const ScrollTrigger = window.ScrollTrigger;
    const Lenis = window.Lenis;

    gsap.registerPlugin(ScrollTrigger);

    // Lenis wired to the Overview's internal scroller, NOT the window.
    const lenis = new Lenis({
      wrapper: scroller,
      content: scroller.firstElementChild,
      duration: 1.15,
      easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
      smoothWheel: true,
      wheelMultiplier: 1,
      touchMultiplier: 1.5,
    });

    // Tell ScrollTrigger how to read/write scroll on this scroller.
    ScrollTrigger.scrollerProxy(scroller, {
      scrollTop(value) {
        if (arguments.length) {
          scroller.scrollTop = value;
        }
        return scroller.scrollTop;
      },
      getBoundingClientRect() {
        // The scroller occupies the full viewport (inset: 0 on its parent).
        return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
      },
      // Scrollbars are hidden via CSS; pinType is 'transform' for nested scrollers.
      pinType: 'transform',
    });

    // Every ScrollTrigger below implicitly targets our scroller.
    ScrollTrigger.defaults({ scroller });

    // Pump Lenis via GSAP's ticker; notify ScrollTrigger on each scroll.
    lenis.on('scroll', ScrollTrigger.update);
    gsap.ticker.add((time) => lenis.raf(time * 1000));
    gsap.ticker.lagSmoothing(0);

    // Expose lenis to scrollTo() via the shared closure var.
    lenisInstance = lenis;

    // ─── Europe map fade ─────────────────────────────────────────
    //
    // Fully visible at scrollTop=0. Fades to 0 between 20% and 80%
    // of viewport height. Fades back in on scrolling up. Opacity
    // ceiling is 0.42 — matches the original .page-overview.is-active
    // .overview-globe svg look.
    const mapSvg = document.querySelector('#overview-globe svg');
    const MAP_MAX_OPACITY = 0.42;
    if (mapSvg) {
      // Initial: visible (map is the hero backdrop). We'll drive opacity
      // directly on every scroll tick, so kill any CSS opacity transition
      // (the existing rule animates opacity over 900ms — would be janky).
      mapSvg.style.transition = 'none';
      mapSvg.style.opacity = MAP_MAX_OPACITY.toFixed(3);
    }

    function updateMapOpacity() {
      if (!mapSvg) return;
      const vh = window.innerHeight;
      const y = scroller.scrollTop;
      // Between 0.20 × vh and 0.80 × vh, ramp from full to zero.
      const startFade = vh * 0.20;
      const endFade   = vh * 0.80;
      let t;
      if (y <= startFade) {
        t = 0; // fully visible
      } else if (y >= endFade) {
        t = 1; // fully hidden
      } else {
        t = (y - startFade) / (endFade - startFade);
      }
      mapSvg.style.opacity = (MAP_MAX_OPACITY * (1 - t)).toFixed(3);
    }

    lenis.on('scroll', updateMapOpacity);
    // Initial paint
    updateMapOpacity();

    // ─── Scroll-spy for the dot-nav ──────────────────────────────
    //
    // As the user scrolls through the Overview, update the active dot
    // to match the scene currently in view. Called from lenis.on('scroll')
    // at display rate; debounced implicitly by requestAnimationFrame
    // through the shared refresh scheduler.
    //
    // Logic: a scene is "active" when its top is above the viewport's
    // midpoint AND its bottom is below it. For stacked pinned scenes
    // (S2) the pin duration makes "bottom" go well past the viewport,
    // so the first-match wins — scenes are checked top-to-bottom.
    const sceneOrder = [
      'hero', 'stack-scene', 'words-scene',
      'bifrost', 'bifrost-meaning', 'bifrost-join',
    ];
    let lastActiveScene = null;
    function updateActiveSceneDot() {
      if (typeof window.__setActiveDot !== 'function') return;
      const midY = window.innerHeight * 0.5;
      let visibleId = sceneOrder[0];
      for (const id of sceneOrder) {
        const el = document.getElementById(id);
        if (!el) continue;
        const r = el.getBoundingClientRect();
        // A scene whose top is at or above the midline, but whose
        // bottom hasn't scrolled past the midline yet.
        if (r.top <= midY && r.bottom > midY) {
          visibleId = id;
          break;
        }
        // Edge case: scrolled past — keep latest seen as fallback.
        if (r.top <= midY) visibleId = id;
      }
      if (visibleId !== lastActiveScene) {
        lastActiveScene = visibleId;
        window.__setActiveDot('page-overview', visibleId);
      }
    }
    lenis.on('scroll', updateActiveSceneDot);
    // Initial paint: set "hero" active since we start at top.
    requestAnimationFrame(updateActiveSceneDot);

    // ─── Topography parallax ─────────────────────────────────────
    //
    // Concentric-ring topographic layer that sits behind the Europe
    // map (z-index 0). Generated at runtime — same formula as the
    // entrance page's .currents pattern but rotated/offset so it reads
    // as a visual sibling rather than a duplicate.
    //
    // Parallax speed: 0.15× of the scroller's scrollTop. Slow enough to
    // feel atmospheric, fast enough that the layer doesn't appear
    // static on casual scroll. Applied as a translateY composed WITH
    // the 40° rotate from CSS (so we build the full transform here to
    // avoid clobbering the CSS rotate).
    const topoWrap = document.getElementById('overview-topography');
    if (topoWrap && !topoWrap.querySelector('svg')) {
      const svgNS = 'http://www.w3.org/2000/svg';
      const svg = document.createElementNS(svgNS, 'svg');
      const W = 1600, H = 1600, cx = W * 0.5, cy = H * 0.5;
      svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
      svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');

      // Slightly different parameters from the entrance page's currents
      // so the rings read as "related but not identical":
      //   - more rings (34 vs 26) — denser
      //   - smaller step (28 vs 32) — tighter
      //   - larger amplitude (32 vs 26) — wavier
      //   - lower base opacity — quieter behind the map
      const RINGS = 34, BASE_R = 60, STEP = 28, AMP = 32;
      for (let i = 0; i < RINGS; i++) {
        const r = BASE_R + i * STEP, segs = 280;
        const p1 = (i * 0.7) % (Math.PI * 2);
        const p2 = (i * 1.4 + 1.3) % (Math.PI * 2);
        const a1 = AMP * (0.9 + (i % 5) * 0.08);
        const a2 = AMP * 0.35;
        let d = '';
        for (let s = 0; s <= segs; s++) {
          const t = (s / segs) * Math.PI * 2;
          const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15)
                       + a2 * Math.sin(t * 5 + p2 + i * 0.22)
                       + AMP * 0.18 * Math.sin(t * 7 + i);
          const x = cx + Math.cos(t) * rr;
          const y = cy + Math.sin(t) * rr * 0.92;
          d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1);
        }
        d += ' Z';
        const path = document.createElementNS(svgNS, 'path');
        path.setAttribute('d', d);
        path.setAttribute('fill', 'none');
        path.setAttribute('stroke', '#383831');
        path.setAttribute('stroke-width', '1');
        path.setAttribute('stroke-linejoin', 'round');
        // Lower opacity than entrance page — sits behind the map,
        // shouldn't compete for attention.
        path.setAttribute('opacity', (i % 3 === 0 ? 0.07 : 0.04).toString());
        svg.appendChild(path);
      }
      topoWrap.appendChild(svg);
    }

    const PARALLAX_SPEED = 0.15;
    const topoSvg = topoWrap ? topoWrap.querySelector('svg') : null;
    function updateTopographyParallax() {
      if (!topoSvg) return;
      const y = scroller.scrollTop * -PARALLAX_SPEED;
      // Rotate comes from CSS (40deg); we compose our translateY into
      // the same transform string so both apply. `rotate` first so the
      // translate happens in screen space, not the rotated local space.
      topoSvg.style.transform = `translateY(${y.toFixed(1)}px) rotate(40deg)`;
    }
    lenis.on('scroll', updateTopographyParallax);
    // Initial paint
    updateTopographyParallax();

    // ─── Sticky-scroll damping ───────────────────────────────────
    //
    // When a scene's vertical center is close to the viewport center,
    // damp Lenis's velocity briefly — scenes "catch" the eye instead
    // of flying past at full scroll speed. Applied only to non-pinned
    // scenes (S1, S3, S4, S5, S6). S2 is already pinned + scrubbed
    // by GSAP ScrollTrigger; adding damping on top would make its
    // card-fall feel like a drag.
    //
    // Implementation: on each scroll tick, if the active scene is in
    // the "damp zone" (±20% of viewport height from viewport center),
    // scale Lenis's next frame velocity by a damping factor (0.82).
    // When outside the zone, pass through unchanged. Keeps power-users
    // happy — they can still scroll fast, just feel a soft hold at
    // scene boundaries.
    const STICKY_SCENES = new Set([
      'hero', 'words-scene', 'bifrost', 'bifrost-meaning', 'bifrost-join',
    ]); // note: 'stack-scene' deliberately excluded (already pinned)
    const DAMP_ZONE_VH = 0.20;   // ±20% of viewport height from center
    const DAMP_FACTOR  = 0.82;   // multiply velocity while in zone
    const DAMP_MAX_MS  = 300;    // cap: never damp for longer than this per scene-crossing

    let dampedUntil = 0;         // timestamp (ms); damping active while > now
    let lastDampedScene = null;  // id of the scene we last entered

    function updateStickyDamping() {
      const now = performance.now();
      const midY = window.innerHeight * 0.5;
      const zone = window.innerHeight * DAMP_ZONE_VH;
      let inZone = false, zoneScene = null;
      for (const id of STICKY_SCENES) {
        const el = document.getElementById(id);
        if (!el) continue;
        const r = el.getBoundingClientRect();
        // Use the scene's vertical midpoint as the "attractor."
        const sceneMid = (r.top + r.bottom) * 0.5;
        if (Math.abs(sceneMid - midY) < zone) {
          inZone = true;
          zoneScene = id;
          break;
        }
      }
      if (inZone) {
        // New scene entered the zone → arm a fresh damping window.
        // Reusing the same scene (still in zone, already damped) doesn't
        // extend — DAMP_MAX_MS is a hard cap per entry to avoid an
        // indefinite hold.
        if (zoneScene !== lastDampedScene) {
          lastDampedScene = zoneScene;
          dampedUntil = now + DAMP_MAX_MS;
        }
        if (now < dampedUntil && lenis.velocity) {
          // Lenis exposes .velocity (a number). Multiplying in place
          // dampens the next-frame step. Small, contained effect — no
          // chance of interfering with ScrollTrigger.
          lenis.velocity *= DAMP_FACTOR;
        }
      } else {
        // Left the zone — reset so the NEXT time we enter a scene,
        // we get a fresh damping window.
        if (lastDampedScene !== null) {
          lastDampedScene = null;
          dampedUntil = 0;
        }
      }
    }
    lenis.on('scroll', updateStickyDamping);

    // ─── Site-2 scene animations ─────────────────────────────────
    // (transplanted verbatim; all ScrollTriggers below automatically
    // use the Overview scroller via ScrollTrigger.defaults above.)

    // Script 1 body — HERO + SCENE 2 (architecture stack) + SCENE 3 (words) + SCENE 4 (bifrost arc)
/* -------------------------------------------------------------
     HERO — single overall fade-in. The wrap is hidden via CSS
     (`.js .hero-wrap { opacity: 0 }`) so the hero stays invisible
     during the page-activation transition, then fades in once
     Bifrost has booted.
     ------------------------------------------------------------- */
  gsap.to('.hero-wrap', {
    opacity: 1,
    duration: 1.0,
    ease: 'power2.out',
    delay: 0.1,
  });

  /* -------------------------------------------------------------
     ARCHITECTURE — two-phase scrubbed sequence
     Phase A (0.00 – 0.45): each of 4 layer-cards falls from above
       and lands at a progressively higher Y offset so the previous
       card's bottom strip peeks out below. Only the topmost card's
       eyebrow is visible at any time.
     Phase B (0.50 – 1.00): the stack rearranges into a 2x2 grid on
       the right side. Body text in each card fades out; eyebrow
       stays. Explanatory copy crossfades on the LEFT, three panels:
         ~0.55  "All the capabilities to solve business use cases"
         ~0.70  "Full client control / Complete sovereignty"
         ~0.85  "Built in Denmark / For Europe"
     ------------------------------------------------------------- */
  const theatre = document.querySelector('.layer-theatre');
  const cards = gsap.utils.toArray('.layer-card');
  const copyLayers = gsap.utils.toArray('.copy-layer');

  // Each card lands N pixels higher than the previous — previous's
  // bottom strip is visible below.
  const STACK_OFFSET_PER_CARD = 22; // px, upward

  // Compute grid target positions. In .in-grid mode, each card-box is
  // 20vw square and centered (via margin:auto) inside its full-width
  // parent .layer-card. We translate the parent card so the box lands
  // at the correct grid-cell position.
  function computeGridPlan() {
    const W = theatre.offsetWidth;
    const H = theatre.offsetHeight;
    const vw = window.innerWidth;

    const cellSize = vw * 0.17;       // matches .in-grid .card-box width (17vw)
    const gap = Math.max(14, vw * 0.014);

    const totalW = 2 * cellSize + gap;
    const totalH = 2 * cellSize + gap;

    // Right-anchor grid so it sits flush with the right side of the theatre
    const gridRight = W * 0.99;
    const gridStartX = gridRight - totalW;
    const gridStartY = (H - totalH) / 2;

    // Grid cell centers (in theatre coordinates), reading order: TL, TR, BL, BR
    const centers = [
      { cx: gridStartX + cellSize / 2,                   cy: gridStartY + cellSize / 2                   },
      { cx: gridStartX + cellSize / 2 + cellSize + gap,  cy: gridStartY + cellSize / 2                   },
      { cx: gridStartX + cellSize / 2,                   cy: gridStartY + cellSize / 2 + cellSize + gap  },
      { cx: gridStartX + cellSize / 2 + cellSize + gap,  cy: gridStartY + cellSize / 2 + cellSize + gap  },
    ];

    // In grid mode the card-box's horizontal center is the theatre horizontal
    // center (via margin:auto). That's our anchor for dx computations.
    const theatreCx = W / 2;
    const theatreCy = H / 2;

    return { cellSize, theatreCx, theatreCy, centers };
  }

  // Initial state — hide everything, set card translations.
  // Cards are positioned via left:0/right:0 + top:50% in CSS; we use
  // yPercent:-50 to center vertically (so `y` animations remain additive).
  cards.forEach((card, i) => {
    gsap.set(card, { xPercent: 0, yPercent: -50, opacity: 0, x: 0, y: 0, rotation: 0, scale: 1 });
    gsap.set(card.querySelector('.card-eyebrow'), { opacity: 0 });
  });
  // Copy layers vertically centered in copy-stage via yPercent: -50.
  // The animation uses `y` for the little drop-in offset (which is additive
  // to yPercent, so centering is preserved).
  copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 }));

  const stackTl = gsap.timeline({
    scrollTrigger: {
      trigger: '#stack-scene',
      start: 'top top',
      end: '+=5000',    // 5.5 viewports — more scroll for the new sequence
      scrub: 0.6,
      pin: '.stack-pin',
      pinSpacing: true,
      anticipatePin: 1,
      invalidateOnRefresh: true,
    }
  });

  // -------- Phase A: card landings --------
  // Card i lands at y = -i * STACK_OFFSET_PER_CARD (above baseline).
  // Its eyebrow fades IN on landing; the previous card's eyebrow fades OUT.
  cards.forEach((card, i) => {
    const landingY = -i * STACK_OFFSET_PER_CARD;
    const t = i * 0.105; // each card gets ~10.5% of timeline

    // Y motion — starts above viewport. Distance reduced to -900 so the
    // visible portion of the fall (from viewport top down to landing) is
    // a meaningful share of the animation rather than being swallowed by
    // off-screen travel that the user never sees.
    stackTl
      .fromTo(card,
        { y: -900, rotation: (i % 2 === 0 ? -4 : 4), scale: 0.97 },
        { y: landingY, rotation: 0, scale: 1, duration: 0.09, ease: 'power3.out' },
        t);

    // Opacity ramps up across most of the fall so the user sees the card
    // traveling rather than just popping in at the end.
    stackTl.fromTo(card,
      { opacity: 0 },
      { opacity: 1, duration: 0.065, ease: 'power2.out' },
      t + 0.015);

    // Settle bounce
    stackTl
      .to(card, { y: landingY + 4, duration: 0.012, ease: 'power1.out' }, t + 0.092)
      .to(card, { y: landingY,     duration: 0.02,  ease: 'power2.inOut' }, t + 0.105);

    // This card's eyebrow fades in
    stackTl.to(card.querySelector('.card-eyebrow'),
      { opacity: 1, duration: 0.025, ease: 'power2.out' },
      t + 0.06);

    // Previous card's eyebrow fades out (it's now covered)
    if (i > 0) {
      stackTl.to(cards[i - 1].querySelector('.card-eyebrow'),
        { opacity: 0, duration: 0.02, ease: 'power2.in' },
        t);
    }
  });

  // Short hold after all 4 have landed (0.42 to 0.50)

  // -------- Phase B: rearrange to grid + fade copy --------
  // Phase A's 4th card (Agents) finishes its fade-in around timeline 0.42,
  // but Lenis + scrub:0.6 adds smoothing so visually cards settle around
  // 0.55 of scroll progress. Starting Phase B at 0.58 ensures the user
  // sees the complete stack briefly before the grid morph begins.
  const PHASE_B_START = 0.58;

  // Transition each card to its grid cell. The .in-grid class
  // (applied via a separate ScrollTrigger at Phase B start) restructures
  // each card-box into a 30vw square centered within its full-width card.
  // GSAP only needs to translate — scale stays 1.
  //
  // The card's effective visual center in grid phase is the card-box's
  // center, which is the theatre horizontal center (margin:auto). So
  // dx = targetCellCenterX − theatreCenterX, dy = same for Y.
  function scheduleGridTransition() {
    const plan = computeGridPlan();

    // Target scales for the morph. Cards start as wide rectangles
    // (~1324×526 at 1440vw) and need to morph to squares (~288×288).
    // Using independent scaleX/scaleY lets the rectangle SHAPE-CHANGE
    // into a square as it shrinks — so at morph-end the pre-snap and
    // post-snap aspect ratios match and the .in-grid CSS handoff is
    // imperceptible. Without this, ending at uniform scale would leave
    // a flat 2.5:1 rectangle that pops to a 1:1 square on snap.
    const vw = window.innerWidth;
    const cardRect = cards[0].getBoundingClientRect();
    const cardW = cardRect.width || vw;
    const cardH = cardRect.height || 600;
    const targetW = vw * 0.17;      // matches .in-grid .card-box width (17vw)
    const targetH = targetW; // square
    const targetScaleX = targetW / cardW;
    const targetScaleY = targetH / cardH;

    cards.forEach((card, i) => {
      const target = plan.centers[i];
      const dx = target.cx - plan.theatreCx;
      const dy = target.cy - plan.theatreCy;
      const content = card.querySelector('.card-content');
      const gridLabel = card.querySelector('.card-grid-label');
      const brain = card.querySelector('.card-brain');

      // Translate card to grid-cell position AND morph its SHAPE from
      // wide rectangle to square via independent scaleX/scaleY. Ending
      // at the exact target aspect ratio means the CSS .in-grid snap
      // (where card-box becomes aspect-ratio 1:1) produces no visual
      // change — the user sees a continuous morph.
      stackTl.to(card,
        { x: dx, y: dy,
          scaleX: targetScaleX, scaleY: targetScaleY,
          rotation: 0,
          duration: 0.14, ease: 'power2.inOut',
          transformOrigin: 'center center' },
        PHASE_B_START);

      // COUNTER-SCALE the brain to prevent it being visually squeezed
      // by the card's non-uniform scale. Without this, the brain would
      // appear horizontally compressed (stretched tall/narrow) during
      // the morph because scaleX (0.22) is 2.5× more compressed than
      // scaleY (0.55).
      //
      // Applying additional scaleX = targetScaleY / targetScaleX (~2.5)
      // to the brain combines with the card's scale multiplicatively:
      //   brain.visual.scaleX = card.scaleX × brain.scaleX
      //                       = 0.22 × 2.5 = 0.55 = card.scaleY
      // giving the brain UNIFORM visual scaling (both axes reduced by
      // card.scaleY factor), preserving its natural aspect ratio.
      //
      // Using transformOrigin: 'right center' on the brain keeps its
      // right edge anchored and expands the scale LEFTWARD into the
      // card's interior — not rightward into blank space or adjacent
      // cards. The brain already sits on the right side of the card
      // (grid column), so this keeps it where the user expects it.
      //
      // Content (title+body) and grid-label are NOT counter-scaled —
      // content fades to 0 opacity early in the morph, masking any
      // distortion; grid-label is tiny text, distortion barely visible.
      const counterScaleX = targetScaleY / targetScaleX;
      stackTl.to(brain,
        { scaleX: counterScaleX,
          duration: 0.14, ease: 'power2.inOut',
          transformOrigin: 'right center',
          immediateRender: false },
        PHASE_B_START);

      // INSTANT scale reset at the end of the morph window. Using a
      // tiny duration (0.00001) with immediateRender:false means scale
      // jumps from targetScale to 1 essentially in a single scrub frame
      // — no visible ramp (0.00001 of a 1-second timeline is far below
      // one render frame). Piggy-back the .in-grid CSS class toggle on
      // the FIRST card's scale-reset tween via onStart (forward) and
      // onReverseComplete (backward), so the scale snap and the class
      // apply happen in the same GSAP render pass. Previously the class
      // toggle was a separate tween or a separate ScrollTrigger; either
      // way GSAP and ScrollTrigger didn't guarantee same-frame
      // execution, producing a visible moment where scale=1 but
      // box=1324 (the "becomes large briefly" glitch the user saw).
      const resetVars = {
        scaleX: 1, scaleY: 1,
        duration: 0.00001,
        immediateRender: false,
      };
      if (i === 0) {
        resetVars.onStart = function() {
          theatre.classList.add('in-grid');
        };
        resetVars.onReverseComplete = function() {
          theatre.classList.remove('in-grid');
        };
      }
      stackTl.to(card, resetVars, PHASE_B_START + 0.14);

      // Reset brain counter-scale atomically with the card's scale
      // snap. After this, CSS .in-grid takes over layout (brain fills
      // the square flex-column centered, with no inline scaleX).
      stackTl.to(brain,
        { scaleX: 1, duration: 0.00001, immediateRender: false },
        PHASE_B_START + 0.14);

      // Crossfade: the old text content fades out while the grid label
      // fades in. Both run alongside the scale/translate so all changes
      // happen simultaneously as a single coherent morph.
      stackTl.to(content,
        { opacity: 0, duration: 0.08, ease: 'power2.in' },
        PHASE_B_START);
      stackTl.to(gridLabel,
        { opacity: 0.88, duration: 0.08, ease: 'power2.out' },
        PHASE_B_START + 0.06);

      // Fade the outside-box eyebrow out as we transition to grid.
      stackTl.to(card.querySelector('.card-eyebrow'),
        { opacity: 0, duration: 0.06, ease: 'power2.in' },
        PHASE_B_START);
    });
  }
  scheduleGridTransition();

  // (Class-toggle is now piggy-backed on card[0]'s scale-reset tween
  // above — see the i === 0 branch. Keeping them on the same tween
  // guarantees they fire in the same GSAP render pass.)

  // On resize we need to recompute. ScrollTrigger.invalidateOnRefresh
  // only rebuilds positions if our tweens use function-based values or
  // we kill/rebuild. Simplest: rebuild timeline entirely on resize.
  let resizeTimer;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => ScrollTrigger.refresh(), 250);
  });

  // -------- Copy layer crossfade on the LEFT (during grid phase) --------
  const FADE = 0.025;
  const swap = (fromIdx, toIdx, pos) => {
    if (fromIdx !== null) {
      stackTl.to(copyLayers[fromIdx], { opacity: 0, y: -14, duration: FADE, ease: 'power2.in' }, pos);
    }
    stackTl.fromTo(copyLayers[toIdx],
      { opacity: 0, y: 16 },
      { opacity: 1, y: 0, duration: FADE, ease: 'power2.out' },
      pos + FADE + 0.002);
  };

  // 3 panels: capabilities → sovereignty → Denmark
  stackTl.fromTo(copyLayers[0],
    { opacity: 0, y: 16 },
    { opacity: 1, y: 0, duration: FADE * 1.5, ease: 'power2.out' },
    PHASE_B_START + 0.08);

  swap(0, 1, 0.77);  // sovereignty
  swap(1, 2, 0.90);  // Denmark

  // Clean exit: fade the whole stack-pin contents just before the pin
  // releases, so the scroll gap before #words-scene shows clean paper
  // rather than stack content receding away.
  stackTl.to('.layer-theatre', { opacity: 0, duration: 0.03, ease: 'power2.in' }, 0.97);

/* -------------------------------------------------------------
     SCENE 3 — WORDS fly in one at a time, driven by scroll
     ------------------------------------------------------------- */

  // Before capturing the .words spans, rebuild the sentence with the
  // user's first name if we have one. window.__fenjaFirstName is set
  // by timeline.js's /auth/me fetch. Falls back to the no-name variant
  // already in the DOM (see public/entrance.html's static fallback).
  //
  // Sentence shape:
  //   With name:  "This is why we've invited you, <hi>Erik.</hi>
  //                To ensure Fenja AI is not just built for you — but
  //                <hi>with</hi> <hi>you.</hi>"
  //   No name:    "This is why we've invited <hi>you.</hi> To ensure
  //                Fenja AI is not just built for you — but
  //                <hi>with</hi> <hi>you.</hi>"
  //
  // We rebuild the .words paragraph in place. The hi-classed spans are
  // the ones that fly in from center with extra weight (see below).
  (function rebuildWordsSentence() {
    const wordsP = document.getElementById('words-sentence');
    if (!wordsP) return;

    const firstName = (typeof window.__fenjaFirstName === 'string')
      ? window.__fenjaFirstName.trim()
      : null;

    // Build the token list. Each token is { text, hi }. Whitespace
    // between tokens is handled by natural text-wrap — each .w has
    // `display: inline-block` plus normal spacing between siblings.
    let tokens;
    if (firstName) {
      tokens = [
        { text: 'This' },           { text: 'is' },           { text: 'why' },
        { text: 'we\u2019ve' },     { text: 'invited' },      { text: 'you,' },
        { text: firstName + '.',   hi: true },
        { text: 'To' },             { text: 'ensure' },       { text: 'Fenja' },
        { text: 'AI' },             { text: 'is' },           { text: 'not' },
        { text: 'just' },           { text: 'built' },        { text: 'for' },
        { text: 'you' },            { text: '\u2014' },       { text: 'but' },
        { text: 'with', hi: true }, { text: 'you.', hi: true },
      ];
    } else {
      // No name — structurally identical layout so the same fly-in
      // curves work without retuning. "you." after "invited" gets .hi
      // to carry the weight the name would've carried.
      tokens = [
        { text: 'This' },           { text: 'is' },           { text: 'why' },
        { text: 'we\u2019ve' },     { text: 'invited' },
        { text: 'you.', hi: true },
        { text: 'To' },             { text: 'ensure' },       { text: 'Fenja' },
        { text: 'AI' },             { text: 'is' },           { text: 'not' },
        { text: 'just' },           { text: 'built' },        { text: 'for' },
        { text: 'you' },            { text: '\u2014' },       { text: 'but' },
        { text: 'with', hi: true }, { text: 'you.', hi: true },
      ];
    }

    // Flush the fallback content, rebuild. Using explicit createElement
    // rather than innerHTML so firstName is never HTML-interpolated.
    wordsP.textContent = '';
    tokens.forEach((t, i) => {
      const span = document.createElement('span');
      span.className = t.hi ? 'w hi' : 'w';
      span.textContent = t.text;
      wordsP.appendChild(span);
      // Preserve natural whitespace between tokens (critical for text-wrap).
      if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' '));
    });
  })();

  const wordEls = gsap.utils.toArray('.words .w');

  // Give each word a random fly-in vector (stable per word), and a scale pop.
  // The "with them" words (marked .hi) come in from center with more weight.
  const rnd = (i, seed) => {
    // simple deterministic pseudo-random so layout is stable per word
    const s = Math.sin((i + 1) * seed) * 10000;
    return s - Math.floor(s);
  };

  wordEls.forEach((w, i) => {
    const hi = w.classList.contains('hi');
    const fromX = hi ? 0 : (rnd(i, 12.9898) - 0.5) * 220;
    const fromY = hi ? 80 : (rnd(i, 78.233) - 0.5) * 160;
    const rot  = hi ? 0 : (rnd(i, 37.719) - 0.5) * 16;
    gsap.set(w, {
      opacity: 0,
      x: fromX,
      y: fromY,
      rotate: rot,
      scale: hi ? 1.05 : 0.9,
      filter: 'blur(6px)',
    });
  });

  const wordsTl = gsap.timeline({
    scrollTrigger: {
      trigger: '#words-scene',
      start: 'top top',
      end: 'bottom bottom',
      scrub: 0.4,
    }
  });

  wordEls.forEach((w, i) => {
    const hi = w.classList.contains('hi');
    const dur = hi ? 0.14 : 0.1;
    wordsTl.to(w, {
      opacity: 1,
      x: 0, y: 0, rotate: 0,
      scale: 1,
      filter: 'blur(0px)',
      duration: dur,
      ease: 'power3.out',
    }, i * 0.055);
    if (hi) {
      wordsTl.to(w, { scale: 1.0, duration: 0.05 }, '>-0.02');
    }
  });

  /* -------------------------------------------------------------
     SCENE 4 — PROJECT BIFROST REVEAL
     Arc draws in, then the words settle.
     ------------------------------------------------------------- */
  const arcMain = document.getElementById('arcMain');
  const arcThin = document.getElementById('arcThin');
  const arcHalo = document.getElementById('arcHalo');
  [arcMain, arcThin, arcHalo].forEach(el => {
    if (!el) return;
    const len = el.getTotalLength();
    el.style.strokeDasharray = len;
    el.style.strokeDashoffset = len;
  });

  const tokens = gsap.utils.toArray('.bifrost-name .token');
  tokens.forEach((t, i) => {
    gsap.set(t, { opacity: 0, y: 40, filter: 'blur(10px)' });
  });

  const bifrostTl = gsap.timeline({
    scrollTrigger: {
      trigger: '#bifrost',
      start: 'top top',
      end: 'bottom bottom',
      scrub: 0.6,
    }
  });

  bifrostTl
    .to('.arc-wrap', { opacity: 1, duration: 0.1, ease: 'power2.out' }, 0.02)
    .to(arcHalo, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.02)
    .to(arcMain, { strokeDashoffset: 0, duration: 0.5, ease: 'power2.inOut' }, 0.05)
    .to(arcThin, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.12)
    .to('.bifrost-eyebrow', { opacity: 1, y: 0, duration: 0.1, ease: 'power2.out' }, 0.30)
    .fromTo('.bifrost-eyebrow', { y: 20 }, { y: 0, duration: 0.1 }, 0.30)
    .to(tokens[0], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.15, ease: 'power3.out' }, 0.40)
    .to(tokens[1], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.2, ease: 'power3.out' }, 0.50)
    .to('.bifrost-sub', { opacity: 1, y: 0, duration: 0.15, ease: 'power2.out' }, 0.68)
    .fromTo('.bifrost-sub', { y: 20 }, { y: 0, duration: 0.15 }, 0.68);

  // Slight parallax on the arc while the user continues to scroll
  bifrostTl.to('.arc-wrap', { y: -40, duration: 0.3, ease: 'none' }, 0.5);

  /* -------------------------------------------------------------
     On load — kick hero animation
     ------------------------------------------------------------- */
  // Ensure ScrollTrigger measures correctly after fonts load.
  if (document.fonts && document.fonts.ready) {
    document.fonts.ready.then(() => ScrollTrigger.refresh());
  }

    // Script 2 body — SCENE 5 (treasure-map) + SCENE 6 (join CTA + footer)
/* =================================================================
   PROJECT BIFROST — "What it means" treasure-map + summary cards.

   Self-contained additive IIFE. Reads the global `gsap` and
   `ScrollTrigger` registered by the existing scripts above; does
   not touch any pre-existing timelines or DOM nodes.
   ================================================================= */
/* -------------------------------------------------------------
     SCENE 5 — Treasure-map path draw + per-stop reveals
     ------------------------------------------------------------- */

  // -------- Build the path geometry from actual dot positions --------
  //
  // The treasure-map path needs to thread cleanly through each dot.
  // The dots are positioned by CSS grid/flex, so their Y positions in
  // the canvas depend on the rendered height of each stop's content
  // (which varies with viewport width and font metrics). We compute
  // the path here AFTER layout, so it always passes through the dots.
  //
  // Coordinate system: the SVG viewBox is "0 0 100 200" with
  // preserveAspectRatio="none", so X is normalised to canvas width
  // and Y to canvas height. We measure each dot's centre in canvas
  // coordinates, normalise, and emit a chain of cubic bezier segments
  // — each one bowing out to alternating sides for a meandering feel.
  const mapCanvasEl = document.querySelector('.map-canvas');
  const pathBg   = document.getElementById('mapPathBg');
  const pathDraw = document.getElementById('mapPathDraw');
  let drawScrollTrigger = null;

  function buildMapPath() {
    if (!mapCanvasEl || !pathDraw || !pathBg) return;
    const dots = mapCanvasEl.querySelectorAll('.dot-anchor');
    if (dots.length < 2) return;
    const cb = mapCanvasEl.getBoundingClientRect();
    if (cb.width === 0 || cb.height === 0) return;

    // Normalise dot centres to viewBox units (0-100 X, 0-200 Y)
    const pts = Array.from(dots).map((d) => {
      const r = d.getBoundingClientRect();
      return {
        x: ((r.left + r.width / 2 - cb.left) / cb.width) * 100,
        y: ((r.top  + r.height / 2 - cb.top)  / cb.height) * 200,
      };
    });

    // Build path: M to first, then cubic beziers to each subsequent
    // dot. Control points sit at the same X as the dots (so the path
    // exits/enters each dot along the vertical axis) but bow out to
    // alternating sides between them — gives a Nordic-river feel.
    const segs = [`M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`];
    for (let i = 1; i < pts.length; i++) {
      const a = pts[i - 1];
      const b = pts[i];
      // Bow direction alternates by segment index. Amplitude is in
      // viewBox X units (0-100) — clamped down on tall segments to
      // avoid the path drifting outside the canvas.
      const bowDir = (i % 2 === 1) ? 1 : -1;
      const bowAmount = Math.min(20, (b.y - a.y) * 0.18);
      const cx1 = 50 + bowAmount * bowDir;
      const cy1 = a.y + (b.y - a.y) * 0.35;
      const cx2 = 50 + bowAmount * bowDir;
      const cy2 = a.y + (b.y - a.y) * 0.65;
      segs.push(`C ${cx1.toFixed(2)} ${cy1.toFixed(2)}, ${cx2.toFixed(2)} ${cy2.toFixed(2)}, ${b.x.toFixed(2)} ${b.y.toFixed(2)}`);
    }
    const d = segs.join(' ');
    pathBg.setAttribute('d', d);
    pathDraw.setAttribute('d', d);

    // Re-measure the drawn path's length and reset the dash offset
    // so the draw-in animation covers the full new geometry.
    const len = pathDraw.getTotalLength();
    pathDraw.style.strokeDasharray = len;
    // If the scroll-trigger animation already played to completion,
    // keep the path drawn; otherwise hide it pending scroll.
    const trig = drawScrollTrigger;
    if (trig && trig.progress >= 1) {
      pathDraw.style.strokeDashoffset = 0;
    } else {
      pathDraw.style.strokeDashoffset = len;
    }
  }

  // Build initially after a paint
  requestAnimationFrame(buildMapPath);

  // Animate the accent path drawing in as the user scrolls down
  // through the meaning section
  if (pathDraw) {
    const tween = gsap.to(pathDraw, {
      strokeDashoffset: 0,
      ease: 'none',
      scrollTrigger: {
        trigger: '#bifrost-meaning',
        start: 'top 65%',
        end: 'bottom 75%',
        scrub: 0.6,
        invalidateOnRefresh: true,
        onRefresh: () => {
          // Re-measure dasharray on every ScrollTrigger refresh so
          // the animation stays in sync with any path changes
          const len = pathDraw.getTotalLength();
          pathDraw.style.strokeDasharray = len;
        },
      }
    });
    drawScrollTrigger = tween.scrollTrigger;
  }

  // Rebuild the path on resize (debounced) since dot positions move
  let mapPathResizeTimer;
  window.addEventListener('resize', () => {
    clearTimeout(mapPathResizeTimer);
    mapPathResizeTimer = setTimeout(() => {
      buildMapPath();
      ScrollTrigger.refresh();
    }, 220);
  });

  // Mobile fallback — the SVG path is hidden and replaced by a CSS
  // pseudo-element rail. Drive its progress with a CSS custom prop
  // so the same scroll range animates a vertical fill.
  const mapCanvas = document.querySelector('.map-canvas');
  if (mapCanvas) {
    const railObj = { p: 0 };
    gsap.to(railObj, {
      p: 100,
      ease: 'none',
      scrollTrigger: {
        trigger: '#bifrost-meaning',
        start: 'top 65%',
        end: 'bottom 75%',
        scrub: 0.6,
        onUpdate: () => {
          mapCanvas.style.setProperty('--rail-progress', railObj.p + '%');
        }
      }
    });
  }

  // Per-stop reveal — dot pops in first, then content + image rise
  // and fade in alongside each other. ToggleActions: play forward
  // when entering, reverse when scrolling back up past the trigger.
  gsap.utils.toArray('.map-stop').forEach((stop) => {
    const dot = stop.querySelector('.dot');
    const contentBits = stop.querySelectorAll('.stop-content > *');
    const image = stop.querySelector('.stop-image');

    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: stop,
        start: 'top 78%',
        toggleActions: 'play none none reverse',
      }
    });

    // Dot pops in with a subtle back ease — feels like a pin
    // dropping into the map
    if (dot) {
      tl.to(dot, {
        scale: 1, opacity: 1,
        duration: 0.55, ease: 'back.out(2)',
      });
    }

    // Text bits stagger in
    if (contentBits.length) {
      tl.to(contentBits, {
        opacity: 1, y: 0,
        duration: 0.7, stagger: 0.08,
        ease: 'power3.out',
      }, '-=0.35');
    }

    // Image animates in alongside the text (overlapping for unity)
    if (image) {
      tl.to(image, {
        opacity: 1, y: 0,
        duration: 0.9,
        ease: 'power3.out',
      }, '-=0.6');
    }
  });

  /* -------------------------------------------------------------
     SCENE 6 — Join section: scroll-triggered reveals + CTA click
     ------------------------------------------------------------- */

  // Reveal the CTA panel when the section scrolls into view.
  // Captured to a variable so the click handler can kill this
  // ScrollTrigger once the user has joined — otherwise scrolling up
  // and back down would re-play the reveal and the CTA would fade
  // back in over the confirmation.
  const ctaRevealTween = gsap.to('.join-cta', {
    opacity: 1, y: 0,
    duration: 0.9, ease: 'power3.out',
    scrollTrigger: {
      trigger: '#bifrost-join',
      start: 'top 70%',
      toggleActions: 'play none none reverse',
    }
  });

  // Reveal the three footer marks in sequence
  gsap.to('.join-footer > *', {
    opacity: 1, y: 0,
    duration: 0.8, stagger: 0.14,
    ease: 'power3.out',
    scrollTrigger: {
      trigger: '.join-footer',
      start: 'top 88%',
      toggleActions: 'play none none reverse',
    }
  });

  // CTA click handler — crossfade CTA out, confirmation in, then stagger
  // the checkmarks on each list item so the list feels like it's
  // filling in as the user reads it.
  const joinBtn     = document.getElementById('joinBtn');
  const joinCTA     = document.getElementById('joinCTA');
  const joinConfirm = document.getElementById('joinConfirm');

  if (joinBtn && joinCTA && joinConfirm) {
    joinBtn.addEventListener('click', () => {
      if (joinBtn.disabled) return;
      joinBtn.disabled = true;

      // Record the click on the server. Fire-and-forget — the UI
      // transitions below run regardless of network outcome so a
      // temporary failure doesn't trap the user in a broken state.
      // The server uses INSERT OR IGNORE keyed on email, so repeat
      // clicks from the same user are safely deduplicated.
      fetch('/api/bifrost-join', {
        method: 'POST',
        credentials: 'same-origin',
      }).catch(() => {
        // Network/server error — intentionally swallowed. An admin
        // listing missing entries can follow up out-of-band.
      });

      // Kill the CTA's scroll-reveal trigger so scrolling up + back
      // down can't replay the reveal and bring the CTA back over the
      // confirmation. After click, the CTA stays in whatever state
      // the click-timeline puts it in (fading out, then hidden).
      if (ctaRevealTween && ctaRevealTween.scrollTrigger) {
        ctaRevealTween.scrollTrigger.kill();
      }

      const items = joinConfirm.querySelectorAll('.confirm-list li');

      const tl = gsap.timeline();
      // Fade the CTA out
      tl.to(joinCTA, {
        opacity: 0, y: -16,
        duration: 0.5, ease: 'power2.in',
        onComplete: () => {
          joinCTA.setAttribute('aria-hidden', 'true');
          joinCTA.style.pointerEvents = 'none';
        }
      });
      // Fade the confirmation in
      tl.fromTo(joinConfirm,
        { opacity: 0, y: 16 },
        {
          opacity: 1, y: 0,
          duration: 0.7, ease: 'power3.out',
          onStart: () => {
            joinConfirm.setAttribute('aria-hidden', 'false');
            joinConfirm.style.pointerEvents = 'auto';
          },
        }, '-=0.1');

      // Stagger the circle+check markers by toggling `.is-checked`
      // on each list item — CSS handles the pop-in transition.
      items.forEach((li, i) => {
        gsap.delayedCall(0.45 + i * 0.16, () => {
          li.classList.add('is-checked');
        });
      });
    });
  }

  /* -------------------------------------------------------------
     Refresh ScrollTrigger after fonts and images load so positions
     are accurate — large embedded illustrations affect layout.
     ------------------------------------------------------------- */
  if (document.fonts && document.fonts.ready) {
    document.fonts.ready.then(() => ScrollTrigger.refresh());
  }
  // Refresh once illustrations have laid out
  const illustrations = document.querySelectorAll('#bifrost-meaning img');
  let pending = illustrations.length;
  if (pending === 0) ScrollTrigger.refresh();
  illustrations.forEach((img) => {
    if (img.complete) {
      if (--pending === 0) ScrollTrigger.refresh();
    } else {
      img.addEventListener('load',  () => { if (--pending === 0) ScrollTrigger.refresh(); });
      img.addEventListener('error', () => { if (--pending === 0) ScrollTrigger.refresh(); });
    }
  });

    // ─── One consolidated resize handler ─────────────────────────
    // Site 2 had two separate resize listeners; we defer to ScrollTrigger's
    // own handling + our de-duped refresh. Scroller-relative measurements
    // get recalculated whenever ScrollTrigger.refresh fires.
    let resizeT = null;
    window.addEventListener('resize', () => {
      clearTimeout(resizeT);
      resizeT = setTimeout(scheduleRefresh, 220);
    });

    // After fonts load, re-measure (headline wrap can shift positions).
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(scheduleRefresh);
    }

    // After all illustrations load, re-measure (treasure-map stops change height).
    const bgIllustrations = document.querySelectorAll('#page-overview .stop-illust');
    let pendingBg = bgIllustrations.length;
    if (pendingBg === 0) scheduleRefresh();
    bgIllustrations.forEach((el) => {
      // Illustrations are CSS-background images; use an Image() to listen for load
      const bg = getComputedStyle(el).backgroundImage;
      const url = bg && bg.match(/url\("?([^")]+)"?\)/);
      if (!url) { if (--pendingBg === 0) scheduleRefresh(); return; }
      const img = new Image();
      img.onload = img.onerror = () => { if (--pendingBg === 0) scheduleRefresh(); };
      img.src = url[1];
    });

    // Final refresh after everything wires up
    scheduleRefresh();
  }

  /**
   * Smooth-scroll the Overview's internal scroller to a scene.
   * Called by the dot-nav click handler in timeline.js.
   *
   * @param {string} sceneId  id of the scene section (e.g. "stack-scene")
   *                          — see sceneOrder[] inside init().
   *                          Special value "hero" scrolls to top (0).
   */
  function scrollTo(sceneId) {
    if (!scrollerEl) return; // init() hasn't run yet — ignore
    const target = document.getElementById(sceneId);
    if (!target) return;

    // "hero" is the first scene and sits at scrollTop 0. Scrolling to
    // the scene element directly works in most cases but produces a tiny
    // non-zero offset (padding / border) — hard-code 0 for hero.
    const scrollY = sceneId === 'hero' ? 0 : target.offsetTop;

    if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
      // Lenis does the smooth animation. `immediate: false` uses the
      // same easing as wheel input — feels consistent.
      lenisInstance.scrollTo(scrollY, { immediate: false });
    } else {
      // Fallback for pre-init / reduced-motion: hard-jump.
      scrollerEl.scrollTo({ top: scrollY, behavior: 'smooth' });
    }
  }

  // Public surface — timeline.js calls these when the Overview tab
  // activates (init) or when a dot-nav button targeting a scene is
  // clicked (scrollTo).
  window.__bifrost = { init, scrollTo };
})();
