// G-continuous.jsx — Ursprung V landing page
// 9 pages: Hero / Caresse quote / ur·sprung definition / Format / Castle /
//          Past gatherings / Testimonials / Philosophy / Hosts / Apply

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

// ---------- HOOKS ----------
function useScrollY() {
  const [y, setY] = useState(0);
  useEffect(() => {
    let raf = null;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => { setY(window.scrollY); raf = null; });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  return y;
}

function useCursor() {
  const [pos, setPos] = useState({ mx: 0, my: 0, raw: { x: 0.5, y: 0.5 } });
  const target = useRef({ x: 0, y: 0 });
  const cur = useRef({ x: 0, y: 0 });
  const raw = useRef({ x: 0.5, y: 0.5 });
  useEffect(() => {
    const onMove = (e) => {
      target.current.x = e.clientX / window.innerWidth * 2 - 1;
      target.current.y = e.clientY / window.innerHeight * 2 - 1;
      raw.current.x = e.clientX / window.innerWidth;
      raw.current.y = e.clientY / window.innerHeight;
    };
    window.addEventListener('mousemove', onMove);
    let raf;
    const loop = () => {
      cur.current.x += (target.current.x - cur.current.x) * 0.05;
      cur.current.y += (target.current.y - cur.current.y) * 0.05;
      setPos({ mx: cur.current.x, my: cur.current.y, raw: { ...raw.current } });
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => { window.removeEventListener('mousemove', onMove); cancelAnimationFrame(raf); };
  }, []);
  return pos;
}

// Inner-panel scrolling: routes wheel events from anywhere inside `scopeRef`
// to the inner scrollable element `scrollRef`. Used by Format / Philosophy /
// Castle / Past where content overflows the visible 84vh panel.
//
// Behavior:
//  - Section must be fully sharp (opacity ~1) before in-page scroll engages.
//  - After becoming sharp, a 300ms "settle pause" is required: the user must
//    NOT continue scrolling for 300ms before in-page scroll unlocks. This
//    creates the "Oh, I can stop scrolling now" moment.
//  - When section is not visible, scrollTop resets to 0 — every entry is a
//    fresh start.
//  - When the user reaches the top/bottom of the inner content and continues
//    scrolling in that direction, page-scroll resumes (overscroll bridges).
function useInnerWheelScroll() {
  const scrollRef = useRef(null);
  const scopeRef = useRef(null);
  // Tracks when the section first reached "sharp" (opacity >= 0.985) AND the
  // user paused. After the pause, in-page scroll engages.
  const sharpSinceRef = useRef(0);
  const lastWheelAtRef = useRef(0);
  const unlockedRef = useRef(false);
  const wasVisibleRef = useRef(false);

  useEffect(() => {
    const inner = scrollRef.current;
    const scope = scopeRef.current;
    if (!inner || !scope) return;

    // Poll opacity to track sharp/visible state and reset scroll when leaving.
    let raf;
    const tick = () => {
      const op = parseFloat(getComputedStyle(scope).opacity);
      const visible = isFinite(op) && op > 0.05;
      const sharp = isFinite(op) && op >= 0.985;
      const now = performance.now();

      // Reset on leave: when the section becomes invisible, reset inner scroll
      // and the unlock state so re-entry starts fresh from the top.
      if (wasVisibleRef.current && !visible) {
        inner.scrollTop = 0;
        sharpSinceRef.current = 0;
        unlockedRef.current = false;
      }
      wasVisibleRef.current = visible;

      if (sharp) {
        if (sharpSinceRef.current === 0) {
          sharpSinceRef.current = now;
        }
        // Unlock once the user has been "still" for 300ms after sharp.
        if (!unlockedRef.current) {
          const stillFor = now - Math.max(sharpSinceRef.current, lastWheelAtRef.current);
          if (stillFor >= 300) {
            unlockedRef.current = true;
          }
        }
      } else {
        // Not sharp yet — keep locked.
        sharpSinceRef.current = 0;
        unlockedRef.current = false;
      }

      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    const onWheel = (e) => {
      const dy = e.deltaY;
      if (dy === 0) return;
      const op = parseFloat(getComputedStyle(scope).opacity);
      if (!isFinite(op)) return;

      // Section not even close to visible — let page scroll.
      if (op < 0.85) return;

      // Track wheel activity so unlock waits for stillness.
      const now = performance.now();
      lastWheelAtRef.current = now;

      // Section visible but not yet sharp+settled — block in-page scroll AND
      // let page-scroll proceed (don't preventDefault). User keeps scrolling
      // through the timing window; CSS opacity reaches 1; then they pause.
      if (!unlockedRef.current) return;

      const max = inner.scrollHeight - inner.clientHeight;
      if (max <= 1) return;
      const top = inner.scrollTop;
      const atEnd = dy > 0 ? top >= max - 0.5 : top <= 0.5;
      if (atEnd) return;
      e.preventDefault();
      e.stopPropagation();
      inner.scrollTop = Math.max(0, Math.min(max, top + dy));
    };
    window.addEventListener('wheel', onWheel, { passive: false, capture: true });
    return () => {
      window.removeEventListener('wheel', onWheel, { capture: true });
      cancelAnimationFrame(raf);
    };
  }, []);
  return { scrollRef, scopeRef };
}

function useNow() {
  const [now, setNow] = useState(performance.now());
  useEffect(() => {
    let raf;
    const loop = () => { setNow(performance.now()); raf = requestAnimationFrame(loop); };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, []);
  return now;
}

const clamp01 = (t) => Math.max(0, Math.min(1, t));
const ramp = (y, a, b) => clamp01((y - a) / (b - a));
const smooth = (t) => t * t * (3 - 2 * t);
const sramp = (y, a, b) => smooth(ramp(y, a, b));

function crossfade(yvh, inStart, inEnd, outStart, outEnd, maxBlur = 8) {
  const a = sramp(yvh, inStart, inEnd);
  const b = sramp(yvh, outStart, outEnd);
  const op = a * (1 - b);
  const inBlur = (1 - a) * maxBlur;
  const outBlur = b * maxBlur;
  const blur = Math.max(inBlur, outBlur);
  return { opacity: op, blur };
}

// ---------- TIMELINE (in vh) ----------
const T = {
  HERO_HOLD_END: 80,
  HERO_OUT: 120,

  QUOTE_IN: 110,
  QUOTE_HOLD_END: 220,
  QUOTE_OUT: 260,

  DEF_IN: 250,
  DEF_HOLD_END: 350,
  DEF_OUT: 390,

  FORMAT_IN: 380,
  FORMAT_HOLD_END: 510,
  FORMAT_OUT: 550,

  // P5 Castle — shortened (~50% of previous span)
  CASTLE_BLACK_IN: 540,
  CASTLE_BLACK_PEAK: 560,
  PLAN_IN: 555,
  PLAN_ZOOM_END: 640,
  CASTLE_TEXT_IN: 615,
  PLAN_OUT: 720,
  GALLERY_IN: 700,
  CASTLE_HOLD_END: 800,
  CASTLE_OUT: 830,

  // P6 Past gatherings (timeline)
  PAST_IN: 820,
  PAST_HOLD_END: 940,
  PAST_OUT: 990,

  // P6.2 Testimonials
  TEST_IN: 970,
  TEST_HOLD_END: 1080,
  TEST_OUT: 1120,

  // P7 Philosophy
  PHIL_IN: 1110,
  PHIL_HOLD_END: 1230,
  PHIL_OUT: 1270,

  // P8 Hosts
  HOSTS_IN: 1260,
  HOSTS_HOLD_END: 1390,
  HOSTS_OUT: 1430,

  // P9 Apply
  APPLY_IN: 1420,
  APPLY_HOLD_END: 1560,

  TOTAL: 1640,
};

// ---------- FOREST BEAMS ----------
const BEAMS = [
  { id: 0, x: 18, angle: -10, width: 360, blur: 80, opacity: 0.10, color: '230,230,160', depth: 0.15, parallax: 0.5, cutIn: 0,  side: 'left' },
  { id: 1, x: 78, angle: 8,   width: 400, blur: 90, opacity: 0.09, color: '215,220,150', depth: 0.15, parallax: 0.5, cutIn: 22, side: 'right' },
  { id: 2, x: 42, angle: -3,  width: 220, blur: 50, opacity: 0.18, color: '235,235,170', depth: 0.5,  parallax: 0.9, cutIn: 37, side: 'left' },
  { id: 3, x: 88, angle: 14,  width: 180, blur: 45, opacity: 0.14, color: '230,225,155', depth: 0.5,  parallax: 0.9, cutIn: 48, side: 'right' },
  { id: 4, x: 9,  angle: -16, width: 160, blur: 40, opacity: 0.13, color: '220,220,145', depth: 0.5,  parallax: 0.9, cutIn: 56, side: 'left' },
  { id: 5, x: 60, angle: 2,   width: 90,  blur: 22, opacity: 0.30, color: '240,245,200', depth: 0.9,  parallax: 1.3, cutIn: 61, side: 'right' },
  { id: 6, x: 30, angle: -7,  width: 72,  blur: 18, opacity: 0.34, color: '240,245,205', depth: 0.95, parallax: 1.4, cutIn: 65, side: 'left' },
  { id: 7, x: 92, angle: 9,   width: 60,  blur: 16, opacity: 0.24, color: '235,240,195', depth: 0.95, parallax: 1.4, cutIn: 68, side: 'right' },
];

const breathe = (t, seed) => 0.78 + 0.20 * Math.sin(t / 1100 + seed * 1.3) + 0.05 * Math.sin(t / 430 + seed * 2.7);

function ForestAtmosphere({ heroProgress, mx, my, opacity, evolution, now, beamsExtraSpread, beamsDim }) {
  return (
    <div className="forest" style={{ opacity }}>
      <div className="canopy" style={{
        background: `radial-gradient(circle at 30% 12%, rgba(30, 56, 32, 0.28) 0%, transparent 50%),
                     radial-gradient(circle at 75% 25%, rgba(18, 38, 24, 0.26) 0%, transparent 45%),
                     radial-gradient(circle at 55% 80%, rgba(36, 60, 38, 0.24) 0%, transparent 55%),
                     rgba(8, 14, 9, 1)`
      }} />
      <div className="ambient" style={{
        background: `radial-gradient(ellipse at ${30 + evolution * 4}% 5%, rgba(170, 165, 95, 0.16), transparent 60%)`
      }} />
      {BEAMS.map((b) => {
        const phase = evolution * 0.9 + b.id * 0.7;
        const xShift = Math.sin(phase) * 3;
        const angShift = Math.cos(phase) * 5;
        const elapsed = heroProgress * 100;
        const cut = sramp(elapsed, b.cutIn, b.cutIn + 12);
        const breath = breathe(now, b.id);
        const px = mx * b.depth * 30 * b.parallax;
        const py = my * b.depth * 12 * b.parallax;
        // beams move outward when castle approaches
        const spreadX = beamsExtraSpread ? (b.x - 50) * beamsExtraSpread * 0.4 : 0;
        const opa = b.opacity * cut * breath * (1 - (beamsDim || 0) * 0.4);
        return (
          <div key={b.id} className="beam" style={{
            left: `${b.x + xShift + spreadX}vw`,
            top: '-12vh',
            width: `${b.width}px`,
            height: `120vh`,
            background: `linear-gradient(to bottom, rgba(${b.color}, ${opa}) 0%, rgba(${b.color}, ${opa * 0.85}) 28%, rgba(${b.color}, ${opa * 0.4}) 60%, rgba(${b.color}, 0) 100%)`,
            filter: `blur(${b.blur}px)`,
            transform: `translate(${px}px, ${py}px) translateX(-50%) rotate(${b.angle + angShift}deg)`
          }} />
        );
      })}
      <div className="vignette" />
    </div>
  );
}

// ---------- POLLEN ----------
function PollenLayer({ count, depth, mx, my, sizeMin, sizeMax, blurMax, opacityRange }) {
  const particles = useMemo(() => Array.from({ length: count }, () => {
    const angle = Math.random() * Math.PI * 2;
    const distance = 60 + Math.random() * 120;
    return {
      x: Math.random() * 100, y: Math.random() * 110,
      size: sizeMin + Math.random() * (sizeMax - sizeMin),
      delay: -Math.random() * 40, duration: 28 + Math.random() * 36,
      dx: Math.cos(angle) * distance, dy: Math.sin(angle) * distance,
      blur: Math.random() * blurMax,
      opacity: opacityRange[0] + Math.random() * (opacityRange[1] - opacityRange[0])
    };
  }), [count, sizeMin, sizeMax, blurMax]);
  const px = mx * depth * 18; const py = my * depth * 8;
  return (
    <div className="pollen-layer" style={{ transform: `translate(${px}px, ${py}px)` }}>
      {particles.map((p, i) =>
        <div key={i} className="pollen-particle" style={{
          left: `${p.x}%`, top: `${p.y}%`,
          width: `${p.size}px`, height: `${p.size}px`,
          opacity: p.opacity, filter: `blur(${p.blur}px)`,
          boxShadow: `0 0 ${p.size * 3}px rgba(245,235,170,${p.opacity * 0.6})`,
          animation: `pollenDrift ${p.duration}s ease-in-out ${p.delay}s infinite`,
          '--dx': `${p.dx}px`, '--dy': `${p.dy}px`
        }} />
      )}
    </div>
  );
}

// ---------- DAYLIGHT GLOW (P3 cursor-follow) ----------
function DaylightGlow({ opacity, raw }) {
  if (opacity < 0.005) return null;
  const x = (raw?.x ?? 0.5) * 100;
  const y = (raw?.y ?? 0.5) * 100;
  return (
    <div className="daylight-glow" style={{
      opacity: opacity * 0.95,
      background: `radial-gradient(circle 620px at ${x}% ${y}%, rgba(255,244,194,0.55) 0%, rgba(243,201,138,0.30) 30%, rgba(201,165,71,0.12) 55%, transparent 78%)`
    }} />
  );
}

// ---------- P1 HERO ----------
function Hero({ opacity, blur, titleT, words }) {
  // iPhone: split into 3 lines for staircase ("An ongoing quest" / "for interdisciplinary" / "creative space")
  const wordStyle = (w) => ({
    opacity: w.t,
    transform: `translateY(${(1 - w.t) * 5}px)`,
    filter: `blur(${(1 - w.t) * 8}px)`,
    transition: 'opacity 0.55s ease, transform 0.55s ease, filter 0.55s ease'
  });
  return (
    <div className="hero" style={{ opacity, filter: `blur(${blur}px)` }}>
      <div className="hero-sentence">
        {/* Desktop / iPad — 2 lines */}
        <span className="desktop-only">
          <span className="line l1">
            {words.filter(w => w.line === 1).map((w, i) =>
              <span key={i} className="word" style={wordStyle(w)}>{w.text}</span>
            )}
          </span>
          <span className="line l2">
            {words.filter(w => w.line === 2).map((w, i) =>
              <span key={i} className="word" style={wordStyle(w)}>{w.text}</span>
            )}
          </span>
        </span>
        {/* iPhone — 3-line staircase */}
        <span className="iphone-only">
          <span className="line line-iphone-1">
            {words.filter((_, i) => i < 3).map((w, i) =>
              <span key={i} className="word" style={wordStyle(w)}>{w.text}</span>
            )}
          </span>
          <span className="line line-iphone-2">
            {words.filter((_, i) => i >= 3 && i < 5).map((w, i) =>
              <span key={i} className="word" style={wordStyle(w)}>{w.text}</span>
            )}
          </span>
          <span className="line line-iphone-3">
            {words.filter((_, i) => i >= 5).map((w, i) =>
              <span key={i} className="word" style={wordStyle(w)}>{w.text}</span>
            )}
          </span>
        </span>
      </div>

      <h1 className="hero-title" style={{
        opacity: titleT,
        transform: `translateY(${(1 - titleT) * 18}px)`,
        transition: 'opacity 0.9s ease, transform 0.9s ease'
      }}>
        <span className="line-1">
          <span className="ursprung-mark">ursprung</span>
          <span className="roman-v">V</span>
        </span>
        <span className="line-2">Rocca Sinibalda · October 1 — 5, 2026</span>
      </h1>
    </div>
  );
}

// ---------- P2 CARESSE QUOTE — four corners ----------
const CARESSE_CORNERS = [
  { cls: 'caresse-tl', text: 'There is nothing as strong as an idea' },
  { cls: 'caresse-tr', text: 'whose time has come' },
  { cls: 'caresse-bl', text: 'but it must be given an eagle\u2019s nest' },
  { cls: 'caresse-br', text: 'from which to soar.' }
];

function CaresseQuote({ opacity, blur, scrollT }) {
  const [timeT, setTimeT] = useState(0);
  useEffect(() => {
    if (opacity < 0.35) { setTimeT(0); return; }
    const start = performance.now();
    let raf;
    const loop = () => {
      const dur = 6500;
      setTimeT(Math.min(1, (performance.now() - start) / dur));
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [opacity > 0.35]);
  const revealT = Math.max(timeT, scrollT);
  const total = CARESSE_CORNERS.length;
  return (
    <div className="caresse" style={{ opacity, filter: `blur(${blur}px)` }}>
      <div className="caresse-bg" />
      <div className="caresse-scrim" />
      {CARESSE_CORNERS.map((c, i) => {
        const start = i / (total + 0.5);
        const end = (i + 1.0) / (total + 0.5);
        const t = clamp01((revealT - start) / (end - start));
        return (
          <p key={i} className={`caresse-corner ${c.cls}`} style={{
            opacity: t,
            filter: `blur(${(1 - t) * 6}px)`,
            transform: `translateY(${(1 - t) * 6}px)`,
            transition: 'opacity 0.5s ease, filter 0.5s ease, transform 0.5s ease'
          }}>{c.text}</p>
        );
      })}
      <p className="caresse-attr" style={{
        opacity: clamp01((revealT - 0.94) / 0.06),
        transition: 'opacity 0.6s ease'
      }}>— Caresse Crosby, 1968</p>
    </div>
  );
}

// ---------- P3 — ur·sprung dictionary entry ----------
function Definition({ opacity, blur }) {
  return (
    <div className="definition" style={{
      opacity, filter: `blur(${blur}px)`
    }}>
      <div className="def-card">
        <div className="def-headword">
          <span className="def-mark">ur</span>
          <span className="def-dot">·</span>
          <span className="def-mark">sprung</span>
        </div>
        <div className="def-meta">
          <span className="def-ipa">[ˈuːɐ̯ ʃpʁʊŋ]</span>
          <span className="def-pos"><em>noun</em></span>
          <span className="def-lang">German</span>
        </div>
        <div className="def-rule" />
        <p className="def-gloss">
          <span className="def-num">1.</span> origin, source; the point from which something arises.
        </p>
        <p className="def-announce">
          This October we're <em>inviting you to</em> our fifth gathering, at a Renaissance castle in the hills north of Rome.
        </p>
      </div>
    </div>
  );
}

// ---------- P4 — Format ----------
function Format({ opacity, blur, photoOpacity, photoBlur, beamsOpacity, now }) {
  const [hover, setHover] = useState(false);
  const { scrollRef: gridRef, scopeRef: formatScopeRef } = useInnerWheelScroll();
  // Light-bleed beams in the seam between forest (left) and image (right) — 50/50 split
  const seamRays = [
    { x: 44, angle: -10, width: 160, blur: 44, opa: 0.30, color: '255,232,180' },
    { x: 50, angle: -3,  width: 120, blur: 32, opa: 0.42, color: '255,242,200' },
    { x: 56, angle: 4,   width: 90,  blur: 26, opa: 0.46, color: '255,248,215' },
    { x: 62, angle: 9,   width: 140, blur: 40, opa: 0.30, color: '250,225,170' }
  ];
  const stats = (
    <>
      <div className="format-stat">
        <div className="format-stat-row">
          <span className="format-stat-num">3</span>
          <span className="format-stat-label">Full days</span>
        </div>
        <span className="format-stat-sub"><em>Arrival</em> Oct 1 · <em>Departure</em> Oct 5</span>
      </div>
      <div className="format-stat">
        <div className="format-stat-row">
          <span className="format-stat-num">~25</span>
          <span className="format-stat-label">Invited</span>
        </div>
      </div>
    </>
  );
  return (
    <>
      {/* Right-half image with alpha-mask fade (desktop/iPad).
          On iPhone, the image is full-width with a left→right blur fade. */}
      <div className="photo-edge" style={{ opacity: photoOpacity, filter: `blur(${photoBlur}px)` }}>
        <div className="photo-edge-inner">
          <img src="photos/battlements.webp" alt="" />
          {/* iPhone-only blur veil (full blur on left, fading to clear on right) */}
          <div className="photo-edge-iphone-darken" />
          <div className="photo-edge-iphone-blur" />
        </div>
      </div>
      {/* Light beams bleeding across the seam */}
      <div className="forest format-seam-beams" style={{ opacity: beamsOpacity }}>
        {seamRays.map((r, i) => {
          const breath = breathe(now, i + 11);
          const opa = r.opa * breath;
          return (
            <div key={i} className="beam" style={{
              left: `${r.x}vw`,
              top: '-14vh',
              width: `${r.width}px`,
              height: '135vh',
              background: `linear-gradient(to bottom, rgba(${r.color}, ${opa}) 0%, rgba(${r.color}, ${opa * 0.85}) 30%, rgba(${r.color}, ${opa * 0.4}) 65%, rgba(${r.color}, 0) 100%)`,
              filter: `blur(${r.blur}px)`,
              transform: `translateX(-50%) rotate(${r.angle}deg)`
            }} />
          );
        })}
      </div>
    {/* Stats overlay — desktop/iPad: bottom-right of photo. iPhone: relative top of column (CSS handles). */}
    <aside className="format-stats-overlay" style={{ opacity }}>
      {stats}
    </aside>
    <div className="section section-format" ref={formatScopeRef} style={{
      opacity, filter: `blur(${blur}px)`
    }}>
      <div className="format-grid" ref={gridRef}>
        {/* iPhone — stats appear at top of column (visible only on iPhone via CSS) */}
        <aside className="format-stats-overlay format-stats-iphone-inline" style={{ display: 'none' }}>
          {stats}
        </aside>
        <div className="format-body">
          <p
            className={`format-intro ${hover ? 'expanded' : ''}`}
            onMouseEnter={() => setHover(true)}
            onMouseLeave={() => setHover(false)}
          >
            We're inviting <em>~25 practitioners</em> from across{' '}
            <span className="ff-wrap">
              <span className="ff-short">
                <span className="ff-shimmer">all creative fields</span>
              </span>
              <span className="ff-long">
                visual and performing arts, writing and film, design and architecture, the sciences and engineering, and the organisational and entrepreneurial work that touches all of them
              </span>
            </span>{' '}
            for three days together.
          </p>
          <p>
            No theme. A light scaffolding for the conversations and work that tend to follow.
          </p>
          <p className="meals-line"><em>Meals that run long, walks in the hills, fires in the courtyard, conversations that drift into the night.</em></p>
          <p>
            The rooms become a living exhibition and studio of the work participants bring. We spend our days as small, ever-changing groups formed around a common curiosity, cycling through two modes:
          </p>
          <ul className="format-modes">
            <li><strong>Witnessing.</strong> A participant practises their craft.</li>
            <li><strong>Making together.</strong> The others bring outside ideas to that craft; the practitioner translates between the two.</li>
          </ul>
          <p>
            Over the three days, everyone passes through both sides at least once. You bring what you're already in the middle of, and decide how you want to share it.
          </p>
        </div>

        <aside className="format-stats format-stats-incolumn">
          {stats}
        </aside>
      </div>
    </div>
    </>
  );
}

// ---------- P5 — Castle ----------
const VENUE_PHOTOS = [
  'photos/aerial.jpeg',
  'photos/castleside.webp',
  'photos/library.webp',
  'photos/banquet.webp',
  'photos/theatre.webp',
  'photos/garden.webp'
];

function CastlePage({
  planOpacity, planScale, planBlur,
  textOpacity, textBlur,
  galleryOpacity, autoIndex
}) {
  const { scrollRef: castleScrollRef, scopeRef: castleScopeRef } = useInnerWheelScroll();
  return (
    <>
      <div className="castle-plan" style={{
        opacity: planOpacity,
        filter: `blur(${planBlur}px)`,
        transform: `scale(${planScale})`
      }}>
        <img src="photos/peruzzi-plan.webp" alt="Rocca Sinibalda plan" />
      </div>

      <div className="castle-gallery" data-active={galleryOpacity > 0.5 ? '1' : '0'} style={{ opacity: galleryOpacity }}>
        {VENUE_PHOTOS.map((src, i) => {
          const active = i === autoIndex;
          return (
            <div key={i} className="castle-gallery-frame" style={{
              opacity: active ? 1 : 0,
              filter: active ? 'blur(0px)' : 'blur(10px)',
              transition: 'opacity 1.6s ease, filter 1.6s ease'
            }}>
              <div className="castle-gallery-inner">
                <img src={src} alt="" />
              </div>
            </div>
          );
        })}
        <div className="castle-gallery-tint" />
      </div>

      {/* Tahoe-style blur veil — sibling of textpanel so backdrop-filter is unaffected by text's parent filter */}
      <div className="castle-blur-veil" data-gallery-active={galleryOpacity > 0.5 ? '1' : '0'} style={{ opacity: textOpacity }} />

      <div className="castle-textpanel" ref={castleScopeRef} style={{
        opacity: textOpacity,
        filter: `blur(${textBlur}px)`,
        pointerEvents: textOpacity > 0.05 ? 'auto' : 'none'
      }}>
        <div className="castle-textpanel-inner">
         <div className="castle-textpanel-scroll" ref={castleScrollRef}>
          <div className="castle-eyebrow">The castle</div>
          <h2 className="castle-h"><em>Rocca Sinibalda</em></h2>
          <p>
            The castle stands on a spur above the Turano valley, seventy kilometres northeast of Rome. Built in 1530 on the bones of an eleventh-century fortress, it's Europe's only zoomorphic castle, designed as an eagle with outspread wings.
          </p>
          <p className="italic-line">
            Seventeenth-century frescoes inside; underground passages cut through the rock; a small amphitheatre carved into it.
          </p>
          <p>
            In 1968, Caresse Crosby bought the castle. American publisher, godmother of the Lost Generation. She set the place up as a meeting point for what she called <em>Citizens of the World</em>. Among those who came were Ezra Pound, Salvador Dalí, Allen Ginsberg, Peggy Guggenheim, and the founders of the Living Theatre. Caresse died two years later, and the project went unfinished.
          </p>
          <p className="castle-coda">The rooms are still there. We'll fill them again soon.</p>
         </div>
        </div>
      </div>
    </>
  );
}

// ---------- P6 — Past gatherings (timeline only) ----------
// Testimonials data is also used inline at narrow widths (below the timeline,
// in the same scrollable column).
const TESTIMONIALS = [
  { quote: "Coming to Ursprung felt like visiting the future; it was an opportunity to learn from bright minds of different ages and unexpected talents, united by their passion for learning and contributing to the world. If you need a dose of optimism, this is the place for you.",
    name: "Olga Yakimenko", role: "film director" },
  { quote: "For me, Ursprung was about breaking free from my daily routine to explore ideas from completely different worlds. The best part was doing it all with an incredible group of people — a community I'm genuinely excited to build the future with.",
    name: "Niclas Dern", role: "mathematician" },
  { quote: "Ursprung gave me a chance to learn things I'd never encountered before — from poetry techniques to film theory — alongside people who were genuinely curious about different fields. This is the place to go for anyone who craves intellectual adventure and wants to rediscover the joy of learning.",
    name: "Sahil Shah", role: "entrepreneur" }
];

const PAST_EVENTS = [
  { when: 'Jan 2025 · Allgäu · 12 people', roman: 'I', title: 'The first gathering',
    img: 'photos/event-allgau.webp',
    desc: "Snowmobile up to a cabin above the treeline. Lightning talks, origami, long conversations on art and AI. A reading group started here that's still meeting." },
  { when: 'May 2025 · Tyrol · 18 people', roman: 'II', title: 'On Salon Culture',
    img: 'photos/event-tyrol1.webp',
    desc: "A deliberate experiment in the salon form. Cross-pollination studio pairing orthogonal participants for creation sprints. Experiments with Bohmian dialogue." },
  { when: 'Nov 2025 · Tyrol · 26 people', roman: 'III', title: 'On Tools for Thought',
    img: 'photos/event-tyrol2.webp',
    desc: "A system to map a live conversation in real time. A tool to disentangle a research paper from the programme behind it. A spaced-repetition thread that's still running a year later." },
  { when: 'Jan 2026 · Vorarlberg · 18 people', roman: 'IV', title: 'On Creative Communities',
    img: 'photos/event-vorarlberg.webp',
    desc: "What it takes to keep a creative community alive past the weekend. Spaces for silent, collaborative, and chaotic creating." }
];

function PastGatherings({ opacity, blur, translateY }) {
  const { scrollRef: pastScrollRef, scopeRef: pastScopeRef } = useInnerWheelScroll();
  // On narrow widths, the whole .past element is the scrollable surface
  // (heading scrolls along with content). The hook needs scrollRef to point
  // to whatever element actually has overflow-y:auto. We attach it to .past
  // via a combined ref so both scope and scroll are the same node on narrow,
  // while desktop CSS keeps .past as overflow:hidden and an inner scrollable
  // wrapper takes over (we still keep .past-timeline as a sibling ref target
  // for desktop's case where overflow can also be inner). To keep this simple,
  // we point both refs at .past — the hook only intercepts wheel when there's
  // actual scrollable overflow on that element, which only happens on narrow.
  const setPastNode = (node) => {
    pastScopeRef.current = node;
    pastScrollRef.current = node;
  };

  // Reveal-on-scroll for events + inline testimonials (narrow viewports only)
  useEffect(() => {
    const root = pastScopeRef.current;
    if (!root) return;
    if (window.innerWidth >= 1200) return; // desktop has its own opacity timing
    const targets = root.querySelectorAll('.past-event, .pti-group');
    if (!targets.length || typeof IntersectionObserver === 'undefined') {
      // Fallback: reveal everything
      targets.forEach(el => el.classList.add('is-revealed'));
      return;
    }
    const io = new IntersectionObserver((entries) => {
      entries.forEach(en => {
        if (en.isIntersecting) {
          en.target.classList.add('is-revealed');
          io.unobserve(en.target);
        }
      });
    }, {
      root, // observe within the .past scrollable container
      rootMargin: '0px 0px -20% 0px', // trigger when ~20% from bottom
      threshold: 0.15
    });
    targets.forEach(el => io.observe(el));
    return () => io.disconnect();
  }, []);

  return (
    <div className="past" ref={setPastNode} style={{
      opacity, filter: `blur(${blur}px)`,
      transform: `translateY(${translateY}px)`
    }}>
      <h2 className="past-h"><em>Past gatherings</em></h2>
      <div className="past-timeline">
        <div className="past-timeline-inner">
          <div className="past-line" />
          {PAST_EVENTS.map((it, i) => {
          const x = 11 + 78 * i / (PAST_EVENTS.length - 1);
          // Alternate: even = img-down/text-up, odd = img-up/text-down
          const imgSide = (i % 2 === 0) ? 'down' : 'up';
          const textSide = imgSide === 'down' ? 'up' : 'down';
          return (
            <div key={i} className="past-event" style={{ '--x': `${x}%` }}>
              <div className="past-dot" />
              <div className={`past-imgmeta ${imgSide}`}>
                <div className="past-img-wrap">
                  <div className="past-img-glow" style={{ backgroundImage: `url(${it.img})` }} />
                  <img src={it.img} alt="" />
                </div>
                <span className="past-when">{it.when}</span>
              </div>
              <div className={`past-titledesc ${textSide}`}>
                <span className="past-what"><span className="roman"><em>{it.roman}</em>{' — '}</span><em>{it.title}</em></span>
                <span className="past-desc">{it.desc}</span>
              </div>
            </div>
          );
        })}
        {/* Inline testimonials — visible only on narrow viewports (CSS-controlled).
            Lives inside .past-timeline so it scrolls with the inner-wheel handler. */}
        <div className="past-testimonials-inline" aria-hidden="false">
          {TESTIMONIALS.map((t, i) => (
            <div key={i} className={`pti-group pti-group-${i+1}`}>
              <p className="pti-quote">{t.quote}</p>
              <div className="pti-cite">
                <span className="pti-name">{t.name}</span>
                <span className="pti-role">{t.role}</span>
              </div>
            </div>
          ))}
        </div>
        </div>
      </div>
    </div>
  );
}

// ---------- P6.2 — Testimonials (free-flow, three groups, sequenced) ----------
// Order matches layout: 1=Olga (top-left), 2=Niclas (right), 3=Sahil (centre)
// (TESTIMONIALS const is declared above with PAST_EVENTS, since narrow-width
// layout renders the testimonials inline at the bottom of the past timeline.)

function Testimonials({ opacity, perQuote }) {
  return (
    <div className="testimonials" style={{ opacity }}>
      <div className="testimonials-inner">
        {TESTIMONIALS.map((t, i) => {
          const q = perQuote ? perQuote[i] : 1;
          return (
            <div key={i} className={`t-group t-group-${i+1}`} style={{
              opacity: q,
              transform: `translateY(${(1 - q) * 18}px)`,
              transition: 'opacity 0.6s ease, transform 0.6s ease'
            }}>
              <p className="t-quote">{t.quote}</p>
              <div className="t-cite">
                <span className="t-name">{t.name}</span>
                <span className="t-role">{t.role}</span>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ---------- P7 — Philosophy ----------
function Philosophy({ opacity, blur }) {
  const { scrollRef: bodyRef, scopeRef: philScopeRef } = useInnerWheelScroll();
  return (
    <div className="philosophy" ref={philScopeRef} style={{
      opacity, filter: `blur(${blur}px)`
    }}>
      <div className="phil-body" ref={bodyRef}>
        <p>
          <span className="phil-open">Creativity,</span> for us, is acting upon the world with intention and care. It is not limited to the traditional arts, and not defined by medium. An essay, an organisation, an experiment, a composition: what unites them is the stance of <strong>making something real that wasn't there before, and being thoughtful about how that act ripples outward</strong>.
        </p>
        <p>
          We're drawn to people we sometimes call <em><strong>thoughtful doers</strong></em>: deep specialists who read widely, deep thinkers who also build. Most feel a little alone in this. Their professional structures are organised against breadth; their social circles understand one dimension of who they are.
        </p>
        <p className="phil-coda">
          <em>There aren't many places that take this combination seriously. We're trying to make one.</em>
        </p>
      </div>
    </div>
  );
}

// ---------- P8 — Hosts ----------
const HOSTS_TOP = [
  { name: 'Adrian Cipriani', bio: 'Many years in painting, film, and music. Founded a young composers community, then Ursprung. Now studying engineering science, specialising in biotech. Sailing, the wild, jazz.' },
  { name: 'Betsy Herbert',   bio: 'Thinks about minds — small organisms, swarms of crowds, everything between. PhD in computational neuroscience, modelling how the brain gets built. Also drawing, photography, graphic design, the violin.' },
];
const HOSTS_BOTTOM = [
  { name: 'Bianca Pozzi',    bio: '[ bio — to source from Bianca ]', todo: true },
  { name: 'Max Lutz',        bio: 'Doctoral research in quantum algorithms; previously computer science and physics. Interested in tools for thought and better scientific institutions. Too many Wikipedia tabs open.' },
  { name: 'Hugo Berg',       bio: 'Computer science and math; runs a cybersecurity startup. Double bass and choir singing. Runs international olympiads in astronomy and astrophysics.' },
];

function Hosts({ opacity, blur }) {
  // openIdx is the open accordion item on iPhone (where bios are collapsed by default).
  // On desktop and iPad bios are always visible; the toggle is harmless there.
  const [openIdx, setOpenIdx] = useState(null);
  const [isPhone, setIsPhone] = useState(typeof window !== 'undefined' ? window.innerWidth < 520 : false);
  useEffect(() => {
    const onResize = () => setIsPhone(window.innerWidth < 520);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  const toggle = (key) => {
    if (!isPhone) return;
    setOpenIdx((cur) => cur === key ? null : key);
  };
  // Use onPointerDown so the toggle fires before the design-mode text overlay (__om-t)
  // can swallow the click event during development. We also tag a pointer id so we
  // don't double-fire on the synthesized onClick that follows.
  const lastPointerRef = useRef(0);
  const handlePointer = (key) => (e) => {
    e.stopPropagation();
    lastPointerRef.current = Date.now();
    toggle(key);
  };
  const handleClick = (key) => (e) => {
    e.stopPropagation();
    // If pointerdown just fired (<400ms ago), the toggle already ran; skip.
    if (Date.now() - lastPointerRef.current < 400) return;
    toggle(key);
  };
  const renderHost = (h, key) => (
    <div key={key} className={`host ${openIdx === key ? 'is-open' : ''}`}>
      <button
        type="button"
        className="host-name"
        onPointerDown={handlePointer(key)}
        onClick={handleClick(key)}
        aria-expanded={openIdx === key}>
        <em className="host-name-text">{h.name}</em>
        <span className="host-plus" aria-hidden="true">+</span>
      </button>
      <div className={`host-bio ${h.todo ? 'host-bio-todo' : ''}`}>{h.bio}</div>
    </div>
  );
  return (
    <div className="hosts" style={{ opacity, filter: `blur(${blur}px)` }}>
      {/* Desktop: top row of 2 */}
      <div className="hosts-row row-top">
        {HOSTS_TOP.map((h, i) => renderHost(h, `top-${i}`))}
      </div>
      {/* Desktop: bottom row of 3 (also used by iPad — Hugo centers via CSS) */}
      <div className="hosts-row row-bottom">
        {HOSTS_BOTTOM.map((h, i) => renderHost(h, `bot-${i}`))}
      </div>
      <div className="hosts-contact">
        <span className="hosts-contact-label">Contact</span>
        <a href="mailto:hello@ursprung.community">hello@ursprung.community</a>
      </div>
    </div>
  );
}

// ---------- P9 — Apply ----------
function ApplyPage({ bloomT, opacity, blur, onTileClick }) {
  return (
    <>
      <div className="bloom" style={{ opacity: bloomT * 0.8 }} />
      <div className="apply" style={{ opacity, filter: `blur(${blur}px)`, pointerEvents: opacity > 0.5 ? 'auto' : 'none' }}>
        <p className="apply-lede">
          Rolling applications. <em>We write back to everyone we hear from.</em>
        </p>
        <div className="apply-pair">
          <button className="apply-word apply-word-l" onClick={(e) => onTileClick && onTileClick('apply', e)}>
            <em>Apply</em>
          </button>
          <span className="apply-sep">/</span>
          <button className="apply-word apply-word-r" onClick={(e) => onTileClick && onTileClick('nominate', e)}>
            <em>Nominate someone</em>
          </button>
        </div>
        <p className="apply-fine">
          Once we receive your application, we share the practical details — price, food, accommodation, travel.
        </p>
      </div>
    </>
  );
}

// ---------- PILL NAV ----------
function PillNav({ onJump, currentSection, opacity, showBack, onBack }) {
  const [isNarrow, setIsNarrow] = useState(typeof window !== 'undefined' ? window.innerWidth < 1200 : false);
  const pillRef = useRef(null);
  useEffect(() => {
    const onResize = () => setIsNarrow(window.innerWidth < 1200);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  // Click-only auto-center: scroll the active item into the horizontal middle of the pill.
  const handleJump = (id) => {
    onJump(id);
    if (!isNarrow) return;
    requestAnimationFrame(() => {
      const pill = pillRef.current;
      if (!pill) return;
      // Find the button just clicked. For Apply / Nominate it lives inside .pill-stack.
      const btn = pill.querySelector(`[data-pill-id="${id}"]`);
      if (!btn) return;
      const target = btn.closest('.pill-stack') || btn;
      const tRect = target.getBoundingClientRect();
      const pRect = pill.getBoundingClientRect();
      const targetCenter = tRect.left + tRect.width / 2;
      const pillCenter = pRect.left + pRect.width / 2;
      const delta = targetCenter - pillCenter;
      pill.scrollTo({ left: pill.scrollLeft + delta, behavior: 'smooth' });
    });
  };
  const sections = [
    { id: 'castle', label: 'Castle' },
    { id: 'past', label: '2025' },
    { id: 'philosophy', label: 'Philosophy' },
    { id: 'hosts', label: 'Hosts' },
  ];
  return (
    <div className="pill" ref={pillRef} style={{ opacity, transition: 'opacity 0.9s ease' }}>
      <button
        className="pill-back"
        onClick={onBack}
        aria-label="Back to top"
        style={{ opacity: showBack ? 1 : 0, pointerEvents: showBack ? 'auto' : 'none', transition: 'opacity 0.6s ease' }}>
        <svg width="22" height="14" viewBox="0 0 22 14" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round">
          <path d="M21 7 H2" />
          <path d="M8 1 L2 7 L8 13" />
        </svg>
      </button>
      {isNarrow ? (
        <div className="pill-stack">
          <button
            data-pill-id="apply"
            className={`pill-item pill-bold ${currentSection === 'apply' ? 'active' : ''}`}
            onClick={() => handleJump('apply')}>Apply</button>
          <button
            data-pill-id="nominate"
            className={`pill-item pill-bold ${currentSection === 'nominate' ? 'active' : ''}`}
            onClick={() => handleJump('nominate')}>Nominate</button>
        </div>
      ) : (
        <>
          <button
            data-pill-id="apply"
            className={`pill-item pill-bold ${currentSection === 'apply' ? 'active' : ''}`}
            onClick={() => handleJump('apply')}>Apply</button>
          <button
            data-pill-id="nominate"
            className={`pill-item pill-bold ${currentSection === 'nominate' ? 'active' : ''}`}
            onClick={() => handleJump('nominate')}>Nominate</button>
        </>
      )}
      <span className="pill-divider" aria-hidden="true" />
      {sections.map((it) => (
        <button
          key={it.id}
          data-pill-id={it.id}
          className={`pill-item ${currentSection === it.id ? 'active' : ''}`}
          onClick={() => handleJump(it.id)}>
          {it.label}
        </button>
      ))}
    </div>
  );
}

// ---------- APP ----------
function App() {
  const FormModal = window.FormModal;
  const yPx = useScrollY();
  const { mx, my, raw } = useCursor();
  const now = useNow();
  const vh = typeof window !== 'undefined' ? window.innerHeight : 1000;
  const yvh = yPx / vh * 100;

  const [intro, setIntro] = useState(0);
  useEffect(() => {
    const start = performance.now();
    let raf;
    const tick = () => { setIntro(performance.now() - start); raf = requestAnimationFrame(tick); };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const BEAM_DUR_MS = 2200;
  const TEXT_FADE_START = 1900;
  const TEXT_FADE_DUR = 800;
  const TITLE_START = 2100;
  const TITLE_DUR = 700;
  const WORDS_START = 2300;
  const WORD_PER = 220;

  const autoT = clamp01(intro / BEAM_DUR_MS);
  const postIntroT = clamp01((intro - TEXT_FADE_START) / TEXT_FADE_DUR);
  const titleT = clamp01((intro - TITLE_START) / TITLE_DUR);
  // new tagline: "An ongoing quest / for interdisciplinary creative space."
  const wordTexts = [
    { text: 'An', line: 1 },
    { text: 'ongoing', line: 1 },
    { text: 'quest', line: 1 },
    { text: 'for', line: 2 },
    { text: 'interdisciplinary', line: 2 },
    { text: 'creative', line: 2 },
    { text: 'space.', line: 2 }
  ];
  const words = wordTexts.map((w, i) => ({
    text: w.text, line: w.line, t: clamp01((intro - (WORDS_START + i * WORD_PER)) / 500)
  }));

  const heroScrollOut = sramp(yvh, T.HERO_HOLD_END, T.HERO_OUT);
  const introBlur = (1 - postIntroT) * 8;
  const heroBlurTotal = Math.max(heroScrollOut * 10, introBlur);
  const heroOpacityCombined = postIntroT * (1 - heroScrollOut);

  const quote = crossfade(yvh, T.QUOTE_IN, T.QUOTE_IN + 30, T.QUOTE_HOLD_END, T.QUOTE_OUT, 8);
  const quoteScrollT = clamp01((yvh - T.QUOTE_IN) / (T.QUOTE_HOLD_END - T.QUOTE_IN));

  // forest visibility
  const forestHero = sramp(yvh, -20, 5) * (1 - sramp(yvh, T.HERO_HOLD_END - 5, T.HERO_OUT));
  const forestDef = sramp(yvh, T.DEF_IN - 20, T.DEF_IN + 10) * (1 - sramp(yvh, T.FORMAT_OUT, T.PLAN_IN));
  const forestPast = sramp(yvh, T.PAST_IN - 20, T.PAST_IN + 20) * (1 - sramp(yvh, T.APPLY_HOLD_END, T.APPLY_HOLD_END + 40));
  const forestOpacity = clamp01(Math.max(forestHero, forestDef, forestPast));

  const heroProgress = yvh > 5 ? 1 : autoT;

  let evolution = 0;
  evolution += sramp(yvh, T.QUOTE_OUT - 30, T.DEF_IN + 30);
  evolution += sramp(yvh, T.DEF_HOLD_END, T.FORMAT_IN + 30);
  evolution += sramp(yvh, T.FORMAT_HOLD_END, T.CASTLE_BLACK_IN + 20);
  evolution += sramp(yvh, T.CASTLE_OUT - 20, T.PAST_IN + 30);
  evolution += sramp(yvh, T.PAST_HOLD_END, T.PHIL_IN + 30);
  evolution += sramp(yvh, T.PHIL_HOLD_END, T.HOSTS_IN + 30);

  // beams spread + dim during castle transition
  const castleApproach = sramp(yvh, T.FORMAT_HOLD_END, T.PLAN_IN);
  const beamsExtraSpread = castleApproach;  // 0..1 → push outward
  const beamsDim = castleApproach;

  const def = crossfade(yvh, T.DEF_IN, T.DEF_IN + 36, T.DEF_HOLD_END, T.DEF_OUT, 12);
  const format = crossfade(yvh, T.FORMAT_IN, T.FORMAT_IN + 36, T.FORMAT_HOLD_END, T.FORMAT_OUT, 12);

  // P5 Castle
  // forest dims as plan emerges; black veil bridges
  const castleBlackUp = sramp(yvh, T.CASTLE_BLACK_IN, T.CASTLE_BLACK_PEAK);
  const castleBlackDown = sramp(yvh, T.CASTLE_OUT - 20, T.CASTLE_OUT + 20);
  const castleBlackOpacity = castleBlackUp * (1 - castleBlackDown) * 0.55; // partial — let plan blend

  // Plan: starts very zoomed-in (3.4×) on central detail, scales to 1× as user scrolls
  const planFadeIn = sramp(yvh, T.PLAN_IN, T.PLAN_IN + 30);
  const planFadeOut = sramp(yvh, T.PLAN_OUT - 40, T.PLAN_OUT);
  const planOpacity = planFadeIn * (1 - planFadeOut) * 0.95;
  const planScaleT = sramp(yvh, T.PLAN_IN, T.PLAN_ZOOM_END);
  const planScale = 3.4 - 2.4 * planScaleT;
  const planBlur = (1 - planFadeIn) * 14 + planFadeOut * 8;

  const textIn = sramp(yvh, T.CASTLE_TEXT_IN, T.CASTLE_TEXT_IN + 35);
  const textOut = sramp(yvh, T.CASTLE_HOLD_END, T.CASTLE_OUT);
  const castleTextOpacity = textIn * (1 - textOut);
  const castleTextBlur = (1 - textIn) * 8 + textOut * 8;

  const galleryIn = sramp(yvh, T.GALLERY_IN, T.GALLERY_IN + 40);
  const galleryOut = sramp(yvh, T.CASTLE_HOLD_END, T.CASTLE_OUT);
  const galleryOpacity = galleryIn * (1 - galleryOut);

  // P6 Past — scrolls upward + blurs out as testimonials enter
  const pastIn = sramp(yvh, T.PAST_IN, T.PAST_IN + 30);
  const pastOut = sramp(yvh, T.PAST_OUT - 50, T.PAST_OUT + 10);
  const pastOpacity = pastIn * (1 - pastOut);
  const pastBlur = (1 - pastIn) * 8 + pastOut * 14;
  const pastTranslate = -pastOut * 220; // upward scroll

  const test = crossfade(yvh, T.TEST_IN, T.TEST_IN + 30, T.TEST_HOLD_END, T.TEST_OUT, 10);
  // Per-quote sequenced fade-in: Olga first, then Niclas, then Sahil
  const testPerQuote = [
    sramp(yvh, T.TEST_IN, T.TEST_IN + 28),
    sramp(yvh, T.TEST_IN + 22, T.TEST_IN + 50),
    sramp(yvh, T.TEST_IN + 44, T.TEST_IN + 72),
  ];
  const phil = crossfade(yvh, T.PHIL_IN, T.PHIL_IN + 36, T.PHIL_HOLD_END, T.PHIL_OUT, 10);
  const hosts = crossfade(yvh, T.HOSTS_IN, T.HOSTS_IN + 36, T.HOSTS_HOLD_END, T.HOSTS_OUT, 10);
  const apply = crossfade(yvh, T.APPLY_IN, T.APPLY_IN + 36, T.APPLY_HOLD_END + 80, T.APPLY_HOLD_END + 120, 10);
  const applyBloomT = sramp(yvh, T.APPLY_IN, T.APPLY_IN + 60);

  // Daylight glow only on P3
  const daylightOpacity = clamp01(def.opacity);

  // gallery cycle
  const [galleryIdx, setGalleryIdx] = useState(0);
  const inGallery = yvh > T.GALLERY_IN - 10 && yvh < T.CASTLE_OUT + 20;
  useEffect(() => {
    if (!inGallery) return;
    const t = setInterval(() => setGalleryIdx((i) => (i + 1) % VENUE_PHOTOS.length), 5000);
    return () => clearInterval(t);
  }, [inGallery]);

  const onJump = (target) => {
    // Land at the fully-sharp state: each crossfade reaches opacity 1 at
    // (IN + 36). For Castle, the text panel becomes sharp at CASTLE_TEXT_IN+35.
    // We add a small buffer (+4vh) so the lock-window detector sees opacity==1.
    const positions = {
      apply: T.APPLY_IN + 40,
      nominate: T.APPLY_IN + 40,
      castle: T.CASTLE_TEXT_IN + 40,
      past: T.PAST_IN + 40,
      philosophy: T.PHIL_IN + 40,
      hosts: T.HOSTS_IN + 40,
    };
    const py = positions[target] / 100 * vh;
    window.scrollTo({ top: py, behavior: 'smooth' });

    if (target === 'apply' || target === 'nominate') {
      const waitMs = Math.min(1100, 350 + Math.abs(py - window.scrollY) * 0.6);
      setTimeout(() => {
        const tile = document.querySelector(target === 'apply' ? '.apply-word-l' : '.apply-word-r');
        if (tile) {
          const r = tile.getBoundingClientRect();
          setFormOrigin({ x: r.left, y: r.top, w: r.width, h: r.height });
        } else { setFormOrigin(null); }
        setFormKind(target);
      }, waitMs);
    }
  };

  let currentSection = '';
  if (yvh >= T.CASTLE_BLACK_PEAK && yvh < T.CASTLE_OUT) currentSection = 'castle';
  else if (yvh >= T.PAST_IN - 5 && yvh < T.TEST_OUT) currentSection = 'past';
  else if (yvh >= T.PHIL_IN - 5 && yvh < T.PHIL_OUT) currentSection = 'philosophy';
  else if (yvh >= T.HOSTS_IN - 5 && yvh < T.HOSTS_OUT) currentSection = 'hosts';
  else if (yvh >= T.APPLY_IN - 5) currentSection = 'apply';

  const pillOpacity = yvh > 5 ? 1 : postIntroT;

  const [formKind, setFormKind] = useState(null);
  const [formOrigin, setFormOrigin] = useState(null);
  const openForm = (which, evt) => {
    if (evt && evt.currentTarget) {
      const r = evt.currentTarget.getBoundingClientRect();
      setFormOrigin({ x: r.left, y: r.top, w: r.width, h: r.height });
    }
    setFormKind(which);
  };
  const closeForm = () => setFormKind(null);

  const footerOpacity = sramp(yvh, T.APPLY_IN + 20, T.APPLY_IN + 60);

  return (
    <>
      <div style={{ height: `${T.TOTAL}vh` }} />

      <div className={`stage ${formKind ? 'stage-form-open' : ''}`}>
        <div className="black-base" />

        <ForestAtmosphere
          heroProgress={heroProgress}
          mx={mx} my={my}
          opacity={forestOpacity}
          evolution={evolution}
          now={now}
          beamsExtraSpread={beamsExtraSpread}
          beamsDim={beamsDim} />

        <div className="pollen-stack" style={{ opacity: 0.35 + 0.65 * forestOpacity }}>
          <PollenLayer count={22} depth={0.18} mx={mx} my={my} sizeMin={0.6} sizeMax={1.4} blurMax={2.2} opacityRange={[0.30, 0.65]} />
          <PollenLayer count={14} depth={0.5} mx={mx} my={my} sizeMin={1.2} sizeMax={2.4} blurMax={1.4} opacityRange={[0.45, 0.80]} />
          <PollenLayer count={8}  depth={0.9} mx={mx} my={my} sizeMin={2.0} sizeMax={3.6} blurMax={0.6} opacityRange={[0.55, 0.90]} />
        </div>

        <DaylightGlow opacity={daylightOpacity} raw={raw} />

        <Hero opacity={heroOpacityCombined} blur={heroBlurTotal} titleT={titleT} words={words} />
        <CaresseQuote opacity={quote.opacity} blur={quote.blur} scrollT={quoteScrollT} />
        <Definition opacity={def.opacity} blur={def.blur} />
        <Format opacity={format.opacity} blur={format.blur}
          photoOpacity={format.opacity} photoBlur={format.blur}
          beamsOpacity={format.opacity} now={now} />

        <div className="black-veil" style={{ opacity: castleBlackOpacity, zIndex: 21 }} />

        <CastlePage
          planOpacity={planOpacity}
          planScale={planScale}
          planBlur={planBlur}
          textOpacity={castleTextOpacity}
          textBlur={castleTextBlur}
          galleryOpacity={galleryOpacity}
          autoIndex={galleryIdx} />

        <PastGatherings opacity={pastOpacity} blur={pastBlur} translateY={pastTranslate} />
        <Testimonials opacity={test.opacity} perQuote={testPerQuote} />
        <Philosophy opacity={phil.opacity} blur={phil.blur} />
        <Hosts opacity={hosts.opacity} blur={hosts.blur} />

        <ApplyPage
          bloomT={applyBloomT}
          opacity={apply.opacity}
          blur={apply.blur}
          onTileClick={(which, evt) => openForm(which, evt)} />

        <div className="grain" />
        <div className="vignette-final" />
      </div>

      <PillNav
        onJump={onJump}
        currentSection={currentSection}
        opacity={pillOpacity}
        showBack={yvh > 20}
        onBack={() => window.scrollTo({ top: 0, behavior: 'smooth' })} />

      {footerOpacity > 0.01 && (
        <div className="us-legal" style={{ opacity: footerOpacity }}>
          <div className="us-legal-row">
            <a href="#" onClick={(e) => e.preventDefault()}>Privacy Policy</a>
            <span className="us-legal-sep">·</span>
            <a href="#" onClick={(e) => e.preventDefault()}>Legal Notice</a>
            <span className="us-legal-sep">·</span>
            <a href="mailto:hello@ursprung.community">hello@ursprung.community</a>
            <span className="us-legal-sep">·</span>
            <span>© 2026 Ursprung Labs</span>
          </div>
        </div>
      )}

      <FormModal kind={formKind} origin={formOrigin} onClose={closeForm} />
    </>
  );
}

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