/* ---------------------- FALLING FIGURES (scroll-scrubbed) ---------------------- */
// Two characters fall from the FOLLY portal: Chris (right) and Andrew (left).
// Both are 100% scroll-driven — no autoplay. Pose AND vertical position are
// derived from window.scrollY each frame. They emerge from the same liquid-glass
// slit at the bottom of the FOLLY wordmark; a CSS mask clips their bodies
// above the slit so they look like they're coming OUT of the page surface.
//
// Layering (z-indices set on hero children):
//   z:0  hero video + cream wash + bottom fade
//   z:1  ← FALLING FIGURES live here (behind all content)
//   z:2  FOLLY wordmark, definition line, hero meta rows, portal slit
//   z:20 navbar
//
// Composition: their midpoint is centered on the page. Chris is offset
// to the right, Andrew the same distance to the left. Andrew sits slightly
// LOWER so they don't crash into each other and so it reads as a small
// height stagger rather than a perfect mirror.

const CHRIS_FRAME_COUNT  = 41;
const ANDREW_FRAME_COUNT = 41;


// `-blend` frame variants have the page-bg colour baked into their
// semi-transparent edge pixels (alpha unchanged, only edge RGB tinted
// toward bg by 1-alpha). The figure body is byte-identical to the
// originals; only the anti-aliased silhouette band now composites flush
// into the cream page bg instead of leaving a visible cream halo around
// the silhouette. Originals are preserved at media/{chris,andrew}-frames/.
const chrisFramePath  = (i) => `media/chris-frames-blend/f${String(i).padStart(3,'0')}.webp`;
const andrewFramePath = (i) => `media/andrew-frames-blend/f${String(i).padStart(3,'0')}.webp?v=overshirt-fix-20260608`;

// ---- Theme awareness for the canvas-painted figures + hand catcher ----
// The falling figures and the hand are dark silhouettes drawn into <canvas>
// with mix-blend-mode:multiply so they darken onto the cream page. On the
// dark theme that blend makes them vanish, so we flip to screen and prepend
// invert(1) to the per-tick filter string (dark figure -> light figure ->
// shows on the dark page). tick() reads follyIsDark() every frame and also
// keeps canvas.style.mixBlendMode in sync, so a theme toggle updates the
// live canvases without a reload.
let __follyDark = (typeof document !== 'undefined') && document.documentElement.classList.contains('dark');
if (typeof window !== 'undefined') {
  window.addEventListener('folly-theme-change', () => {
    __follyDark = document.documentElement.classList.contains('dark');
  });
}
const follyIsDark = () => __follyDark;

// Horizontal offsets (in viewport widths). Positive = right of center.
// Canvas frames are 32vw wide max but the figure inside each only fills
// the middle ~70%, so ±15 puts ~30vw between centers and still leaves a
// small visible gap between bodies. Centered midpoint stays on the page
// midline, both fall under the width of the FOLLY wordmark.
const CHRIS_X_OFFSET_VW  =  15;  // Chris drifts right
const ANDREW_X_OFFSET_VW = -15;  // Andrew drifts left, mirror distance

// Vertical stagger — controls how each figure's fall TIMING differs.
// Both characters emerge from the SAME horizontal seam (same start Y),
// so scrolling back to the top fully retracts both. The "stagger" only
// affects how far/fast they fall once moving — Andrew lags slightly
// behind Chris on the descent for visual variety.
const CHRIS_FALL_SCALE  = 1.00;
const ANDREW_FALL_SCALE = 0.88;

// ---- Shared scroll listener so both figures + the portal stay in sync ----
function useScrollY() {
  const [sy, setSy] = React.useState(() =>
    typeof window !== 'undefined' ? window.scrollY || 0 : 0
  );
  React.useEffect(() => {
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => { raf = 0; setSy(window.scrollY || 0); });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);
  return sy;
}

// ---- A single falling figure (canvas with frame-by-frame painting) ----
function FallingFigure({ framePath, frameCount, className, xOffsetVw, fallScale, poseMaxIdx }) {
  const canvasRef = React.useRef(null);
  const framesRef = React.useRef([]);
  const [ready, setReady] = React.useState(false);

  // Preload all frames once. We require BOTH `onload` (network) AND a
  // successful `decode()` (GPU-ready) before flipping ready, so the very
  // first paint draws a fully-decoded frame instead of a partial one.
  React.useEffect(() => {
    let cancelled = false;
    let loaded = 0;
    const imgs = [];
    for (let i = 0; i < frameCount; i++) {
      const img = new Image();
      img.decoding = 'async';
      img.src = framePath(i);
      img.decode().then(() => {
        if (cancelled) return;
        loaded++;
        if (loaded === frameCount) setReady(true);
      }).catch(() => {
        // If decode rejects (e.g. cancelled), still count it so we don't deadlock
        if (cancelled) return;
        loaded++;
        if (loaded === frameCount) setReady(true);
      });
      imgs.push(img);
    }
    framesRef.current = imgs;
    return () => { cancelled = true; };
  }, [framePath, frameCount]);

  React.useEffect(() => {
    if (!ready) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    // High-quality downscaling when the source frame (720px) gets drawn
    // into the smaller display canvas — keeps edges crisp instead of
    // bilinearly fuzzy.
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';

    // Reduced-motion: this falling-figure canvas is aria-hidden decoration.
    // Hide it and bail before the rAF loop starts so no per-frame work runs.
    // Refs are already resolved above, so no hook rules are violated.
    if (window.PREFERS_REDUCED) {
      canvas.style.opacity = 0;
      return;
    }

    let raf = 0;

    // Smoothed scroll input — `displayedSy` chases the real `scrollY`
    // each frame via an adaptive lerp.
    let displayedSy = window.scrollY || 0;
    const lerpFactor = (delta) => {
      const d = Math.abs(delta);
      return Math.min(0.35, 0.08 + d / 1800);
    };

    // Smoothed sway/rotation gain — lerps toward whatever the current
    // phase wants (full during emerge, 0 during the About lock, ramp
    // back up during tail). Lerping prevents the visible "lurch" the
    // figures used to do when the gain hard-cut from tEmerge to 0
    // exactly when About reached the top of the viewport.
    let motionGainSmoothed = 0;

    // Pose driven by SCROLL DELTA + a short post-scroll carry. The pose
    // accumulates a velocity each tick equal to (smoothed-scroll delta ×
    // POSE_SCROLL_RATE), capped at POSE_MAX_DELTA so a violent flick
    // doesn't make the pose teleport. When scroll input goes still, the
    // pose CONTINUES at the last velocity for POSE_CARRY_FRAMES more
    // ticks — that's the "I scrolled, then it kept moving for a beat,
    // then stopped" behaviour. Once the carry runs out, posInput snaps
    // to the nearest integer (a single source frame) and freezes hard.
    //
    // `fallScale` (1.00 / 0.88) multiplies the rate per figure so Andrew
    // and Chris drift slightly out of sync. POSE_OFFSET adds a random
    // initial pose so they don't strike the same shape on first paint.
    const POSE_SCROLL_RATE   = 0.055 * fallScale; // pose-units per scroll-px (was 0.030)
    const POSE_MAX_DELTA     = 2.0;               // cap pose change per render tick
    const POSE_CARRY_FRAMES  = 24;                // ~400ms at 60fps before hard stop
    const POSE_OFFSET        = Math.random() * (frameCount - 1);

    // Pose accumulator state. Lives across ticks via closure.
    let posInput          = POSE_OFFSET;
    let lastDisplayedSy   = window.scrollY || 0;
    let carryVelocity     = 0;
    let carryFramesLeft   = 0;
    let lockedToInteger   = false;
    let lastDrawnFrameIdx = -1;
    let cssW = 0;
    let cssH = 0;
    let viewportW = window.innerWidth || 0;
    let viewportH = window.innerHeight || 800;
    let follyEl = document.querySelector('[data-folly-wordmark]');
    let aboutEl = document.querySelector('[data-about-lock]');

    // Style write-coalescing cache. The continuous rAF loop calls
    // tick() 60×/s, but `canvas.style.filter` is a multi-function
    // chain (grayscale + sepia + brightness + drop-shadow + blur)
    // whose drop-shadow term forces a full-canvas Gaussian
    // convolution every time it is *assigned* — even if the string
    // is byte-identical to last frame. Through the entire emerge /
    // hold phase that string never changes (blur term is 0 until
    // recede), yet it was being re-assigned every frame on BOTH
    // figure canvases — the dominant cause of the choppy/laggy
    // mobile drop. Same story for the mask gradient and opacity.
    // Caching the last written value and skipping the assignment
    // when unchanged turns hundreds of per-frame rasterizations
    // into one, with zero visual difference (the value written is
    // identical — we just stop writing it redundantly).
    let lastFilter  = '';
    let lastMask    = '';
    let lastOpacity = '';
    let lastTransform = '';

    const sizeCanvas = () => {
      // DPR capped at 1.5 (was 2) to reduce per-frame compositor cost
      // on the falling-figure canvases. At dpr=2 on a retina screen
      // the canvas internal buffer was 760×760 (for css 380×380 max);
      // now 570×570 — ~44% less area for the per-tick drop-shadow
      // Gaussian convolution, drawImage destination, and mask gradient
      // rasterisation. Combined with the tightened shadow blur radius
      // (was 30px, now 8px) this is the main lever for scroll
      // smoothness through the hero / About region where both figures
      // are on screen and the drop-shadow filter runs every tick.
      // Visual: very slightly softer on retina, masked by the
      // drop-shadow's own blur + the recede-phase blur.
      const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
      cssW = canvas.clientWidth;
      cssH = canvas.clientHeight;
      viewportW = window.innerWidth || 0;
      viewportH = window.innerHeight || 800;
      canvas.width  = Math.round(cssW * dpr);
      canvas.height = Math.round(cssH * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      // Re-apply smoothing — setting canvas.width/height resets context state.
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = 'high';
      lastDrawnFrameIdx = -1;
    };

    const tick = () => {
      const lenis = window.__lenis;
      const sy = lenis && typeof lenis.scroll === 'number'
        ? lenis.scroll
        : (window.scrollY || window.pageYOffset || 0);
      const vh = viewportH;

      // Lerp the displayed scroll value toward the real one so frame
      // selection rides on a continuous, low-pass-filtered input. Snaps
      // exactly at convergence so the rAF loop can stop cleanly.
      const delta = sy - displayedSy;
      displayedSy += delta * lerpFactor(delta);
      if (Math.abs(sy - displayedSy) < 0.5) displayedSy = sy;

      // Locate FOLLY for the portal Y reference. Round to whole pixels so
      // sub-pixel jitter from getBoundingClientRect() never causes the
      // emerging body to shimmer at the slit edge.
      if (!follyEl || !follyEl.isConnected) {
        follyEl = document.querySelector('[data-folly-wordmark]');
      }
      let portalY = null;
      if (follyEl) {
        portalY = Math.round(follyEl.getBoundingClientRect().bottom + 2);
      }

      // Locate About so we can hold the figures at viewport centre while
      // the box is on screen, and key the catch off the box's bottom edge.
      // The attribute name `data-about-lock` is historical; there is no
      // scroll-lock anymore, just a tall natural-height box.
      if (!aboutEl || !aboutEl.isConnected) {
        aboutEl = document.querySelector('[data-about-lock]');
      }
      const aboutRectEarly = aboutEl ? aboutEl.getBoundingClientRect() : null;

      // Pose advance — scroll-driven with carry. The previous build froze
      // the pose during the About scroll-lock so that snap-induced scroll
      // didn't cycle figures behind the panel. Without that lock, the
      // pose advances continuously the whole way down the box — the user
      // wanted the figures to "fall for longer all the way to the bottom
      // of the liquid box," and continuous pose advance is what makes that
      // visible.
      const dy = displayedSy - lastDisplayedSy;
      lastDisplayedSy = displayedSy;

      if (Math.abs(dy) > 0.4) {
        const dPos = Math.max(
          -POSE_MAX_DELTA,
          Math.min(POSE_MAX_DELTA, dy * POSE_SCROLL_RATE),
        );
        posInput += dPos;
        carryVelocity   = dPos;
        carryFramesLeft = POSE_CARRY_FRAMES;
        lockedToInteger = false;
      } else if (!lockedToInteger) {
        if (carryFramesLeft > 0 && Math.abs(carryVelocity) > 0.01) {
          posInput += carryVelocity;
          carryFramesLeft -= 1;
        } else {
          // Hard cut — snap to nearest integer pose so painter shows a
          // single, crisp source frame instead of a fractional blend.
          posInput        = Math.round(posInput);
          carryVelocity   = 0;
          lockedToInteger = true;
        }
      }

      // MONOTONIC pose index — drive the frame from ABSOLUTE scroll
      // progress (page top → About box bottom), so scrolling DOWN plays
      // the tumble exactly once (frame 0 → last) and holds; scrolling UP
      // reverses it. The previous build accumulated `posInput` from scroll
      // DELTAS and ran it through a triangle wave
      //   period = (frameCount-1)*2;  posFloat bounced 0→(n-1)→0→…
      // so a continuous one-way scroll made the figure ping-pong: it
      // played the fall forward, then REWOUND it, then forward again,
      // replaying poses it had already shown — which read as the figure
      // "jumping between versions over and over." Absolute mapping removes
      // the bounce entirely: down = forward, up = back, never a rewind on
      // a single-direction scroll. (`posInput` accumulation above is now
      // unused; left in place to avoid disturbing the carry/lerp state.)
      let poseProg = 0;
      if (aboutRectEarly) {
        // Absolute document Y of the box bottom = stable constant
        // (offsetTop + height), independent of current scroll.
        const boxBottomAbs = aboutRectEarly.bottom + window.scrollY;
        poseProg = Math.max(0, Math.min(1, displayedSy / Math.max(boxBottomAbs, 1)));
      }
      // Cap the top of the pose range when the outfit only reads correctly
      // over part of the rotation (Andrew's open overshirt) — see
      // ANDREW_POSE_MAX_IDX. Chris (plain tee) passes no cap and uses the
      // full sequence.
      const poseTop = (poseMaxIdx != null ? poseMaxIdx : frameCount - 1);
      const posFloat = poseProg * poseTop;
      // Single-frame paint — pick the nearest source frame and draw it
      // at full opacity. No cross-fade, no double exposure. Smoothness
      // comes from the displayedSy lerp above (low-pass filter on raw
      // scrollY), so the pose advances continuously with the user's
      // scroll instead of in jagged wheel-event steps. With a
      // smooth scroll input, single-frame paint reads as a high-fps
      // animation rather than discrete frame steps.
      const idx = Math.max(0, Math.min(frameCount - 1, Math.round(posFloat)));
      const img = framesRef.current[idx];

      // POSITIONING — three phases:
      //   A: emerge from the FOLLY seam → ease to viewport CENTER
      //      over the first ~0.6vh of scroll.
      //   B: stay LOCKED at viewport center while the About section
      //      occupies the screen. Pose still cycles with scroll, but
      //      they don't move. They sit BEHIND the liquid-glass panel
      //      (which has higher z-index) so the glass refracts them.
      //   C: as About scrolls out (its bottom approaching viewport
      //      top), fall down to a resting pose just above the People
      //      section's top edge.
      // Anchor falling start at the FOLLY portal seam.
      const seamY = (portalY ?? vh * 0.5) - cssH * 0.92;

      // Centered position: dead-center in the viewport. Kept fractional —
      // GPU translate3d handles sub-pixel smoothly and rounding here
      // contributed visible 1px steps during lerp settle.
      const centeredY = vh * 0.5 - cssH * 0.45;

      // About already located up top for the centred-hold logic; reuse
      // it here under the original name so the rest of the phase logic
      // doesn't need to change. The catch/recede choreography keys off
      // `aboutRect.bottom` (the bottom edge of the box), so the catch
      // fires exactly when the figures reach / cross the box's bottom
      // edge as it scrolls up out of viewport. peopleRect is no longer
      // needed by FallingFigure (HandCatcher used to share the same
      // trigger, but it's been switched to aboutRect.bottom too).
      const aboutRect = aboutRectEarly;

      // ---- Phase A: emerge → center (over first ~0.18vh of scroll) ----
      // Uses `displayedSy` (smoothed) so the body's emerge curve stays in
      // lockstep with the pose cross-fade — otherwise the body snaps on
      // raw scroll while the pose tweens, which reads as "low fps."
      // tEmerge: 0..1 across the first 18% of one viewport of scroll.
      // fallScale is reapplied (Andrew 0.88, Chris 1.0) so the two
      // figures emerge at slightly different rates — Andrew lags
      // behind for visual variety. The earlier removal of fallScale
      // was meant to fix a 4% lock-in jump, but a smaller side-effect
      // left the figures emerging higher than the original feel; this
      // restores the previous trajectory the user remembers.
      // HERO_LOCK_END used to delay figure emergence by 600px so the
      // figures stayed hidden inside the FOLLY portal through the hero
      // scroll-lock zoom-through-window phase. That whole phase has
      // moved into the IntroLoader (time-driven, runs once at page load
      // before any scroll), so the figures should now begin emerging at
      // scroll=0 — the moment the user starts scrolling, post-loader.
      // Must stay in sync with the same-named constant in sections.jsx.
      const HERO_LOCK_END = 0;
      const lockedSy = Math.max(0, displayedSy - HERO_LOCK_END);
      // fallScale belongs INSIDE the clamp, not multiplied onto a
      // clamped [0,1]. Outside, Andrew (fallScale=0.88) topped out at
      // tEmergeRaw=0.88 — he never reached centeredY during the emerge,
      // so the moment Phase B kicked in (aboutRect.top crossed 0) the
      // code snap-cut his canvas from `0.12*seamY + 0.88*centeredY`
      // straight to centeredY in one frame: a ~30px drop right as the
      // About text became readable. Chris (fallScale=1.00) reached 1
      // either way so he was glitch-free. Moving fallScale inside the
      // min makes Andrew progress slower (he takes scroll = vh*0.18/0.88
      // ≈ 0.20vh to finish emerging, vs Chris's 0.18vh) but he DOES
      // reach full center before Phase B begins — same lagging-emerge
      // feel, no discontinuity.
      const tEmergeRaw = Math.max(0, Math.min(lockedSy * fallScale / (vh * 0.18), 1));
      const tE = Math.min(1, tEmergeRaw);
      const tEmerge = tE * tE * (3 - 2 * tE);
      const phaseA_Y = seamY * (1 - tEmerge) + centeredY * tEmerge;

      // ---- Phase B: held centered while About is in view ----
      // Figures stay LOCKED at viewport centre from the moment they finish
      // emerging through the FOLLY portal, all the way through the about
      // lock zone, the about-exit transition, the catch, and the recede.
      // They never "fall to rest above People" anymore — the recede phase
      // (shrink + fade + blur) handles their disappearance instead.
      const phaseB_Y = centeredY;

      // ---- preCatch / recede progress (computed early so y-drop can use it) ----
      // Keyed off `aboutRect.bottom` (box bottom edge in viewport coords).
      // The figures are visually pinned to viewport centre (y ≈ vh*0.5),
      // so "figures leave the bottom of the box" is the moment when
      // aboutRect.bottom passes through vh*0.5 — that's where the
      // preCatch entry window starts. The entry/exit deltas (0.40vh
      // entry, 0.50vh exit on desktop; 0.35 / 0.40 on mobile) are
      // PRESERVED EXACTLY from the previous peopleRect.top-driven
      // version so the hand's internal rhythm is byte-identical.
      //
      //   DESKTOP preCatch: aboutRect.bottom 0.85vh →  0.45vh (Δ 0.40)
      //   DESKTOP recede  : aboutRect.bottom 0.45vh → -0.05vh (Δ 0.50)
      //   MOBILE  preCatch: aboutRect.bottom 0.60vh →  0.42vh (Δ 0.18)
      //   MOBILE  recede  : aboutRect.bottom 0.42vh →  0.04vh (Δ 0.38)
      // (Mobile retuned: shrink starts at the box's END, fade
      //  finishes as the box clears the viewport — tighter span,
      //  no premature pinch, no dead gap before BUILT BY OPERATORS.)
      //
      // The recede window ends a fraction past `aboutRect.bottom = 0`
      // (i.e. once the box has fully scrolled out the top), so the
      // figures + hand are guaranteed to be invisible by the time the
      // catch-zone hands off to the People section below.
      let preCatchT = 0;
      let recedeT = 0;
      if (aboutRect) {
        const isMobileFig = viewportW < 640;
        if (isMobileFig) {
          // Tightened + shifted LATER. The old 0.80→0.45 / 0.45→0.05
          // windows triggered the shrink while the (taller) mobile
          // box was still ~80% on screen — the figures pinched in
          // far too early — and the recede finished at box.bottom
          // = 0.05vh, i.e. before the box had fully scrolled out,
          // so the figures lingered and a dead gap opened before
          // BUILT BY OPERATORS.
          //
          // New windows key the whole catch to the box's END:
          //   preCatch: box.bottom 0.64vh → 0.42vh  (Δ 0.22)
          //             — shrink/converge begins only as the box
          //               bottom nears the pinned figures (~0.5vh)
          //               and completes the instant the bottom edge
          //               sweeps up past them: the hands close right
          //               as the box ends.
          //   recede  : box.bottom 0.42vh → -0.02vh (Δ 0.44)
          //             — fade + final shrink + blur, finishing a
          //               hair PAST box.bottom = 0 so the figures
          //               are gone exactly as the box clears the
          //               viewport top. No lingering, no dead space
          //               before People.
          // Net span 0.66vh — tighter than desktop's 0.90vh but the
          // same structural rhythm (catch at box-end, cleared as it
          // exits), re-proportioned so the taller mobile box doesn't
          // make the sequence feel stretched/premature.
          preCatchT = Math.max(0, Math.min(1,
            (vh * 0.60 - aboutRect.bottom) / (vh * 0.18)));
          recedeT = Math.max(0, Math.min(1,
            (vh * 0.42 - aboutRect.bottom) / (vh * 0.38)));
        } else {
          preCatchT = Math.max(0, Math.min(1,
            (vh * 0.85 - aboutRect.bottom) / (vh * 0.40)));
          recedeT = Math.max(0, Math.min(1,
            (vh * 0.45 - aboutRect.bottom) / (vh * 0.50)));
        }
      }
      const preCatchEased = preCatchT * preCatchT * (3 - 2 * preCatchT);
      const recedeEased   = recedeT   * recedeT   * (3 - 2 * recedeT);

      // ---- Compose Y phases ----
      // A: about hasn't reached the top of the viewport yet — figures
      //    are still emerging from the FOLLY portal.
      // B: about reached/past viewport top — figures held at centeredY,
      //    then settle to TRUE viewport centre (canvas top = vh/2 - cssH/2)
      //    during the preCatch transition. centeredY has a small cssH-based
      //    offset that puts the canvas-centre slightly below vp centre;
      //    the lerp here pulls the figures up to land dead-centre on
      //    screen at the catch moment. About-lock initial position is
      //    unchanged (preCatchEased = 0 there).
      const yAtCatch = vh * 0.5 - cssH * 0.5;
      let y;
      if (!aboutRect) {
        y = phaseA_Y;
      } else if (aboutRect.top > 0) {
        y = phaseA_Y;
      } else {
        y = phaseB_Y + (yAtCatch - phaseB_Y) * preCatchEased;
      }

      // Sway/rot: gentle organic drift only during the FOLLY emerge.
      // Once figures reach centre they hold still — sway/rot would make
      // the "held centred" pose feel restless. Hard-cutting motionGain
      // from tEmerge to 0 the moment About reached the viewport top
      // used to be visible as a small lurch; lerping the gain via
      // motionGainSmoothed across ~10 frames removes that jolt without
      // adding any perceptible motion in the steady state.
      const motionGainTarget = (!aboutRect || aboutRect.top > 0)
        ? tEmerge
        : 0;
      motionGainSmoothed += (motionGainTarget - motionGainSmoothed) * 0.18;
      const sway = Math.sin(displayedSy / 520) * (vh * 0.018) * motionGainSmoothed;
      const rot  = Math.sin(displayedSy / 600) * 4 * motionGainSmoothed;

      // Build mask FIRST, before showing the canvas. This guarantees the
      // very first painted frame is already clipped at the portal line —
      // no flash of full-body-above-the-slit. The feather is wide (14px)
      // so the emerging edge dissolves like liquid glass — characters
      // appear to materialize through an invisible horizontal seam at
      // the bottom of the FOLLY wordmark, with no hard cutoff line.
      let maskImg = null;
      if (portalY != null) {
        const cutoffPx = Math.max(0, Math.min(cssH, portalY - y));
        const cutoffPct = (cutoffPx / cssH) * 100;
        const featherPct = (14 / cssH) * 100;
        const top = Math.max(0, cutoffPct - featherPct);
        const bot = Math.min(100, cutoffPct + featherPct);
        maskImg =
          `linear-gradient(to bottom, ` +
            `rgba(0,0,0,0) 0%, ` +
            `rgba(0,0,0,0) ${top}%, ` +
            `rgba(0,0,0,1) ${bot}%, ` +
            `rgba(0,0,0,1) 100%)`;
        if (maskImg !== lastMask) {
          canvas.style.webkitMaskImage = maskImg;
          canvas.style.maskImage = maskImg;
          lastMask = maskImg;
        }
      }

      // figureScale: 1.0 → 0.7 (preCatch) → 0.4 (recede). Two-stage drop.
      const figureScale = 1 - 0.30 * preCatchEased - 0.30 * recedeEased;
      // xOffsetMul: 1.0 → 0.62 → 0.50. preCatch pulls Andrew and Chris
      // from their base ±X vw centres toward each other so they land
      // close inside the palm cup at scale 0.7 — but they DELIBERATELY
      // stay separated (~0.5 of base spread) rather than converging to
      // 0.15 and "nearly merging." The previous near-merge stacked the
      // two DIFFERENT figures on top of each other (multiply blend), so
      // their overlapping limbs read as a single morphing/duplicated
      // body — exactly the "overlay" the owner flagged. Holding them
      // apart keeps two distinct people cradled side-by-side in the palm.
      const xOffsetMul  = 1 - 0.38 * preCatchEased - 0.12 * recedeEased;
      // MOBILE base-spread bump. Desktop uses ±15vw which lands ~42%
      // body overlap at the catch on a 1280px viewport (canvas
      // width ~384px). At 375px the canvas is only 190px (clamped
      // floor), so the same ±15vw produces ~66% overlap — the two
      // bodies read as one shape. ±22vw is the tuned middle value:
      // ~50% overlap at the catch — visibly distinct from each
      // other but still close enough to read as the intended
      // "Andrew near Chris in the palm" composition (±28vw was
      // tried first and felt too far apart). Re-evaluated per tick
      // so a device rotation reflows correctly.
      const isMobileFig = viewportW < 640;
      const baseXOffsetVw = isMobileFig
        ? Math.sign(xOffsetVw) * 22
        : xOffsetVw;
      const effectiveXOffsetVw = baseXOffsetVw * xOffsetMul;

      const nextTransform =
        `translate3d(calc(-50% + ${effectiveXOffsetVw}vw + ${sway}px), ${y}px, 0)` +
        ` rotate(${rot}deg) scale(${figureScale.toFixed(3)})`;
      if (nextTransform !== lastTransform) {
        canvas.style.transform = nextTransform;
        lastTransform = nextTransform;
      }
      // Blur builds only during recede so the pre-catch shrink stays sharp.
      // grayscale(1) + drop-shadow live in this string (not CSS) because the
      // .chris-falling class is on the canvas itself, and any inline filter
      // we set here would otherwise clobber the stylesheet rule. Mobile
      // gets a tighter, lower-opacity shadow — at the ~160px mobile figure
      // size, the desktop's 30px blur reads as a halo around the silhouette
      // (matches the comment on the now-stale .chris-falling mobile CSS).
      // (`isMobileFig` is hoisted above where the mobile X-spread is
      // computed so both the spread and the shadow share one check.)
      // Desktop shadow tightened from `0 18px 30px / 0.30` to `0 8px 12px
      // / 0.22` for scroll performance. The drop-shadow filter is a
      // Gaussian convolution over the canvas alpha channel — cost scales
      // roughly linearly with blur radius, and at 30px it was the single
      // most expensive per-frame operation on the page (× 2 canvases ×
      // every scroll tick). At 12px it costs ~40% as much and still
      // grounds the figure on the page. Visual: figures sit closer to
      // the page surface, less "floating in front of it."
      const dropShadow = isMobileFig
        ? 'drop-shadow(0 6px 10px rgb(var(--c-text) / 0.18))'
        : 'drop-shadow(0 8px 12px rgb(var(--c-text) / 0.22))';
      // sepia(0.04) + brightness(0.90) + mix-blend-mode: multiply (set
      // on the canvas JSX) push the figures toward a charcoal-gray
      // tone instead of warm sepia. The earlier sepia(0.14) + brightness
      // (1.0) read as too orange/yellow-cast on the cream page; sepia
      // pulled almost to zero (a touch of warmth held back so they don't
      // go cool-blue against the warm surround), and brightness dropped
      // to 0.90 so the multiplied midtones land deep charcoal rather
      // than tan.
      // Quantize the recede blur to 0.5px steps so micro-deltas in
      // recedeEased don't produce a brand-new filter string (and a
      // fresh full-canvas rasterization) every single frame during
      // the recede. Visually indistinguishable; collapses the
      // recede-phase filter churn to ~24 distinct values instead of
      // one-per-frame.
      const blurPx = (Math.round(recedeEased * 12 * 2) / 2).toFixed(1);
      const dark = follyIsDark();
      const nextFilter =
        `${dark ? 'invert(1) ' : ''}grayscale(1) sepia(0.04) brightness() ${dropShadow} blur(${blurPx}px)`;
      if (nextFilter !== lastFilter) {
        canvas.style.filter = nextFilter;
        lastFilter = nextFilter;
      }
      const blend = dark ? 'screen' : 'multiply';
      if (canvas.style.mixBlendMode !== blend) canvas.style.mixBlendMode = blend;

      // Single-frame paint — one source frame at full opacity. The
      // displayedSy lerp earlier in the tick smooths the scroll input,
      // so pose progression already reads as continuous; an extra
      // cross-fade here just double-exposes the silhouette. Canvas pixels
      // only need repainting when the selected source frame changes; CSS
      // transform handles all per-frame position/scale/rotation motion.
      if (idx !== lastDrawnFrameIdx && img && img.complete) {
        ctx.clearRect(0, 0, cssW, cssH);
        ctx.globalAlpha = 1;
        // Inset the draw so the silhouette never touches the canvas edge.
        // Several frames have a hand/shoe reaching the source PNG's
        // boundary; with no transparent space there, the drop-shadow
        // filter's blur has nothing to fade into and clips against the
        // canvas bounding box as a hard straight line — reading as a
        // "square cut-out" around the figure rather than a soft shadow.
        const shadowPad = Math.round(cssW * 0.10);
        ctx.drawImage(
          img,
          shadowPad, shadowPad,
          cssW - 2 * shadowPad, cssH - 2 * shadowPad
        );
        lastDrawnFrameIdx = idx;
      }

      // Reveal logic — only after the user has actually scrolled, FOLLY
      // is locatable, and we successfully painted a frame.
      const revealed =
        (sy > 0 || canvas.dataset.revealed === '1') &&
        portalY != null && img && img.complete;
      const nextOpacity = revealed
        ? `${(1 - recedeEased).toFixed(3)}`
        : '0';
      if (revealed) canvas.dataset.revealed = '1';
      if (nextOpacity !== lastOpacity) {
        canvas.style.opacity = nextOpacity;
        lastOpacity = nextOpacity;
      }

      // AUTO-PARK signal. Once the figure is fully receded it contributes
      // nothing, so the loop can sleep until the next scroll. This keeps the
      // rest of the page from paying for the falling-canvas choreography after
      // the handoff is complete.
      dormant = recedeEased >= 1 && Math.abs(sy - displayedSy) < 0.5;
    };

    // CONTINUOUS rAF loop. Earlier we only ticked on scroll events plus a
    // small lerp-settle tail — but scroll events fire at the input device's
    // rate (often 30Hz on a wheel, irregular on trackpad), so between
    // events the canvas wasn't repainting and the eye saw a sub-60Hz
    // animation. Now we paint every animation frame the browser hands us.
    // The work is tiny (two drawImage calls on a ~460px canvas), so a
    // permanent 60Hz loop is cheap. We auto-stop only when the figures
    // are far below the viewport (past People + a buffer) so we don't
    // burn CPU when the user is reading the rest of the page.
    let stopped = false;
    let dormant = false;
    const loop = () => {
      if (stopped) return;
      tick();
      // Park when the figure is fully receded & off-screen (set by
      // tick). One last frame has already painted the opacity:0
      // state, so freezing here is invisible. onScroll un-parks.
      if (dormant) { stopped = true; return; }
      raf = requestAnimationFrame(loop);
    };
    const onScroll = () => {
      // Restart the loop if it had been auto-paused while figures were
      // off-screen and the user has scrolled back up.
      if (stopped) {
        stopped = false;
        raf = requestAnimationFrame(loop);
      }
    };
    // Wrapped through stableMobileResize so iOS URL-bar collapses don't
    // re-size the canvas + re-bake portal Y mid-scroll. Rotation and
    // browser resize still propagate (width-change or >150px height delta).
    const onResize = stableMobileResize(() => { sizeCanvas(); tick(); });

    sizeCanvas();
    tick();
    const settle = [50, 200, 600, 1500].map(ms => setTimeout(tick, ms));

    // Kick off the continuous loop.
    raf = requestAnimationFrame(loop);

    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    return () => {
      stopped = true;
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      if (raf) cancelAnimationFrame(raf);
      settle.forEach(clearTimeout);
    };
  }, [ready, xOffsetVw, fallScale, frameCount]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden="true"
      className={className}
      style={{
        // Multiply the figure's grayscale pixels against the page bg so
        // the silhouette picks up the warm cream cast. Pairs with the
        // brightness(1.02) in tick()'s filter chain.
        mixBlendMode: 'multiply',
      }}
    />
  );
}

// ---- Portal slit — independent, tracks FOLLY each scroll/resize tick ----
function PortalSlit() {
  const slitRef = React.useRef(null);

  React.useEffect(() => {
    const slit = slitRef.current;
    if (!slit) return;
    let raf = 0;

    const tick = () => {
      const sy = window.scrollY || 0;
      const folly = document.querySelector('[data-folly-wordmark]');
      if (!folly) { slit.style.opacity = '0'; return; }
      const r = folly.getBoundingClientRect();
      // Pixel-snap so the slit doesn't shimmer at sub-pixel boundaries.
      const portalY = Math.round(r.bottom + 2);
      const padX = Math.max(40, r.width * 0.15);
      const portalLeft = Math.round(r.left - padX);
      const portalWidth = Math.round(r.width + padX * 2);
      slit.style.transform = `translate3d(${portalLeft}px, ${portalY}px, 0)`;
      slit.style.width = `${portalWidth}px`;
      // Instant on/off — no CSS transition. Locked to scroll position so it
      // can't fade in/out independently of the figures.
      slit.style.opacity = sy > 0 ? '1' : '0';
    };

    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => { raf = 0; tick(); });
    };
    // Same URL-bar guard as the falling-figure canvas — portal slit's
    // top/width are anchored to FOLLY's rect, which itself drifts when
    // viewport reflows on URL-bar collapse. Filter at the boundary.
    const onResize = stableMobileResize(tick);
    tick();
    const settle = [50, 200, 600, 1500].map(ms => setTimeout(tick, ms));
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      if (raf) cancelAnimationFrame(raf);
      settle.forEach(clearTimeout);
    };
  }, []);

  return (
    <div
      ref={slitRef}
      aria-hidden="true"
      className="portal-slit"
      style={{ top: 0, left: 0, opacity: 0, transformOrigin: '0 0' }}
    />
  );
}

function FallingDuo() {
  return (
    <React.Fragment>
      <FallingFigure
        framePath={andrewFramePath}
        frameCount={ANDREW_FRAME_COUNT}
        className="chris-falling"
        xOffsetVw={ANDREW_X_OFFSET_VW}
        fallScale={ANDREW_FALL_SCALE}
      />
      <FallingFigure
        framePath={chrisFramePath}
        frameCount={CHRIS_FRAME_COUNT}
        className="chris-falling"
        xOffsetVw={CHRIS_X_OFFSET_VW}
        fallScale={CHRIS_FALL_SCALE}
      />
    </React.Fragment>
  );
}

/* ---------------------- HAND CATCHER (scroll-scrubbed) ---------------------- */
// A lavender hand that slides in from the LEFT in the empty catch zone
// between the About card and the BUILT BY OPERATORS section, catching
// Andrew + Chris dead-centre on screen at the apex of the motion.
//
// Layering: z:0 — behind the falling figures (z:1) so the figures appear
// cradled in the cupped palm.
//
// Geometry: anchored at viewport centre via top:50% / left:50% in CSS.
// JS animates the X component of `transform` from -150% (off-screen left)
// to -50% (centred), and the Y component is held at -50% so the container
// stays vertically centred at all times. The container is sized to a 55vw
// (max 800px) square — well under the native 960px frame size, so no
// upscaling and no pixelation.
//
// Choreography is keyed to `aboutRect.bottom` (the bottom edge of the
// liquid-glass About box in viewport coordinates). The figures are
// pinned to viewport centre (≈ vh*0.5), so the trigger fires when the
// box's bottom edge passes through that centre region — i.e. the moment
// the figures are "leaving the bottom of the box" as it scrolls up.
//
//   ENTRY  aboutRect.bottom  vh*0.50 → vh*0.10  slide in (-163% → -63%)
//                                                frames scrub from 0 → end
//   EXIT                     vh*0.10 → vh*-0.40 fade + blur + scale back
//
// Entry/exit deltas (0.40vh / 0.50vh on desktop, 0.35vh / 0.40vh on
// mobile) are PRESERVED EXACTLY from the previous peopleRect.top-driven
// version, so the hand's internal animation rhythm is byte-identical;
// only the trigger anchor changed, shifting the whole sequence to fire
// when the box bottom exits viewport instead of when the People section's
// top approaches it. Reverses naturally on scroll-up because every value
// is a pure function of scroll position.
// Frame-sequence pipeline (mirrors FallingFigure for Chris/Andrew). The
// previous implementation used a <video> with currentTime scrubbing, which
// hit a "stuck on first frame" bug across browsers — the autoplay-policy
// race + fastSeek's approximate-seek-without-repaint behaviour + paused-
// element seek decoding inconsistencies stacked into a fragile pipeline
// that fell back to a frozen frame even with the safety net's wall-time
// threshold tuned generously. Switching to preloaded image frames painted
// to a canvas each tick removes ALL of those failure modes at once.
const HAND_FRAME_COUNT = 41;
const handFramePath = (i) => `media/hand-frames-cutout/f${String(i).padStart(3,'0')}.webp`;

function HandCatcher() {
  const containerRef = React.useRef(null);
  const canvasRef    = React.useRef(null);
  const framesRef    = React.useRef([]);
  const [ready, setReady] = React.useState(false);

  // Preload every frame as a fully-decoded Image. Same pattern as
  // FallingFigure: require BOTH onload AND a successful decode() before
  // flipping ready, so the very first paint draws a fully-decoded frame
  // and never a partial one.
  React.useEffect(() => {
    let cancelled = false;
    let loaded = 0;
    const imgs = [];
    for (let i = 0; i < HAND_FRAME_COUNT; i++) {
      const img = new Image();
      img.decoding = 'async';
      img.src = handFramePath(i);
      img.decode().then(() => {
        if (cancelled) return;
        loaded++;
        if (loaded === HAND_FRAME_COUNT) setReady(true);
      }).catch(() => {
        if (cancelled) return;
        loaded++;
        if (loaded === HAND_FRAME_COUNT) setReady(true);
      });
      imgs.push(img);
    }
    framesRef.current = imgs;
    return () => { cancelled = true; };
  }, []);

  React.useEffect(() => {
    if (!ready) return;
    const container = containerRef.current;
    const canvas    = canvasRef.current;
    if (!container || !canvas) return;
    const ctx = canvas.getContext('2d');
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';

    // Reduced-motion: the hand-catcher is aria-hidden decoration. Hide
    // it (both the fixed container and its canvas) and bail before the
    // rAF loop so no per-frame compositor work runs. Refs are resolved
    // above, so no hook rules are violated.
    if (window.PREFERS_REDUCED) {
      canvas.style.opacity = 0;
      container.style.opacity = 0;
      return;
    }

    let raf = 0;
    let lastFrameIdx = -1;
    // Style write-coalescing cache — same fix that smoothed the
    // FallingFigures. The scroll-coalesced tick reassigns
    // canvas.style.filter (grayscale+sepia+brightness+blur, on a
    // large mix-blend-mode:multiply canvas) on EVERY tick, even
    // though it is byte-constant for the entire entry phase
    // (exitEased = 0 → blur 0). Re-assigning a filter re-rasterizes
    // the whole canvas through the filter pipeline and re-composites
    // the multiply — the dominant cause of the "super low frame
    // rate / glitchy" hand on mobile. `top` writes also dirty
    // layout for the fixed container every tick. Cache last written
    // value and skip when unchanged: hundreds of redundant
    // rasterizations collapse to the few frames where the value
    // truly changes. Pixel-identical output.
    let lastFilter = '';
    let lastTransform = '';
    let lastOpacity = '';
    let lastTop = '';

    // ── Smoothed scroll input (mirrors FallingFigure) ──────────────
    // The hand used to be SCROLL-EVENT driven: tick() ran only when a
    // 'scroll' event fired, and the frame index mapped off the RAW
    // live scroll position. On a fast flick scroll events are sparse
    // and large-delta, so the 41-frame sequence got sampled coarsely
    // and only repainted on those sparse events — the "leggy / low
    // frame rate on fast scroll" the user reported. The FallingFigures
    // don't have this because they run a CONTINUOUS rAF loop off a
    // lerped (low-pass-filtered) scroll value. We give the hand the
    // exact same treatment so it glides through its frames on fast
    // scroll instead of teleporting, painting every animation frame.
    let displayedSy = window.scrollY || 0;
    // Adaptive lerp: gentle when slow (0.08 → buttery follow), opens
    // up on big deltas so a fast flick still catches up quickly
    // without feeling rubber-banded. Identical curve to FallingFigure
    // so the hand and the figures stay in lockstep at every speed.
    const lerpFactor = (delta) => Math.min(0.35, 0.08 + Math.abs(delta) / 1800);
    // Continuous-loop park state. Without this the loop would run
    // 60fps forever after the catch (the exact bug fixed earlier on
    // the figures). dormant=true once the hand is fully receded &
    // gone; onScroll un-parks so scrolling back up resumes it.
    let stopped = false;
    let dormant = false;

    const sizeCanvas = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
      const cssW = canvas.clientWidth;
      const cssH = canvas.clientHeight;
      canvas.width  = Math.round(cssW * dpr);
      canvas.height = Math.round(cssH * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = 'high';
      lastFrameIdx = -1; // force repaint after resize
    };

    const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
    const easeInOut = (t) => t * t * (3 - 2 * t);

    const tick = () => {
      const aboutEl = document.querySelector('[data-about-lock]');
      const vh = window.innerHeight || 800;
      if (!aboutEl) {
        container.style.opacity = '0';
        return;
      }
      const aboutRect = aboutEl.getBoundingClientRect();

      // Lerp the smoothed scroll toward the real one. `lag` is how far
      // the smoothed position trails real scroll; since the box's
      // viewport-space bottom moves down exactly 1px per 1px of scroll
      // UP, the smoothed bottom is realBottom + lag. Driving the whole
      // choreography off `smoothBottom` (instead of the raw rect) is
      // what turns a fast flick into a smooth eased glide through the
      // frames rather than a coarse teleport.
      const sy = window.scrollY || 0;
      const d = sy - displayedSy;
      displayedSy += d * lerpFactor(d);
      if (Math.abs(sy - displayedSy) < 0.5) displayedSy = sy;
      const smoothBottom = aboutRect.bottom + (sy - displayedSy);

      // Mobile (< 640px) uses tighter entry/exit deltas than desktop so
      // the choreography fits the smaller viewport's faster scroll cadence.
      // Both branches match the FallingFigure preCatch/recede windows in
      // lockstep, so hand & figures move together. Windows are anchored
      // off the smoothed box bottom so the catch fires exactly when
      // figures leave the bottom of the box.
      const isMobileHand = (window.innerWidth || 0) < 640;
      // Mobile values MUST stay byte-identical to the FallingFigure
      // mobile preCatch/recede windows (retuned: start at the box's
      // end, tighter span, fade clears as the box exits) or the hand
      // and the figures desync — the hand would close before/after
      // the bodies pinch in.
      const entryStart = isMobileHand ? vh * 0.60 : vh * 0.85;
      const entrySpan  = isMobileHand ? vh * 0.18 : vh * 0.40;
      const entryRaw = (entryStart - smoothBottom) / entrySpan;
      const entryT = clamp(entryRaw, 0, 1);
      const exitStart = isMobileHand ? vh * 0.42 : vh * 0.45;
      const exitSpan  = isMobileHand ? vh * 0.38 : vh * 0.50;
      const exitRaw = (exitStart - smoothBottom) / exitSpan;
      const exitT = clamp(exitRaw, 0, 1);

      const entryEased = easeInOut(entryT);
      const exitEased  = easeInOut(exitT);

      // ---- VERTICAL ANCHOR ----
      // The hand sits with its palm cup ABOVE the figures' body bottom by
      // a small overlap, so the figures read as floating just over the
      // palm. During recede the figures shrink (0.7 → 0.4); the hand
      // tracks the shrunken body bottom AND the palm-gap closes (50 → 15
      // desktop, 24 → 0 mobile) so the unit reads as "hand staying close,
      // almost sitting under the figures" as everything zooms back.
      const cssH = Math.max(280, Math.min(window.innerWidth * 0.32, 460));
      const FIGURE_SCALE_AT_CATCH = 0.7;
      const figureCenterAtCatch = vh * 0.5;
      const recedeFigureScale = FIGURE_SCALE_AT_CATCH - 0.30 * exitEased;
      const figureBodyBottomNow =
        figureCenterAtCatch + 0.35 * cssH * recedeFigureScale;
      const palmGap = isMobileHand ? (24 - 24 * exitEased) : (50 - 35 * exitEased);
      const palmTargetVp = figureBodyBottomNow + palmGap;
      const cw = container.clientWidth;
      // 0.55 = palm-cup y position within the source frame.
      const containerTop = palmTargetVp - 0.55 * cw;
      const nextTop = `${Math.round(containerTop)}px`;
      if (nextTop !== lastTop) {
        container.style.top = nextTop;
        lastTop = nextTop;
      }

      // X: -163% (off-screen left) → -63% (hand shifted left so both
      // figures sit over the palm without empty hand-area sticking out
      // to the right of Chris).
      const tx = -163 + 100 * entryEased;
      // Scale: 1.0 during entry, shrinks to 0.5 during recede so the
      // hand visually pulls back along with the figures.
      const scale = 1 - 0.5 * exitEased;

      const nextTransform =
        `translate3d(${tx.toFixed(2)}%, 0, 0) scale(${scale.toFixed(3)})`;
      if (nextTransform !== lastTransform) {
        container.style.transform = nextTransform;
        lastTransform = nextTransform;
      }
      const nextOpacity = `${(1 - exitEased).toFixed(3)}`;
      if (nextOpacity !== lastOpacity) {
        container.style.opacity = nextOpacity;
        lastOpacity = nextOpacity;
      }
      // Filter tuned to make the hand land at the SAME visible color
      // as the FallingFigures despite a structural difference: the
      // figures' canvas multiplies directly against the cream page
      // bg (no wrapping container), but the hand's `.hand-catcher`
      // container creates a stacking context (transform + opacity +
      // will-change in index.html), which isolates mix-blend-mode:
      // multiply so it never reaches the page bg. Identical filter
      // strings therefore produce different visible tones.
      //
      // Solved the per-channel match equations: with the figures'
      // sepia(0.04) brightness(0.90) plus cream multiply landing
      // mid-gray at (111, 108, 102), the hand needs sepia(0.21)
      // brightness(0.81) to land at the same color WITHOUT the
      // cream multiply boost. Verified at midtone (128) and
      // highlight (200) — both match within 1 RGB unit.
      //
      // blur(...) is exit-phase only, softening the hand as the
      // catch recedes out of frame.
      // Quantize exit blur to 0.5px steps so micro-deltas in
      // exitEased don't spawn a fresh full-canvas filter
      // rasterization every frame during recede (visually
      // indistinguishable; ~28 distinct values instead of one per
      // frame). Through the entire ENTRY phase exitEased = 0, so
      // this string is constant and the guard below makes it a
      // single write instead of one-per-tick.
      const handBlur = (Math.round(exitEased * 14 * 2) / 2).toFixed(1);
      const dark = follyIsDark();
      const nextFilter =
        `${dark ? 'invert(1) ' : ''}grayscale(1) sepia(0.21) brightness(${dark ? '1.02' : '0.81'}) blur(${handBlur}px)`;
      if (nextFilter !== lastFilter) {
        canvas.style.filter = nextFilter;
        lastFilter = nextFilter;
      }
      const blend = dark ? 'screen' : 'multiply';
      if (canvas.style.mixBlendMode !== blend) canvas.style.mixBlendMode = blend;

      // Frame painting — map entryEased linearly onto the frame range
      // [0, HAND_FRAME_COUNT-1] so the whole sequence plays across the
      // entry phase, identical to the original video-scrub mapping.
      // Only paint when the frame index changes — skips redundant
      // drawImage calls when the user is sitting still.
      const frameIdx = clamp(
        Math.round(entryEased * (HAND_FRAME_COUNT - 1)),
        0,
        HAND_FRAME_COUNT - 1
      );
      if (frameIdx !== lastFrameIdx) {
        const img = framesRef.current[frameIdx];
        if (img && img.complete && img.naturalWidth > 0) {
          const cssW = canvas.clientWidth;
          const cssCH = canvas.clientHeight;
          ctx.clearRect(0, 0, cssW, cssCH);
          ctx.drawImage(img, 0, 0, cssW, cssCH);
          lastFrameIdx = frameIdx;
        }
      }

      // Park once the hand is fully receded (opacity 0, gone) AND the
      // lerp has settled — so we never freeze mid-glide. onScroll
      // un-parks. This is what keeps the now-continuous loop from
      // burning 60fps forever after the catch (same park the figures
      // got). Pre-entry the loop runs like the figures' does — the
      // user is actively scrolling there anyway.
      dormant = exitEased >= 1 && Math.abs(sy - displayedSy) < 0.5;
    };

    // CONTINUOUS rAF loop — paint every animation frame, not just on
    // sparse scroll events. Combined with the lerped `smoothBottom`
    // this is what makes a fast flick read as a smooth eased glide
    // through the 41 frames instead of a coarse low-frame-rate
    // teleport. Mirrors FallingFigure exactly so hand + figures share
    // identical motion characteristics at every scroll speed.
    const loop = () => {
      if (stopped) return;
      tick();
      if (dormant) { stopped = true; return; }
      raf = requestAnimationFrame(loop);
    };
    const onScroll = () => {
      // Un-park: resume the loop if it parked itself after the catch
      // and the user is scrolling back toward it.
      if (stopped) {
        stopped = false;
        raf = requestAnimationFrame(loop);
      }
    };
    // Hand-catcher's window math pivots off vh; iOS URL-bar collapse
    // would otherwise reshape the canvas + entry/exit ranges mid-scroll
    // and the hand would slide in at the wrong moment. Real rotation
    // still passes through (width changes).
    const onResize = stableMobileResize(() => { sizeCanvas(); tick(); });

    sizeCanvas();
    tick();
    // Re-tick after layout settles — people section's geometry depends
    // on the About lock height + catch zone height, both of which
    // depend on vh and aren't fully settled on first paint.
    const settle = [50, 200, 600, 1500].map(ms => setTimeout(tick, ms));

    // Kick off the continuous loop.
    raf = requestAnimationFrame(loop);

    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);

    return () => {
      stopped = true;
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      if (raf) cancelAnimationFrame(raf);
      settle.forEach(clearTimeout);
    };
  }, [ready]);

  return (
    <div ref={containerRef} className="hand-catcher" aria-hidden="true">
      <canvas
        ref={canvasRef}
        style={{
          width: '100%',
          height: '100%',
          display: 'block',
          // Multiply blends the hand's grayscale pixels against the page
          // bg the same way Andrew + Chris do, giving the hand the same
          // warm cream tint instead of neutral cool gray. Backdrop is
          // already transparent (chroma-keyed in hand-frames-cutout/) so
          // multiply only affects hand pixels, not a giant cream block.
          // Filter (grayscale + brightness + blur) is set in tick() so
          // the multiply isn't isolated by a container-level filter.
          mixBlendMode: 'multiply',
          // Left-edge feather: linear-gradient mask fading the leftmost
          // ~14% of the canvas from transparent → opaque. Physically
          // removes the chroma-key feathered wrist pixels (where the
          // halo originates) before they ever hit the multiply blend,
          // so the wrist dissolves into the page rather than ending in
          // a hard cutout edge. The remaining ~86% of the canvas (the
          // palm + fingers + falling-figure landing zone) is fully
          // opaque, so the catch composition is unchanged.
          WebkitMaskImage:
            'linear-gradient(to right, transparent 0%, rgba(0,0,0,0.35) 5%, rgba(0,0,0,0.85) 11%, black 15%, black 100%)',
          maskImage:
            'linear-gradient(to right, transparent 0%, rgba(0,0,0,0.35) 5%, rgba(0,0,0,0.85) 11%, black 15%, black 100%)',
        }}
      />
    </div>
  );
}

/* =======================================================================
   INTRO LOADER
   -----------------------------------------------------------------------
   Full-viewport overlay shown on first paint. Renders ONLY the FOLLY
   wordmark — at the *exact* same place, size, and font as the hero's
   wordmark — and runs a quick choreography:

     1. Each letter ignites in turn (F → O → L → L → Y), fading from
        the page text color into the lavender accent (#CCBCEE). Letters
        accumulate as the wave passes — once Y lights up, all five are
        purple together.
     2. A short hold on full-purple.
     3. All five fade back to text color.
     4. The whole overlay cross-fades out while the rest of the page
        fades in beneath it. Because the loader's wordmark sits in the
        identical layout as the hero's (same flex container, same
        dynamic-fontSize definition spacer below), the FOLLY at the
        moment of cross-fade is in pixel-identical position in both
        layers — so there's no visible "snap" when the loader unmounts.

   Total animation ≈ 800ms; cross-fade tail ≈ 450ms. */

const motionApp = (window.Motion && window.Motion.motion) || null;

/* ============================== SMOOTH SCROLL =============================== */
// Page-level smooth scroll system, two layers stacked top-to-bottom:
//
//   1. LENIS — page-level smooth scroll (loaded in index.html). Native
//      wheel/touchpad input is lerped into a continuous, low-pass-filtered
//      curve so every scroll-coupled animation downstream (HandCatcher
//      entry, FallingFigure pose, Features blur-fade reveals) reads as
//      buttery motion instead of jagged native events.
//
//   2. GUIDED SECTION SNAP — after scroll-end with low velocity, glide to
//      the nearest data-snap section. Glide duration is DISTANCE-SCALED so
//      a 50px nudge feels snappy (~0.5s) and a 1500px pull feels
//      deliberate (~1.0s) — no "long snaps feel slow / short snaps feel
//      lazy" mismatch. Velocity threshold + quiet wait are tuned tight
//      enough to feel responsive but loose enough that mid-scroll pauses
//      don't false-fire a snap.
//
//   (Historical: a third layer used to snap between the three slides of
//   the About scroll-lock. That lock is gone — About is a single natural-
//   height card now — so the slide-snap branch was removed.)
//
//   + KEYBOARD NAV — Arrow keys / PageUp / PageDown / Home / End advance
//     between data-snap sections via the same scrollTo glide. Standard
//     accessibility expectation, costs almost nothing to add.
//
// All values are vh-relative so the system adapts cleanly across monitor
// sizes. Touch devices use native momentum (Lenis smoothTouch=false) since
// fighting iOS/Android's own smoothing makes touch scrolling feel sticky.
function useLenis() {
  React.useEffect(() => {
    // Reduced-motion: skip the smooth-scroll lerp entirely and fall back
    // to the browser's native scroll. The effect's other logic (snap,
    // rAF loop) never starts — native scroll is the right behaviour for
    // users who asked to reduce motion.
    if (window.PREFERS_REDUCED) return;
    if (typeof window.Lenis !== 'function') return;

    const lenis = new window.Lenis({
      // Shorter than the old 1.2s tail: still eased, but it no longer makes
      // the first hero/About scroll feel like it is catching up to the wheel.
      duration: 0.82,
      // Crisp ease-out with less lingering tail than expo.
      easing: (t) => 1 - Math.pow(1 - t, 4),
      orientation: 'vertical',
      smoothWheel: true,
      // iOS/Android native momentum is already buttery — Lenis on top
      // makes touch scrolling feel sticky and over-damped.
      smoothTouch: false,
      // Let desktop wheel/trackpad input keep its natural distance; the
      // duration/easing above provide smoothness without making scroll feel slow.
      wheelMultiplier: 1.0,
      touchMultiplier: 1.0,
    });

    // Expose so other code can read scroll state if needed.
    window.__lenis = lenis;

    let rafId = 0;
    const raf = (time) => {
      lenis.raf(time);
      rafId = requestAnimationFrame(raf);
    };
    rafId = requestAnimationFrame(raf);

    // ---- Snap state ----
    let snapTimer = null;
    let isSnapping = false;

    // Distance-scaled snap glide. Short snaps land quickly; long snaps
    // take more time so they feel deliberate rather than abrupt.
    const snapDurationFor = (distPx, vh) => {
      const distVh = distPx / Math.max(1, vh);
      // 0.55s floor (snappy short hops) → 1.05s cap (gentle long pulls).
      return Math.max(0.55, Math.min(1.05, 0.45 + distVh * 0.55));
    };

    const findNearestSnapTarget = () => {
      const currentY = lenis.scroll;
      const vh = window.innerHeight;

      // The About-lock slide-snap branch lived here before — it forced
      // discrete snap targets at 0/70vh/140vh from About's top to
      // paginate between three "slides." About is now a single natural-
      // height card with continuous scroll, so the snap is removed.

      // ---- Section snap ----
      // Only fires within snapWindow of a target — beyond that, the
      // user's final position is respected (they intentionally
      // stopped between sections, no yank-back).
      //
      // `data-snap-strong` is a priority tier with a wider capture window
      // so the user can't easily blow past key sections (Investments,
      // footer Get-In-Touch) on a fast scroll.
      const targets = document.querySelectorAll('[data-snap], [data-snap-strong]');
      let nearest = null;
      let minDist = Infinity;
      targets.forEach((el) => {
        const isStrong = el.hasAttribute('data-snap-strong');
        const snapWindow = vh * (isStrong ? 0.45 : 0.30);
        const rect = el.getBoundingClientRect();
        const top = rect.top + currentY;
        const dist = Math.abs(top - currentY);
        if (dist < snapWindow && dist < minDist) {
          minDist = dist;
          nearest = top;
        }
      });
      return nearest;
    };

    const trySnap = () => {
      if (isSnapping) return;
      const target = findNearestSnapTarget();
      if (target == null) return;
      const currentY = lenis.scroll;
      const dist = Math.abs(target - currentY);
      if (dist < 4) return; // already there
      isSnapping = true;
      lenis.scrollTo(target, {
        duration: snapDurationFor(dist, window.innerHeight),
        // Cubic ease-out — gentler tail than a quartic, so the snap
        // glides in without "slamming" into the target. Pairs with
        // Lenis's own expo curve when scrollTo internally animates.
        easing: (t) => 1 - Math.pow(1 - t, 3),
        onComplete: () => { isSnapping = false; },
      });
    };

    // ---- Auto section-snap: DISABLED (per UX direction) ----
    // Previously, after scroll-end with low velocity, the page glided
    // ("snapped") to the nearest [data-snap]/[data-snap-strong]
    // section. That magnetic pull read as "the scroll snaps weird /
    // feels locked" — the page tugging itself somewhere the user
    // didn't ask for. Removed entirely: the scroll listener that
    // scheduled trySnap is no longer registered, so wheel/trackpad
    // scrolling is now pure Lenis smooth-scroll (continuous lerped
    // motion, no destination magnetism). `trySnap` /
    // `findNearestSnapTarget` / `snapTimer` are intentionally left
    // defined but unreferenced — keyboard nav (below) does its own
    // explicit lenis.scrollTo and never used trySnap, so it is
    // unaffected. The data-snap attributes in the markup are now
    // inert for wheel scrolling and only steer keyboard paging.
    void trySnap; void findNearestSnapTarget; void snapTimer;

    // ---- Keyboard navigation ----
    // ArrowUp/Down, PageUp/Down, Home, End — advance between data-snap
    // sections. Standard a11y expectation; users who can't (or won't)
    // wheel-scroll get the same guided snap experience.
    const onKey = (e) => {
      if (isSnapping) return;
      const t = e.target;
      // Don't hijack typing in form controls / contenteditable surfaces.
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;

      const targets = Array.from(document.querySelectorAll('[data-snap]'));
      if (targets.length === 0) return;
      const currentY = lenis.scroll;

      // Find the section currently most "in" the viewport (its top is
      // closest to currentY).
      let curIdx = 0;
      let minDist = Infinity;
      targets.forEach((el, i) => {
        const top = el.getBoundingClientRect().top + currentY;
        const d = Math.abs(top - currentY);
        if (d < minDist) { minDist = d; curIdx = i; }
      });

      let nextIdx = curIdx;
      if (e.key === 'ArrowDown' || e.key === 'PageDown') {
        nextIdx = Math.min(targets.length - 1, curIdx + 1);
      } else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
        nextIdx = Math.max(0, curIdx - 1);
      } else if (e.key === 'Home') {
        nextIdx = 0;
      } else if (e.key === 'End') {
        nextIdx = targets.length - 1;
      } else {
        return;
      }
      if (nextIdx === curIdx && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
        // Already at the boundary — let the browser fall through so the
        // user gets normal arrow-key behaviour beyond the snap targets.
        return;
      }
      e.preventDefault();
      const targetTop =
        targets[nextIdx].getBoundingClientRect().top + currentY;
      const dist = Math.abs(targetTop - currentY);
      isSnapping = true;
      lenis.scrollTo(targetTop, {
        duration: snapDurationFor(dist, window.innerHeight),
        easing: (t) => 1 - Math.pow(1 - t, 3),
        onComplete: () => { isSnapping = false; },
      });
    };
    window.addEventListener('keydown', onKey);

    return () => {
      cancelAnimationFrame(rafId);
      clearTimeout(snapTimer);
      // (No lenis.off('scroll', ...) — the auto-snap scroll listener
      // is no longer registered; see "Auto section-snap: DISABLED".)
      window.removeEventListener('keydown', onKey);
      lenis.destroy();
      delete window.__lenis;
    };
  }, []);
}

/* =======================================================================
   INTRO LOADER — LUMA SPIN
   -----------------------------------------------------------------------
   Cream cover (#F2EDE3) with a 65px frame containing two charcoal-
   stroked rounded squares chasing each other around the eight cardinal/
   edge positions of a 2×2 grid. Animation lives in index.html under
   `.luma-spin` — this component just emits the markup the CSS hooks
   into. The loader stays visible until the hero video reports
   `canplay`, then cross-fades out over 450ms (no fixed min-hold).
   Failsafe at 5s in case the video element never reports ready
   (broken network, codec issue). */
function IntroLoader({ fadingOut }) {
  return (
    <div
      aria-hidden="true"
      style={{
        position: 'fixed',
        inset: 0,
        zIndex: 100,
        background: 'rgb(var(--c-bg))',
        opacity: fadingOut ? 0 : 1,
        transition: 'opacity 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
        pointerEvents: 'none',
        overflow: 'hidden',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <div className="luma-spin">
        <span className="luma-spin-tile" />
        <span className="luma-spin-tile luma-spin-tile--delay" />
      </div>
    </div>
  );
}

function App() {
  useLenis();

  // ---- Loader phase machine ----
  // Phase machine:
  //   loading    — IntroLoader visible. Box-loader cubes cycle on a
  //                cream background until hero.mp4 reports `canplay`
  //                (or the 5s failsafe fires).
  //   revealing  — Loader cross-fades out over 450ms, revealing Hero.
  //   done       — Loader unmounted; navbar mounts; scroll engages.
  const [phase, setPhase] = React.useState('loading');

  React.useEffect(() => {
    let cancelled = false;

    const reveal = () => {
      if (cancelled) return;
      setPhase('revealing');
      // 450ms cross-fade matches the loader's transition duration so
      // the loader is fully gone by the time phase flips to 'done'.
      setTimeout(() => { if (!cancelled) setPhase('done'); }, 450);
    };

    // No min hold — the box-loader runs as long as it takes for the
    // hero video to be ready, then exits immediately. The cubes are an
    // infinite CSS loop so they look identical whether they're shown
    // for 50ms or 5s.

    // Watch the hero video for canplay. The element may not exist on
    // first effect tick (Hero hasn't rendered yet), so we poll for it.
    let pollId = 0;
    let attached = null;
    const findAndAttach = () => {
      if (cancelled) return;
      const v = document.querySelector('video[src*="hero.mp4"]');
      if (!v) {
        pollId = setTimeout(findAndAttach, 80);
        return;
      }
      // HAVE_FUTURE_DATA (3) or higher means we have enough buffered to
      // start playing — sufficient as a "video ready" gate.
      if (v.readyState >= 3) { reveal(); return; }
      const onReady = () => reveal();
      v.addEventListener('canplay',        onReady, { once: true });
      v.addEventListener('canplaythrough', onReady, { once: true });
      // Don't get stuck on a network error — fall back to revealing.
      v.addEventListener('error',          onReady, { once: true });
      attached = { el: v, fn: onReady };
    };
    findAndAttach();

    // Failsafe: if the video genuinely never reports ready (broken
    // network, codec issue), don't trap the user behind the loader
    // forever.
    const failsafe = setTimeout(reveal, 2500);

    return () => {
      cancelled = true;
      clearTimeout(failsafe);
      if (pollId) clearTimeout(pollId);
      if (attached) {
        attached.el.removeEventListener('canplay',        attached.fn);
        attached.el.removeEventListener('canplaythrough', attached.fn);
        attached.el.removeEventListener('error',          attached.fn);
      }
    };
  }, []);

  return (
    <React.Fragment>
      {phase !== 'done' && (
        <IntroLoader fadingOut={phase === 'revealing'} />
      )}
      {/* Navbar lives at app-root (sibling of <main>) so its position:fixed
          is anchored to the viewport across the full scroll. Mounting it
          inside Hero put it under transformed/will-change ancestors, which
          create a containing block and break position:fixed — the bar
          would scroll away with Hero after the first section.
          Only mounted after the IntroLoader finishes — the loader owns
          the visible composition during boot (cream cover + two merging
          FOLLYs) and the navbar would peek through z-wise. */}
      {phase === 'done' && <Navbar />}
      {/* Main is always rendered. The IntroLoader cream cover (z:100)
          sits on top during boot and hides everything underneath — the
          hero's centered FOLLY is rendered at z:2 (under the cover) so
          the loader's two merging FOLLYs are the only wordmark visible
          during the loading phase. When the loader cross-fades out, the
          merged FOLLY-at-centre lands pixel-aligned on Hero's already-
          painted wordmark, so the handoff is invisible. */}
      <main>
        <HandCatcher />
        <FallingDuo />
        <Hero />
        <About />
        {/* Empty zone between About and Features that gives the falling
            figures + catching hand a stage of their own. 50vh keeps the
            section-2-to-section-3 transition tight while still leaving
            enough scroll runway for the catch + recede choreography.
            Mobile drops to 25vh (CSS override in index.html) so the
            handoff doesn't dwell on a small screen. */}
        <section
          data-catch-zone
          aria-hidden="true"
          className="catch-zone"
          style={{ position: 'relative' }}
        />
        <Features />
        <Investments />
        <ContactCTA />
        <Footer />
      </main>
    </React.Fragment>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
