// app.jsx — Orchestrator for the cinematic scroll
// - Mounts the three Three.js worlds into their stage divs
// - Drives them with a single rAF loop fed by window scroll
// - Renders the overlay text for each act
// - Renders the capsule, manifesto strip and footer below the cinematic block

const { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } = React;

// ── Scroll map ───────────────────────────────────────────────────────────────
// Page is 600vh of pinned scroll: each act gets 200vh during which its canvas
// is fully visible and progresses from p=0 to p=1. Cross-fades happen in
// 40vh-wide bands at the seams (180-220vh, 380-420vh).
//
// World ranges (scrollY in viewport heights):
//   COSMOS    : 0 → 200vh   (p = scrollY / 200vh)
//   MOUNTAIN  : 200 → 400vh
//   ATLAS     : 400 → 600vh
const ACT_VH = 400;
const FADE_VH = 30;

function App() {
  const [t, setTweak] = useTweaks(window.TWEAK_DEFAULTS);

  // Build a fontFamily override from the tweak so all `var(--font-display)` uses cascade.
  // We rewrite the CSS variables on :root.
  useLayoutEffect(() => {
    const pairs = {
      geist:    { display: '"Geist","Archivo",sans-serif', sans: '"Geist","Archivo",sans-serif', mono: '"Geist Mono",ui-monospace,monospace' },
      archivo:  { display: '"Archivo",sans-serif',         sans: '"Archivo",sans-serif',         mono: '"Geist Mono",ui-monospace,monospace' },
      editorial:{ display: '"Fraunces","Cormorant Garamond",serif', sans: '"Geist","Archivo",sans-serif', mono: '"Geist Mono",ui-monospace,monospace' },
      stoic:    { display: '"Cormorant Garamond","Fraunces",serif', sans: '"Geist","Archivo",sans-serif', mono: '"Geist Mono",ui-monospace,monospace' },
    };
    const p = pairs[t.fontPair] || pairs.geist;
    document.documentElement.style.setProperty('--font-display', p.display);
    document.documentElement.style.setProperty('--font-sans', p.sans);
    document.documentElement.style.setProperty('--font-mono', p.mono);
  }, [t.fontPair]);

  // Apply "direction" tweak — adjusts palette + density
  useLayoutEffect(() => {
    const root = document.documentElement;
    const dir = t.direction;
    if (dir === 'cosmic') {
      root.style.setProperty('--bg', '#ebebeb');
      root.style.setProperty('--accent', '#c9b896');
      root.style.setProperty('--ink', '#f4f1ea');
    } else if (dir === 'stone') {
      root.style.setProperty('--bg', '#ebebeb');
      root.style.setProperty('--accent', '#8b7350');
      root.style.setProperty('--ink', '#e8dfd0');
    } else if (dir === 'alpine') {
      root.style.setProperty('--bg', '#ebebeb');
      root.style.setProperty('--accent', '#a8b4be');
      root.style.setProperty('--ink', '#eef2f5');
    }
  }, [t.direction]);

  // Hide loader once worlds are ready
  useEffect(() => {
    const id = setTimeout(() => {
      const l = document.getElementById('loader');
      if (l) l.classList.add('gone');
    }, 1100);
    return () => clearTimeout(id);
  }, []);

  // Mount the three worlds + drive them with rAF
  useEffect(() => {
    const stageCosmos   = document.getElementById('stage-cosmos');
    const stageMountain = document.getElementById('stage-mountain');
    const stageAtlas    = document.getElementById('stage-atlas');

    const cosmos   = initCosmos(stageCosmos);
    const mountain = initMountain(stageMountain);
    const atlas    = initAtlas(stageAtlas);

    // Style: earth render style from tweak
    cosmos.setStyle(t.earthStyle);

    // Track scrollY globally; rAF reads from a ref so we don't thrash React
    const scrollRef = { current: window.scrollY };
    const onScroll = () => { scrollRef.current = window.scrollY; };
    window.addEventListener('scroll', onScroll, { passive: true });

    const onResize = () => {
      cosmos.resize(); mountain.resize(); atlas.resize();
    };
    window.addEventListener('resize', onResize);

    // HUD elements
    const hudLat = document.getElementById('hud-lat');
    const hudLon = document.getElementById('hud-lon');
    const hudAlt = document.getElementById('hud-alt');
    const hudScene = document.getElementById('hud-scene');
    const hud = document.getElementById('hud');
    const hudR = document.getElementById('hud-r');
    const nav = document.querySelector('.nav');
    const progressBar = document.querySelector('.progress i');
    const cue = document.getElementById('cue');

    let rafId;
    const t0 = performance.now();
    const tick = () => {
      const time = (performance.now() - t0) / 1000;
      const vh = window.innerHeight;
      const speed = (t.scrollSpeed || 1);
      // Convert scrollY to "story" position
      const sy = scrollRef.current * speed;
      // Scroll layout: cosmos(200vh) + logo(120vh) + mountain(800vh) + atlas(200vh)
      const LOGO_VH = 120;
      const MTN_VH  = 1280; // mountain section height in vh (must match Act2Mountain's CSS height)
      const actVh = ACT_VH * vh / 100;
      const logoVh = LOGO_VH * vh / 100;
      const mtnVh  = MTN_VH * vh / 100;

      const cosmosStart   = 0;
      const mountainStart = actVh + logoVh;
      const atlasStart    = actVh + logoVh + mtnVh;

      const pCosmos   = clamp01((sy - cosmosStart)   / actVh);
      const pMountain = clamp01((sy - mountainStart)  / mtnVh);
      const atlasVh   = vh * 0.1; // atlas section = 10vh
      // Atlas camera starts when it becomes visible (cross-fade at 42% of mountain)
      const atlasVisualStart = mountainStart + mtnVh * 0.36;
      const pAtlas    = clamp01((sy - atlasVisualStart) / ((atlasStart + atlasVh - atlasVisualStart) / 3));

      // Fades
      const fadeMountainIn  = clamp01((sy - mountainStart + FADE_VH*vh/200) / (FADE_VH*vh/100));
      // Atlas fades in aligned with mountain fade-out (cross-fade, no black gap)
      const fadeAtlasIn     = clamp01((sy - (mountainStart + mtnVh * 0.36)) / (mtnVh * 0.10));
      // Atlas fades out right after camera animation completes (~217vh after atlasVisualStart)
      const atlasCameraDuration = (atlasStart + atlasVh - atlasVisualStart) / 3;
      const atlasEndScroll = atlasVisualStart + atlasCameraDuration;
      const fadeAtlasOut    = clamp01(1 - (sy - atlasEndScroll) / (FADE_VH*vh/100));

      // Cosmos fades out during the logo section (between actVh and actVh+logoVh)
      const fadeCosmosOut = clamp01((sy - actVh) / (logoVh * 0.5));
      const opCosmos = 1 - fadeCosmosOut;
      // Mountain canvas fades out aligned with the atlas fade-in (cross-fade)
      const fadeMountainOut = clamp01((sy - (mountainStart + mtnVh * 0.36)) / (mtnVh * 0.10));
      const opMountain = fadeMountainIn * (1 - Math.max(fadeAtlasIn, fadeMountainOut));
      const opAtlas = fadeAtlasIn * fadeAtlasOut;

      stageCosmos.style.opacity   = opCosmos.toFixed(3);
      stageMountain.style.opacity = opMountain.toFixed(3);
      stageAtlas.style.opacity    = opAtlas.toFixed(3);

      // Only render & step the active worlds
      if (opCosmos > 0.01)   { cosmos.update(pCosmos, time); cosmos.renderer.render(cosmos.scene, cosmos.camera); }
      if (opMountain > 0.01) { mountain.update(pMountain, time); mountain.renderer.render(mountain.scene, mountain.camera); }
      if (opAtlas > 0.01)    { atlas.update(pAtlas, time); atlas.renderer.render(atlas.scene, atlas.camera); }

      // Debug: show current scroll position in vh
      const scrollVhDisplay = document.getElementById('scroll-vh-hud');
      if (scrollVhDisplay) scrollVhDisplay.textContent = `${(scrollRef.current / vh * 100).toFixed(0)}vh`;

      // Page progress
      const docH = (document.documentElement.scrollHeight - window.innerHeight);
      const pPage = clamp01(window.scrollY / docH);
      if (progressBar) progressBar.style.setProperty('--p', pPage.toFixed(4));

      // HUD content
      if (hudLat && hudLon && hudAlt && hudScene) {
        const alt = lerp(405000, 0.05, ease(clamp01(sy / (3 * ACT_VH * vh / 100))));
        const altStr = alt > 1000 ? `${Math.round(alt).toLocaleString('fr-FR')} km`
                     : alt > 1 ? `${alt.toFixed(2)} km` : `${(alt * 1000).toFixed(0)} m`;
        if (sy < actVh) {
          hudScene.textContent = '01 / COSMOS';
        } else if (sy < mountainStart + actVh) {
          hudScene.textContent = '02 / MONT-BLANC';
        } else if (sy < atlasStart + actVh) {
          hudScene.textContent = '03 / ATLAS';
        } else {
          hudScene.textContent = '04 / CAPSULE';
        }
        hudAlt.textContent = altStr;
        const lat = 47.5 - (sy / (vh * 6)) * 0.05;
        const lon = 6.8 + Math.sin(time * 0.1) * 0.02;
        hudLat.textContent = `${lat >= 0 ? '+' : ''}${lat.toFixed(3)}°`;
        hudLon.textContent = `${lon >= 0 ? '+' : ''}${lon.toFixed(3).padStart(7, '0')}°`;
      }

      // Hide cue when user starts scrolling
      if (cue) cue.style.opacity = sy < vh * 0.4 ? 1 : 0;

      // Fade out HUD chrome during the Mountain act so it's completely gone by Atlas.
      // Starts fading at the start of Act 2 (sy/vh = ACT_VH/100), fully gone halfway through.
      const chromeFade = clamp01((sy/vh - ACT_VH/100*0.85) / 0.6);
      const chromeOp = 1 - chromeFade;
      if (hud)  hud.style.opacity  = chromeOp.toFixed(3);
      if (hudR) hudR.style.opacity = chromeOp.toFixed(3);
      if (hud)  hud.style.pointerEvents  = chromeOp < 0.1 ? 'none' : '';
      if (hudR) hudR.style.pointerEvents = chromeOp < 0.1 ? 'none' : '';
      // Nav fades a bit later (still visible during mountain)
      const navFade = clamp01((sy/vh - (2*ACT_VH/100 + 0.10)) / 0.25);
      if (nav) nav.style.opacity = (1 - navFade).toFixed(3);

      rafId = requestAnimationFrame(tick);
    };
    tick();

    return () => {
      cancelAnimationFrame(rafId);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      cosmos.dispose(); mountain.dispose(); atlas.dispose();
    };
    // We intentionally avoid hard-rebuilding worlds on every tweak change;
    // earthStyle is the only one that needs a setter call:
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Update earth style when tweak changes — needs ref to the world instance.
  // We re-mount the world only when style changes? No — store a window-level ref.
  // Simpler: register a 'tweakchange' listener inside initCosmos? Easier here:
  useEffect(() => {
    // The cosmos world exposes setStyle via the closure inside the mount effect.
    // We re-fire it by dispatching a synthetic event the mount effect listens to.
    window.__pendingEarthStyle = t.earthStyle;
    window.dispatchEvent(new CustomEvent('earthstylechange', { detail: t.earthStyle }));
  }, [t.earthStyle]);

  // ── Show/hide HUD via tweak
  useEffect(() => {
    const hud = document.getElementById('hud');
    const hudR = document.getElementById('hud-r');
    [hud, hudR].forEach(el => { if (el) el.style.display = t.showHud ? '' : 'none'; });
  }, [t.showHud]);

  return (
    <>
      <CinematicBlock />
      <ManifestoStrip />
      <CapsuleSection />
      <FooterBlock />
      <ChromeOverlay />
      <TweaksUI t={t} setTweak={setTweak} />
    </>
  );
}

// ── Helpers ────────────────────────────────────────────────────────────────
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const lerp = (a,b,t) => a + (b-a)*t;
const ease = (t) => t*t*(3-2*t);

// ── Cinematic block: 3 acts, each pinned for 200vh ─────────────────────────
function CinematicBlock() {
  return (
    <div data-screen-label="00 Cinematic" style={{height: '1450vh', overflow: 'hidden'}}>
      <Act1Cosmos />
      <LogoInterlude />
      <Act2Mountain />
      <Act3Atlas />
    </div>
  );
}

// ── LOGO INTERLUDE — black screen with white logo, fades in and out
function LogoInterlude() {
  const ref = useRef(null);
  const [p, setP] = useState(0);
  useEffect(() => {
    const onScroll = () => {
      const el = ref.current;
      if (!el) return;
      const vh = window.innerHeight;
      const rect = el.getBoundingClientRect();
      // p=0 when top of section hits top of viewport
      // p=1 when we've scrolled through the extra height (200vh - 100vh = 100vh)
      const traveled = clamp01(-rect.top / vh);
      setP(traveled);
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Fondu fixe : apparaît sur place, tient, puis disparaît
  const fadeIn  = ease(clamp01(p / 0.3));
  const fadeOut = clamp01((p - 0.7) / 0.3);
  const opacity = fadeIn * (1 - fadeOut);

  return (
    <section ref={ref} style={{
      position:'relative', height:'120vh'
    }}>
      {/* Fixed fullscreen overlay — pure opacity fade, no sliding */}
      <div style={{
        position:'fixed', inset:0, zIndex:6,
        background:'#b3b3b3',
        opacity: opacity,
        pointerEvents: opacity > 0.01 ? 'auto' : 'none'
      }}>
        <img src="3.png" alt="The Discipline"
          style={{
            position:'absolute', inset:0,
            width:'100%', height:'100%',
            objectFit:'cover'
          }}
        />
      </div>
    </section>
  );
}

// ─── ACT 1 ─────────────────────────────────────────────────────────────────
function Act1Cosmos() {
  const ref = useRef(null);
  const [p, setP] = useState(0);
  // Local progress for this act, used to drive overlay opacity / parallax
  useEffect(() => {
    const onScroll = () => {
      const r = ref.current?.getBoundingClientRect();
      if (!r) return;
      const vh = window.innerHeight;
      const total = ref.current.offsetHeight;
      const start = r.top;
      // p=0 at start (top hits viewport top), p=1 when the bottom hits viewport bottom
      const traveled = clamp01(-start / (total - vh));
      setP(traveled);
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Animation segments
  const introP   = 1; // visible on load — the monogram is the landing hero
  const holdP    = clamp01((p - 0.10) / 0.30);
  const exitP    = clamp01((p - 0.70) / 0.30);     // monogram + subtitle exit
  const tagStartFadeVh = 395; // vh depuis le début du cosmos où le fade-out commence
  const tagFadeDurationVh = 5; // vh de durée du fade-out (395+5=400 = fin exacte du cosmos)
  const tagExitP = clamp01((p - tagStartFadeVh/ACT_VH) / (tagFadeDurationVh/ACT_VH));
  const titleOpacity = (1 - exitP);
  const titleY = lerp(0, -40, exitP);
  const subOpacity = (1 - exitP);

  return (
    <section className="act act-cosmos" ref={ref} data-screen-label="01 Cosmos"
             style={{ height: `${ACT_VH}vh` }}>
      <div className="pin">
        <div className="overlay">
          <div className="crosshair tl" /><div className="crosshair tr" />
          <div className="crosshair bl" /><div className="crosshair br" />
          <div className="mono-tag l">A LIFE PROJECT · EST. MMXXIV</div>
          <div className="mono-tag r">47.5°N · 6.864°E · MONT-BLANC</div>

          <div style={{ transform: `translateY(${titleY}px)`, opacity: titleOpacity,
                        display:'flex', flexDirection:'column', alignItems:'center' }}>
            <h1 className="monogram" aria-label="The Discipline">
              <span className="a">D</span><span className="a">S</span><span className="a">C</span>
              <span className="a">P</span><span className="a">L</span><span className="a">N</span>
            </h1>
          </div>

          <div className="mono-sub" style={{ opacity: subOpacity }}>
            <b>THE&nbsp;DISCIPLINE</b> &nbsp;—&nbsp; A LIFE PROJECT &nbsp;—&nbsp; EST. MMXXIV
          </div>

          {/* Tagline — slides up from below then holds at center */}
          <div style={{
            position:'fixed', left:'50%',
            top: `${lerp(70, 50, ease(holdP))}vh`,
            transform:'translate(-50%, -50%)',
            opacity: holdP * (1 - tagExitP),
            textAlign:'center', maxWidth:'72ch', padding:'0 32px',
            zIndex:10, pointerEvents:'none'
          }}>
            <p style={{
              margin:0, fontFamily:'var(--font-display)', fontWeight:200,
              fontSize:'clamp(20px,2.2vw,30px)', lineHeight:1.3, letterSpacing:'-.005em',
              color:'var(--ink)'
            }}>
              La discipline est le pont entre <em style={{fontStyle:'normal', color:'var(--accent)'}}>qui tu es</em>
              {' '}et <em style={{fontStyle:'normal', color:'var(--accent)'}}>qui tu veux devenir</em>.
            </p>
            <p style={{
              margin:'10px 0 0', fontFamily:'var(--font-mono)',
              fontSize:'11px', letterSpacing:'.28em', textTransform:'uppercase',
              color:'var(--mute)'
            }}>
              Discipline is the bridge between who you are and who you want to be.
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

// ─── ACT 2 ─────────────────────────────────────────────────────────────────
function Act2Mountain() {
  const ref = useRef(null);
  const [p, setP] = useState(0);
  useEffect(() => {
    const onScroll = () => {
      const r = ref.current?.getBoundingClientRect();
      if (!r) return;
      const vh = window.innerHeight;
      const total = ref.current.offsetHeight;
      const start = r.top;
      const traveled = clamp01(-start / (total - vh));
      setP(traveled);
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Sequence: title → coords → carousel, spread across 800vh
  // 1) Title: 3% → 15%
  const titleIn   = clamp01((p - 0.03) / 0.05);
  const titleOut  = clamp01((p - 0.14) / 0.04);
  const titleOpacity = ease(titleIn) * (1 - titleOut);
  // 2) Coords: 18% → 30%
  const coordIn   = clamp01((p - 0.18) / 0.05);
  const coordOut  = clamp01((p - 0.29) / 0.04);
  const coordP    = ease(coordIn) * (1 - coordOut);
  // 3) Clothes carousel: 35% → 56% — enters from right, exits to left
  //    Must be fully gone before atlas text appears (~p=0.578 = 1260vh abs)
  const carouselProgress = clamp01((p - 0.35) / 0.15);  // scrolls through in 15% instead of 50%
  const carouselFadeIn   = ease(clamp01((p - 0.35) / 0.06));
  const carouselFadeOut  = clamp01((p - 0.50) / 0.06);  // gone by p=0.56
  const manifestoP       = carouselFadeIn * (1 - carouselFadeOut);
  const exitP = clamp01((p - 0.85) / 0.05);

  const clothesImages = [
    'clothes/mens-classic-tee-black-front-659d8122f1eaf.png',
    'clothes/mens-classic-tee-white-front-659d939101728.png',
    'clothes/mens-classic-tee-white-back-659d9391033aa.png',
    'clothes/mens-classic-tee-white-right-659d9391037c2.png',
    'clothes/mens-classic-tee-white-back-659d9ad78fe91.png',
    'clothes/unisex-heavy-blend-hoodie-navy-front-6599232c5ee46.png',
    'clothes/unisex-heavy-blend-hoodie-white-front-659dacf3aa9e2.png',
    'clothes/unisex-heavy-blend-hoodie-white-back-659dacf3ac135.png',
    'clothes/unisex-heavy-blend-hoodie-sport-grey-front-659923e839df9.png',
    'clothes/unisex-staple-t-shirt-ash-front-657e289958d44.png',
  ];

  return (
    <section className="act act-mountain" ref={ref} data-screen-label="02 Mont-Blanc"
             style={{ height: '1280vh' }}>
      <div className="pin">
        <div className="overlay">
          <div className="mount-coord" style={{
            position:'fixed', top:`${lerp(60, 50, ease(coordIn))}vh`, left:'50%',
            transform:'translate(-50%, -50%)',
            opacity: coordP, textAlign:'center',
            zIndex:10, pointerEvents:'none'
          }}>
            <b>SITE — 47.5°N · 6.864°E</b>
            ELEV. 4,805.59 M<br/>
            BURDEN-CLASS · I<br/>
            DATE · MMXXIV
          </div>

          {/* Title — same centered mono style as coords */}
          <div className="mount-coord" style={{
            position:'fixed', top:'50vh', left:'50%',
            transform:'translate(-50%, -50%)',
            opacity: titleOpacity, textAlign:'center',
            zIndex:10, pointerEvents:'none'
          }}>
            <b style={{fontSize:'22px', letterSpacing:'.28em', marginBottom:12}}>CHAPTER II · DESCENT</b>
            <div style={{
              fontFamily:'var(--font-display)', fontWeight:200,
              fontSize:'clamp(36px, 8vw, 72px)', lineHeight:.95,
              letterSpacing:'-.02em', color:'var(--ink)',
              margin:'16px 0',
              textShadow:'0 0 4px rgba(0,0,0,.2), 0 0 12px rgba(0,0,0,.35), 0 0 28px rgba(0,0,0,.5), 0 0 55px rgba(0,0,0,.6), 0 0 100px rgba(0,0,0,.5), 0 0 150px rgba(0,0,0,.4)'
            }}>
              FROM SPACE,<br/>
              TO <span style={{color:'var(--accent)'}}>STONE</span>.
            </div>
            <span style={{fontSize:'15px', color:'var(--accent)', letterSpacing:'.22em',
              textShadow:'0 0 4px rgba(0,0,0,.35), 0 0 12px rgba(0,0,0,.55), 0 0 28px rgba(0,0,0,.95), 0 0 55px rgba(0,0,0,1), 0 0 100px rgba(0,0,0,1), 0 0 150px rgba(0,0,0,.9), 0 0 220px rgba(0,0,0,.7)'}}>
              Le sommet ne récompense pas la vitesse — il récompense la répétition.
            </span>
          </div>

          {/* Clothes carousel — enters from right edge, scrolls across to left */}
          <div style={{
            position:'fixed', top:'50%', left:0, right:0,
            transform:'translateY(-50%)',
            opacity: manifestoP,
            zIndex:10, pointerEvents:'none',
            overflow:'visible', height:'55vh'
          }}>
            <div style={{
              display:'flex', gap:28, alignItems:'center', height:'100%',
              paddingLeft:'100vw',
              transform: `translateX(${lerp(0, -600, carouselProgress)}%)`,
            }}>
              {clothesImages.map((src, i) => (
                <img key={i} src={src} alt=""
                  style={{
                    height:'85%', width:'auto', objectFit:'contain',
                    flexShrink:0, borderRadius:4,
                    filter:'drop-shadow(0 8px 30px rgba(0,0,0,.5))'
                  }}
                />
              ))}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

// ─── ACT 3 ─────────────────────────────────────────────────────────────────
function Act3Atlas() {
  const ref = useRef(null);
  const [p, setP] = useState(0);
  useEffect(() => {
    const onScroll = () => {
      const r = ref.current?.getBoundingClientRect();
      if (!r) return;
      const vh = window.innerHeight;
      const total = ref.current.offsetHeight;
      // The atlas canvas appears ~30% of mountain-section-height BEFORE this
      // section's top reaches the viewport (cross-fade). We offset p so the
      // text starts animating when the canvas becomes visible, not when the
      // section enters. An advance of ~384vh (0.30 × 1280) translated into
      // this section's total gives us a head-start fraction.
      const advance = 0.64 * 1280 / 100 * vh; // 819vh in px — matches cross-fade at 36% (atlas canvas appears at 36% of mtn)
      const start = r.top;
      const traveled = clamp01((-start + advance) / (total - vh + advance));
      setP(traveled);
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  const introP = clamp01((p - 0.10) / 0.08); // text appears at ~1260vh
  const closeP = clamp01((p - 0.18) / 0.07); // fully visible at ~1298vh
  const exitP  = clamp01((p - 0.35) / 0.05); // text fades with the canvas (synced with camera end)

  return (
    <section className="act act-atlas" ref={ref} data-screen-label="03 Atlas"
             style={{ height: '10vh' }}>
      <div className="pin">
        <div className="overlay">
          <div className="atlas-bottom" style={{
            position:'fixed', top:`${lerp(70, 50, ease(closeP))}vh`, left:'50%',
            transform:'translate(-50%, -50%)',
            opacity: closeP * (1 - exitP),
            textAlign:'center', maxWidth:'60ch', padding:'0 32px',
            zIndex:10, pointerEvents:'none'
          }}>
            <div className="atlas-eyebrow" style={{marginBottom:18}}>CHAPTER III · THE TITAN</div>
            <p className="lead">
              Atlas porte le ciel.<br/>
              Toi, tu portes <em style={{fontStyle:'normal', color:'var(--accent)'}}>ton jour</em>.
            </p>
            <p className="body">
              Même posture, plus petite échelle. La discipline n'a pas besoin d'un mythe pour exister
              — juste d'une promesse tenue, ce matin, sans témoin. Le rocher se souvient. Toi aussi.
            </p>
            <div className="sig">— THE DISCIPLE&apos;S CREED</div>
          </div>
        </div>
      </div>
    </section>
  );
}

// ─── Manifesto strip (after the 3D acts) ────────────────────────────────────
function ManifestoStrip() {
  return (
    <section className="strip" data-screen-label="04 Manifesto">
      <div className="strip-inner">
        <div className="strip-l">
          <div className="eyebrow">THE DISCIPLE</div>
          <h2>
            Ce n&apos;est pas une marque<br/>
            pour <em>tout le monde</em>.<br/>
            C&apos;est une marque pour <em>quelqu&apos;un</em>.
          </h2>
        </div>
        <div className="strip-r">
          <div className="row">
            <div className="num">A.</div>
            <h4>Se lève tôt.</h4>
            <p>Pas pour la photo — parce que c&apos;est l&apos;heure où la journée se gagne. Avant les notifs, avant le bruit.</p>
          </div>
          <div className="row">
            <div className="num">B.</div>
            <h4>S&apos;entraîne.</h4>
            <p>Sportif amateur ou confirmé. Le corps est le premier outil. On le respecte, on l&apos;use, on le répare.</p>
          </div>
          <div className="row">
            <div className="num">C.</div>
            <h4>Bosse.</h4>
            <p>Étudiant ambitieux, jeune actif, artisan. Le travail comme verbe, pas comme nom.</p>
          </div>
          <div className="row">
            <div className="num">D.</div>
            <h4>Et n&apos;a pas besoin de le crier.</h4>
            <p>Porte la marque pour se rappeler à lui-même qui il veut être. Pas pour qu&apos;on le voie.</p>
          </div>
        </div>
      </div>
    </section>
  );
}

// ─── Capsule preview ────────────────────────────────────────────────────────
function CapsuleSection() {
  const items = [
    { num:'001', name:'DAILY GRAVITY',  type:'Heavy Tee · 240gsm',     price:'€60',  tag:'CARRY · STONE',   ph:'Cotton · Garment-dyed' },
    { num:'002', name:'BASE CAMP',      type:'Cropped Hoodie · 380gsm', price:'€120', tag:'WARM · ASH',      ph:'French Terry · Heavy' },
    { num:'003', name:'ATLAS',          type:'Six-Panel Cap',           price:'€40',  tag:'EMBROIDERED',     ph:'Brushed Twill' },
    { num:'004', name:'BURDEN',         type:'Carry Tote · 14oz',       price:'€50',  tag:'EVERYDAY',        ph:'Heavy Canvas · Sand' },
  ];
  return (
    <section className="capsule" data-screen-label="05 Capsule">
      <div className="capsule-hd">
        <div className="l">
          <div className="eyebrow">CAPSULE 01 — STONE</div>
          <h2>Garments<br/>for the work,<br/>not the wear.</h2>
        </div>
        <div className="r">
          <p>
            Quatre pièces. Pas de hype, pas de drop infini. Coton lourd, tissus garment-dyed,
            détails enlevés un par un jusqu&apos;à ce qu&apos;il n&apos;en reste que l&apos;essentiel.
            Conçus pour vieillir avec toi.
          </p>
          <div className="meta">SHIPPING · OCT MMXXIV — PARIS</div>
        </div>
      </div>
      <div className="grid">
        {items.map((it) => (
          <article key={it.num} className="card">
            <div className="card-img">
              <span className="num">{it.num}</span>
              <span className="badge">{it.tag}</span>
              <span className="ph"><b>PRODUCT IMAGE</b>{it.ph}</span>
            </div>
            <div className="card-meta">
              <h3>{it.name}</h3>
              <span className="price">{it.price}</span>
            </div>
            <div className="card-tag">{it.type}</div>
          </article>
        ))}
      </div>
    </section>
  );
}

// ─── Footer ─────────────────────────────────────────────────────────────────
function FooterBlock() {
  return (
    <footer className="footer" data-screen-label="06 Footer">
      <div className="footer-grid">
        <div>
          <h5>NEWSLETTER · DROP NOTIFY</h5>
          <p style={{margin:0, color:'var(--ink-2)', fontSize:14.5, lineHeight:1.5, maxWidth:'42ch'}}>
            Un mail toutes les six semaines. Drops, journal de marche, et rappels à la discipline.
            Rien d&apos;autre.
          </p>
          <form className="nl" onSubmit={(e)=>e.preventDefault()}>
            <input type="email" placeholder="ton@email.fr" />
            <button type="submit">Subscribe →</button>
          </form>
        </div>
        <div>
          <h5>SHOP</h5>
          <ul>
            <li>Capsule 01</li>
            <li>Tous les produits</li>
            <li>Lookbook</li>
            <li>Cartes cadeaux</li>
          </ul>
        </div>
        <div>
          <h5>MAISON</h5>
          <ul>
            <li>Manifeste</li>
            <li>Le Disciple</li>
            <li>Journal de marche</li>
            <li>Contact</li>
          </ul>
        </div>
        <div>
          <h5>LÉGAL</h5>
          <ul>
            <li>Livraison · Retour</li>
            <li>Confidentialité</li>
            <li>CGV</li>
            <li>Instagram ↗</li>
          </ul>
        </div>
      </div>
      <div className="footer-mono">THE&nbsp;DISCIPLINE</div>
      <div className="footer-bot">
        <span>© MMXXIV · Tous droits réservés</span>
        <span>FOUNDED IN PARIS · MADE IN PORTUGAL</span>
        <span>EST. MMXXIV</span>
      </div>
    </footer>
  );
}

// ── Chrome (currently nothing extra; placeholder for future) ───────────────
function ChromeOverlay() { return null; }

// ── Tweaks UI ───────────────────────────────────────────────────────────────
function TweaksUI({ t, setTweak }) {
  return (
    <TweaksPanel title="Tweaks">
      <TweakSection label="Direction" />
      <TweakRadio
        label="Visual" value={t.direction}
        options={[
          { value: 'cosmic', label: 'Cosmic' },
          { value: 'stone',  label: 'Stone' },
          { value: 'alpine', label: 'Alpine' },
        ]}
        onChange={(v) => setTweak('direction', v)}
      />
      <TweakSection label="Typography" />
      <TweakSelect
        label="Pairing" value={t.fontPair}
        options={[
          { value: 'geist',     label: 'Geist (technical sans)' },
          { value: 'archivo',   label: 'Archivo (grotesk)' },
          { value: 'editorial', label: 'Fraunces display + Geist body' },
          { value: 'stoic',     label: 'Cormorant display + Geist body' },
        ]}
        onChange={(v) => setTweak('fontPair', v)}
      />
      <TweakSection label="Motion" />
      <TweakSlider
        label="Scroll speed" value={t.scrollSpeed}
        min={0.5} max={2.0} step={0.1} unit="×"
        onChange={(v) => setTweak('scrollSpeed', v)}
      />
      <TweakSection label="Earth render" />
      <TweakRadio
        label="Style" value={t.earthStyle}
        options={[
          { value: 'realistic', label: 'Realistic' },
          { value: 'stylized',  label: 'Mono' },
          { value: 'wireframe', label: 'Wire' },
        ]}
        onChange={(v) => setTweak('earthStyle', v)}
      />
      <TweakSection label="Display" />
      <TweakToggle label="Show HUD" value={t.showHud} onChange={(v) => setTweak('showHud', v)} />
    </TweaksPanel>
  );
}

// ── Mount ───────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<App />);
