/* ════════════════════════════════════════════════════════════
   OFFMAT — scroll-driven 3D journey
   8 scenes · global WebGL particle world · cinematic focus-shift transitions
   Brand: #050505 / #e63946 / #f5f5f7 · system font stack
════════════════════════════════════════════════════════════ */
const { useState, useEffect, useRef } = React;
const gsap = window.gsap;
const ScrollTrigger = window.ScrollTrigger;
const THREE = window.THREE;
// NOTE: the GSAP ticker also drives Lenis smooth-scroll (see useGlobalFX), so it
// must run at the display's native refresh rate. A previous fps(40) cap throttled
// the shared scroll loop → choppy scroll / dropped frames. lagSmoothing(0) below
// is the correct knob; the heavy WebGL loops run on their own rAF and are
// unaffected by the ticker rate.

const isTouch = () =>
  typeof window !== 'undefined' &&
  (window.matchMedia('(hover: none)').matches || window.innerWidth < 768);

const reducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// PHASE 2 — mobile/desktop split (standard 768px breakpoint), viewport-width based.
// Uses the widest reliable signal (same trick as the belt: some embedded/headless
// contexts report a bogus tiny innerWidth; screen.width stays sane). Decided once
// at mount — desktop keeps the scroll-scrubbed video, mobile gets a static poster.
const isMobileVW = () =>
  typeof window !== 'undefined' &&
  Math.max(
    window.innerWidth || 0,
    document.documentElement.clientWidth || 0,
    (window.screen && window.screen.width) || 0
  ) < 768;

/* ════════════════════════════════════════════
   useDismissable — ONE consistent open/close rule for every click/tap-to-open
   element on the page (Journey nodes, Game Builder tooltip, the #problem and #ai
   expandable panels, the #different flip cards). Behaviour, identical everywhere:
     • opening one CLOSES any other open element (single-open, via the module-level
       registry below) — desktop click and mobile tap alike;
     • tapping the same trigger again closes it (each component's existing
       `x === i ? null : i` toggle, unchanged);
     • a pointerdown ANYWHERE outside the element's root closes it.
   Attach the returned rootRef to the container wrapping the triggers (+ any popup);
   clicks INSIDE it are left to the component's own handlers, so buttons, links and
   the nav — all outside these roots — keep working normally (a click on them simply
   also dismisses whatever was open, which is the desired behaviour).
════════════════════════════════════════════ */
const _dismissers = new Set();                 // every mounted dismissable's close fn
function useDismissable() {
  const [value, setVal] = useState(null);
  const rootRef = useRef(null);
  const selfClose = useRef(() => setVal(null)).current;   // stable identity for the registry

  useEffect(() => () => { _dismissers.delete(selfClose); }, []);

  const setValue = (next) => {
    setVal(prev => {
      const resolved = typeof next === 'function' ? next(prev) : next;
      if (resolved != null) {
        _dismissers.forEach(fn => { if (fn !== selfClose) fn(); });   // close every other open one
        _dismissers.add(selfClose);
      } else {
        _dismissers.delete(selfClose);
      }
      return resolved;
    });
  };

  useEffect(() => {
    if (value == null) return;
    const onDown = (e) => {
      const root = rootRef.current;
      if (root && root.contains(e.target)) return;   // inside → the component's own handlers decide
      setVal(null);
      _dismissers.delete(selfClose);
    };
    // pointerdown covers mouse + touch; the listener attaches AFTER the opening
    // click's own event, so it never self-closes the thing that just opened.
    document.addEventListener('pointerdown', onDown);
    return () => document.removeEventListener('pointerdown', onDown);
  }, [value]);

  return [value, setValue, rootRef];
}

// ── Android-not-yet toast (Google Play buttons) ──
// One shared, on-brand toast: dark surface, restrained, auto-dismisses. Plain DOM so
// every component (Nav, Hero, MobileCTA, Scene8) can call it without shared state.
let _toastEl = null, _toastTimer = 0;
function showAndroidToast() {
  if (!_toastEl) {
    _toastEl = document.createElement('div');
    _toastEl.className = 'om-toast';
    _toastEl.setAttribute('role', 'status');
    _toastEl.textContent = 'Android testing isn\u2019t available yet \u2014 coming at launch.';
    document.body.appendChild(_toastEl);
  }
  clearTimeout(_toastTimer);
  _toastEl.classList.remove('on');
  void _toastEl.offsetWidth;                 // restart the enter transition
  _toastEl.classList.add('on');
  _toastTimer = setTimeout(() => _toastEl.classList.remove('on'), 2800);
}

// Lenis instance (premium smooth scroll); falls back to native if unavailable.
let lenis = null;
function scrollToId(id) {
  const el = document.querySelector(id);
  if (!el) return;
  if (lenis) lenis.scrollTo(el, { offset: 0 });
  else el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}


/* ════════════════════════════════════════════
   GLOBAL FX — progress bar, crossfades, reveals, counts
   PHASE 1 REBUILD: the slide-deck engine (body.slides pager, #slide-driver,
   darkness-devour mask transitions, ScrollTrigger snap) is REMOVED. This is
   plain, reliable NATIVE vertical scrolling — sections flow into each other.
   "Smooth" comes from lightweight lerping of the scroll-DRIVEN values, never
   from hijacking the scroll itself (no Lenis / wheel interception — that is
   what lagged on mobile): the video scrub eases via its own rAF lerp, the belt
   via its 0.045 tick lerp, and the scene crossfade below uses scrub:0.6 so it
   glides ~0.6s behind the scroll position.
════════════════════════════════════════════ */
/* Cinematic crossfade with depth — like a camera focus shift.
   Incoming scene: blur(4px) scale 0.97 opacity 0 → sharp, full. */
function focusShift(scene) {
  const inner = scene.querySelector('.scene-inner') || scene;
  // Incoming: a cheap blur-in that plays ONCE, plus a parallax rise + scale.
  gsap.fromTo(inner,
    { opacity: 0, y: 34, scale: 0.985, filter: 'blur(4px)' },
    {
      opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
      duration: 0.7, ease: 'power3.out',
      scrollTrigger: { trigger: scene, start: 'top 82%', toggleActions: 'play none none none' },
      onComplete: () => { gsap.set(inner, { clearProps: 'filter' }); },
    }
  );
  // Outgoing: as the section leaves, its content FULLY fades out (opacity 0) with a slight
  // upward drift — so only the active section's content shows over the background video, and
  // it disappears gracefully as the next one arrives. GPU-only (opacity/transform), scrubbed.
  // fromTo + immediateRender:false avoids the "content vanishes on scroll" race (a plain .to()
  // scrub would re-assert a stale 0 on in-view content); from:{opacity:1} keeps the active
  // section solid until it actually starts leaving.
  gsap.fromTo(inner,
    { opacity: 1, scale: 1, y: 0 },
    {
      opacity: 0, scale: 1.015, y: -24, ease: 'none', immediateRender: false,
      scrollTrigger: { trigger: scene, start: 'bottom 55%', end: 'bottom 8%', scrub: 0.6 },
    }
  );
}

function useGlobalFX() {
  useEffect(() => {
    const root = document.getElementById('root');
    if (root) root.classList.add('ready'); // direct — never gate visibility on rAF
    if (!gsap || !ScrollTrigger || reducedMotion()) {
      document.querySelectorAll('.rv, [data-si]').forEach(el => { el.style.opacity = 1; el.style.transform = 'none'; });
      return;
    }

    // Lenis intentionally NOT used: native scroll is the only thing that stays
    // buttery on mobile. ScrollTrigger runs on native scroll fine.
    let lenisRaf = null;

    const bar = document.getElementById('scroll-progress');
    // PERF (Item 1): coalesced to one rAF per frame; the bar animates via scaleX
    // (compositor-only) instead of width (which invalidated layout every scroll frame).
    let scrollRaf = 0;
    const applyScroll = () => {
      scrollRaf = 0;
      const h = document.documentElement;
      const max = h.scrollHeight - h.clientHeight;
      if (bar) bar.style.transform = 'scaleX(' + (max > 0 ? h.scrollTop / max : 0).toFixed(4) + ')';
      const m = document.getElementById('mcta');
      if (m) {
        const close = document.getElementById('close');
        let hide = false;
        if (close) hide = close.getBoundingClientRect().top < window.innerHeight;
        m.classList.toggle('on', h.scrollTop > window.innerHeight * 0.8 && !hide);
      }
    };
    const onScroll = () => { if (!scrollRaf) scrollRaf = requestAnimationFrame(applyScroll); };
    window.addEventListener('scroll', onScroll, { passive: true });
    applyScroll();

    const ctx = gsap.context(() => {
      // cinematic crossfade between scenes (all except hero)
      gsap.utils.toArray('.scene[id]:not(#hero)').forEach(focusShift);

      // generic reveals
      gsap.utils.toArray('.rv').forEach((el) => {
        gsap.fromTo(el, { opacity: 0, y: 36 }, {
          opacity: 1, y: 0, duration: 0.8, ease: 'power3.out',
          scrollTrigger: { trigger: el, start: 'top 86%', toggleActions: 'play none none none' },
        });
      });
      gsap.utils.toArray('[data-stagger]').forEach((g) => {
        const items = g.querySelectorAll('[data-si]');
        if (!items.length) return;
        gsap.fromTo(items, { opacity: 0, y: 28 }, {
          opacity: 1, y: 0, duration: 0.6, ease: 'power3.out', stagger: 0.07,
          scrollTrigger: { trigger: g, start: 'top 84%', toggleActions: 'play none none none' },
        });
      });

      // count-ups
      gsap.utils.toArray('[data-count]').forEach((el) => {
        const v = parseFloat(el.getAttribute('data-count'));
        const sfx = el.getAttribute('data-sfx') || '';
        const dec = parseInt(el.getAttribute('data-dec') || '0', 10);
        const o = { n: 0 };
        gsap.to(o, {
          n: v, duration: 1.5, ease: 'power2.out',
          scrollTrigger: { trigger: el, start: 'top 88%', toggleActions: 'play none none none' },
          onUpdate: () => { el.textContent = o.n.toFixed(dec) + sfx; },
        });
      });

      // SplitType curtain reveals on scene headlines
      if (window.SplitType) {
        const splitReveal = (sel, kind, stagger) => {
          const el = document.querySelector(sel);
          if (!el) return;
          el.classList.remove('rv');                 // avoid double-animating
          gsap.set(el, { opacity: 1, y: 0 });
          const split = new window.SplitType(el, { types: 'lines,words,chars' });
          (split.lines || []).forEach(l => l.classList.add('reveal-line'));
          const units = kind === 'chars' ? split.chars : split.words;
          units.forEach(u => u.classList.add('split-unit'));
          // ITEM 4: 120 → 140 — the clip window now extends 0.18em below the line
          // (descender fix), so units start deeper to stay fully hidden pre-reveal.
          gsap.set(units, { yPercent: 140 });
          gsap.to(units, {
            yPercent: 0, duration: 0.8, ease: 'power3.out', stagger,
            scrollTrigger: { trigger: el, start: 'top 85%', toggleActions: 'play none none none' },
          });
        };
        splitReveal('#problem .scene2-head', 'chars', 0.03); // scene 2 — by character
        splitReveal('#journey  h2', 'words', 0.06);   // scene 3
        splitReveal('#builder  h2', 'words', 0.06);   // scene 4 — dramatic
        splitReveal('#ai       h2', 'words', 0.06);
        splitReveal('#progress h2', 'words', 0.06);
        splitReveal('#different h2', 'words', 0.06);
        splitReveal('#close     h2', 'words', 0.06);
      }

      // hero "compound." red glow pulse after the headline assembles
      gsap.fromTo('.hero-glow-word',
        { textShadow: '0 0 0px rgba(230,57,70,0)' },
        { textShadow: '0 0 30px rgba(230,57,70,0.9)', duration: 0.6, delay: 2.4, ease: 'power2.out' });

      // hero headline scales up + fades as you scroll past
      gsap.to('#hero h1', {
        scale: 1.05, opacity: 0, ease: 'none',
        scrollTrigger: { trigger: '#hero', start: 'top top', end: 'bottom 30%', scrub: true },
      });

      // section-enter accent — a soft glow band that CROSSFADES in/out
      gsap.utils.toArray('section.scene[id]').forEach((sec) => {
        const line = document.createElement('div');
        line.className = 'scanner';
        sec.appendChild(line);
        gsap.timeline({ scrollTrigger: { trigger: sec, start: 'top 75%', toggleActions: 'play none none none' } })
          .fromTo(line, { opacity: 0 }, { opacity: 1, duration: 0.45, ease: 'power2.out' })
          .to(line, { opacity: 0, duration: 0.5, ease: 'power1.inOut' }, '+=0.04');
      });
    });

    // ── PART 5: CTA ripple + magnetic pull (delegated; outside gsap ctx) ──
    const ctas = Array.from(document.querySelectorAll('.btn-primary, .btn-ghost'));
    const ctaCleanups = ctas.map((btn) => {
      const onClick = (e) => {
        const r = btn.getBoundingClientRect();
        const rip = document.createElement('span');
        rip.className = 'ripple';
        rip.style.left = (e.clientX - r.left) + 'px';
        rip.style.top = (e.clientY - r.top) + 'px';
        btn.appendChild(rip);
        gsap.to(rip, { scale: 18, opacity: 0, duration: 0.6, ease: 'power2.out', onComplete: () => rip.remove() });
      };
      const onMove = (e) => {
        if (isTouch()) return;
        const r = btn.getBoundingClientRect();
        const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
        const dx = e.clientX - cx, dy = e.clientY - cy;
        if (Math.hypot(dx, dy) < r.width / 2 + 60) {
          gsap.to(btn, { x: gsap.utils.clamp(-8, 8, dx * 0.3), y: gsap.utils.clamp(-8, 8, dy * 0.3), duration: 0.3, ease: 'power2.out' });
        }
      };
      const onLeave = () => gsap.to(btn, { x: 0, y: 0, duration: 0.4, ease: 'elastic.out(1, 0.5)' });
      btn.addEventListener('click', onClick);
      btn.addEventListener('mousemove', onMove);
      btn.addEventListener('mouseleave', onLeave);
      return () => { btn.removeEventListener('click', onClick); btn.removeEventListener('mousemove', onMove); btn.removeEventListener('mouseleave', onLeave); };
    });

    // Safety net: a play-once reveal whose trigger start lands BEYOND max scroll can
    // never fire (e.g. the #close footer at the very bottom — its 'top 86%' start is
    // past document end), stranding content at opacity 0 forever. After every refresh,
    // complete any such reveal instantly. Scrubbed triggers are skipped on purpose.
    const unstrand = () => {
      const max = ScrollTrigger.maxScroll(window);
      ScrollTrigger.getAll().forEach((t) => {
        if (t.animation && !t.vars.scrub && /play/.test(t.vars.toggleActions || '') && t.start > max) {
          t.animation.progress(1);
        }
      });
    };
    ScrollTrigger.addEventListener('refresh', unstrand);

    ScrollTrigger.refresh();
    // Trigger positions computed at setup are wrong until images, fonts and the
    // 3D canvas settle — otherwise reveals can be stranded at opacity:0 and the
    // content "vanishes". Recompute once everything has loaded.
    const onLoad = () => ScrollTrigger.refresh();
    window.addEventListener('load', onLoad);
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => ScrollTrigger.refresh());
    }
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (scrollRaf) cancelAnimationFrame(scrollRaf);
      window.removeEventListener('load', onLoad);
      ScrollTrigger.removeEventListener('refresh', unstrand);
      ctx.revert();
      ctaCleanups.forEach(fn => fn());
      if (lenisRaf) gsap.ticker.remove(lenisRaf);
      if (lenis) { lenis.destroy(); lenis = null; }
    };
  }, []);
}

function Count({ v, sfx = '', dec = 0, className = '' }) {
  return <span className={className} data-count={v} data-sfx={sfx} data-dec={dec}>0{sfx}</span>;
}

/* ════════════════════════════════════════════
   INTRO — black → red line → split open
════════════════════════════════════════════ */
function Intro() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (!gsap || reducedMotion()) { el.style.display = 'none'; return; }
    const tl = gsap.timeline({ onComplete: () => { el.style.display = 'none'; } });
    tl.to(el.querySelector('.intro-line'), { scaleX: 1, duration: 0.5, ease: 'power2.inOut' })
      .to(el.querySelector('.intro-line'), { opacity: 0, duration: 0.12 }, '+=0.05')
      .to(el.querySelector('.intro-half.t'), { yPercent: -100, duration: 0.4, ease: 'power3.inOut' }, '<')
      .to(el.querySelector('.intro-half.b'), { yPercent: 100, duration: 0.4, ease: 'power3.inOut' }, '<')
      .fromTo('nav', { y: -36, opacity: 0 }, { y: 0, opacity: 1, duration: 0.3, ease: 'power3.out', clearProps: 'all' }, '-=0.1');
    // safety: never strand the page behind the intro (background-tab rAF throttling etc.)
    const failsafe = setTimeout(() => { tl.kill(); el.style.display = 'none'; gsap.set('nav', { clearProps: 'all' }); }, 2500);
    return () => { clearTimeout(failsafe); tl.kill(); };
  }, []);
  return (
    <div ref={ref} className="intro" aria-hidden="true">
      <div className="intro-half t" /><div className="intro-half b" /><div className="intro-line" />
    </div>
  );
}

/* ════════════════════════════════════════════
   NAV
════════════════════════════════════════════ */
const LINKS = [
  { label: 'Problem', href: '#problem' },
  { label: 'Journey', href: '#journey' },
  { label: 'Game Builder', href: '#builder' },
  { label: 'AI', href: '#ai' },
  { label: 'Progress', href: '#progress' },
];

function Logo({ h = 32 }) {
  const [ok, setOk] = useState(true);
  if (!ok) return null; // logo missing → OFFMAT text alone carries the brand
  // logo.png is a JPEG with a baked near-black, vignetted background (no alpha).
  // We feather the square edge with a radial mask so the dark padding melts into the
  // nav in BOTH its transparent (over-hero) and frosted-solid states. This replaces
  // mixBlendMode:'screen', which turned black transparent and was erasing the black
  // belt — the most on-brand part of the mark — leaving only a faint red blob.
  const mask = 'radial-gradient(circle at 50% 49%, #000 60%, rgba(0,0,0,0) 86%)';
  return (
    <span style={{ display: 'block', width: h + 'px', height: h + 'px', flex: 'none', WebkitMaskImage: mask, maskImage: mask }}>
      <img src="assets/logo.png?v=3" alt="OFFMAT" width={h} height={h}
        style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', filter: 'contrast(1.06) saturate(1.06)' }}
        onError={() => setOk(false)} />
    </span>
  );
}

function Nav() {
  const [solid, setSolid] = useState(false);
  const [active, setActive] = useState('');
  useEffect(() => {
    const ids = LINKS.map(l => l.href.slice(1));
    // PERF (Item 1): 5 getBoundingClientRect reads per scroll EVENT → coalesced to at
    // most one batch per frame via rAF (scroll can fire multiple times a frame).
    let raf = 0;
    const apply = () => {
      raf = 0;
      setSolid(window.scrollY > 30);
      let cur = '';
      ids.forEach(id => {
        const el = document.getElementById(id);
        if (el && el.getBoundingClientRect().top <= window.innerHeight * 0.45) cur = id;
      });
      setActive(cur);
    };
    const fn = () => { if (!raf) raf = requestAnimationFrame(apply); };
    window.addEventListener('scroll', fn, { passive: true });
    apply();
    return () => { window.removeEventListener('scroll', fn); if (raf) cancelAnimationFrame(raf); };
  }, []);
  return (
    <nav className="transition-all duration-300"
      style={{
        position: 'fixed', top: 0, left: 0, width: '100%', zIndex: 9999,
        // transparent over the hero, frosted glass once you start scrolling
        background: solid ? 'rgba(10,10,12,0.66)' : 'transparent',
        backdropFilter: solid ? 'saturate(150%) blur(16px)' : 'none',
        WebkitBackdropFilter: solid ? 'saturate(150%) blur(16px)' : 'none',
        borderBottom: solid ? '1px solid rgba(255,255,255,0.07)' : '1px solid transparent',
        boxShadow: solid ? '0 10px 34px rgba(0,0,0,0.4)' : 'none',
      }}>
      <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
        <a href="#hero" onClick={e => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }} className="flex items-center gap-2.5 cursor-pointer tap" style={{ background: 'transparent', backgroundColor: 'transparent', border: 'none', boxShadow: 'none', borderRadius: 0, padding: 0 }}>
          <Logo h={32} />
          <span className="wordmark font-display font-bold tracking-widest text-white">OFFMAT</span>
        </a>
        <div className="flex items-center gap-6">
          <div className="hidden lg:flex items-center gap-6">
            {LINKS.map(l => (
              <a key={l.href} href={l.href}
                onClick={e => { e.preventDefault(); scrollToId(l.href); }}
                className={`navlink text-sm ${active === l.href.slice(1) ? 'on text-white' : 'text-om-gray hover:text-white'}`}>
                {l.label}
              </a>
            ))}
          </div>
          {/* ITEM 3 — public launch: store buttons replace the TestFlight CTA */}
          <div className="hidden sm:flex items-center gap-2.5">
            <a href="https://testflight.apple.com/join/bkw4sBR8" target="_blank" rel="noopener" className="btn-primary flex items-center gap-1.5 px-4 py-2.5 text-xs tap">
              <AppleIcon s={13} /> Download on the App Store
            </a>
            <a href="#" onClick={e => { e.preventDefault(); showAndroidToast(); }} className="btn-ghost flex items-center gap-1.5 px-4 py-2.5 text-xs tap">
              <PlayIcon s={13} /> Get it on Google Play
            </a>
          </div>
        </div>
      </div>
    </nav>
  );
}


/* ════════════════════════════════════════════
   STORE CTAs — ITEM 3: public launch buttons (App Store + Google Play).
   Replaces every "Get Early Access on TestFlight" CTA. Placeholder hrefs (#) —
   the app isn't live yet; preventDefault stops the jump-to-top. `size` maps to
   the same padding scale the old CTAs used at each spot.
════════════════════════════════════════════ */
function AppleIcon({ s = 15 }) {
  return (
    <svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M17.05 12.54c-.03-2.89 2.36-4.28 2.47-4.35-1.35-1.97-3.44-2.24-4.18-2.27-1.78-.18-3.47 1.05-4.37 1.05-.9 0-2.29-1.02-3.77-1-1.94.03-3.72 1.13-4.72 2.86-2.01 3.49-.51 8.66 1.45 11.49.96 1.39 2.1 2.95 3.6 2.89 1.44-.06 1.99-.93 3.73-.93s2.23.93 3.76.9c1.55-.03 2.53-1.41 3.48-2.8 1.1-1.61 1.55-3.17 1.57-3.25-.03-.02-3-1.15-3.02-4.59zM14.16 4.06c.79-.96 1.33-2.3 1.18-3.63-1.14.05-2.53.76-3.35 1.72-.73.85-1.38 2.21-1.21 3.51 1.28.1 2.58-.65 3.38-1.6z"/>
    </svg>
  );
}
function PlayIcon({ s = 15 }) {
  return (
    <svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M3.6 1.8c-.35.37-.55.94-.55 1.68v17.07c0 .74.2 1.31.56 1.67l.09.08 9.56-9.56v-.22L3.7 1.72l-.1.08zm12.85 12.13-3.19-3.19 3.19-3.19 3.85 2.19c1.1.62 1.1 1.64 0 2.27l-3.85 2.18-.0-.26zm-.63.63-9.9 9.9c.36.04.8-.05 1.29-.33l11.6-6.59-2.99-2.98zm0-6.9L4.22-.63c-.49-.28-.93-.36-1.3-.32l9.91 9.9 2.99-3.29z" transform="scale(0.92) translate(1,1)"/>
    </svg>
  );
}
/* ════════════════════════════════════════════
   PERSISTENT — privacy, mobile CTA
════════════════════════════════════════════ */
function Privacy() {
  const [off, setOff] = useState(() => { try { return localStorage.getItem('om-priv') === '1'; } catch (e) { return false; } });
  if (off) return null;
  return (
    <div className="privacy">
      <p className="text-xs text-white leading-relaxed mb-2">OFFMAT uses cookies. We respect your privacy.</p>
      <button className="btn-primary px-4 py-1 text-xs" style={{ minHeight: '32px' }}
        onClick={() => { try { localStorage.setItem('om-priv', '1'); } catch (e) {} setOff(true); }}>OK</button>
    </div>
  );
}

function MobileCTA() {
  // ITEM 3 — public launch: side-by-side compact store buttons fit the bottom bar
  return (
    <div id="mcta" className="mcta">
      <div className="flex gap-2.5">
        <a href="https://testflight.apple.com/join/bkw4sBR8" target="_blank" rel="noopener"
          className="btn-primary flex-1 flex items-center justify-center gap-1.5 py-3 text-[11px] leading-tight text-center tap">
          <AppleIcon s={13} /> Download on the App Store
        </a>
        <a href="#" onClick={e => { e.preventDefault(); showAndroidToast(); }}
          className="btn-ghost flex-1 flex items-center justify-center gap-1.5 py-3 text-[11px] leading-tight text-center tap">
          <PlayIcon s={13} /> Get it on Google Play
        </a>
      </div>
    </div>
  );
}

/* ════════════════════════════════════════════
   SCENE 1 — THE OPENING
════════════════════════════════════════════ */
/* Realistic device: screenshot fills full width, screen continues in the
   app's own bg color (no crop, no letterbox). Gloss + home indicator on top. */
function PhoneShell({ src, alt, className, style, fallback, screenBg = '#0A0A0A', align = 'top', dark = true }) {
  const [ok, setOk] = useState(true);
  return (
    <div className={`phone ${className}`} style={style}>
      <div className="relative w-full h-full overflow-hidden" style={{ background: screenBg }}>
        {ok ? (
          // object-contain shows the FULL screenshot (no crop/zoom) inside the frame; the
          // screenshots are wider in aspect than the frame, so the screen's own bg (screenBg)
          // fills the remaining band. Top-anchored so the app header sits at the top.
          <img src={src} alt={alt}
            className="w-full h-full block"
            style={{ objectFit: 'contain', objectPosition: align === 'center' ? 'center' : 'top center' }}
            onError={() => setOk(false)} />
        ) : (
          <div className="w-full h-full flex items-center justify-center text-om-gray text-xs p-6 text-center">{fallback}</div>
        )}
        {/* screen gloss */}
        <div className="pointer-events-none absolute inset-0"
          style={{ background: 'linear-gradient(115deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.02) 18%, transparent 35%)' }} />
        {/* home indicator */}
        <div className="pointer-events-none absolute bottom-1.5 left-1/2 -translate-x-1/2 h-1 w-[34%] rounded-full"
          style={{ background: dark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.35)' }} />
      </div>
    </div>
  );
}

function Scene1() {
  const headRef = useRef(null);
  const phonesRef = useRef(null);

  useEffect(() => {
    if (!gsap || reducedMotion()) return;
    const words = headRef.current ? headRef.current.querySelectorAll('.w') : [];
    const tl = gsap.timeline({ delay: 1.1 });
    tl.from('.s1-eyebrow', { opacity: 0, y: 18, duration: 0.45, ease: 'power2.out' })
      .from(words, { opacity: 0, y: 44, rotateX: -55, duration: 0.65, ease: 'power3.out', stagger: 0.12 }, '-=0.15')
      .from('.s1-sub', { opacity: 0, y: 18, duration: 0.5, ease: 'power2.out' }, '-=0.2')
      .from('.s1-cta', { opacity: 0, y: 18, duration: 0.5, ease: 'power2.out', stagger: 0.1 }, '-=0.25')
      .from('.s1-phone', { opacity: 0, y: 70, duration: 1, ease: 'elastic.out(1, 0.65)', stagger: 0.12 }, '-=0.9')
      .from('.s1-chip', { opacity: 0, scale: 0.5, duration: 0.65, ease: 'elastic.out(1, 0.6)', stagger: 0.14 }, '-=0.5');
    return () => tl.kill();
  }, []);

  // mouse tilt (desktop)
  useEffect(() => {
    const box = phonesRef.current;
    if (!box || isTouch() || !gsap || reducedMotion()) return;
    const items = box.querySelectorAll('.tiltable');
    const mv = e => {
      const r = box.getBoundingClientRect();
      const px = (e.clientX - r.left) / r.width - 0.5;
      const py = (e.clientY - r.top) / r.height - 0.5;
      gsap.to(items, { rotateY: px * 10, rotateX: -py * 10, duration: 0.5, ease: 'power2.out', transformPerspective: 900 });
    };
    const lv = () => gsap.to(items, { rotateY: 0, rotateX: 0, duration: 0.7, ease: 'power3.out' });
    box.addEventListener('mousemove', mv);
    box.addEventListener('mouseleave', lv);
    return () => { box.removeEventListener('mousemove', mv); box.removeEventListener('mouseleave', lv); };
  }, []);

  return (
    <section id="hero" className="scene" style={{ paddingTop: '72px' }}>
      <div className="scene-inner !max-w-6xl">
        <div className="grid lg:grid-cols-2 gap-14 items-center pt-10">
          {/* Copy */}
          <div className="text-center lg:text-left flex flex-col gap-6">
            <p className="s1-eyebrow eyebrow">OFFMAT</p>
            <h1 ref={headRef} className="display-xl text-5xl sm:text-6xl lg:text-7xl" style={{ perspective: '600px' }}>
              <span className="w inline-block mr-3">Make</span>
              <span className="w inline-block mr-3">your</span>
              <span className="w inline-block">jiu-jitsu</span><br />
              <span className="w hero-glow-word inline-block text-om-red glow-red-text">compound.</span>
            </h1>
            <p className="s1-sub text-om-gray text-lg sm:text-xl">Log training, review rounds, build your game, and stay connected to your academy &mdash; without turning jiu-jitsu into a dashboard.</p>
            {/* ITEM 3 — public launch: store buttons replace the TestFlight CTA (Enter unchanged) */}
            <div className="flex flex-col sm:flex-row flex-wrap gap-3 justify-center lg:justify-start">
              <a href="https://testflight.apple.com/join/bkw4sBR8" target="_blank" rel="noopener"
                className="s1-cta btn-primary px-6 py-4 text-sm sm:text-base flex items-center justify-center gap-2 tap">
                <AppleIcon s={16} /> Download on the App Store
              </a>
              <a href="#" onClick={e => { e.preventDefault(); showAndroidToast(); }}
                className="s1-cta btn-ghost px-6 py-4 text-sm sm:text-base flex items-center justify-center gap-2 tap">
                <PlayIcon s={15} /> Get it on Google Play
              </a>
              <a href="#problem" onClick={e => { e.preventDefault(); scrollToId('#problem'); }}
                className="s1-cta btn-ghost px-6 py-4 text-sm sm:text-base flex items-center justify-center gap-2 tap">
                Enter
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
              </a>
            </div>
          </div>

          {/* Phones */}
          <div ref={phonesRef} className="relative h-[460px] sm:h-[520px] lg:h-[600px] flex items-center justify-center" style={{ perspective: '1000px' }}>
            <div className="tiltable phone-float absolute inset-0 flex items-center justify-center" style={{ transformStyle: 'preserve-3d' }}>
              {/* left: Film Study — Footage Review (dark portrait, fills frame seamlessly) */}
              <PhoneShell src="assets/screenshots/stats.png?v=3" alt="OFFMAT Film Study — Footage Review"
                screenBg="#0A0A0A" align="top" dark
                className="s1-phone absolute w-[150px] h-[310px] sm:w-[170px] sm:h-[350px] z-10 -left-1 sm:left-2 lg:-left-2"
                style={{ transform: 'rotate(-9deg) translateY(28px) translateZ(-30px)' }} fallback="stats.png" />
              {/* center: home screen — GOAT Level 15, dark screen continues seamlessly */}
              <PhoneShell src="assets/screenshots/home.png?v=2" alt="OFFMAT home — GOAT Level 15"
                screenBg="#0A0A0A" align="top" dark
                className="s1-phone absolute w-[190px] h-[400px] sm:w-[210px] sm:h-[440px] z-20"
                style={{ transform: 'translateZ(40px)' }} fallback="home.png" />
              {/* right: Game Builder Library — light screen continues seamlessly */}
              <PhoneShell src="assets/screenshots/review.png?v=2" alt="OFFMAT Game Builder Library"
                screenBg="#F4F3F8" align="top" dark={false}
                className="s1-phone absolute w-[150px] h-[310px] sm:w-[170px] sm:h-[350px] z-10 -right-1 sm:right-2 lg:-right-2"
                style={{ transform: 'rotate(9deg) translateY(28px) translateZ(-30px)' }} fallback="review.png" />

              {/* chips */}
              <div className="s1-chip absolute bottom-10 left-0 bg-om-card border border-om-line rounded-2xl px-4 py-2.5 z-30" style={{ transform: 'translateZ(70px)' }}>
                <p className="text-xs font-bold">Session logged</p>
                <p className="text-om-gray text-[10px]">AI note ready</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 2 — THE PROBLEM
════════════════════════════════════════════ */
function Scene2() {
  const lines = [
    { t: 'Your footage sits unwatched.', d: 'Rounds pile up in your camera roll. Film Study marks the moments that matter, and AI Round Review hands you the pattern worth fixing.' },
    { t: 'Your progress is invisible.', d: 'You are improving — you just can’t see it. Mat Time, Practice Rhythm, and your record make the slow middle of improvement measurable.' },
    { t: 'Your game has no map.', d: 'Techniques collected at random never connect. Game Builder links positions, attacks, and counters into a system that is unmistakably yours: My Game.' },
  ];
  const [open, setOpen, rootRef] = useDismissable();
  return (
    <section id="problem" className="scene">
      <div className="scene-inner">
        <div className="grid md:grid-cols-2 gap-10 items-center mb-14">
          <div className="rv text-center md:text-right">
            <span className="display-xl text-8xl sm:text-9xl text-om-red glow-red-text"><Count v={72} sfx="%" /></span>
          </div>
          <p className="rv scene2-head text-2xl sm:text-3xl font-display font-semibold leading-snug text-center md:text-left">
            of grapplers can&rsquo;t remember what they drilled two sessions ago.
          </p>
        </div>
        <div ref={rootRef} className="flex flex-col gap-4 max-w-xl mx-auto" data-stagger>
          {lines.map((l, i) => (
            <div key={i} data-si>
              <button onClick={() => setOpen(open === i ? null : i)}
                className={`w-full text-left flex items-center justify-between gap-4 px-5 py-4 rounded-2xl border transition-all duration-300 cursor-pointer tap ${open === i ? 'border-om-red/60 bg-om-card' : 'border-om-line bg-om-card/60 hover:border-om-red/30'}`}>
                <span className="font-display font-semibold text-lg sm:text-xl">{l.t}</span>
                <span className={`text-om-red transition-transform duration-300 ${open === i ? 'rotate-45' : ''}`}>
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
                </span>
              </button>
              <div className="grid transition-all duration-500" style={{ gridTemplateRows: open === i ? '1fr' : '0fr' }}>
                <div className="overflow-hidden">
                  <div className="mt-2 px-5 py-4 rounded-2xl border border-om-red/40 bg-om-red/5 text-sm text-white/85 leading-relaxed">{l.d}</div>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 3 — THE JOURNEY (4 steps)
════════════════════════════════════════════ */
function Scene3() {
  const steps = [
    { t: 'Capture', d: 'Log the session before the details disappear.' },
    { t: 'Review', d: 'Turn notes, rounds, and video into useful feedback.' },
    { t: 'Build', d: 'Connect positions, attacks, and counters into your game.' },
    { t: 'Return', d: 'Walk back onto the mat knowing what to focus on.' },
  ];
  // Journey nodes use the shared dismissable rule (outside-tap + single-open, both
  // desktop and mobile). tap-again-to-close stays on each node's onClick below.
  const [sel, setSel, rootRef] = useDismissable();
  const ref = useRef(null);

  useEffect(() => {
    if (!gsap || reducedMotion() || !ref.current) return;
    const ctx = gsap.context(() => {
      const line = ref.current.querySelector('.j-line');
      const nodes = ref.current.querySelectorAll('.j-node');
      const labels = ref.current.querySelectorAll('.j-label');
      const len = line.getTotalLength ? line.getTotalLength() : 1000;
      gsap.set(line, { strokeDasharray: len, strokeDashoffset: len });
      gsap.set(nodes, { scale: 0, transformOrigin: 'center', transformBox: 'fill-box' });
      gsap.set(labels, { opacity: 0, y: 12 });
      const tl = gsap.timeline({ scrollTrigger: { trigger: ref.current, start: 'top 75%', toggleActions: 'play none none none' } });
      tl.to(line, { strokeDashoffset: 0, duration: 1.2, ease: 'power2.inOut' })
        .to(nodes, { scale: 1, duration: 0.6, ease: 'elastic.out(1, 0.55)', stagger: 0.12 }, '-=0.9')
        .to(labels, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', stagger: 0.1 }, '-=0.6');
    }, ref);
    return () => ctx.revert();
  }, []);

  const X = [12.5, 37.5, 62.5, 87.5];
  return (
    <section id="journey" className="scene">
      <div className="scene-inner">
        <p className="rv eyebrow text-center mb-4">The Journey</p>
        <h2 className="rv display-xl text-4xl sm:text-5xl text-center mb-14">Capture what happened. Review the pattern. <span className="text-om-red">Build your game. Return with direction.</span></h2>

        <div ref={(el) => { ref.current = el; rootRef.current = el; }} className="relative">
          <svg viewBox="0 0 100 26" className="w-full" preserveAspectRatio="xMidYMid meet">
            <line className="j-line" x1="12.5" y1="10" x2="87.5" y2="10" stroke="#e63946" strokeWidth="0.5" strokeOpacity="0.7" strokeLinecap="round" />
            {steps.map((s, i) => (
              <g key={i} className="j-node cursor-pointer" onClick={() => setSel(sel === i ? null : i)}>
                {/* Phase 5A — transparent oversized hit target (~44px) so a single tap lands */}
                <circle className="j-hit" cx={X[i]} cy="10" r="7" />
                <circle cx={X[i]} cy="10" r={sel === i ? 3.6 : 2.8} fill={sel === i ? '#e63946' : '#161616'} stroke="#e63946" strokeWidth="0.6"
                  style={{ transition: 'r 0.25s, fill 0.25s', filter: sel === i ? 'drop-shadow(0 0 5px rgba(230,57,70,0.9))' : 'none' }} />
                <circle cx={X[i]} cy="10" r="1" fill="#fff" opacity={sel === i ? 1 : 0.55} />
              </g>
            ))}
            {steps.map((s, i) => (
              <text key={i} className="j-label" x={X[i]} y="20" textAnchor="middle" fill={sel === i ? '#e63946' : '#fff'}
                style={{ fontSize: '3.2px', fontWeight: 700, fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, \"SF Pro Display\", \"SF Pro Text\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif', letterSpacing: '0.08em', cursor: 'pointer' }}
                onClick={() => setSel(sel === i ? null : i)}>
                {s.t.toUpperCase()}
              </text>
            ))}
          </svg>

          {sel != null && (
            <div className="absolute z-20 -top-2 left-1/2 -translate-x-1/2 sm:translate-x-0 w-[88%] sm:w-72 panel p-4 border-om-red/50"
              style={{ left: isTouch() ? '50%' : `clamp(0%, ${X[sel] - 15}%, 70%)` }}>
              <p className="text-om-red text-[10px] font-bold tracking-widest uppercase mb-1">{steps[sel].t}</p>
              <p className="text-sm text-white/85 leading-relaxed">{steps[sel].d}</p>
            </div>
          )}
        </div>

        <p className="rv text-om-gray text-center mt-10">Four actions, repeated until they compound.</p>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 4 — GAME BUILDER (showstopper)
════════════════════════════════════════════ */
const GB_NODES = [
  { id: 'guard', l: 'Guard', x: 50, y: 50, hub: true, d: 'Home base — sweeps, submissions, and retention start here' },
  { id: 'passing', l: 'Passing', x: 50, y: 14, d: 'Breaking through guard to establish top control' },
  { id: 'escapes', l: 'Escapes', x: 84, y: 20, d: 'Getting out of bad positions and back to neutral' },
  { id: 'mount', l: 'Mount', x: 80, y: 38, d: 'Chest-to-chest top control — heavy pressure, high threat' },
  { id: 'submissions', l: 'Submissions', x: 88, y: 62, d: 'Finishing chains — chokes, locks, and the paths into them' },
  { id: 'back', l: 'Back', x: 72, y: 82, d: 'The most dominant control in grappling' },
  { id: 'backtake', l: 'Back Take', x: 46, y: 88, d: 'The transition that turns control into the back' },
  { id: 'dogfight', l: 'Dogfight', x: 20, y: 78, d: 'The half-guard scramble — sweep or take the back' },
  { id: 'underhook', l: 'Underhook', x: 12, y: 52, d: 'The battle for inside control' },
  { id: 'kneeshield', l: 'Knee Shield', x: 20, y: 24, d: 'Half guard frame that creates distance and options' },
];
const GB_LINKS = [
  ['guard','passing'],['guard','escapes'],['guard','kneeshield'],['guard','mount'],
  ['kneeshield','underhook'],['underhook','dogfight'],['dogfight','backtake'],
  ['backtake','back'],['back','submissions'],['mount','submissions'],
];

function Scene4() {
  const [focus, setFocus] = useState(null);   // hover-only (desktop) — auto-clears on mouseleave
  const [tip, setTip, tipRootRef] = useDismissable();   // click/tap tooltip — shared dismiss rule
  const ref = useRef(null);
  const zoomRef = useRef(null);

  const adj = {};
  GB_NODES.forEach(n => { adj[n.id] = new Set([n.id]); });
  GB_LINKS.forEach(([a, b]) => { adj[a].add(b); adj[b].add(a); });
  const P = id => GB_NODES.find(n => n.id === id);
  const hot = focus || tip;

  useEffect(() => {
    if (!gsap || reducedMotion() || !ref.current) return;
    const ctx = gsap.context(() => {
      // camera-like zoom 0.6 → 1 while scrolling through
      gsap.fromTo(zoomRef.current, { scale: 0.6 }, {
        scale: 1, ease: 'none',
        scrollTrigger: { trigger: ref.current, start: 'top 90%', end: 'top 20%', scrub: 0.5 },
      });
      // draw lines + pop nodes
      const lines = ref.current.querySelectorAll('.gb-l');
      lines.forEach(l => {
        const len = parseFloat(l.dataset.len) || 100;
        gsap.set(l, { strokeDasharray: len, strokeDashoffset: len });
      });
      const nodes = ref.current.querySelectorAll('.gb-n');
      gsap.set(nodes, { scale: 0, transformOrigin: 'center', transformBox: 'fill-box' });
      const tl = gsap.timeline({ scrollTrigger: { trigger: ref.current, start: 'top 70%', toggleActions: 'play none none none' } });
      tl.to(lines, { strokeDashoffset: 0, duration: 2, ease: 'power2.inOut', stagger: 0.08 })
        .to(nodes, { scale: 1, duration: 0.7, ease: 'elastic.out(1, 0.55)', stagger: 0.08 }, '-=1.6')
        // PART 5: after build, hand each node a slow independent idle pulse
        .add(() => {
          nodes.forEach((n, i) => {
            gsap.set(n, { clearProps: 'transform' });
            n.style.setProperty('--pulse', (2 + (i % 5) * 0.5) + 's');
            n.classList.add('idle');
          });
        });
    }, ref);
    // FINAL PASS (perf) — the idle node pulse + pulse-ring animate `transform` on SVG
    // elements, which Chromium runs on the MAIN THREAD (not the compositor). Left
    // running while #builder is offscreen, they repainted every frame for the rest of
    // the session (~2.1s of long tasks per 3s idle, measured). Pause them whenever the
    // graph is out of view: CSS holds them paused unless `.gb-live` is present.
    const io = ('IntersectionObserver' in window)
      ? new IntersectionObserver(([entry]) => {
          if (ref.current) ref.current.classList.toggle('gb-live', entry.isIntersecting);
        }, { rootMargin: '80px' })
      : null;
    if (io && ref.current) io.observe(ref.current);
    else if (ref.current) ref.current.classList.add('gb-live');   // ancient fallback: always run
    return () => { ctx.revert(); if (io) io.disconnect(); };
  }, []);

  return (
    <section id="builder" className="scene">
      <div className="scene-inner !max-w-5xl">
        <h2 className="rv display-xl text-4xl sm:text-5xl text-center mb-2">Stop collecting techniques. Start connecting them.</h2>

        <div ref={(el) => { ref.current = el; tipRootRef.current = el; }} className="relative my-8" onClick={() => setTip(null)}>
          <div ref={zoomRef} className="relative w-full max-w-[640px] mx-auto aspect-square rounded-3xl bg-om-black/70 border border-om-line overflow-hidden">
            {/* dotted grid */}
            <div className="absolute inset-0 opacity-35 pointer-events-none"
              style={{ backgroundImage: 'radial-gradient(rgba(255,255,255,0.07) 1px, transparent 1px)', backgroundSize: '26px 26px' }} />
            <svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
              {GB_LINKS.map(([a, b], i) => {
                const p1 = P(a), p2 = P(b);
                const len = Math.hypot(p2.x - p1.x, p2.y - p1.y);
                const on = hot && (a === hot || b === hot);
                const dim = hot && !on;
                return <line key={i} className="gb-l" data-len={len}
                  x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y}
                  stroke="#e63946" strokeLinecap="round"
                  strokeWidth={on ? 0.9 : 0.45} strokeOpacity={dim ? 0.1 : on ? 1 : 0.35}
                  style={{ transition: 'stroke-opacity 0.25s, stroke-width 0.25s' }} />;
              })}
              {GB_NODES.map(n => {
                const dim = hot && !adj[hot].has(n.id);
                const on = hot === n.id;
                return (
                  <g key={n.id} className={`gnode gb-n ${dim ? 'gdim' : ''}`}
                    onMouseEnter={() => !isTouch() && setFocus(n.id)}
                    onMouseLeave={() => !isTouch() && setFocus(null)}
                    onClick={e => { e.stopPropagation(); setTip(tip === n.id ? null : n.id); }}>
                    {/* Phase 5A — transparent oversized hit target (~44px) so a single tap lands */}
                    <circle className="gb-hit" cx={n.x} cy={n.y} r="6.5" />
                    {n.hub && <circle className="pulse-ring" cx={n.x} cy={n.y} r="6" fill="none" stroke="#e63946" strokeWidth="0.4" strokeOpacity="0.6" />}
                    <circle cx={n.x} cy={n.y} r={n.hub ? 4.2 : 2.6}
                      fill={on || n.hub ? '#e63946' : '#161616'} stroke="#e63946" strokeWidth="0.5"
                      style={{ transition: 'fill 0.25s, r 0.25s', transform: on ? 'scale(1.15)' : 'scale(1)', transformOrigin: `${n.x}px ${n.y}px` }} />
                    {(n.hub || on) && <circle cx={n.x} cy={n.y} r="1.1" fill="#fff" />}
                    <text x={n.x} y={n.y + (n.y > 80 ? -5.5 : n.hub ? 8.5 : 6)} textAnchor="middle" fill="#fff"
                      style={{ fontSize: '3px', fontWeight: 700, fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, \"SF Pro Display\", \"SF Pro Text\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif', letterSpacing: '0.06em', pointerEvents: 'none' }}>
                      {n.l.toUpperCase()}
                    </text>
                  </g>
                );
              })}
            </svg>

            {tip && (() => {
              const n = P(tip);
              // BUG2 FIX (mobile only): the container is overflow:hidden, so clamp the
              // tooltip x inside it (max-width is 72% on mobile → half = 36%) and flip
              // it BELOW top-area nodes (y < 45) instead of above, so it is never
              // cropped by the container edges. Desktop path identical (isTouch false).
              const touch = isTouch();
              const below = touch && n.y < 45;
              return (
                <div className={`gtip${below ? ' gtip-below' : ''}`}
                  style={{ left: touch ? `clamp(36%, ${n.x}%, 64%)` : n.x + '%', top: n.y + '%' }}>
                  <p className="text-om-red text-[10px] font-bold tracking-widest uppercase mb-1">{n.l}</p>
                  <p className="text-white text-[11px] leading-snug">{n.d}</p>
                </div>
              );
            })()}
          </div>
        </div>

        <h3 className="rv display-xl text-3xl sm:text-4xl text-center text-om-red mb-3">Build your game like a map.</h3>
        <p className="rv text-om-gray text-xs text-center">Add positions, connect attacks, map counters &mdash; and see where your game is strong, thin, or missing an answer. Tap any position to trace its connections.</p>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 5 — AI FEATURES
════════════════════════════════════════════ */
function Scene5() {
  const panels = [
    {
      t: 'One adjustment. After every session.',
      b: 'AI Danaher reads your logged session and finds the single most important thing to fix next.',
      more: 'No fifty-item correction lists. The model weighs your last sessions, your stated goals, and your recurring leaks — then hands you one focus, phrased like a coach who watched you roll.',
      icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#e63946" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9.5 2a4.5 4.5 0 0 0-2.4 8.3 4.5 4.5 0 0 0 .4 8.2 4.5 4.5 0 0 0 9 0 4.5 4.5 0 0 0 .4-8.2A4.5 4.5 0 0 0 14.5 2a4.4 4.4 0 0 0-2.5.8A4.4 4.4 0 0 0 9.5 2Z"/><path d="M12 7v10"/></svg>,
    },
    {
      t: 'Your footage finally works for you.',
      b: 'Upload a roll. AI Round Review marks positions, key moments, and patterns — and hands you a clear next action.',
      more: 'Film Study marks where positions changed, where you repeated the same mistake, and the moment that decided the round — so seven minutes of footage becomes ninety seconds of direction.',
      icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#e63946" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>,
    },
    {
      t: 'One clear focus for your next session.',
      b: 'Ask what to do from any position, or let Next Best Action pick the thing that moves your game most.',
      more: 'Guidance stays short, technical, and tied to your actual training — your logged sessions, your game map, your recurring leaks. Nothing generic, nothing from nowhere.',
      icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#e63946" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>,
    },
  ];
  const [open, setOpen, rootRef] = useDismissable();
  return (
    <section id="ai" className="scene">
      <div className="scene-inner">
        <p className="rv eyebrow text-center mb-4">AI, on the mat&rsquo;s side</p>
        <h2 className="rv display-xl text-4xl sm:text-5xl text-center mb-12">Know what to <span className="text-om-red">work on next.</span></h2>
        <div ref={rootRef} className="flex flex-col gap-4" data-stagger>
          {panels.map((p, i) => (
            <div key={i} data-si className="panel p-6 sm:p-7 cursor-pointer" onClick={() => setOpen(open === i ? null : i)}>
              <div className="flex items-start gap-4">
                <div className="w-11 h-11 rounded-2xl bg-om-red/10 border border-om-red/25 flex items-center justify-center flex-shrink-0">{p.icon}</div>
                <div className="flex-1">
                  <h3 className="font-display font-bold text-xl sm:text-2xl">{p.t}</h3>
                  <p className="text-om-gray text-sm sm:text-base mt-1.5 leading-relaxed">{p.b}</p>
                  <div className="grid transition-all duration-500" style={{ gridTemplateRows: open === i ? '1fr' : '0fr' }}>
                    <div className="overflow-hidden">
                      <p className="mt-3 pt-3 border-t border-om-red/25 text-sm text-white/85 leading-relaxed">{p.more}</p>
                    </div>
                  </div>
                </div>
                <span className={`text-om-red transition-transform duration-300 flex-shrink-0 mt-1 ${open === i ? 'rotate-45' : ''}`}>
                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
                </span>
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 6 — PROGRESS & COMMUNITY
════════════════════════════════════════════ */
function Scene6() {
  const belts = ['#E5E7EB', '#3B82F6', '#9333EA', '#92400E'];
  return (
    <section id="progress" className="scene">
      <div className="scene-inner !max-w-6xl">
        <p className="rv eyebrow text-center mb-4">Built around the room you train in</p>
        <h2 className="rv display-xl text-4xl sm:text-5xl text-center mb-12">See the invisible middle <span className="text-om-green">of improvement.</span></h2>

        <div className="grid lg:grid-cols-2 gap-6">
          {/* Left: 2×2 stats */}
          <div className="grid grid-cols-2 gap-4" data-stagger>
            <div data-si className="panel p-5 group">
              <p className="text-om-gray text-xs mb-2">Practice Rhythm</p>
              <p className="font-display font-bold text-3xl text-om-green"><Count v={3.4} dec={1} /></p>
              <p className="text-om-gray text-[11px] mt-0.5">sessions / week</p>
              <div className="mt-3 h-1.5 bg-white/10 rounded-full overflow-hidden">
                <div className="h-full bg-om-green rounded-full transition-all duration-700 w-[68%] group-hover:w-[78%]" />
              </div>
            </div>
            <div data-si className="panel p-5 group">
              <p className="text-om-gray text-xs mb-2">Positions Improving</p>
              <p className="font-display font-bold text-3xl text-om-green">+<Count v={78} sfx="%" /></p>
              <p className="text-om-gray text-[11px] mt-0.5">mount escapes lead</p>
              <div className="mt-3 h-1.5 bg-white/10 rounded-full overflow-hidden">
                <div className="h-full bg-om-green rounded-full transition-all duration-700 w-[78%] group-hover:w-[88%]" />
              </div>
            </div>
            <div data-si className="panel p-5">
              <p className="text-om-gray text-xs mb-2">Most Studied</p>
              <p className="font-display font-bold text-xl">Guard passing</p>
              <p className="text-om-gray text-[11px] mt-0.5"><Count v={14} /> sessions this cycle</p>
            </div>
            <div data-si className="panel p-5 border-om-red/40" style={{ background: 'linear-gradient(135deg, rgba(230,57,70,0.12), #161616)' }}>
              <p className="text-om-red text-xs font-bold mb-2 uppercase tracking-wider">Next Best Action</p>
              <p className="font-display font-bold text-xl leading-tight">Guard retention under pressure</p>
              <p className="text-om-gray text-[11px] mt-1.5">flagged by AI Round Review from 3 recent rounds</p>
            </div>
          </div>

          {/* Right: community */}
          <div className="flex flex-col gap-4" data-stagger>
            <div data-si className="panel p-5">
              <div className="flex items-center justify-between mb-4">
                <p className="font-display font-bold">This week&rsquo;s leaderboard</p>
                <div className="flex gap-1.5">
                  {belts.map((c, i) => <span key={i} className="w-3.5 h-3.5 rounded-full border border-white/20" style={{ background: c }} title="belt filter" />)}
                </div>
              </div>
              {[
                ['Ana Ferreira', 'Buggy choke · Gracie Lab', 312, '#9333EA'],
                ['Kenji Watanabe', 'Flying armbar · Tora MMA', 287, '#111111'],
                ['Marcus R.', 'Flying triangle · Atlas BJJ', 264, '#9333EA'],
              ].map(([name, sub, votes, belt], i) => (
                <div key={i} className={`flex items-center gap-3 rounded-xl px-3 py-2.5 mb-2 ${i === 0 ? 'bg-om-red/10 border border-om-red/30' : 'bg-black/30'}`}>
                  <span className={`w-5 text-center font-bold text-sm ${i === 0 ? 'text-om-red' : 'text-om-gray'}`}>{i + 1}</span>
                  <span className="w-8 h-8 rounded-full border-2 flex items-center justify-center text-xs font-bold" style={{ borderColor: belt, background: '#161616' }}>{name[0]}</span>
                  <div className="flex-1 min-w-0">
                    <p className="text-sm font-semibold truncate">{name}</p>
                    <p className="text-om-gray text-[11px] truncate">{sub}</p>
                  </div>
                  <Count v={votes} className={`font-bold text-sm ${i === 0 ? 'text-om-red' : ''}`} />
                </div>
              ))}
            </div>
            <div className="grid grid-cols-2 gap-4">
              <div data-si className="panel p-5 border-om-red/35">
                <p className="text-om-red text-[10px] font-bold tracking-widest uppercase mb-1.5">Sub of the Week</p>
                <p className="font-display font-bold text-sm leading-snug">Buggy choke</p>
                <p className="text-om-gray text-[11px] mt-1">312 votes · resets Sunday</p>
              </div>
              <div data-si className="panel p-5">
                <p className="text-om-gray text-[10px] font-bold tracking-widest uppercase mb-1.5">Academy</p>
                <p className="font-display font-bold text-sm leading-snug">Atlas BJJ</p>
                <p className="text-om-gray text-[11px] mt-1"><Count v={64} /> members · <Count v={18} /> comps</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 7 — WHY OFFMAT IS DIFFERENT
════════════════════════════════════════════ */
function Scene7() {
  const cards = [
    ['Training Log', 'Counts sessions', 'OFFMAT turns sessions into direction — and keeps going through time off'],
    ['Notes App', 'Stores text', 'OFFMAT ties notes to rounds, positions, and your game'],
    ['Social App', 'Chases attention', 'OFFMAT builds training culture'],
    ['Fitness Tracker', 'Measures workouts', 'OFFMAT understands grappling'],
  ];
  const [flip, setFlip, rootRef] = useDismissable();
  const touch = isTouch();
  return (
    <section id="different" className="scene">
      <div className="scene-inner !max-w-6xl">
        <h2 className="rv display-xl text-4xl sm:text-5xl text-center">Not a log. Not a feed.</h2>
        <p className="rv display-xl text-2xl sm:text-3xl text-om-red text-center mt-2 mb-12">A journey layer.</p>
        <div ref={rootRef} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-stagger>
          {cards.map(([name, does, ans], i) => (
            <div key={i} data-si className={`flip h-[170px] ${flip === i ? 'flipped' : ''}`} onClick={() => setFlip(flip === i ? null : i)}>
              <div className="flip-in">
                <div className="flip-f panel p-5 flex flex-col justify-between !rounded-2xl">
                  <div>
                    <p className="font-display font-bold text-lg text-om-gray line-through decoration-om-red/60">{name}</p>
                    <p className="text-om-gray text-sm mt-1">{does}</p>
                  </div>
                  <span className="text-om-gray/50 text-[10px] flex items-center gap-1">
                    <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
                    {touch ? 'Tap to flip' : 'Hover to flip'}
                  </span>
                </div>
                <div className="flip-f flip-b p-5 flex items-center !rounded-2xl border border-om-red/50"
                  style={{ background: 'linear-gradient(135deg, rgba(230,57,70,0.18), #161616)' }}>
                  <p className="font-display font-bold text-base leading-snug text-om-red">{ans}</p>
                </div>
              </div>
            </div>
          ))}
        </div>
        {touch && <p className="text-om-gray text-xs text-center mt-5">Tap to flip</p>}
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════
   SCENE 8 — THE CLOSE
════════════════════════════════════════════ */
function Scene8() {
  // ITEM 3 — public launch: the TestFlight email-capture form (its whole purpose was
  // early access) is replaced by the two store buttons. #testflight id kept — nav and
  // scrollToId targets still resolve here.
  return (
    <section id="close" className="scene">
      <div className="scene-inner text-center flex flex-col items-center gap-6">
        <div className="rv"><Logo h={64} /></div>
        <h2 className="rv display-xl text-4xl sm:text-5xl">Start building your<br /><span className="text-om-red glow-red-text">grappling journey.</span></h2>

        <div id="testflight" className="rv w-full max-w-md scroll-mt-28">
          <div className="flex flex-col gap-3">
            <a href="https://testflight.apple.com/join/bkw4sBR8" target="_blank" rel="noopener"
              className="btn-primary py-3.5 text-sm w-full flex items-center justify-center gap-2 tap">
              <AppleIcon s={15} /> Download on the App Store
            </a>
            <a href="#" onClick={e => { e.preventDefault(); showAndroidToast(); }}
              className="btn-ghost py-3.5 text-sm w-full flex items-center justify-center gap-2 tap">
              <PlayIcon s={15} /> Get it on Google Play
            </a>
          </div>
        </div>

        <p className="rv text-om-gray text-sm">Your rounds, notes, game, and academy &mdash; in one place, with memory. Built by grapplers. <a href="https://offmat.app" className="hover:text-white transition-colors">offmat.app</a></p>

        <footer className="rv w-full border-t border-om-line pt-8 mt-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-om-gray">
          <p className="flex items-center gap-2">
            <Logo h={28} />
            <span className="wordmark text-white font-bold tracking-widest">OFFMAT</span>
            <span>· <a href="mailto:support@offmat.app" className="hover:text-white transition-colors">support@offmat.app</a></span>
          </p>
          <div className="flex items-center gap-5">
            <a href="#" className="hover:text-white transition-colors">Privacy</a>
            <a href="#" className="hover:text-white transition-colors">Terms</a>
            <a href="#" className="hover:text-white transition-colors">Instagram</a>
            <a href="#" className="hover:text-white transition-colors">TikTok</a>
          </div>
        </footer>
      </div>
    </section>
  );
}




/* ════════════════════════════════════════════
   CINEMATIC DOJO BACKGROUND — code-only (no images/video except the existing
   OFFMAT logo asset). A fixed, pointer-events:none stack of CSS layers:
   base gradients + red key light + vignette → faint wall logo (screen-blended,
   masked) → slow volumetric beams (blurred gradients, transform-only drift) →
   tatami floor in perspective (repeating gradients, masked to the horizon) →
   two drifting dust layers (box-shadow particles) → ultra-faint AI grid.
   All motion is transform/opacity on composited layers (no per-frame JS);
   prefers-reduced-motion kills every animation via the global media query.
════════════════════════════════════════════ */
function CinematicDojoBackground() {
  return (
    <div className="dojo-bg" aria-hidden="true">
      <div className="dojo-grid" />
      <img className="dojo-logo" src="assets/logo.png?v=3" alt="" decoding="async"
        onError={e => { e.currentTarget.style.display = 'none'; }} />
      <div className="dojo-beam db1" />
      <div className="dojo-beam db2" />
      <div className="dojo-beam db3" />
      <div className="dojo-floor" />
      <div className="dojo-dust dd1" />
      <div className="dojo-dust dd2" />
    </div>
  );
}

/* ════════════════════════════════════════════
   APP
════════════════════════════════════════════ */
/* ════════════════════════════════════════════
   BADGE FIELD — OFFMAT level badges
   Desktop (Phase 5A): a tasteful chain of badges trails the cursor with easing/lag.
   Mobile  (Phase 5A upgrade): a premium LIVING FIELD — 14 well-defined badges that
     float + drift continuously (compound CSS animation, compositor-only → ZERO
     scroll-time JS) and periodically MORPH (crossfade + subtle scale) into the next
     badge in the full 15-badge set, staggered so they never change in unison. All 15
     images are preloaded so morphs never flicker or hit the network; animations pause
     when the tab is hidden. Fixed, pointer-events:none overlay → never blocks taps/scroll.
   Art = the existing OFFMAT badges, downscaled to assets/badges/b1..b15.png (~30KB each).
════════════════════════════════════════════ */
// ── DESKTOP cursor trail — Ropik-style image trail, SITE-WIDE (all slides) ──
// Behavioural model: a RIBBON, not a follower chain. A smoothed (lerped) cursor
// travels behind the mouse; every EMIT_DIST px of ITS path, one OFFMAT badge is
// released at that point with a fraction of the cursor's momentum, drifts to rest
// under friction, and fades out over a fixed lifetime. Restraint over quantity:
// consistent spacing, weighty interpolation, subtle direction-based rotation.
const TRAIL_CFG = {
  POOL: 10,          // recycled DOM nodes — max simultaneously visible badges
  EMIT_DIST: 92,     // px of smoothed-cursor travel between releases (the spacing)
  LIFE: 700,         // ms each badge lives (was 1150 — user: fade out faster); envelope shape unchanged
  SIZE: 54,          // base px size (tiny per-badge variation applied at emit)
  PEAK: 0.9,         // peak opacity
  FOLLOW: 10,        // cursor lerp rate (1 - e^(-dt*FOLLOW)) — weighty, not reactive
  MOMENTUM: 0.13,    // fraction of cursor velocity a badge keeps when released
  FRICTION: 3.4,     // exponential drift decay (v *= e^(-dt*FRICTION))
};
const BADGE_IMGS = Array.from({ length: 15 }, (_, i) => `assets/badges/b${i + 1}.png`);

function BadgeField() {
  const layerRef = useRef(null);
  const mobile = isMobileVW();

  useEffect(() => {
    if (reducedMotion()) return;
    const layer = layerRef.current;
    if (!layer) return;
    const badges = Array.from(layer.children);
    if (!badges.length) return;

    if (!mobile) {
      // ── SITE-WIDE cursor trail — ribbon emitter (see TRAIL_CFG above) ──
      // (Was hero-only; the active area is now the whole page. Mechanics, spacing,
      // timing, lifetime and restraint are IDENTICAL — only the gate was removed.)
      const C = TRAIL_CFG;
      const S = BADGE_IMGS.length;
      // preload the badge set so an emit never waits on the network
      BADGE_IMGS.forEach(src => { const im = new Image(); im.decoding = 'async'; im.src = src; });

      // pooled slots — recycled, zero DOM churn
      const slots = badges.map(el => ({
        el, img: el.querySelector('img'),
        active: false, born: 0, x: 0, y: 0, vx: 0, vy: 0, rot: 0, scale: 1,
      }));

      const mouse = { x: -1e4, y: -1e4 };            // raw target (offscreen until first move)
      const cur = { x: -1e4, y: -1e4 };              // smoothed cursor — ALL motion derives from this
      let hasCursor = false;
      let distAcc = 0, emitN = 0, raf = 0, prevT = 0;
      let pvx = 0, pvy = 0;                          // smoothed cursor velocity (px/s)

      const emit = () => {
        const slot = slots[emitN % C.POOL];
        slot.img.src = BADGE_IMGS[emitN % S];        // cycle the OFFMAT set in order (calm, varied)
        slot.active = true;
        slot.born = prevT;
        slot.x = cur.x; slot.y = cur.y;
        slot.vx = pvx * C.MOMENTUM;                  // released with a fraction of the cursor's momentum
        slot.vy = pvy * C.MOMENTUM;
        // subtle rotation from movement direction (±10°) + a whisper of variation
        slot.rot = Math.max(-10, Math.min(10, pvx * 0.010)) + (Math.random() * 4 - 2);
        slot.scale = 0.94 + Math.random() * 0.12;    // tiny size variation, never repetitive
        emitN++;
      };

      const tick = (t) => {
        raf = 0;
        const dt = Math.min(0.05, (t - prevT) / 1000) || 0.016;
        prevT = t;

        // smoothed cursor: time-based exponential lerp — weighty, frame-rate independent
        const k = 1 - Math.exp(-dt * C.FOLLOW);
        const px = cur.x, py = cur.y;
        cur.x += (mouse.x - cur.x) * k;
        cur.y += (mouse.y - cur.y) * k;
        pvx = (cur.x - px) / dt; pvy = (cur.y - py) / dt;

        // distance-based emission: one badge per EMIT_DIST px of smoothed-cursor travel
        if (hasCursor) {
          distAcc += Math.hypot(cur.x - px, cur.y - py);
          if (distAcc >= C.EMIT_DIST) { distAcc = 0; emit(); }
        }

        // advance live badges: drift under friction, gradual fade over LIFE
        let anyActive = false;
        for (const s of slots) {
          if (!s.active) continue;
          const age = t - s.born;
          if (age >= C.LIFE) { s.active = false; s.el.style.opacity = '0'; continue; }
          anyActive = true;
          const f = Math.exp(-dt * C.FRICTION);
          s.vx *= f; s.vy *= f;
          s.x += s.vx * dt; s.y += s.vy * dt;
          const life = age / C.LIFE;
          // opacity envelope: quick soft entry (12%), then a long eased fade — never abrupt
          const op = life < 0.12
            ? C.PEAK * (life / 0.12)
            : C.PEAK * (1 - Math.pow((life - 0.12) / 0.88, 1.6));
          // gentle birth settle: 0.86 → full scale over the first 18% of life
          const sc = s.scale * (life < 0.18 ? 0.86 + 0.14 * (life / 0.18) : 1);
          const h = C.SIZE / 2;
          s.el.style.opacity = op.toFixed(3);
          s.el.style.transform =
            `translate3d(${(s.x - h).toFixed(1)}px, ${(s.y - h).toFixed(1)}px, 0) rotate(${s.rot.toFixed(1)}deg) scale(${sc.toFixed(3)})`;
        }

        // keep animating while badges live or the cursor is still settling (inertia);
        // otherwise idle completely — mousemove re-arms.
        const converging = Math.abs(mouse.x - cur.x) + Math.abs(mouse.y - cur.y) > 0.5;
        if (anyActive || converging) raf = requestAnimationFrame(tick);
      };

      const arm = () => { if (!raf) { prevT = performance.now(); raf = requestAnimationFrame(tick); } };
      const onMove = (e) => {
        if (!hasCursor) { hasCursor = true; cur.x = e.clientX; cur.y = e.clientY; distAcc = 0; }
        mouse.x = e.clientX; mouse.y = e.clientY;
        arm();
      };
      window.addEventListener('mousemove', onMove, { passive: true });
      return () => {
        window.removeEventListener('mousemove', onMove);
        if (raf) cancelAnimationFrame(raf);
      };
    }

  }, [mobile]);

  if (!mobile) {
    // ── DESKTOP render: pooled ribbon-trail slots (recycled — zero DOM churn) ──
    if (reducedMotion()) return null;
    return (
      <div ref={layerRef} className="cursor-ribbon" aria-hidden="true">
        {Array.from({ length: TRAIL_CFG.POOL }, (_, i) => (
          <div key={i} className="cr-badge" style={{ width: TRAIL_CFG.SIZE, height: TRAIL_CFG.SIZE, opacity: 0 }}>
            <img alt="" decoding="async" src={BADGE_IMGS[i % BADGE_IMGS.length]} />
          </div>
        ))}
      </div>
    );
  }
  // MOBILE (user request): no badges at all — the scrubbed video carries the atmosphere.
  return null;
}

function App() {
  useGlobalFX();
  return (
    <div className="relative">
      <CinematicDojoBackground />
      <Intro />
      <LeftEdgeBelt />
      <BadgeField />
      <Nav />
      <main className="relative z-[1]">
        <Scene1 />
        <Scene2 />
        <Scene3 />
        <Scene4 />
        <Scene5 />
        <Scene6 />
        <Scene7 />
        <Scene8 />
      </main>
      <Privacy />
      <MobileCTA />
    </div>
  );
}


/* ════════════════════════════════════════════
   BJJ BELT — 3D GLB (Three.js r170 + cannon-es).
   • Loads assets/belt.glb via GLTFLoader.
   • Scroll position scrubs BOTH baked clips at once:
       - "Belt"        → morph-target unroll (frame 0..240)
       - "Belt_Stripe" → KHR_animation_pointer base-color (white→…→black)
     0% scroll = frame 0 (rolled), 100% = frame 240 (unrolled).
   • Fixed to the left edge, full viewport height, ~120px wide.
   • Drag the hanging strip sideways → cannon-es spring snaps it back.
════════════════════════════════════════════ */
function LeftEdgeBelt() {
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    if (typeof window === 'undefined') return;
    // PHASE 2 — the belt now renders on MOBILE too (was desktop-only). Same code,
    // same rotation, same materials; the ONLY mobile difference is a smaller
    // beltPivot scale so it fits the narrow viewport (see setScalar below).
    // vw uses the widest reliable signal (some embedded/headless contexts report
    // a bogus tiny innerWidth; screen.width stays sane).
    const vw = Math.max(
      window.innerWidth || 0,
      document.documentElement.clientWidth || 0,
      (window.screen && window.screen.width) || 0
    );
    const isMobile = vw < 768;
    const canvas = canvasRef.current;
    if (!canvas) return;

    let raf = 0, poll = 0, disposed = false;
    let renderer = null, scene = null, camera = null, mixer = null, world = null, tipBody = null, beltST = null, beltPivot = null, beltRoot = null, beltInner = null;
    let detach = () => {};
    const bendUniform = { value: 0 };                    // shared across all belt materials
    const SPAN = 1.4;                                    // strip length used to normalise the bend
    const W = 280;                                        // wider column (FIX 2: room for the 1.15× belt)

    const libsReady = () => window.__THREE3D && window.__GLTFLoader && window.CANNON && window.__three_addons;

    function start() {
      if (disposed) return;
      const THREE = window.__THREE3D;
      const GLTFLoader = window.__GLTFLoader;
      const CANNON = window.CANNON;
      const ADD = window.__three_addons;

      let H = window.innerHeight;

      // PERF (Item 1): preserveDrawingBuffer forced a full framebuffer COPY every frame
      // (instead of a cheap swap) — a constant GPU tax that competed with scrolling.
      // Nothing reads the canvas back, so the default (false) is safe.
      renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
      renderer.setClearColor(0x000000, 0);
      renderer.setSize(W, H, false);
      renderer.outputColorSpace = THREE.SRGBColorSpace;
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1.05;

      scene = new THREE.Scene();

      // ── soft studio environment, kept dim so it lends sheen to the gunmetal
      //    hanger without washing out the matte fabric (directional lights below
      //    do the shaping that reveals the coil layers) ──
      const pmrem = new THREE.PMREMGenerator(renderer);
      scene.environment = pmrem.fromScene(new ADD.RoomEnvironment(), 0.04).texture;
      scene.environmentIntensity = 0.16;   // low → matte canvas, no plastic sheen

      // ── framing: roll up top (wound side, slight top-3/4), red hanger as the
      //    T-bar at its lower edge, strip dropping straight down as the stem ──
      const HALF_H = 0.55;       // vertical half-extent (world units)
      const VIEW_TOP = 0.30;     // world-y at top edge → headroom above the roll
      const cy = VIEW_TOP - HALF_H;
      // near-front: the roll's lie-back is baked into the geometry, so the strip
      // stays facing the camera while the coil reads as a receding ellipse.
      const AZ = 0.0, EL = 0.12;   // head-on; the belt's own ~10° yaw provides the depth
      function buildCam() {
        const halfW = HALF_H * (W / H);
        const c = new THREE.OrthographicCamera(-halfW, halfW, HALF_H, -HALF_H, -10, 10);
        const d = 3;
        c.position.set(Math.sin(AZ) * Math.cos(EL) * d, cy + Math.sin(EL) * d, Math.cos(AZ) * Math.cos(EL) * d);
        c.up.set(0, 1, 0);
        c.lookAt(0, cy, 0);
        return c;
      }
      camera = buildCam();

      // ── lighting: a strong key shapes the coil layers; env adds sheen ──
      // FIX 3 — lit for the new orientation (disc now faces the viewer, ~+Z).
      // Key rakes across the front face from upper-right so the coil grooves read;
      // fill lifts shadows from below-left for depth; rim grazes the top-back edge.
      // Colours/intensities unchanged from before — positions only.
      const key = new THREE.DirectionalLight(0xffffff, 2.0);
      key.position.set(1.1, 0.8, 1.3); scene.add(key);          // front, raking from upper-right
      const fill = new THREE.DirectionalLight(0xaec3ea, 0.7);
      fill.position.set(-1.2, -0.9, 0.9); scene.add(fill);      // subtle fill from below-left
      const rim = new THREE.DirectionalLight(0xE8453C, 0.5);    // brand-red rim
      rim.position.set(-0.6, 0.9, -0.7); scene.add(rim);        // edge rim, upper-back-left
      // FIX 4 — subtle ambient boost + a secondary key from a different angle so the coil
      // grooves and edge geometry read (smooth-shaded fabric needs the extra modelling).
      const amb = new THREE.AmbientLight(0xffffff, 0.3); scene.add(amb);
      const key2 = new THREE.DirectionalLight(0xffffff, 0.7);
      key2.position.set(-0.5, 0.4, 1.5); scene.add(key2);       // front-left, rakes the grooves

      // ── glow halo texture (additive sprites fake bloom while keeping the
      //    canvas transparent — UnrealBloomPass forces an opaque background) ──
      const glowTex = (() => {
        const s = 128, cnv = document.createElement('canvas'); cnv.width = cnv.height = s;
        const g = cnv.getContext('2d');
        const grd = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
        grd.addColorStop(0.0, 'rgba(255,244,220,1)');
        grd.addColorStop(0.30, 'rgba(255,214,150,0.55)');
        grd.addColorStop(1.0, 'rgba(255,190,120,0)');
        g.fillStyle = grd; g.fillRect(0, 0, s, s);
        const t = new THREE.CanvasTexture(cnv); t.colorSpace = THREE.SRGBColorSpace; return t;
      })();
      function addGlow(pos, w, h) {
        const m = new THREE.SpriteMaterial({ map: glowTex, blending: THREE.AdditiveBlending,
          depthWrite: false, depthTest: false, transparent: true, opacity: 0.85 });
        const sp = new THREE.Sprite(m); sp.position.copy(pos); sp.scale.set(w, h, 1);
        sp.renderOrder = 10; scene.add(sp); return sp;
      }

      // ── physics: a 1-D damped spring that pulls the strip tip back to x=0 ──
      world = new CANNON.World();
      world.gravity.set(0, 0, 0);
      const anchor = new CANNON.Body({ mass: 0 });
      world.addBody(anchor);
      tipBody = new CANNON.Body({ mass: 1 });
      tipBody.linearDamping = 0.10;
      tipBody.linearFactor = new CANNON.Vec3(1, 0, 0);        // horizontal only
      world.addBody(tipBody);
      const spring = new CANNON.Spring(tipBody, anchor, {
        restLength: 0, stiffness: 42, damping: 2.6,
        localAnchorA: new CANNON.Vec3(0, 0, 0),
        localAnchorB: new CANNON.Vec3(0, 0, 0),
      });

      // Belt material patch: (1) a sideways bend on the hanging strip, and
      // (2) for the body only, a worn/faded look — desaturated + a vertical
      // world-Y gradient (lighter & pinker at the roll → deeper down the strip).
      function patchMaterial(mat, grad) {
        mat.onBeforeCompile = (shader) => {
          shader.uniforms.uBend = bendUniform;
          shader.uniforms.uBendSpan = { value: SPAN };
          let v = shader.vertexShader
            .replace('#include <common>',
              '#include <common>\nuniform float uBend;\nuniform float uBendSpan;' + (grad ? '\nvarying float vWY;' : ''))
            .replace('#include <morphtarget_vertex>',
              '#include <morphtarget_vertex>\n'
              + '{ float bd = clamp((0.06 - transformed.y) / uBendSpan, 0.0, 1.0);'
              + '  transformed.x += uBend * bd * bd; }'
              + (grad ? '\nvWY = (modelMatrix * vec4(transformed, 1.0)).y;' : ''));
          shader.vertexShader = v;
          // CHANGE 3 + Item 2 — soft edge falloff: fade alpha where the surface turns away
          // from the camera (fresnel), so the mesh silhouette melts off like worn fabric
          // fibres instead of ending in a hard geometric edge. Item 2 deepened it: wider
          // falloff band (0.04–0.45 → 0.02–0.62) and a lower edge floor (0.30 → 0.16),
          // so the last visible sliver of a curved edge dissolves gradually — natural,
          // not exaggerated. Geometry/rotation/position/scale/animation untouched.
          shader.fragmentShader = shader.fragmentShader.replace(
            '#include <dithering_fragment>',
            '#include <dithering_fragment>\n'
            + '{ float ndv = abs(dot(normalize(vViewPosition), normalize(normal)));'
            + '  gl_FragColor.a *= mix(0.16, 1.0, smoothstep(0.02, 0.62, ndv)); }');
          if (grad) {
            shader.fragmentShader = shader.fragmentShader
              .replace('#include <common>', '#include <common>\nvarying float vWY;')
              .replace('#include <map_fragment>',
                '#include <map_fragment>\n'
                + '{ float gy = clamp((vWY + 1.15) / 1.3, 0.0, 1.0);'      // 0 bottom → 1 top
                + '  vec3 c = diffuseColor.rgb;'
                + '  float l = dot(c, vec3(0.299, 0.587, 0.114));'
                + '  c = mix(c, vec3(l), 0.20);'                            // pale / desaturate
                + '  c *= mix(0.58, 1.20, gy);'                            // deep at strip, light at roll
                + '  c += vec3(0.11, 0.0, 0.06) * gy * gy;'                // pink lift at the roll
                + '  diffuseColor.rgb = c; }');
          }
        };
        mat.customProgramCacheKey = () => 'offmat-belt-' + (grad ? 'grad' : 'bend');
        mat.needsUpdate = true;
      }

      // KHR_animation_pointer playback.
      // three.js' GLTFLoader does NOT parse KHR_animation_pointer (the colour
      // track imports as an empty clip), so we read the baked base-colour
      // samples straight out of the GLB and drive material.color ourselves —
      // exact same data, played on the same scroll-driven timeline as the unroll.
      let bodyMat = null, colorKeys = null;
      function parsePointerColors(buf) {
        const dv = new DataView(buf);
        let off = 12, json = null, bin = null;
        while (off < dv.byteLength) {
          const clen = dv.getUint32(off, true), ctype = dv.getUint32(off + 4, true); off += 8;
          const chunk = buf.slice(off, off + clen); off += clen;
          if (ctype === 0x4E4F534A) json = JSON.parse(new TextDecoder().decode(chunk));
          else if (ctype === 0x004E4942) bin = chunk;
        }
        if (!json || !bin) return null;
        const binDV = new DataView(bin);
        const readAcc = (i) => {
          const a = json.accessors[i], bv = json.bufferViews[a.bufferView];
          const base = (bv.byteOffset || 0) + (a.byteOffset || 0);
          const comp = { SCALAR: 1, VEC3: 3, VEC4: 4 }[a.type];
          const out = [];
          for (let k = 0; k < a.count; k++) {
            const row = [];
            for (let c = 0; c < comp; c++) row.push(binDV.getFloat32(base + (k * comp + c) * 4, true));
            out.push(comp === 1 ? row[0] : row);
          }
          return out;
        };
        for (const anim of (json.animations || [])) {
          for (const ch of anim.channels) {
            const t = ch.target || {};
            const ptr = t.extensions && t.extensions.KHR_animation_pointer && t.extensions.KHR_animation_pointer.pointer;
            if (t.path === 'pointer' && ptr && /baseColorFactor/.test(ptr)) {
              const s = anim.samplers[ch.sampler];
              return { times: readAcc(s.input), colors: readAcc(s.output) };  // linear vec4
            }
          }
        }
        return null;
      }
      function sampleColor(keys, tt) {
        const T = keys.times, C = keys.colors, n = T.length;
        if (tt <= T[0]) return C[0];
        if (tt >= T[n - 1]) return C[n - 1];
        let i = 0; while (i < n - 1 && T[i + 1] < tt) i++;
        const f = (tt - T[i]) / (T[i + 1] - T[i]), a = C[i], b = C[i + 1];
        return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
      }

      // ── load the belt + wire up both animations ──
      let duration = 10;
      new GLTFLoader().load(
        'assets/belt.glb',
        (gltf) => {
          if (disposed) return;
          const gltfScene = gltf.scene;
          const beltMeshes = [];
          gltfScene.traverse((o) => {
            if (!o.isMesh) return;
            if (o.geometry) o.geometry.computeVertexNormals();   // FIX 4 — smooth shading (rounder edges)
            if (o.material) {
              o.material.flatShading = false;                     // FIX 4
              const nm = o.material.name || '';
              if (/^Belt_/.test(nm)) {                            // the fabric (bend + fabric surface)
                o.material.roughness = 0.75;                      // FIX 4 — fabric-like surface
                o.material.metalness = 0.05;
                o.material.transparent = true;                    // CHANGE 3 — needed for the fresnel edge falloff
                patchMaterial(o.material);                        // bend only the fabric, never the hanger
                if (/Body/i.test(nm) || (!bodyMat && o.morphTargetInfluences)) bodyMat = o.material;
              }
              o.material.needsUpdate = true;
            }
            beltMeshes.push(o);
          });

          // FIX 3 — outline: a slightly larger, back-face-only grey shell rendered behind
          // each belt mesh so the silhouette stays visible on dark (purple/brown/black) belt
          // scenes. Unlit MeshBasicMaterial → never glows; shares the source geometry AND
          // morphTargetInfluences array, so it unrolls in lockstep with the belt.
          // CHANGE 3 + Item 2 — softer silhouette: the shell is translucent (no depth
          // write) so the rim reads as a soft fabric halo rather than a hard grey
          // outline. Item 2 dimmed it further (0.38 → 0.22): the halo now just lifts
          // the belt off dark scenes without drawing a visible contour line.
          const outlineMat = new THREE.MeshBasicMaterial({
            color: 0x888888, side: THREE.BackSide,
            transparent: true, opacity: 0.22, depthWrite: false,
          });
          beltMeshes.forEach((o) => {
            const outline = new THREE.Mesh(o.geometry, outlineMat);
            outline.position.copy(o.position);
            outline.quaternion.copy(o.quaternion);
            outline.scale.copy(o.scale).multiplyScalar(1.04);     // thin shell
            if (o.morphTargetInfluences) {
              outline.morphTargetInfluences = o.morphTargetInfluences;   // share array → morphs together
              outline.morphTargetDictionary = o.morphTargetDictionary;
            }
            outline.renderOrder = -1;                             // draw behind the belt
            o.parent.add(outline);
          });
          // ── Belt rig: beltPivot (scale) → beltRoot (debug-slider rotation) → beltInner
          //    (auto counter-rotation, set every frame in the tick) → gltfScene (GLB w/ morph).
          // The morph unrolls the strip along the GLB's LOCAL ~-Y. beltInner re-aligns that
          // axis to WORLD -Y each frame, so the strip ALWAYS falls straight down regardless of
          // how beltRoot is rotated by the sliders. ──
          beltInner = new THREE.Group();
          beltInner.add(gltfScene);

          beltRoot = new THREE.Group();
          beltRoot.rotation.set(
            THREE.MathUtils.degToRad(-36),   // x  (confirmed on real hardware)
            THREE.MathUtils.degToRad(54),    // y
            THREE.MathUtils.degToRad(-97)    // z
          );
          beltRoot.add(beltInner);

          beltPivot = new THREE.Group();
          // PHASE 2 — mobile-only belt values (desktop untouched): a smaller scale so
          // the belt reads thin on a narrow phone viewport, plus a world-space pivot
          // offset that tucks it into the upper-left, clear of the hero content
          // (screen mapping ≈769px per world unit: -0.06 ≈ 45px left, +0.11 ≈ 90px up).
          // Rotation, orientation, materials, animation: identical to desktop.
          beltPivot.scale.setScalar(isMobile ? 0.56 : 1.15);       // desktop unchanged (vertical handled by beltInner)
          if (isMobile) {
            // CHANGE 2 — the belt's on-screen size scales with viewport HEIGHT (the
            // ortho camera is height-fitted) but the old fixed world-x offset did not,
            // so on tall phones (~932px) the coil clipped ~11px off the left edge.
            // Anchor the coil to a FIXED 2px left margin on every device instead:
            //   px-per-world-unit = H / 1.1 ; coil radius ≈ 0.0456 world (at 0.56 scale)
            //   world origin sits at screen x = 104 (canvas centre 140 + container −36)
            const pxPerUnit = H / 1.1;
            const coilR = 0.0456 * pxPerUnit;
            const centerPx = 2 + coilR;                            // 2px margin — fully visible, hugs the edge
            beltPivot.position.set((centerPx - 104) / pxPerUnit, 0.11, 0);
          }
          beltPivot.add(beltRoot);
          scene.add(beltPivot);
          window.__beltDirty = true;                       // paint the freshly-loaded belt

          mixer = new THREE.AnimationMixer(gltfScene);
          duration = 0;
          // morph-target unroll clip (colour clip is empty in three → handled below)
          gltf.animations.forEach((clip) => {
            duration = Math.max(duration, clip.duration);
            mixer.clipAction(clip).play();
          });
          if (!duration) duration = 10;
          mixer.setTime(0);

          // pull the baked KHR_animation_pointer colour samples out of the GLB
          fetch('assets/belt.glb').then((r) => r.arrayBuffer()).then((buf) => {
            if (disposed) return;
            colorKeys = parsePointerColors(buf);
            window.__beltDirty = true;                     // repaint with the rank colour applied
          }).catch((e) => console.warn('[LeftEdgeBelt] colour track parse failed', e));
        },
        undefined,
        (err) => { console.warn('[LeftEdgeBelt] failed to load assets/belt.glb', err); }
      );

      // ── scroll progress (reuse the page's ScrollTrigger if present) ──
      let scrollP = 0;
      const usingST = !!(window.gsap && window.ScrollTrigger);
      if (usingST) {
        beltST = window.ScrollTrigger.create({
          trigger: 'body', start: 'top top', end: 'bottom bottom', scrub: true,
          onUpdate: (s) => { scrollP = s.progress; },
        });
      }
      const nativeProgress = () => {
        const max = document.documentElement.scrollHeight - window.innerHeight;
        return max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
      };

      // ── drag-to-pull interaction (only grabs the hanging strip) ──
      const MAXB = 0.10, PXK = 0.0009;
      let dragging = false, startX = 0, lastX = 0, lastT = 0, vel = 0;
      const cx = (e) => (e.touches ? e.touches[0].clientX : e.clientX);
      const cyp = (e) => (e.touches ? e.touches[0].clientY : e.clientY);

      function onDown(e) {
        // only the hanging strip is grabbable: left column, below the roll/hanger
        if (cx(e) > W || cyp(e) < H * 0.42) return;
        dragging = true;
        tipBody.type = CANNON.Body.KINEMATIC; tipBody.updateMassProperties();
        startX = cx(e); lastX = startX; lastT = performance.now(); vel = 0;
      }
      function onMove(e) {
        if (!dragging) return;
        const x = cx(e), now = performance.now();
        const bx = Math.max(-MAXB, Math.min(MAXB, (x - startX) * PXK));
        const dt = Math.max(1, now - lastT);
        vel = ((x - lastX) * PXK) / (dt / 1000);
        tipBody.position.x = bx; tipBody.velocity.set(0, 0, 0);
        lastX = x; lastT = now;
        if (e.cancelable) e.preventDefault();
      }
      function onUp() {
        if (!dragging) return;
        dragging = false;
        tipBody.type = CANNON.Body.DYNAMIC; tipBody.updateMassProperties();
        tipBody.velocity.set(Math.max(-2.5, Math.min(2.5, vel)), 0, 0);   // fling → spring snaps back
      }

      // canvas never intercepts clicks (so the nav/content stay clickable);
      // drag is detected via window listeners with a region check above.
      canvas.style.pointerEvents = 'none';
      // PHASE 2 — drag-to-pull is DESKTOP-ONLY. On a phone the grab zone (left 280px
      // below 42% viewport) covers most of the lower screen, and onMove's
      // preventDefault would swallow native touch scrolling — the exact thing
      // Phase 1 fixed. The belt still unrolls with scroll on mobile; only the
      // sideways drag interaction is skipped.
      if (!isMobile) {
        window.addEventListener('pointerdown', onDown);
        window.addEventListener('pointermove', onMove);
        window.addEventListener('pointerup', onUp);
        window.addEventListener('touchstart', onDown, { passive: true });
        window.addEventListener('touchmove', onMove, { passive: false });
        window.addEventListener('touchend', onUp);
      }

      function onResize() {
        H = window.innerHeight;
        renderer.setSize(W, H, false);
        const halfW = HALF_H * (W / H);                       // only the aspect changes with height
        camera.left = -halfW; camera.right = halfW;
        camera.top = HALF_H; camera.bottom = -HALF_H;
        camera.updateProjectionMatrix();
        window.__beltDirty = true;                          // repaint at the new size
      }
      window.addEventListener('resize', onResize);

      detach = () => {
        window.removeEventListener('pointerdown', onDown);
        window.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
        window.removeEventListener('touchstart', onDown);
        window.removeEventListener('touchmove', onMove);
        window.removeEventListener('touchend', onUp);
        window.removeEventListener('resize', onResize);
      };

      // ── main loop: scrub clips by scroll, step physics, render ──
      // The GLB's local unroll axis (strip direction), measured from the morph geometry.
      const _STRIP_AXIS = new THREE.Vector3(-0.024, -0.999, 0.026).normalize();
      const _stripTarget = new THREE.Vector3();
      const _qTmp = new THREE.Quaternion();
      let smooth = 0, prev = performance.now(), lastBend = 0;
      // PERF (Item 1): render-ON-DEMAND. The old loop called renderer.render() every
      // frame forever — a full WebGL pass (up to 560×~1800px, MSAA) even with the belt
      // motionless, permanently competing with scroll. Now a frame renders only while
      // something is actually moving (scroll lerp chasing, drag/spring bending) or when
      // explicitly flagged dirty (GLB loaded, resize, drag start/end). Idle cost: ~zero.
      window.__beltDirty = true;                           // first paint + async GLB load
      function tick() {
        raf = requestAnimationFrame(tick);
        const now = performance.now();
        const dt = Math.min(0.05, (now - prev) / 1000); prev = now;

        const target = usingST ? scrollP : nativeProgress();
        // CHANGE 4 — heavier lerp (0.045/frame @60fps): the belt DRIFTS toward the
        // scroll target so the unroll reads as one continuous breath across the page.
        // Frame-rate independent: consistent on 120Hz displays and through slow frames.
        smooth += (target - smooth) * (1 - Math.pow(1 - 0.045, dt * 60));
        if (Math.abs(target - smooth) < 4e-4) smooth = target;   // settle exactly → loop can idle
        // clamp just under duration so the end frame doesn't wrap back to frame 0
        const tt = Math.min(smooth * duration, duration - 0.001);

        spring.applyForce();
        world.step(1 / 60, dt, 3);
        bendUniform.value = tipBody.position.x;

        const moving = smooth !== target || Math.abs(bendUniform.value - lastBend) > 5e-4;
        if (!moving && !window.__beltDirty) return;        // idle → skip ALL scene work + render
        window.__beltDirty = false;
        lastBend = bendUniform.value;

        if (mixer) mixer.setTime(tt);                       // morph unroll — scroll drives ONLY this
        // Strip stays vertical: re-align the GLB's local strip axis to WORLD -Y,
        // regardless of beltRoot's rotation. Scroll never touches rotation.
        if (beltRoot && beltInner) {
          _stripTarget.set(0, -1, 0).applyQuaternion(_qTmp.copy(beltRoot.quaternion).invert());
          beltInner.quaternion.setFromUnitVectors(_STRIP_AXIS, _stripTarget);
        }
        if (bodyMat && colorKeys) {                         // KHR_animation_pointer colour
          const c = sampleColor(colorKeys, tt);
          bodyMat.color.setRGB(c[0], c[1], c[2], THREE.LinearSRGBColorSpace);
        }

        renderer.render(scene, camera);
      }
      tick();
    }

    // libs load async (ESM modules + cannon) — wait for all three, then start
    (function waitForLibs() {
      if (disposed) return;
      if (libsReady()) { try { start(); } catch (e) { console.error('[LeftEdgeBelt] start() failed:', e); } return; }
      poll = window.setTimeout(waitForLibs, 60);
    })();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      clearTimeout(poll);
      detach();
      if (beltST) beltST.kill();          // scrub trigger leaked before — kill on unmount
      if (mixer) mixer.stopAllAction();
      if (renderer) { renderer.dispose(); }
    };
  }, []);

  return (
    <div aria-hidden="true"
      style={{ position: 'fixed', left: -36, top: 0, width: 280, height: '100vh', zIndex: 500, pointerEvents: 'none' }}>
      <canvas ref={canvasRef}
        style={{ position: 'absolute', left: 0, top: 0, width: 280, height: '100%', display: 'block' }} />
    </div>
  );
}


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