const { motion: m2, useInView: useInView2, useScroll: useScroll2, useTransform: useTransform2, useMotionValue: useMotionValue2, useSpring: useSpring2 } = window.Motion;
const { useRef: useRef2 } = React;

/* ------------------------------ MOTION SPEC ------------------------------ */
// Every transition in the site uses one of these so motion feels unified:
// a critically-damped spring that trails into place on a soft arc (tiny,
// barely-there settle — life without bounce).
//
// ARC_SPRING_* are for Framer Motion's `transition` prop.
// ARC_CURVE is the equivalent cubic-bezier for plain CSS transitions —
//   a standard "emphasized decelerate" that mirrors the spring's tail arc.
const ARC_SPRING       = { type: 'spring', mass: 1,   stiffness: 160, damping: 22 };
const ARC_SPRING_SOFT  = { type: 'spring', mass: 1,   stiffness: 120, damping: 24 };
const ARC_SPRING_CRISP = { type: 'spring', mass: 0.8, stiffness: 240, damping: 26 };
const ARC_CURVE = 'cubic-bezier(0.2, 0.9, 0.3, 1.15)';

/* ----------------------- ETHEREAL SHADOW ----------------------- */
// Adapted from the etheral-shadow shadcn snippet to fit this project
// (no TS, no build step, no `components/ui/` — framer-motion comes
// from window.Motion, React from the global). Renders a masked
// silhouette tinted by `color`, distorted by an SVG turbulence +
// displacement filter, with the displacement field's hue rotated
// imperatively via setAttribute on the <feColorMatrix> every tick
// to avoid React re-renders. Used in CHAPTER 02 of the About box as
// the visual counterpart to the TINY / FOLLY split.
function EtheralShadow({
  sizing = 'fill',
  color = 'rgba(128, 128, 128, 1)',
  animation,
  noise,
  style,
  className,
}) {
  const rawId = React.useId();
  const id = `shadowoverlay-${rawId.replace(/:/g, '')}`;
  const animationEnabled = animation && animation.scale > 0;
  const feColorMatrixRef = React.useRef(null);
  const hueRotateMotionValue = useMotionValue2(180);
  const hueRotateAnimation = React.useRef(null);

  const mapRange = (v, fL, fH, tL, tH) => {
    if (fL === fH) return tL;
    return tL + ((v - fL) / (fH - fL)) * (tH - tL);
  };

  const displacementScale = animation ? mapRange(animation.scale, 1, 100, 20, 100) : 0;
  const animationDuration = animation ? mapRange(animation.speed, 1, 100, 1000, 50) : 1;

  React.useEffect(() => {
    if (!(feColorMatrixRef.current && animationEnabled)) return;
    if (hueRotateAnimation.current) hueRotateAnimation.current.stop();
    hueRotateMotionValue.set(0);
    hueRotateAnimation.current = window.Motion.animate(hueRotateMotionValue, 360, {
      duration: animationDuration / 25,
      repeat: Infinity,
      repeatType: 'loop',
      repeatDelay: 0,
      ease: 'linear',
      delay: 0,
      onUpdate: (value) => {
        if (feColorMatrixRef.current) {
          feColorMatrixRef.current.setAttribute('values', String(value));
        }
      },
    });
    return () => { if (hueRotateAnimation.current) hueRotateAnimation.current.stop(); };
  }, [animationEnabled, animationDuration]);

  return (
    <div className={className} style={{ overflow: 'hidden', position: 'relative', width: '100%', height: '100%', ...style }}>
      <div style={{ position: 'absolute', inset: -displacementScale, filter: animationEnabled ? `url(#${id}) blur(4px)` : 'none' }}>
        {animationEnabled && (
          <svg style={{ position: 'absolute' }}>
            <defs>
              <filter id={id}>
                <feTurbulence
                  result="undulation"
                  numOctaves="2"
                  baseFrequency={`${mapRange(animation.scale, 0, 100, 0.001, 0.0005)},${mapRange(animation.scale, 0, 100, 0.004, 0.002)}`}
                  seed="0"
                  type="turbulence"
                />
                <feColorMatrix ref={feColorMatrixRef} in="undulation" type="hueRotate" values="180" />
                <feColorMatrix in="dist" result="circulation" type="matrix" values="4 0 0 0 1  4 0 0 0 1  4 0 0 0 1  1 0 0 0 0" />
                <feDisplacementMap in="SourceGraphic" in2="circulation" scale={displacementScale} result="dist" />
                <feDisplacementMap in="dist" in2="undulation" scale={displacementScale} result="output" />
              </filter>
            </defs>
          </svg>
        )}
        <div style={{
          backgroundColor: color,
          maskImage: "url('https://framerusercontent.com/images/ceBGguIpUU8luwByxuQz79t7To.png')",
          WebkitMaskImage: "url('https://framerusercontent.com/images/ceBGguIpUU8luwByxuQz79t7To.png')",
          maskSize: sizing === 'stretch' ? '100% 100%' : 'cover',
          WebkitMaskSize: sizing === 'stretch' ? '100% 100%' : 'cover',
          maskRepeat: 'no-repeat',
          WebkitMaskRepeat: 'no-repeat',
          maskPosition: 'center',
          WebkitMaskPosition: 'center',
          width: '100%',
          height: '100%',
        }} />
      </div>
      {noise && noise.opacity > 0 && (
        <div style={{
          position: 'absolute', inset: 0,
          backgroundImage: 'url("https://framerusercontent.com/images/g0QcWrxr87K0ufOxIUFBakwYA8.png")',
          backgroundSize: noise.scale * 200,
          backgroundRepeat: 'repeat',
          opacity: noise.opacity / 2,
          pointerEvents: 'none',
        }} />
      )}
    </div>
  );
}

const TEXT_COLOR = 'rgb(var(--c-text))';
// Reserved for inverted highlight backgrounds (Investments active row).
// Deeper than TEXT_COLOR so the active row reads as a distinct moment.
const INK = 'rgb(var(--c-ink))';
const SUBTLE_TEXT = 'rgb(var(--c-muted))';
const NAV_LINK_COLOR = 'rgb(var(--c-invert-text) / 0.85)';
const NAV_LINK_HOVER = 'rgb(var(--c-invert-text))';
const CARD_BG = 'rgb(var(--c-card))';
const TILE_BG = 'rgb(var(--c-tile))';

/* ----------------------- BLUR-FADE-UP REVEAL HELPER ----------------------- */
// Aceternity / Magic UI / Motion Primitives all ship a "blur fade" — the
// classic "rise, blur, focus" entry that's become a standard for
// modern marketing pages. We drive it ourselves here, scroll-driven
// rather than `whileInView`-triggered, so the reveal can be tied
// precisely to a chosen scroll range and reverses cleanly on scroll-up.
//
// Three flavours:
//   * VIEWPORT — the element starts hidden and reveals as its OWN top
//     crosses through the viewport. Default knobs:
//       startVh = 0.95   (begin reveal when element top is 95% of vh down)
//       endVh   = 0.50   (fully revealed when element top is at 50% of vh)
//   * RELATIVE — drive the reveal off ANY rect, not just the element's
//     own. Used for the BUILT BY OPERATORS layers, which key off the
//     People section's top so they cascade in time with the hand recede.
//
// Returns a cleanup function for the React effect to call.
const REVEAL_INITIAL_STYLE = {
  opacity: 0,
  transform: 'translate3d(0, 60px, 0)',
  filter: 'blur(18px)',
  willChange: 'opacity, transform, filter',
};

function applyBlurFadeUp(el, opts = {}) {
  if (!el) return () => {};
  const {
    startVh = 0.95,
    endVh   = 0.40,
    dyPx    = 60,
    blurPx  = 18,
    // If `getRefTop` is provided, drive off that y instead of el.top.
    // Used to chain a reveal to a different element's scroll position.
    getRefTop = null,
  } = opts;

  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  // 1 - (1 - t)^4 — strong ease-out, equivalent in feel to the
  // cubic-bezier(0.23, 1, 0.32, 1) the rest of the site uses.
  const easeOut = (t) => 1 - Math.pow(1 - t, 4);

  let raf = 0;
  const tick = () => {
    const vh = window.innerHeight || 800;
    const top = getRefTop ? getRefTop() : el.getBoundingClientRect().top;
    const start = vh * startVh;
    const end   = vh * endVh;
    const raw = (start - top) / (start - end);
    const t = clamp(raw, 0, 1);
    const eased = easeOut(t);
    el.style.opacity   = `${eased.toFixed(3)}`;
    el.style.transform = `translate3d(0, ${((1 - eased) * dyPx).toFixed(2)}px, 0)`;
    el.style.filter    = `blur(${((1 - eased) * blurPx).toFixed(2)}px)`;
  };

  const onScroll = () => {
    if (raf) return;
    raf = requestAnimationFrame(() => { raf = 0; tick(); });
  };

  tick();
  // Re-tick after layout settles — sticky locks, async images, etc.
  // can shift the element's top in the first second after mount.
  const settle = [50, 200, 600, 1500].map((ms) => setTimeout(tick, ms));
  // Filter iOS URL-bar collapse out of resize-tick. The blur-fade math
  // is keyed off vh; a 100px height shift mid-scroll would jump the
  // reveal progress backwards on URL-bar collapse and forwards on
  // re-show. Real rotation/resize still re-ticks normally.
  const onResize = stableMobileResize(tick);
  window.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', onResize);

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

// React-side wrapper: pass a ref + opts, get an effect that wires up the
// reveal and tears down on unmount. Optional `deps` lets you reset when
// dependencies change.
function useBlurFadeUp(ref, opts = {}, deps = []) {
  React.useEffect(() => {
    if (!ref.current) return;
    return applyBlurFadeUp(ref.current, opts);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
}

/* ------------------------------- LAZY VIDEO ------------------------------- */
// Loads + plays only when scrolled near the viewport. Pauses when far off-screen.
function LazyVideo({ src, className = '', style, eager = false, playbackRate = 1, paused = false }) {
  const ref = useRef2(null);
  const [shouldLoad, setShouldLoad] = React.useState(eager);
  // Mirror `paused` into a ref so the IntersectionObserver closure below
  // (which only re-runs when `eager` changes) always reads the latest
  // value when deciding whether to play on intersect.
  const pausedRef = useRef2(paused);
  React.useEffect(() => { pausedRef.current = paused; }, [paused]);

  // Keep playbackRate in sync whenever the element or rate changes
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const apply = () => { try { el.playbackRate = playbackRate; } catch (_) {} };
    apply();
    el.addEventListener('loadedmetadata', apply);
    el.addEventListener('play', apply);
    return () => {
      el.removeEventListener('loadedmetadata', apply);
      el.removeEventListener('play', apply);
    };
  }, [playbackRate, shouldLoad]);

  React.useEffect(() => {
    if (eager) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setShouldLoad(true);
            // Only kick off playback when not externally paused. Otherwise
            // a rowing-into-view event would override an explicit pause
            // (e.g. mobile inactive-row state).
            if (!pausedRef.current) {
              try { el.currentTime = 0; } catch (_) {}
              el.play?.().catch(() => {});
            }
          } else {
            el.pause?.();
            try { el.currentTime = 0; } catch (_) {}
          }
        });
      },
      { rootMargin: '200px 0px', threshold: 0.01 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [eager]);

  // Eager videos (the hero) need a programmatic play() too. The
  // `autoPlay` HTML attribute is necessary but not sufficient on iOS
  // Safari and modern Chrome — those browsers require both `muted` +
  // `playsInline` AND a JS `.play()` call before they'll auto-start.
  // Without this, the video shows its first frame plus a play button
  // overlay on phones. We also retry on `canplay` so a slow network
  // doesn't leave us stalled with the button visible.
  React.useEffect(() => {
    if (!eager) return;
    const el = ref.current;
    if (!el) return;
    const tryPlay = () => {
      if (pausedRef.current) return;
      el.play?.().catch(() => {});
    };
    tryPlay();
    el.addEventListener('canplay', tryPlay);
    el.addEventListener('loadedmetadata', tryPlay);
    return () => {
      el.removeEventListener('canplay', tryPlay);
      el.removeEventListener('loadedmetadata', tryPlay);
    };
  }, [eager]);

  // External paused control — wins over IO and the eager-play hook.
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (paused) {
      el.pause?.();
      try { el.currentTime = 0; } catch (_) {}
    } else if (shouldLoad) {
      try { el.currentTime = 0; } catch (_) {}
      el.play?.().catch(() => {});
    }
  }, [paused, shouldLoad]);

  return (
    <video
      ref={ref}
      className={className}
      style={style}
      autoPlay={eager && !paused}
      loop
      muted
      playsInline
      disablePictureInPicture
      disableRemotePlayback
      preload={eager ? 'auto' : 'metadata'}
      src={shouldLoad ? src : undefined}
    />
  );
}

/* ----------------------------- VIEWPORT HOOKS ----------------------------- */
// `useIsMobile` returns true when the viewport is < 640px wide (Tailwind's
// `sm` breakpoint). Components use this to swap layout structure rather
// than just CSS visibility — e.g. InvestmentRow renders an entirely
// different DOM tree on mobile so we don't double-mount LazyVideo.
// matchMedia listeners fire only when the comparison flips, not on every
// px of resize, so they're effectively free.
function useIsMobile() {
  const get = () =>
    typeof window === 'undefined'
      ? false
      : !window.matchMedia('(min-width: 640px)').matches;
  const [isMobile, setIsMobile] = React.useState(get);
  React.useEffect(() => {
    const mq = window.matchMedia('(min-width: 640px)');
    const onChange = () => setIsMobile(!mq.matches);
    if (mq.addEventListener) mq.addEventListener('change', onChange);
    else mq.addListener(onChange); // Safari < 14 fallback
    return () => {
      if (mq.removeEventListener) mq.removeEventListener('change', onChange);
      else mq.removeListener(onChange);
    };
  }, []);
  return isMobile;
}

/* ------------------------------- NAVBAR ------------------------------- */
// Theme toggle — sits in the nav link group. Flips html.dark, persists the
// choice to localStorage (read by the no-flash bootstrap in index.html on the
// next load), syncs color-scheme, and fires 'folly-theme-change' so the
// canvas-painted figures/hand in app.jsx re-tune live. The icon shows the
// state you'll switch TO (moon while light, sun while dark).
function ThemeToggle() {
  const [dark, setDark] = React.useState(
    typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
  );
  const toggle = () => {
    const next = !document.documentElement.classList.contains('dark');
    document.documentElement.classList.add('theme-ready');
    document.documentElement.classList.toggle('dark', next);
    try { document.documentElement.style.colorScheme = next ? 'dark' : 'light'; } catch (e) {}
    try { localStorage.setItem('folly-theme', next ? 'dark' : 'light'); } catch (e) {}
    try { window.dispatchEvent(new Event('folly-theme-change')); } catch (e) {}
    setDark(next);
  };
  return (
    <button
      type="button"
      onClick={toggle}
      aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
      title={dark ? 'Light mode' : 'Dark mode'}
      className="nav-link press inline-flex items-center justify-center"
      style={{ color: 'inherit', width: '1.55em', height: '1.55em', flex: 'none' }}
    >
      {dark ? (
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
             strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="4" />
          <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
        </svg>
      ) : (
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
             strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
        </svg>
      )}
    </button>
  );
}

function Navbar() {
  // Full-width Liquid Glass bar pinned to the top of the viewport.
  // Brand-left / links-right layout. `position: fixed` keeps it visible
  // across the entire scroll (Hero → About → Features → Investments →
  // Footer).
  //
  // z-index is z-[160] — sits ABOVE the Hero FOLLY wordmark (z:150) so
  // the bar is never covered when the wordmark slides up past it. (Now
  // that the navbar is a sibling of <main>, the wordmark's z:150 lives
  // in the root stacking context and would otherwise paint over a
  // z:50 navbar.) The IntroLoader at z:100 still covers it during the
  // initial page load — IntroLoader uses position:fixed so it sits in
  // the same root context as the navbar; bar appears as the loader
  // cream-fades out.
  return (
    <nav
      aria-label="Primary"
      className="fixed top-0 inset-x-0 z-[160]"
    >
      <div
        className="liquid-nav flex items-center justify-between gap-3 sm:gap-6 md:gap-8 px-5 sm:px-7 md:px-10 py-3 sm:py-3.5 md:py-4 text-[10px] sm:text-[11px] md:text-xs tracking-[0.12em] uppercase font-semibold whitespace-nowrap"
        style={{ color: TEXT_COLOR }}
      >
        <a href="/" className="brand-wordmark whitespace-nowrap" aria-label="Folly Partners">
          <span className="brand-wordmark__folly">Folly</span>
          <span className="brand-wordmark__partners">Partners</span>
        </a>
        <div className="nav-link-group flex items-center gap-4 sm:gap-6 md:gap-8">
          <a
            href="#about"
            onClick={(e) => {
              // Smooth-scroll to the ABOUT narrative panel (the
              // liquid-glass founders/premise lock), NOT the people
              // carousel. The About <section> carries id="about" +
              // data-about-lock; we target it by id and fall back to
              // the data-attribute so the link still resolves if the
              // id is ever renamed.
              //
              // The About section has heavy vertical padding (py-44/56
              // on desktop) so its top edge sits well above the panel.
              // We center the GLASS PANEL in the viewport rather than
              // pinning the section's padded top to the navbar — the
              // panel is what "About" refers to, and centering it lands
              // the founders copy in the readable middle of the screen.
              // Fallback to the section's own rect if the inner panel
              // can't be found.
              //
              // Lenis (page-wide smooth-scroll wrapper) handles the
              // animation; falls back to native smooth scrollTo if
              // Lenis hasn't mounted yet (very early click).
              e.preventDefault();
              const section = document.getElementById('about')
                || document.querySelector('[data-about-lock]');
              if (!section) return;
              // Prefer the inner liquid-glass panel for centering; the
              // outer section is mostly padding.
              const panel = section.querySelector('.liquid-glass') || section;
              const r = panel.getBoundingClientRect();
              const targetTopDoc = r.top + window.scrollY;
              const vh = window.innerHeight || 800;
              const navEl = document.querySelector('nav[aria-label="Primary"]');
              const navH = navEl ? navEl.getBoundingClientRect().height : 0;
              // Keep the panel clear of the fixed nav. Centering looked
              // elegant until the shared sticky header grew taller; now the
              // top edge needs a consistent landing gap.
              const landingGap = window.matchMedia('(max-width: 639px)').matches ? 48 : 112;
              const desiredScroll = Math.max(0, targetTopDoc - navH - landingGap);
              const lenis = window.__lenis;
              if (lenis && typeof lenis.scrollTo === 'function') {
                lenis.scrollTo(desiredScroll);
              } else {
                window.scrollTo({ top: desiredScroll, behavior: 'smooth' });
              }
            }}
            className="nav-link group inline-flex items-center"
          >
            <span>Story</span>
          </a>
          <a href="/investments.html" className="nav-link group inline-flex items-center">
            <span>Portfolio</span>
          </a>
          <a
            href="#contact"
            onClick={(e) => {
              // Hard-cut to the footer (giant Get In Touch CTA at the page
              // bottom) instead of letting the browser smooth-scroll through
              // every section in between. Lenis is the page-wide smooth-
              // scroll wrapper; calling its scrollTo with `immediate: true`
              // bypasses the lerp and the snap settle so the jump is
              // instant. Fallback to native instant scrollTo if Lenis
              // hasn't mounted yet (very early click before useLenis()).
              e.preventDefault();
              const target = document.getElementById('contact');
              if (!target) return;
              const lenis = window.__lenis;
              if (lenis && typeof lenis.scrollTo === 'function') {
                lenis.scrollTo(target, { immediate: true, lock: true, force: true });
              } else {
                target.scrollIntoView({ behavior: 'instant', block: 'start' });
              }
            }}
            className="nav-link group inline-flex items-center"
          >
            <span>Contact</span>
          </a>
          <ThemeToggle />
        </div>
      </div>
    </nav>
  );
}

/* --------------------------- HERO META ROW --------------------------- */
function HeroMetaRow({ label, value, delay = 0 }) {
  return (
    <m2.div
      className="relative flex items-center justify-between text-[10px] sm:text-[11px] md:text-xs tracking-[0.12em] uppercase font-semibold"
      style={{ color: TEXT_COLOR }}
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ ...ARC_SPRING_SOFT, delay }}
    >
      <span className="whitespace-nowrap">{label}</span>
      {/* The line lives between the label and value, extending to the value */}
      <span aria-hidden="true" className="flex-1 mx-3 sm:mx-4 h-px" style={{ backgroundColor: TEXT_COLOR, opacity: 0.9 }} />
      <span className="whitespace-nowrap">{value}</span>
    </m2.div>
  );
}

/* ------------------------------- HERO ------------------------------- */
// Hero used to host the full split-panel "scroll-lock zoom-through-
// window" intro on its first 600px of scroll: a blurred-glass left
// half with a FOLLY cutout sliding from the corner, a solid-FOLLY
// sliding from bottom-right, and a brand block with body copy + Get
// In Touch link. That whole composition is now the IntroLoader (see
// app.jsx) — a time-driven version of the same merge that plays once
// while the hero video loads, then fades out to reveal Hero in its
// post-merge final state. Hero itself is now just that final state:
// centered FOLLY wordmark, width-locked definition line, full-zoom
// desaturated cabin video, bottom meta rows. Scroll on the page goes
// directly into the Y-letter spin-out + falling-figure choreography.
function Hero() {
  // Scroll source for all Hero scroll-driven transforms (Y-letter
  // spin-out + hero video scale). Lenis (loaded in index.html) is
  // already smoothing wheel/trackpad input into a continuous scroll
  // curve before it reaches `window.scrollY`. A previous build wrapped
  // this in `useSpring2(rawScrollY, { stiffness: 320, damping: 80 })`
  // to add a second decel pass, but layering a framer-motion spring
  // ON TOP of Lenis's smoothing meant animations were double-damped
  // and lagged behind user input — the "stiff" feel during fast
  // scrolls through the hero. Removed: framer-motion subscribes to
  // the raw motion value directly now, animations track Lenis 1:1.
  // Falling figures (app.jsx) read `window.scrollY` directly and do
  // their own internal lerp; this change does not affect them.
  const { scrollY } = useScroll2();

  // HERO_LOCK_END was the trailing offset of the (now-removed) hero
  // scroll-lock. Y-letter math and the falling-figure constants in
  // app.jsx are still expressed relative to it; setting it to 0 makes
  // the Y begin its tip → fall → spin-out and the figures begin their
  // emerge the moment the user starts scrolling, which is exactly the
  // post-loader state we want. MUST stay in sync with the same-named
  // constant in app.jsx (FallingFigure).
  const HERO_LOCK_END = 0;

  // Y-letter falling choreography — scroll-linked, gravity-shaped.
  //
  // The previous mapping had a dead first 280px (only a 22° tip +
  // 2vw/1vh — imperceptible) then crammed an 85°→380° spin + 18→85vw
  // flight into the final 240px. That read as "locked: nothing
  // happens, then it violently flings out." Redesigned so the Y
  // responds to scroll from pixel 1 and falls progressively the
  // whole way, like an object under gravity:
  //
  //   - 5 keyframe stops at [0, 140, 340, 540, 720] with values
  //     shaped as an EASE-IN (slow → fast). Each property's
  //     progression is a quadratic-ish ramp (e.g. translateY
  //     0→3→12→34→78 vh ≈ 0/4/15/44/100% over 0/19/47/75/100% of
  //     scroll) so the letter accelerates downward exactly like
  //     real falling motion — no held-then-snap.
  //   - Vertical drop dominates the read (it's *falling*, not
  //     swinging sideways): translateY ends at 78vh, well below
  //     the fold.
  //   - Rotation tops out at 210° — one graceful tumble past
  //     upside-down rather than the old 380° frantic double-spin.
  //   - X flight reduced 85vw → 58vw so the exit is a calm arc,
  //     not a violent sideways launch.
  //   - Range ends ~720px, roughly when the sticky hero pin
  //     releases on common viewports, so there's no dead
  //     "still-locked" tail after the Y has gone.
  //   - Opacity holds through the tumble and fades over the last
  //     leg (560→700px) so the letter is well clear of the
  //     wordmark before it disappears.
  //
  // Net: continuous, scroll-tracked, gentle — the scroll feels
  // smooth and connected the entire first screen instead of
  // gated behind an invisible pause.
  // Compressed back down so the gag resolves before the sticky hero starts
  // feeling like a scroll trap. The page now begins moving sooner while the
  // Y still tumbles far enough to sell the fall.
  const yKeys = [
    HERO_LOCK_END + 0,
    HERO_LOCK_END + 75,
    HERO_LOCK_END + 170,
    HERO_LOCK_END + 275,
    HERO_LOCK_END + 390,
  ];
  const yRotate = useTransform2(scrollY, yKeys, [0, 14, 52, 120, 210]);
  const yTranslateX = useTransform2(
    scrollY,
    yKeys,
    ['0vw', '2.5vw', '9vw', '26vw', '58vw']
  );
  const yTranslateY = useTransform2(
    scrollY,
    yKeys,
    ['0vh', '3vh', '12vh', '34vh', '78vh']
  );
  const yOpacity = useTransform2(scrollY, [HERO_LOCK_END + 285, HERO_LOCK_END + 380], [1, 0]);

  // Hero video — gentle scroll-driven dolly + wash bloom.
  // An earlier 1.0 → 1.16 scale was removed for perf (compositor cost
  // stacked on falling-figure canvases). This is the lighter version
  // requested back: 1.0 → 1.05 scale + 0.80 → 0.86 cream wash over the
  // first 800px of scroll. Stays visible the whole way down; just adds
  // a small breath of life on the first scroll. Both endpoints clamp
  // (Motion useTransform clamps by default) so the video locks at the
  // 1.05 / 0.86 end-state for the rest of the page rather than drifting.
  const heroVideoScale = useTransform2(scrollY, [HERO_LOCK_END + 0, HERO_LOCK_END + 320], [1, 1.05]);
  const heroWashOpacity = useTransform2(scrollY, [HERO_LOCK_END + 0, HERO_LOCK_END + 320], [0.80, 0.86]);

  // WORDMARK_FINAL_SCALE — visible scale of the centered FOLLY. The
  // definition line below it width-matches this exact scale so the
  // two stay edge-aligned at every viewport.
  const WORDMARK_FINAL_SCALE = 0.88;

  // Measure FOLLY's rendered width so the definition can match it
  // exactly. Two-pass system: PASS 1 picks an approximate font-size
  // from a hidden measurer, PASS 2 reads the actually-rendered
  // offsetWidth and applies a sub-percent scaleX correction so the
  // line ends EXACTLY at FOLLY's right edge — pixel-perfect at every
  // viewport regardless of font hinting / kerning drift.
  const follyRef = useRef2(null);
  const defMeasureRef = useRef2(null);
  const defLineRef = useRef2(null);
  const [follyWidth, setFollyWidth] = React.useState(null);
  const [defFontSize, setDefFontSize] = React.useState(null);
  const [defCorrection, setDefCorrection] = React.useState(1);
  // Horizontal translate applied to the def-line in addition to scaleX,
  // so its VISIBLE glyph center (not layout-box center) lands on FOLLY's
  // visible glyph center. FOLLY's right tip (Y diagonal overhang) pushes
  // its visible center to the right of its layout-box center; without
  // this translate the def-line reads slightly left even when widths
  // match. Computed once defCorrection is settled via a Range-based
  // measurement of both visible bboxes.
  const [defTranslateX, setDefTranslateX] = React.useState(0);

  React.useEffect(() => {
    if (!follyRef.current) return;
    const ro = new ResizeObserver(([entry]) => {
      setFollyWidth(entry.contentRect.width);
    });
    ro.observe(follyRef.current);
    return () => ro.disconnect();
  }, []);

  // Compute the visible-glyph / advance-width ratio for both FOLLY and
  // the def-line text. Width-matching the bounding boxes alone leaves a
  // visible drift because FOLLY's F/Y have small side-bearings while the
  // def line starts/ends with characters (capital F, closing curly
  // quote) whose bearings are different. Scaling the target by
  // (follyRatio / defRatio) puts the def line's VISIBLE left/right
  // glyph edges on FOLLY's VISIBLE left/right glyph edges instead.
  const computeGlyphAdjust = React.useCallback(() => {
    const follyEl = follyRef.current;
    const defEl = defMeasureRef.current;
    if (!follyEl || !defEl || typeof document === 'undefined') return 1;
    const make = (el, text) => {
      const cs = window.getComputedStyle(el);
      const c = document.createElement('canvas');
      const ctx = c.getContext('2d');
      if (!ctx) return null;
      const px = parseFloat(cs.fontSize);
      ctx.font = `${cs.fontWeight} ${px}px ${cs.fontFamily}`;
      if (cs.letterSpacing && 'letterSpacing' in ctx) {
        try { ctx.letterSpacing = cs.letterSpacing; } catch (_e) {}
      }
      const m = ctx.measureText(text);
      const advance = m.width;
      const visible = (m.actualBoundingBoxLeft || 0) + (m.actualBoundingBoxRight || 0);
      return advance > 0 && visible > 0 ? { advance, visible } : null;
    };
    const f = make(follyEl, 'FOLLY');
    const d = make(defEl, defEl.textContent || '');
    if (!f || !d) return 1;
    const follyRatio = f.visible / f.advance;
    const defRatio = d.visible / d.advance;
    if (!Number.isFinite(follyRatio) || !Number.isFinite(defRatio) || defRatio <= 0) return 1;
    return follyRatio / defRatio;
  }, []);

  React.useLayoutEffect(() => {
    if (!follyWidth || !defMeasureRef.current) return;
    const measure = () => {
      const el = defMeasureRef.current;
      if (!el) return;
      const naturalWidth = el.getBoundingClientRect().width;
      if (naturalWidth > 0) {
        const targetWidth = follyWidth * WORDMARK_FINAL_SCALE * computeGlyphAdjust();
        setDefFontSize((targetWidth / naturalWidth) * 100);
      }
    };
    measure();
    let cancelled = false;
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => { if (!cancelled) measure(); });
    }
    return () => { cancelled = true; };
  }, [follyWidth, computeGlyphAdjust]);

  React.useLayoutEffect(() => {
    if (!follyWidth || !defLineRef.current || !defFontSize) return;
    let raf = 0;
    const measure = () => {
      const el = defLineRef.current;
      if (!el) return;
      const w = el.offsetWidth;
      if (w > 0) {
        const targetWidth = follyWidth * WORDMARK_FINAL_SCALE * computeGlyphAdjust();
        const next = targetWidth / w;
        if (Math.abs(next - defCorrection) > 0.0005) {
          setDefCorrection(next);
        }
      }
    };
    raf = requestAnimationFrame(measure);
    let cancelled = false;
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => { if (!cancelled) requestAnimationFrame(measure); });
    }
    return () => { cancelled = true; cancelAnimationFrame(raf); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defFontSize, follyWidth, computeGlyphAdjust]);

  // ─── Visible-center alignment ─────────────────────────────────────
  // After scaleX has been computed (defCorrection settled), shift the
  // def-line so its INK center lands on FOLLY's INK center.
  //
  // Previous implementation measured "visible bbox" via Range.
  // getBoundingClientRect — but in every browser that ships today
  // that method returns the text's LAYOUT box (the em-box of each
  // text frame), NOT the ink box. So FOLLY's Y-diagonal overhang
  // (which extends a few px past the Y's layout box) was never
  // captured: fVisOffset came back at ~0, the correction came out
  // to ~0, and the def-line stayed centered on FOLLY's LAYOUT
  // center while FOLLY's ink center actually sat a few pixels to
  // the right. Net visual: def-line drifts left, FOLLY's Y tip
  // extends past the def-line's right edge — exactly the bug.
  //
  // Fix: derive the ink-vs-layout center offset via canvas's
  // actualBoundingBoxLeft / actualBoundingBoxRight metrics, which
  // DO report the ink extent (including the Y's diagonal). The
  // result is the intrinsic ink offset for each element, scaled by
  // whatever CSS transform it currently has applied.
  React.useLayoutEffect(() => {
    const f = follyRef.current;
    const d = defLineRef.current;
    if (!f || !d || !defCorrection) return;
    let raf = 0;
    const measure = () => {
      try {
        const fLayout = f.getBoundingClientRect();
        const dLayout = d.getBoundingClientRect();
        if (fLayout.width <= 0 || dLayout.width <= 0) return;
        const fLayoutCenter = (fLayout.left + fLayout.right) / 2;
        const dLayoutCenter = (dLayout.left + dLayout.right) / 2;

        // Canvas-based ink offset: how far an element's INK center
        // sits from its LAYOUT center, in viewport pixels. POSITIVE
        // when ink extends further right than left of the layout
        // box (FOLLY's Y diagonal). transformScale converts the
        // unscaled font-metric pixels to the element's actually-
        // rendered viewport size (FOLLY = WORDMARK_FINAL_SCALE,
        // def-line = defCorrection along x).
        const inkOffset = (el, text, transformScale) => {
          const cs = window.getComputedStyle(el);
          const c = document.createElement('canvas');
          const ctx = c.getContext('2d');
          if (!ctx) return 0;
          const px = parseFloat(cs.fontSize);
          ctx.font = `${cs.fontWeight} ${px}px ${cs.fontFamily}`;
          if (cs.letterSpacing && 'letterSpacing' in ctx) {
            try { ctx.letterSpacing = cs.letterSpacing; } catch (_e) {}
          }
          const m = ctx.measureText(text);
          const advance = m.width;
          if (!(advance > 0)) return 0;
          const left  = m.actualBoundingBoxLeft  || 0;
          const right = m.actualBoundingBoxRight || 0;
          // Layout box runs [0, advance]; ink box runs [-left, +right].
          // Centers: layout = advance/2, ink = (right - left) / 2.
          // Offset = ink_center - layout_center
          //        = (right - left) / 2  -  advance / 2
          //        = (right - left - advance) / 2.
          return ((right - left - advance) * 0.5) * transformScale;
        };

        const fVisOffset = inkOffset(f, 'FOLLY', WORDMARK_FINAL_SCALE);
        const dVisOffset = inkOffset(d, d.textContent || '', defCorrection);

        // Layout-center difference (≈0 since both are flex-centered
        // in the same column; included so we're robust against any
        // future container reshuffling). dLayoutCenter already
        // includes the currently-applied translateX, so the
        // "prev + needed" expression below converges to the absolute
        // target translate in one step.
        const layoutShift = fLayoutCenter - dLayoutCenter;
        const needed = fVisOffset - dVisOffset + layoutShift;

        setDefTranslateX(prev => {
          const next = prev + needed;
          if (Math.abs(next - prev) < 0.5) return prev;
          return next;
        });
      } catch (_e) { /* getBoundingClientRect can throw on detached nodes */ }
    };
    raf = requestAnimationFrame(measure);
    let cancelled = false;
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => { if (!cancelled) requestAnimationFrame(measure); });
    }
    return () => { cancelled = true; cancelAnimationFrame(raf); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defCorrection, follyWidth]);

  return (
      <section
        className="relative w-full bg-surface"
        style={{ height: '118svh', minHeight: '720px' }}
      >
      {/* Sticky inner — pins the hero content to the viewport for the
          first ~18svh of section scroll, giving the figures a brief stable
          FOLLY-anchored portal to emerge from. After that the parent
          section's bottom reaches the viewport top and the hero scrolls
          away naturally into About. */}
      <div
        className="sticky top-0 h-screen w-full overflow-hidden"
        style={{ perspective: '1200px' }}
      >
        {/* Background video — the framing that used to be produced by
            `scale: 1.95` + `x: -20%` + `y: 15%` + `objectPosition: 65% 45%`
            has been BAKED into the source file. The previous build shipped
            a 1920×1080 source and used CSS transforms to crop down to the
            visible region (~984×554, right-of-centre mountains-only) —
            ~75% of the encoded pixels were never on screen. The source
            has been re-cropped to that visible region exactly and re-
            encoded at higher per-pixel quality (CRF 19, slow preset,
            no audio): file dropped from 9.2 MB → 3.8 MB while every
            visible pixel carries ~60% more bits than before, so the
            mountains read sharper through the cream wash.
            saturate(0.05) is preserved — that desaturation is brand,
            not a quality issue. Backup of the original 1920×1080 file
            lives at `media/hero.mp4.bak`. */}
        <m2.div
          className="absolute inset-0"
          style={{
            filter: 'saturate(0.05)',
            zIndex: 0,
            scale: heroVideoScale,
            transformOrigin: 'center center',
            willChange: 'transform',
          }}
        >
          <LazyVideo
            eager
            playbackRate={1.0}
            className="hero-video-mobile absolute inset-0 w-full h-full object-cover gpu-layer"
            src="media/hero.mp4"
          />
        </m2.div>

        {/* Cream wash — starts at 0.80 (heavy enough to hide hero clip
            upscaling softness) and lifts gently to 0.86 across the first
            800px of scroll, paired with the 1→1.05 scale dolly on the
            video wrapper above. Earlier builds tried 0.30 → 0.80 which
            exposed source softness at the start; this kinder 0.80 → 0.86
            keeps the video legible throughout while adding a subtle
            white-bloom feel as the user scrolls in. */}
        <m2.div
          className="absolute inset-0 pointer-events-none"
          style={{ background: 'rgb(var(--c-bg))', opacity: heroWashOpacity, zIndex: 0 }}
        />

        {/* Bottom fade — dissolves the video into the surface color of the next section */}
        <div
          className="absolute inset-x-0 bottom-0 h-48 sm:h-64 md:h-80 pointer-events-none"
          style={{
            background: 'linear-gradient(to bottom, rgb(var(--c-bg) / 0) 0%, rgb(var(--c-bg) / 0.7) 55%, rgb(var(--c-bg)) 100%)',
            zIndex: 0,
          }}
        />

        {/* Centered wordmark + definition block. The wordmark carries
            the data-folly-wordmark attribute the FallingFigure code in
            app.jsx queries to locate the emerge portal. */}
        <div
          className="absolute inset-0 flex flex-col items-center justify-center px-5 sm:px-8 md:px-10"
          style={{ zIndex: 2, perspective: '1200px' }}
        >
          <m2.h1
            ref={follyRef}
            data-folly-wordmark
            className="font-extrabold select-none relative hero-folly"
            style={{
              color: TEXT_COLOR,
              fontSize: 'clamp(6rem, 20vw, 22rem)',
              lineHeight: 0.82,
              letterSpacing: '-0.055em',
              scale: WORDMARK_FINAL_SCALE,
              opacity: 1,
              // NO permanent `filter` here. A static filter (even
              // blur(0)) puts this enormous wordmark on its own
              // filter-rasterization layer, so every frame the Y
              // child tumbles the browser re-rasterizes the whole
              // FOLLY text through the filter pipeline — the mobile
              // "first roll is glitchy / low frame rate" cause.
              // Without it the Y animates as a pure GPU transform.
              // The mobile intro blur-in is still applied: the
              // `mobileFollyIntro` keyframe in index.html targets
              // `.hero-folly` with `!important` + `fill-mode: both`,
              // so it owns the filter during the intro and holds
              // blur(0) after — no inline filter needed, and desktop
              // never had a visible filter here anyway (blur(0)).
              transformOrigin: 'center center',
              transformStyle: 'preserve-3d',
              willChange: 'transform, opacity',
              position: 'relative',
              zIndex: 2,
            }}
          >
            FOLL<m2.span
              style={{
                display: 'inline-block',
                transformOrigin: '85% 100%', // pivot on bottom-right foot
                rotate: yRotate,
                x: yTranslateX,
                y: yTranslateY,
                opacity: yOpacity,
                willChange: 'transform, opacity',
                marginLeft: '-0.12em',
              }}
            >Y</m2.span>
          </m2.h1>

          {/* Hidden measurer — base size 100px, same fonts/letter-
              spacing as the rendered line. Used by PASS 1 of the
              width-locking measurement above. */}
          <span
            ref={defMeasureRef}
            aria-hidden="true"
            style={{
              position: 'absolute',
              visibility: 'hidden',
              pointerEvents: 'none',
              whiteSpace: 'nowrap',
              fontSize: '100px',
              lineHeight: 1,
              letterSpacing: '-0.008em',
            }}
          >
            <span style={{ fontWeight: 600 }}>Folly</span>{' '}
            <span>/ˈfälē/</span>{' '}
            <span style={{ fontFamily: '"Instrument Serif", serif', fontStyle: 'italic' }}>noun.</span>{' '}
            <span>Lack of good sense. Foolishness.</span>{' '}
            <span style={{ fontFamily: '"Instrument Serif", serif', fontStyle: 'italic' }}>“an act of sheer folly.”</span>
          </span>

          {/* Definition — width-locked to FOLLY's visible width. */}
          <m2.p
            ref={defLineRef}
            className="hero-def-line"
            style={{
              color: TEXT_COLOR,
              fontSize: defFontSize ? `${defFontSize}px` : 'clamp(1rem, 2.4vw, 2.25rem)',
              lineHeight: 1.1,
              letterSpacing: '-0.008em',
              marginTop: 'clamp(1.25rem, 2.5vw, 2.5rem)',
              whiteSpace: 'nowrap',
              opacity: 1,
              filter: 'blur(0px)',
              scaleX: defCorrection,
              x: defTranslateX,
              transformOrigin: 'center center',
              willChange: 'transform, filter, opacity',
            }}
          >
            <span className="font-semibold">Folly</span>{' '}
            <span style={{ opacity: 0.5 }}>/ˈfälē/</span>{' '}
            <span className="font-serif italic" style={{ opacity: 0.6 }}>noun.</span>{' '}
            <span style={{ opacity: 0.8 }}>Lack of good sense. Foolishness.</span>{' '}
            <span className="font-serif italic">“an act of sheer folly.”</span>
          </m2.p>

        </div>

      </div>
    </section>
  );
}

/* ------------------------------- ABOUT ------------------------------- */
// Scroll-locked, three-page composition inside the liquid-glass panel.
//
// HOW IT WORKS
//   The outer <section> is intentionally tall (LOCK_PAGES * 100vh + a
//   small entry/exit pad). Inside it, a `position: sticky` wrapper pins
//   the glass panel to viewport center while the user scrolls through
//   the lock range. We map scroll progress through that range to a
//   page index (0 → 1 → 2) and animate copy in/out between them with
//   a clean blur + lift + fade swap.
//
//   The falling figures (Andrew + Chris) are pinned to viewport-center
//   while this section is locked, so they appear to hover in place
//   while their POSE keeps cycling with scroll. After the lock ends,
//   normal scroll resumes and they continue down the page until they
//   come to rest just above the People section.
//
//   `data-about-lock` is read by the FallingFigure code in app.jsx to
//   detect the lock range; `data-about-progress` (0..1) is updated each
//   frame so app.jsx can stay perfectly in sync without re-measuring.
// Paragraph data is now SEGMENT-BASED so the word-stagger renderer
// (renderWords below) can walk every word individually and animate it as
// its own motion.span. A `paragraph` is an array of `{ text, style? }`
// segments; the renderer splits each segment's text into individual words
// and emits one motion.span per word with the segment's style attached.
//
// Two-slide editorial sequence. Both slides use the same `prose` kind so
// typography reads as one unified voice; emphasis lives in inline segment
// styles ('serif-italic', 'link', etc.) rather than separate slide kinds.
// Slide 2 carries an optional `definition` block that renders as a
// dictionary-style footer beneath the prose (hairline above, smaller
// type, left-aligned within a centered max-width — the visual idiom
// readers already associate with a printed dictionary entry).
const ABOUT_PAGES = [
  {
    eyebrow: '(01) — The premise',
    kind: 'prose',
    paragraphs: [
      [
        { text: 'Folly Partners is the family office' },
        // Explicit breaks around the parenthetical aside so the
        // italic phrase sits on its own visual row instead of
        // fragmenting mid-style across the surrounding prose. The
        // smaller italic ("aside voice") fits cleanly on one line
        // at the prose measure and visually recedes from the
        // main thought.
        { style: 'softbreak' },
        { text: '(rich people speak for holding company that manages personal money)', style: 'serif-italic-small' },
        { style: 'softbreak' },
        { text: 'of' },
        { text: 'Andrew Wilkinson', style: 'link', href: 'https://www.linkedin.com/in/awilkinson/' },
        { text: 'and' },
        { text: 'Chris Sparling,', style: 'link', href: 'https://www.linkedin.com/in/sparlingchris/' },
        { text: 'the founders of' },
        { text: 'Tiny.', style: 'link', href: 'https://www.tiny.com' },
      ],
      [
        { text: 'Tiny buys companies and operates them.' },
      ],
      [
        { text: 'Folly does the opposite: holds their personal money in stocks, bonds, real estate, fund stakes, and the occasional quiet minority — passively, patiently, and mostly out of the way.' },
      ],
    ],
  },
  {
    eyebrow: '(02) — The point',
    kind: 'prose',
    paragraphs: [
      [
        { text: 'Andrew and Chris made a bunch of money building things. The risk, from here, is that they get bored, get clever, and lose it.' },
      ],
      [
        { text: 'We exist to make that harder. To prevent an act of' },
        { text: 'Folly.', style: 'serif-italic' },
      ],
    ],
    definition: {
      word: 'Folly',
      pronunciation: 'fol-ee',
      partOfSpeech: 'noun',
      meaning: 'Lack of good sense; foolishness.',
      example: 'an act of sheer folly',
    },
  },
];

// SCROLL-LOCK + SLIDE STATE MACHINE REMOVED.
// The About section used to be 300vh tall with a sticky panel pinned at
// viewport centre, paginating between two "slides" as the user scrolled
// through a 180vh lock zone. That scroll-jacking experience was poor —
// jumpy on trackpads, fragile on slow wheels, and constantly fighting
// Lenis's snap engine. It is replaced with a single natural-height liquid
// glass card that flows as normal scroll; both editorial beats — eyebrow
// (01) The premise and eyebrow (02) The point plus the dictionary block —
// are stacked inside one card, and the falling figures stay centred
// behind the card the whole way down. Constants previously needed for
// the lock geometry (ABOUT_LOCK_VH / ABOUT_LOCK_MOBILE_VH / ABOUT_TAIL_VH /
// aboutLockVh) and the slide animation variants (ABOUT_SLIDE_VARIANTS /
// ABOUT_WORD_VARIANTS) are deleted — no slides means nothing to drive
// their state machine.

// MARKER-HIGHLIGHT WIPE — replaces the previous CSS hover pill on the
// inline About-section links (Andrew Wilkinson, Chris Sparling, Tiny).
// A lavender bar painted behind the text scales left → right with a
// soft spring on hover/focus/tap, then collapses back smoothly when
// the cursor leaves. transformOrigin: left + scaleX 0 → 1 reproduces
// the Remotion reference's left-to-right wipe; framer-motion (already
// loaded via UMD) drives it without the build pipeline Remotion would
// require.
//
// Color is Folly's lavender accent (#CCBCEE) instead of the reference's
// yellow #facc15, so the highlight stays on-brand.
//
// Variant strategy:
//   below / above  → scaleX 0  (slide off-stage; marker never seen)
//   active         → scaleX 0  (slide on-stage but cursor not over the
//                               link; also the "un-hover" landing state)
//   hover          → scaleX 1  (cursor over the link; spring fill)
//
// IMPLEMENTATION NOTE: the marker is driven by LOCAL React hover state
// (not by the slide's variant chain) so it can never accidentally fire
// on slide-enter. See AboutLink below — the marker has explicit
// initial={{ scaleX: 0 }} and an animate prop derived from `hover`,
// which is fully decoupled from the surrounding ABOUT_WORD_VARIANTS
// cascade.

// ─── ABOUT LINK ────────────────────────────────────────────────────────
// Inline link with a marker-highlight wipe on hover/focus. The marker
// is a motion.span whose scaleX is driven entirely by local hover/focus
// state — initial 0, springs to 1 on enter, eases back to 0 on leave.
// The previous version wired the <a> into the (now-removed) slide
// word-stagger variants; that participation is gone with the slides.
function AboutLink({ href, text }) {
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="about-link whitespace-nowrap align-baseline font-bold"
    >
      {text}
    </a>
  );
}

// ─── WORD RENDERER ─────────────────────────────────────────────────────
// Walk a paragraph (array of `{ text, style?, href? }` segments) and emit
// inline children. Plain segments split into per-word <span>s so wrap
// behaviour matches the editorial line measure; `link` segments render as
// a single <AboutLink> so multi-word names stay on one line. The previous
// version emitted motion.spans driven by ABOUT_WORD_VARIANTS for the now-
// removed slide stagger; the parent card's blur-fade-up reveal handles
// the entrance now, so plain spans are sufficient.
function renderWords(paragraph) {
  return paragraph.flatMap((seg, sIdx) => {
    // Soft break: forces a line break between styled runs so a
    // parenthetical aside or emphasis block can sit on its own visual
    // row instead of fragmenting mid-phrase across the surrounding prose.
    if (seg.style === 'softbreak') {
      return [<br key={`${sIdx}-br`} aria-hidden="true" />];
    }

    if (seg.style === 'link' && seg.href) {
      return [
        <React.Fragment key={`${sIdx}-link`}>
          <AboutLink href={seg.href} text={seg.text.trim()} />
          {' '}
        </React.Fragment>,
      ];
    }

    let cls = 'inline-block whitespace-nowrap align-baseline';
    if (seg.style === 'serif-italic') {
      cls += ' font-serif italic font-normal';
    } else if (seg.style === 'serif-italic-small') {
      // Quieter italic for a parenthetical aside — reads as the
      // editorial "narrator's whisper" rather than emphasis. Sized
      // at 0.88em so the whole aside fits on one line at the
      // prose's measure and visually recedes from the main thought.
      cls += ' font-serif italic font-normal text-[0.88em]';
    } else if (seg.style === 'serif-italic-lowercase') {
      cls += ' font-serif italic font-normal lowercase tracking-[-0.02em]';
    } else if (seg.style === 'bold') {
      cls += ' font-extrabold';
    }
    const words = seg.text.trim().split(/\s+/).filter(Boolean);
    return words.map((word, wIdx) => (
      <React.Fragment key={`${sIdx}-${wIdx}`}>
        <span className={cls}>{word}</span>
        {' '}
      </React.Fragment>
    ));
  });
}

/* ============================================================================
   ABOUT — editorial spread inside the liquid glass
   ----------------------------------------------------------------------------
   Five-zone layout (Le Corbusier × Identifont blend):
     1. Massive cropped FAMILY OFFICE display headline (breaks the right edge)
     2. (01) — THE PREMISE section label row
     3. Numbered FIG. 01 / FIG. 02 rows — portraits for Andrew + Chris
     4. Premise prose paragraphs (existing renderWords pipeline)
     5. (02) — THE POINT label row
     6. 4-cell icon principles grid: PATIENT / PASSIVE / QUIET / MINORITY
     7. Point prose
     8. Dictionary entry for Folly (headword in brick accent)
   Hairlines divide every row so the panel reads as one type system.

   Single accent color: rgb(var(--c-accent)) (warm brick/oxide rust). Pairs with the
   page's warm cream surface + warm charcoal text without clashing — a
   third warm tone, used as a 'wax-seal' moment on figure indices and
   the dictionary headword. Replaces the lavender that no longer fit
   the de-purpled monochrome system.
   ============================================================================ */

const ABOUT_ACCENT   = 'rgb(var(--c-accent))';
const ABOUT_HAIRLINE = '1px solid rgb(var(--c-text) / 0.16)';
const ABOUT_MONO_STYLE = {
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
  textTransform: 'uppercase',
  letterSpacing: '0.18em',
  fontSize: '10.5px',
  color: 'rgb(var(--c-text) / 0.55)',
  fontWeight: 400,
};

/* ---- Lucide icons, inlined ----
   Babel-standalone has no module system so the lucide-react package
   can't be imported the usual way. The icons themselves are just SVG
   path strings, so we inline the four we need here. Paths are the
   official Lucide source (https://lucide.dev). Stroke is currentColor
   so they inherit whatever text color the principle cell is rendering. */
const IconHourglass = (props) => (
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
    <path d="M5 22h14"/><path d="M5 2h14"/>
    <path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/>
    <path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>
  </svg>
);
const IconAnchor = (props) => (
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
    <path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/>
  </svg>
);
const IconMoon = (props) => (
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
    <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
  </svg>
);
const IconPieChart = (props) => (
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
    <path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>
  </svg>
);

/* ---- FAMILY OFFICE handwritten note — Caveat, angled, wipes in left-to-
   right via clip-path. Lives in the bottom-right compartment of Box B.
   The companion arrow that connects this note back to the FAMILY OFFICE
   wordmark is rendered separately (FamilyOfficeArrow), positioned
   absolutely on Box B so it can cross the vertical hairline between the
   two columns. ---- */
function FamilyOfficeHandwriting({ isMobile, triggerRef }) {
  // Reuse the same in-view trigger as the arrow so both animations are
  // driven off the same scroll moment.
  const inView = useInView2(triggerRef, { once: true, margin: '-80px' });
  return (
    <div
      style={{
        fontFamily: '"Caveat", cursive',
        fontWeight: 600,
        fontSize: isMobile
          ? 'clamp(22px, 5.8vw, 30px)'
          : 'clamp(22px, 1.85vw, 28px)',
        lineHeight: 1.15,
        color: TEXT_COLOR,
        transform: isMobile ? 'rotate(-3deg)' : 'rotate(-4deg)',
        transformOrigin: 'left top',
        opacity: 0.92,
        WebkitClipPath: inView ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
        clipPath:        inView ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
        transition: 'clip-path 1200ms linear 250ms, -webkit-clip-path 1200ms linear 250ms',
      }}
    >
      “rich people speak for a<br />
      holding company that<br />
      manages personal money”
    </div>
  );
}

/* ---- FAMILY OFFICE arrow — curved annotation arrow that crosses the
   vertical hairline between Box B's two columns. Tail starts near the
   handwritten note (right column, bottom compartment) and the arrowhead
   lands ON the FAMILY OFFICE wordmark (left column). Stroke-dashoffset
   draws it after the handwriting wipes in. ---- */
function FamilyOfficeArrow({ inView }) {
  // viewBox aspect 6.2:1 matches the SVG container's pixel aspect at
  // typical desktop block sizes (≈ 620 × 100 px). preserveAspectRatio
  // "xMidYMid meet" keeps the path proportional — no horizontal squish
  // of the arrowhead.
  return (
    <svg
      viewBox="0 0 620 100"
      preserveAspectRatio="xMidYMid meet"
      aria-hidden="true"
      style={{
        position: 'absolute',
        // Spans the bottom half of the block, from just past FAMILY
        // OFFICE's right edge over to where the handwriting note begins
        // in the right column's bottom cell. Vertical band sits well
        // below the OFFICE baseline so the curve never crosses the
        // display type.
        left:   '38%',
        right:  '6%',
        top:    '52%',
        height: '32%',
        zIndex: 3,
        pointerEvents: 'none',
        overflow: 'visible',
      }}
    >
      {/* Single confident cubic. Tail starts at the handwriting's
          top-left; the curve arcs gently up over the middle, then
          descends to land its arrowhead just to the right of the
          FAMILY OFFICE wordmark. */}
      <path
        d="M 600 88 C 410 102, 200 -2, 28 56"
        fill="none"
        stroke={TEXT_COLOR}
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
        vectorEffect="non-scaling-stroke"
        style={{
          strokeDasharray: 760,
          strokeDashoffset: inView ? 0 : 760,
          transition: 'stroke-dashoffset 1300ms cubic-bezier(0.32, 0.72, 0, 1) 1500ms',
        }}
      />
      {/* Arrowhead — chevron at the curve's tip (28, 56), opening to
          the right so the arrow reads as ← pointing at FAMILY OFFICE. */}
      <path
        d="M 28 56 L 50 46 M 28 56 L 50 66"
        fill="none"
        stroke={TEXT_COLOR}
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
        vectorEffect="non-scaling-stroke"
        style={{
          opacity: inView ? 1 : 0,
          transition: 'opacity 280ms cubic-bezier(0.32, 0.72, 0, 1) 2800ms',
        }}
      />
    </svg>
  );
}

/* ---- Box B — the FAMILY OFFICE display block. Two columns separated by
   a vertical hairline. Right column splits horizontally into a founders
   sentence (top) and the handwritten annotation note (bottom). An arrow
   SVG is positioned absolutely so it can cross the vertical hairline,
   with its arrowhead landing on the FAMILY OFFICE wordmark in the left
   column. ---- */
function FamilyOfficeBlock({ isMobile }) {
  const blockRef = React.useRef(null);
  const inView = useInView2(blockRef, { once: true, margin: '-80px' });

  // Equal padding all four sides on every compartment so margins are
  // square + consistent across the whole block, regardless of viewport.
  const pad = isMobile ? 24 : 36;

  return (
    <div
      ref={blockRef}
      style={{
        position: 'relative',
        display: isMobile ? 'block' : 'flex',
        flexDirection: 'row',
        alignItems: 'stretch',
        // Tiny gutter above the block + top hairline → the hairline
        // reads as a 'header bar' boxing the content, with just a
        // narrow strip of breathing room between it and the rounded
        // top edge of the liquid-glass card.
        marginTop: isMobile ? 14 : 16,
        borderTop: ABOUT_HAIRLINE,
      }}
    >
      {/* LEFT column — small lead-in + FAMILY OFFICE display. Owns the
          natural row height since it's the tallest content. */}
      <div style={{
        flex: isMobile ? 'none' : '1 1 0',
        padding: pad,
        borderRight:  isMobile ? 'none' : ABOUT_HAIRLINE,
        borderBottom: isMobile ? ABOUT_HAIRLINE : 'none',
        textAlign: 'left',
        display: 'flex',
        flexDirection: 'column',
      }}>
        <div style={{
          fontWeight: 400,
          fontSize: isMobile
            ? 'clamp(18px, 4.4vw, 22px)'
            : 'clamp(22px, 2.2vw, 32px)',
          letterSpacing: '-0.018em',
          lineHeight: 1.05,
          marginBottom: isMobile ? 10 : 14,
          color: TEXT_COLOR,
        }}>
          Folly Partners is the
        </div>
        <h1 style={{
          margin: 0,
          fontWeight: 800,
          fontSize: isMobile
            ? 'clamp(44px, 13vw, 72px)'
            : 'clamp(72px, 7.5vw, 116px)',
          lineHeight: 0.88,
          letterSpacing: '-0.05em',
          color: TEXT_COLOR,
        }}>
          FAMILY<br />OFFICE
        </h1>

        {/* Margin gloss — dictionary-style descriptor for "family office".
            Pulled tight against the left edge in monospace small caps so
            it reads like a footnote engraved into the panel itself,
            not body copy. Low opacity + a brick-accent quote glyph hand
            it off to the eye as "an aside" not "another sentence." */}
        <div
          style={{
            marginTop: isMobile ? 14 : 18,
            ...ABOUT_MONO_STYLE,
            fontSize: isMobile ? '10.5px' : '11.5px',
            letterSpacing: '0.18em',
            lineHeight: 1.7,
            opacity: 0.65,
            color: TEXT_COLOR,
            maxWidth: isMobile ? '100%' : '32ch',
            position: 'relative',
            paddingLeft: isMobile ? 14 : 16,
            borderLeft: `2px solid ${ABOUT_ACCENT}`,
          }}
        >
          <span style={{
            color: ABOUT_ACCENT,
            opacity: 1,
            fontWeight: 700,
            marginRight: '0.45em',
          }}>
            n.
          </span>
          rich-people speak for a holding company that manages personal money.
        </div>
      </div>

      {/* RIGHT column — flex column with TWO cells that size to their
          CONTENT (no `flex: 1 1 0` stretching on the TOP cell). Both
          cells are top-aligned so the founders sentence's first line
          sits at the same baseline as "Folly Partners is the" on the
          left. The BOTTOM cell takes whatever vertical space remains so
          the right column meets the left column's bottom cleanly —
          no L-shape, no orphaned hairline stub. */}
      <div style={{
        flex: isMobile ? 'none' : '1 1 0',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'flex-start',
      }}>
        {/* TOP cell — founders sentence, sized to content */}
        <div style={{
          flex: '0 0 auto',
          padding: pad,
          borderBottom: ABOUT_HAIRLINE,
        }}>
          <div style={{
            fontWeight: 500,
            fontSize: isMobile
              ? 'clamp(18px, 4.4vw, 22px)'
              : 'clamp(20px, 1.6vw, 26px)',
            lineHeight: 1.3,
            letterSpacing: '-0.015em',
          }}>
            <AboutLink href="https://www.linkedin.com/in/awilkinson/" text="Andrew Wilkinson" /> and <AboutLink href="https://www.linkedin.com/in/sparlingchris/" text="Chris Sparling," /> the founders of <AboutLink href="https://www.tiny.com" text="Tiny." />
          </div>
        </div>
        {/* BOTTOM cell — handwriting note. Grows to absorb any remaining
            vertical space so the right column's bottom edge aligns with
            the left column's bottom edge. Note itself sits top-aligned. */}
        <div style={{
          flex: isMobile ? 'none' : '1 1 0',
          padding: pad,
          minHeight: isMobile ? 140 : 0,
          overflow: 'hidden',
        }}>
          <FamilyOfficeHandwriting isMobile={isMobile} triggerRef={blockRef} />
        </div>
      </div>

      {/* ABSOLUTE arrow — overlays the entire block so it can sweep
          across to point at FAMILY OFFICE. Hidden on mobile (column
          layout doesn't need a cross-column arrow). */}
      {!isMobile && <FamilyOfficeArrow inView={inView} />}
    </div>
  );
}

/* ---- FIG. card — vertical centered card with square image, name, role.
   Used in the 3-up founders/Tiny grid. `isTiny` swaps the portrait for
   a 'tiny.' wordmark placeholder card so the third slot reads as the
   operating company alongside the two people. ---- */
function AboutFigureCard({ num, name, role, photo, href, isMobile, isTiny }) {
  // Tiny gets a 'big' treatment: name + role typography bump up; image
  // fills its wider column. Portraits stay restrained.
  const big = !!isTiny;
  // Equal padding on all sides so every card sits with square margins,
  // identical across breakpoints — keeps the grid feeling 'OCD-sorted.'
  const pad = isMobile ? 16 : 18;
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="press block"
      style={{
        textAlign: 'left',
        textDecoration: 'none',
        color: 'inherit',
        padding: pad,
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'stretch',
        boxSizing: 'border-box',
      }}
    >
      <div style={{
        ...ABOUT_MONO_STYLE,
        marginBottom: 10,
      }}>
        FIG.&nbsp;<span style={{ color: ABOUT_ACCENT, fontWeight: 700 }}>{num}</span>
      </div>
      {/* Image container — flex:1 fills the remaining vertical space
          in the card. Combined with the parent figure grid stretching
          all cards to Tiny's height, portraits snap to align with
          Tiny's edges. Heights are kept tight so the whole picture
          row stays close to one fold tall. */}
      <div style={{
        width: '100%',
        flex: '1 1 0',
        minHeight: big
          ? (isMobile ? 200 : 240)
          : (isMobile ? 140 : 108),
        background: 'rgb(var(--c-invert-bg))',
        borderRadius: 6,
        overflow: 'hidden',
        marginBottom: 10,
      }}>
        {isTiny ? (
          <div style={{
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            width: '100%',
            height: '100%',
            color: 'rgb(var(--c-invert-text))',
            fontWeight: 800,
            fontSize: isMobile ? 'clamp(48px, 12vw, 72px)' : 'clamp(56px, 5.5vw, 88px)',
            letterSpacing: '-0.055em',
            lineHeight: 1,
          }}>
            tiny.
          </div>
        ) : (
          <img
            src={photo}
            alt={name}
            draggable="false"
            className="people-photo w-full h-full object-cover select-none"
          />
        )}
      </div>
      <div style={{
        fontWeight: 700,
        fontSize: big
          ? (isMobile ? 'clamp(16px, 4.2vw, 19px)' : 'clamp(17px, 1.35vw, 21px)')
          : (isMobile ? '0.95em'  : 'clamp(13px, 1vw, 15px)'),
        letterSpacing: '-0.015em',
        lineHeight: 1.2,
      }}>{name}</div>
      <div className="font-serif italic font-normal" style={{
        opacity: 0.6,
        fontSize: big
          ? (isMobile ? '0.9em' : '0.92em')
          : '0.85em',
        marginTop: 2,
        lineHeight: 1.25,
      }}>{role}</div>
    </a>
  );
}

function About() {
  const sectionRef    = useRef2(null);
  const cardRevealRef = useRef2(null);
  const isMobile      = useIsMobile();

  React.useEffect(() => {
    if (!sectionRef.current || !cardRevealRef.current) return;
    return applyBlurFadeUp(cardRevealRef.current, {
      startVh: 0.85, endVh: 0.30,
      dyPx: 56, blurPx: 14,
      getRefTop: () => sectionRef.current.getBoundingClientRect().top,
    });
  }, []);

  // Per-bubble scroll reveal. The card effect above brings the liquid-
  // glass FRAME in as one unit; these make the content cascade inside
  // it the same way the People + Features blocks do — each bubble
  // blur-fades up from below as its own top crosses the viewport, so
  // the panel fills in chapter-by-chapter instead of appearing all at
  // once. Same `applyBlurFadeUp` helper + same site-wide ease-out
  // (that's what makes it read as one system); magnitude tuned a touch
  // gentler than the page default (48px / 12px vs. 60 / 18) because
  // these reveal NESTED inside the card's own entrance — the codebase
  // already tunes this helper per element size (the card itself runs
  // 56 / 14). Natural vertical spacing between bubbles supplies the
  // stagger; no explicit delays needed.
  const bubble1Ref = useRef2(null);
  const bubble2Ref = useRef2(null);
  const bubble3Ref = useRef2(null);
  const BUBBLE_REVEAL_INIT = {
    opacity: 0,
    transform: 'translate3d(0, 48px, 0)',
    filter: 'blur(12px)',
    willChange: 'opacity, transform, filter',
  };
  React.useEffect(() => {
    const opts = { startVh: 0.92, endVh: 0.42, dyPx: 48, blurPx: 12 };
    const cleanups = [bubble1Ref, bubble2Ref, bubble3Ref]
      .map((r) => (r.current ? applyBlurFadeUp(r.current, opts) : null));
    return () => cleanups.forEach((fn) => fn && fn());
  }, []);

  /* ════════════════════════════════════════════════════════════════════
     ABOUT — Chaptered editorial composition (Identifont / Nike-grid
     vocabulary, Meta-Labs flow). Five numbered chapters stacked inside
     the rounded liquid-glass capsule, with an ASYMMETRIC interior
     grid in every chapter so cells don't all read the same. No
     FAMILY OFFICE wordmark up top — the chapters carry the read.

       01 / Premise               7:5 split — sentence | family-office dictionary
       02 / Inverse               7:5 split — TINY/FOLLY mega lines | image
       03 / What Folly holds      5:4:3 split — list | image | doctrine quote
       04 / The Point             5:7 split — image | risk + resolution
       — Folly definition footer  full-width tight dictionary block
     ──────────────────────────────────────────────────────────────── */

  // Type tiers — keep them in step with the rest of the page.
  // FONT_DISPLAY / FONT_TITLE share the Investments-row category scale
  // (VENTURE FUNDS / BUSINESS MAJORITY / etc.) so the panel reads at
  // the same editorial weight as the rest of the landing page rather
  // than at the heavy display scale it had before.
  const FONT_DISPLAY = isMobile ? 'clamp(1.05rem, 4.6vw, 1.4rem)'
                                : 'clamp(1.5rem, 2.4vw, 2.25rem)';
  const FONT_TITLE   = FONT_DISPLAY;
  const FONT_BODY_LG = isMobile ? 'clamp(1.05rem, 4.2vw, 1.2rem)'
                                : 'clamp(1.125rem, 1.45vw, 1.5rem)';
  const FONT_BODY    = isMobile ? 'clamp(0.95rem, 3.8vw, 1.05rem)'
                                : 'clamp(1rem, 1.15vw, 1.25rem)';
  const FONT_LABEL   = isMobile ? '10.5px' : '12px';
  const FONT_NUMERAL = isMobile ? '14px'   : '16px';

  // Padding tokens. Identifont-tight, consistent across chapters.
  // X and Y use the same value so every cell has the same cushion.
  const CHAPTER_PAD_X = isMobile ? '20px' : '28px';
  const CHAPTER_PAD_Y = isMobile ? '20px' : '28px';
  const HEADER_PAD_Y  = isMobile ? '14px' : '18px';
  // Inter-element gap inside a cell.
  const GAP_TIGHT     = isMobile ? '10px' : '12px';
  const GAP_BLOCK     = isMobile ? '20px' : '28px';
  // Image-placeholder height. Same value across every image cell
  // so all image moments in the panel sit at identical height —
  // no visual variance.
  const IMG_HEIGHT    = isMobile ? 240 : 360;

  // Bubble design tokens — match the Built by Operators bubble and
  // the Investments accordion rows so this panel reads as part of
  // the same design system: 28px corners, hairline outline, charcoal
  // fill for image moments.
  const BUBBLE_RADIUS         = isMobile ? 22 : 28;
  const BUBBLE_BORDER         = '1px solid rgb(var(--c-text) / 0.22)';
  const BUBBLE_GAP            = isMobile ? '12px' : '16px';
  const BUBBLE_EYEBROW_PAD_Y  = isMobile ? '16px' : '20px';
  const BUBBLE_CONTENT_PAD_Y  = isMobile ? '32px' : '48px';
  const PANEL_INNER_PAD       = isMobile ? '12px' : '20px';
  // Charcoal fill for image bubbles — matches the BBO portrait tile
  // background exactly so the panel's image moments feel of-a-piece
  // with the operators portraits up top.
  const IMG_BG                = 'rgb(var(--c-invert-bg))';

  // Chapter header — cream bg, dark text, hairline rule below. No
  // icon — the label and the numeral carry the read on their own.
  const ChapterHeader = ({ num, title }) => (
    <div style={{
      display: 'flex',
      alignItems: 'baseline',
      gap: 12,
      padding: `${HEADER_PAD_Y} ${CHAPTER_PAD_X}`,
      borderBottom: ABOUT_HAIRLINE,
      color: TEXT_COLOR,
    }}>
      <span style={{
        ...ABOUT_MONO_STYLE,
        fontSize: FONT_NUMERAL,
        fontWeight: 700,
        letterSpacing: '0.06em',
      }}>
        {num}
      </span>
      <span style={{
        ...ABOUT_MONO_STYLE,
        fontSize: FONT_LABEL,
        letterSpacing: '0.18em',
        opacity: 0.5,
      }}>
        {title}
      </span>
    </div>
  );

  // KeyName highlight — wraps a string in a subtle inset pill so it
  // reads as a named token, matching the visual weight of AboutLink
  // hover-marker so all four key names in the premise sentence
  // (Folly Partners + Andrew + Chris + Tiny) read as the same kind
  // of object.
  const KeyName = ({ children }) => (
    <span style={{
      display: 'inline-block',
      padding: '0.04em 0.34em',
      margin: '0 -0.1em',
      borderRadius: '0.28em',
      background: 'rgb(var(--c-text) / 0.07)',
      boxShadow: 'inset 0 0 0 1px rgb(var(--c-text) / 0.10)',
      whiteSpace: 'nowrap',
      lineHeight: 1.05,
    }}>
      {children}
    </span>
  );

  // Icons — minimal geometric vocabulary.
  const ICON = isMobile ? 18 : 22;
  const IconAsterisk = (
    <svg width={ICON} height={ICON} viewBox="0 0 36 36" fill="currentColor" aria-hidden="true">
      <rect x="16" y="2" width="4" height="32" />
      <rect x="2" y="16" width="32" height="4" />
      <rect x="16" y="2" width="4" height="32" transform="rotate(45 18 18)" />
      <rect x="16" y="2" width="4" height="32" transform="rotate(-45 18 18)" />
    </svg>
  );
  const IconTwoCircles = (
    <svg width={ICON} height={ICON} viewBox="0 0 36 36" fill="currentColor" aria-hidden="true">
      <circle cx="13" cy="18" r="10" />
      <circle cx="23" cy="18" r="10" fillOpacity="0.55" />
    </svg>
  );
  const IconGrid = (
    <svg width={ICON} height={ICON} viewBox="0 0 36 36" fill="currentColor" aria-hidden="true">
      <rect x="3" y="3" width="13" height="13" />
      <rect x="20" y="3" width="13" height="13" />
      <rect x="3" y="20" width="13" height="13" />
      <rect x="20" y="20" width="13" height="13" />
    </svg>
  );
  const IconBars = (
    <svg width={ICON} height={ICON} viewBox="0 0 36 36" fill="currentColor" aria-hidden="true">
      <rect x="3" y="6"  width="22" height="4" />
      <rect x="3" y="16" width="22" height="4" />
      <rect x="3" y="26" width="22" height="4" />
      <path d="M27 12 L33 18 L27 24 Z" />
    </svg>
  );

  // Image placeholder cell. Subtle tint + corner ticks (photographer's
  // frame) + mono "IMG / 0X" label. Designed to read as INTENTIONALLY
  // empty — a slot waiting for real imagery — not a broken state.
  const ImagePlaceholder = ({ tag, ratio = '4 / 3' }) => (
    <div style={{
      position: 'relative',
      width: '100%',
      aspectRatio: ratio,
      background: 'rgb(var(--c-text) / 0.045)',
      overflow: 'hidden',
    }}>
      {/* Four corner ticks — 18px L-brackets at each corner. */}
      {['tl', 'tr', 'bl', 'br'].map(corner => {
        const isTop   = corner.startsWith('t');
        const isLeft  = corner.endsWith('l');
        const COLOR = 'rgb(var(--c-text) / 0.32)';
        return (
          <React.Fragment key={corner}>
            <span aria-hidden="true" style={{
              position: 'absolute',
              [isTop ? 'top' : 'bottom']: 8,
              [isLeft ? 'left' : 'right']: 8,
              width: 16, height: 1.5, background: COLOR,
            }} />
            <span aria-hidden="true" style={{
              position: 'absolute',
              [isTop ? 'top' : 'bottom']: 8,
              [isLeft ? 'left' : 'right']: 8,
              width: 1.5, height: 16, background: COLOR,
            }} />
          </React.Fragment>
        );
      })}
      {/* Small mono tag — top-left. */}
      <span style={{
        position: 'absolute',
        top: 14, left: 18,
        ...ABOUT_MONO_STYLE,
        fontSize: FONT_LABEL,
        letterSpacing: '0.18em',
        color: TEXT_COLOR,
        opacity: 0.5,
      }}>
        {tag}
      </span>
      {/* Subtle diagonal hairline so the cell reads as "frame, not flat." */}
      <svg aria-hidden="true" style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none',
      }} preserveAspectRatio="none" viewBox="0 0 100 100">
        <line x1="0" y1="0" x2="100" y2="100" stroke="rgb(var(--c-text) / 0.08)" strokeWidth="0.4" />
      </svg>
    </div>
  );

  return (
    <section
      ref={sectionRef}
      id="about"
      data-about-lock
      data-screen-label="About"
      className="relative px-5 sm:px-8 md:px-10 py-20 sm:py-44 md:py-56"
      style={{ zIndex: 5 }}
    >
      <div
        ref={cardRevealRef}
        className="liquid-glass relative w-full max-w-[1280px] mx-auto rounded-[2rem] md:rounded-[2.75rem] overflow-hidden"
        style={{
          zIndex: 2,
          opacity: 0,
          transform: 'translate3d(0, 56px, 0)',
          filter: 'blur(14px)',
          willChange: 'opacity, transform, filter',
        }}
      >
        <span aria-hidden="true" className="refraction-ring" />

        <div className="relative z-10" style={{
          color: TEXT_COLOR,
          padding: PANEL_INNER_PAD,
        }}>

          {/* ════════════════════════════════════════════════════════
              ABOUT — Stack of rounded "bubble" rows.

              Inspired by the Built by Operators (BBO) bubble pattern
              and the Investments accordion rows: each section is a
              rounded outlined container (`borderRadius: 28`,
              `border: 1px solid rgb(var(--c-text) / 0.18)`), stacked
              vertically inside the liquid-glass capsule with a
              consistent BUBBLE_GAP between them. Image moments are
              charcoal-filled rounded bubbles that match the BBO
              portrait fill exactly.

              All text inside each bubble is centered, so whitespace
              is symmetric on both sides — fixes the lopsided right
              edge that the previous left-aligned blocks had.

                Cover image     rounded charcoal bubble
                Bubble 01       Premise (lead + gloss)
                Bubble 02       What we do (TINY then FOLLY stacked)
                Image break     rounded charcoal bubble
                Bubble 03       The point (context + closing)
                Bubble 04       Definition (Folly stamp)
             ──────────────────────────────────────────────── */}

          <div style={{
            display: 'flex',
            flexDirection: 'column',
            gap: BUBBLE_GAP,
          }}>

            {/* ── Bubble 01 — Premise (FULLY INVERTED) ───────────
                Solid charcoal fill, cream text throughout. Stand-
                alone rounded bubble — separate from Bubble 02
                below it (with a clean BUBBLE_GAP between), so
                the panel reads as four distinct editorial cards
                rather than a fused custom shape. The dark bubble
                is the panel's strong opening contrast. */}
            <div ref={bubble1Ref} className="about-link-on-dark about-invert-pane" style={{
              borderRadius: BUBBLE_RADIUS,
              overflow: 'hidden',
              ...BUBBLE_REVEAL_INIT,
            }}>
              <div style={{
                padding: `${BUBBLE_CONTENT_PAD_Y} ${CHAPTER_PAD_X}`,
                textAlign: 'center',
              }}>
                <p style={{
                  margin: '0 auto',
                  fontSize: FONT_TITLE,
                  fontWeight: 800,
                  lineHeight: 1.1,
                  letterSpacing: '-0.03em',
                  color: 'rgb(var(--c-invert-text))',
                  textWrap: 'balance',
                  maxWidth: '32ch',
                }}>
                  Folly Partners is the family office of{' '}
                  <AboutLink href="https://www.linkedin.com/in/awilkinson/" text="Andrew Wilkinson" />{' '}
                  and{' '}
                  <AboutLink href="https://x.com/_Sparling_" text="Chris Sparling," />
                  {' '}the founders of{' '}
                  <AboutLink href="https://www.tiny.com" text="Tiny" />.
                </p>
              </div>
              {/* Family-office gloss — promoted from a hidden footer
                  strip to a prominent dictionary entry that echoes the
                  hero's definition motif (headword · pronunciation ·
                  part-of-speech · definition). No hairline: with the
                  divider gone it reads as the bubble's second beat, not
                  a cordoned-off footnote. Top padding is 0 so the gap
                  above is carried by the statement block's own bottom
                  padding (BUBBLE_CONTENT_PAD_Y), keeping the bubble
                  vertically symmetric. */}
              <div style={{
                padding: `0 ${CHAPTER_PAD_X} ${BUBBLE_CONTENT_PAD_Y}`,
                textAlign: 'center',
              }}>
                <p style={{
                  margin: '0 auto 0.45em',
                  fontSize: 'clamp(1.15rem, 1.85vw, 1.5rem)',
                  fontWeight: 800,
                  letterSpacing: '-0.02em',
                  lineHeight: 1.05,
                  color: 'rgb(var(--c-accent-soft))',
                }}>
                  family office
                  <span className="font-serif" style={{ fontStyle: 'italic', fontWeight: 400, fontSize: '0.68em', letterSpacing: 0, color: 'rgb(var(--c-invert-text) / 0.66)', marginLeft: '0.5em' }}>
                    /ˈfam·ə·lee ˈä·fəs/
                  </span>
                  <span style={{ fontWeight: 400, fontSize: '0.6em', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgb(var(--c-invert-text) / 0.62)', marginLeft: '0.5em' }}>
                    n.
                  </span>
                </p>
                <p className="font-serif italic font-normal" style={{
                  margin: '0 auto',
                  fontSize: 'clamp(1.05rem, 1.5vw, 1.3rem)',
                  lineHeight: 1.45,
                  letterSpacing: '-0.008em',
                  color: 'rgb(var(--c-invert-text) / 0.75)',
                  maxWidth: '38ch',
                  textWrap: 'balance',
                }}>
                  rich-people speak for a holding company that manages personal money.
                </p>
              </div>
            </div>

            {/* ── Bubble 02 — What we do ───────────────────────
                Magazine treatment: TINY and FOLLY become visual
                design objects, not inline words. Each wordmark
                sits at poster scale (clamp ~3rem → ~6rem) — the
                kind of oversized display type that magazine
                covers and editorial spreads use when there's no
                photograph and the typography itself has to be
                the visual content. Each wordmark is followed by
                a small editorial caption.

                Reads like an Investments accordion row treatment
                applied to the panel: big confident headline,
                short caption, generous breathing. Same family
                as the rest of the page rather than yet another
                container of text. */}
            <div ref={bubble2Ref} style={{
              borderRadius: BUBBLE_RADIUS,
              border: BUBBLE_BORDER,
              overflow: 'hidden',
              ...BUBBLE_REVEAL_INIT,
            }}>
              <div style={{
                paddingTop: isMobile ? 40 : 96,
                paddingBottom: isMobile ? 40 : 96,
                paddingLeft: CHAPTER_PAD_X,
                paddingRight: CHAPTER_PAD_X,
                textAlign: 'center',
              }}>
                {/* TINY — wordmark as design object + caption */}
                <h3 style={{
                  margin: 0,
                  fontSize: 'clamp(2.75rem, 8vw, 6rem)',
                  fontWeight: 800,
                  lineHeight: 0.95,
                  letterSpacing: '-0.05em',
                  color: TEXT_COLOR,
                }}>
                  TINY
                </h3>
                <p style={{
                  margin: `${isMobile ? '14px' : '20px'} auto 0`,
                  fontSize: 'clamp(1.25rem, 2vw, 1.75rem)',
                  lineHeight: 1.5,
                  letterSpacing: '-0.018em',
                  color: TEXT_COLOR,
                  opacity: 0.78,
                  maxWidth: '40ch',
                  textAlign: 'center',
                  textWrap: 'balance',
                }}>
                  Buys companies and operates them.
                </p>

                {/* Generous editorial breath between the two
                    poster moments — the silence is what makes
                    each wordmark land as its own scene. Mobile
                    gets a tighter gap (40 vs 96) so two short
                    captions don't float in a sea of dead space
                    on a small screen. */}
                <div style={{ height: isMobile ? 40 : 96 }} />

                {/* FOLLY — wordmark as design object + caption */}
                <h3 style={{
                  margin: 0,
                  fontSize: 'clamp(2.75rem, 8vw, 6rem)',
                  fontWeight: 800,
                  lineHeight: 0.95,
                  letterSpacing: '-0.05em',
                  color: TEXT_COLOR,
                }}>
                  FOLLY
                </h3>
                <p style={{
                  margin: `${isMobile ? '14px' : '20px'} auto 0`,
                  fontSize: 'clamp(1.25rem, 2vw, 1.75rem)',
                  lineHeight: 1.5,
                  letterSpacing: '-0.018em',
                  color: TEXT_COLOR,
                  opacity: 0.78,
                  maxWidth: '54ch',
                  textAlign: 'center',
                  textWrap: 'balance',
                }}>
                  Does the opposite: holds their personal money in stocks, bonds, real estate, fund stakes, and the occasional quiet minority — patiently, and mostly out of the way.
                </p>
              </div>
            </div>

            {/* ── Bubble 03 — The point + Folly definition ─────
                The closing bubble, now built EXACTLY like Bubble
                01: a main padded content area, a flat full-width
                hairline straight across, then a small italic-
                serif footer gloss. Bubble 01 carries the premise
                statement + "family office:" gloss; this one
                carries the "In short…" stand-first + the "folly:"
                definition. The definition is no longer its own
                Bubble 04 box — it's integrated into this box as
                the footer below the divider, so the panel closes
                on a matched pair of cards rather than a separate
                trailing strip. Divider alpha matches BUBBLE_BORDER
                (0.18) — the light-surface equivalent of Bubble
                01's cream 0.18 rule. */}
            <div ref={bubble3Ref} style={{
              borderRadius: BUBBLE_RADIUS,
              border: BUBBLE_BORDER,
              overflow: 'hidden',
              ...BUBBLE_REVEAL_INIT,
            }}>
              <div style={{
                paddingTop: isMobile ? 40 : 88,
                paddingBottom: isMobile ? 40 : 88,
                paddingLeft: CHAPTER_PAD_X,
                paddingRight: CHAPTER_PAD_X,
                textAlign: 'center',
              }}>
                <p style={{
                  margin: '0 auto',
                  fontSize: 'clamp(1.25rem, 2vw, 1.75rem)',
                  lineHeight: 1.5,
                  letterSpacing: '-0.018em',
                  color: TEXT_COLOR,
                  opacity: 0.78,
                  maxWidth: '54ch',
                  textAlign: 'center',
                  textWrap: 'balance',
                }}>
                  <span style={{ fontWeight: 800, opacity: 1 }}>In short…</span>{' '}
                  Andrew and Chris made a bunch of money building things. The risk is that they get bored, get clever, and lose it.
                </p>
                <p style={{
                  margin: '0.65em auto 0',
                  fontSize: 'clamp(1.25rem, 2vw, 1.75rem)',
                  lineHeight: 1.5,
                  letterSpacing: '-0.018em',
                  color: TEXT_COLOR,
                  opacity: 0.78,
                  maxWidth: '54ch',
                  textAlign: 'center',
                  textWrap: 'balance',
                }}>
                  <span style={{ fontWeight: 800, opacity: 1, whiteSpace: 'nowrap' }}>
                    We exist to make that harder.
                  </span>
                  {' '}To prevent an act of Folly.
                </p>
              </div>
              {/* Folly gloss — promoted to a prominent dictionary
                  entry mirroring Bubble 01's family-office entry, and
                  capitalised: "Folly" is the firm's name doing double
                  duty as the dictionary headword, so the pun lands.
                  No hairline; top padding 0 so the statement block's
                  own bottom padding carries the gap and the bubble
                  stays vertically symmetric. */}
              <div style={{
                padding: `0 ${CHAPTER_PAD_X} ${isMobile ? 40 : 88}px`,
                textAlign: 'center',
              }}>
                <p style={{
                  margin: '0 auto 0.45em',
                  fontSize: 'clamp(1.15rem, 1.85vw, 1.5rem)',
                  fontWeight: 800,
                  letterSpacing: '-0.02em',
                  lineHeight: 1.05,
                  color: 'rgb(var(--c-accent))',
                }}>
                  Folly
                  <span className="font-serif" style={{ fontStyle: 'italic', fontWeight: 400, fontSize: '0.68em', letterSpacing: 0, color: 'rgb(var(--c-text) / 0.4)', marginLeft: '0.5em' }}>
                    /ˈfä·lee/
                  </span>
                  <span style={{ fontWeight: 400, fontSize: '0.6em', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgb(var(--c-text) / 0.34)', marginLeft: '0.5em' }}>
                    n.
                  </span>
                </p>
                <p className="font-serif italic font-normal" style={{
                  margin: '0 auto',
                  fontSize: 'clamp(1.05rem, 1.5vw, 1.3rem)',
                  lineHeight: 1.45,
                  letterSpacing: '-0.008em',
                  color: 'rgb(var(--c-text) / 0.6)',
                  maxWidth: '30ch',
                  textWrap: 'balance',
                }}>
                  lack of good sense; foolishness.
                </p>
              </div>
            </div>

          </div>

        </div>
      </div>
    </section>
  );
}

function FeatureCardShell({ children, index, className = '', style }) {
  const ref = useRef2(null);
  const inView = useInView2(ref, { once: true, margin: '-100px' });
  return (
    <m2.div
      ref={ref}
      className={`relative overflow-hidden rounded-2xl ${className}`}
      style={style}
      initial={{ scale: 0.95, opacity: 0 }}
      animate={inView ? { scale: 1, opacity: 1 } : { scale: 0.95, opacity: 0 }}
      transition={{ ...ARC_SPRING, delay: index * 0.15 }}
    >
      {children}
    </m2.div>
  );
}

function IconPlaceholder({ label }) {
  // Stylized square stand-in for the feature icon
  return (
    <div
      className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center"
      style={{
        background: 'linear-gradient(135deg, #2a2723 0%, #15130f 100%)',
        border: '1px solid rgb(var(--c-text) / 0.2)',
      }}
      aria-label={label}
    >
      <div
        className="w-4 h-4 rounded-sm"
        style={{
          background: 'linear-gradient(135deg, rgb(var(--c-bg2)) 0%, #A8A8A2 100%)',
          opacity: 0.9,
        }}
      />
    </div>
  );
}

function ChecklistItem({ children }) {
  return (
    <li className="flex items-start gap-2.5 text-xs sm:text-sm leading-relaxed" style={{ color: 'rgb(var(--c-text) / 0.7)' }}>
      <span className="mt-0.5 shrink-0" style={{ color: TEXT_COLOR }}>
        <Check size={14} strokeWidth={2.5} />
      </span>
      <span>{children}</span>
    </li>
  );
}

function LearnMoreLink() {
  return (
    <a
      href="#"
      className="group inline-flex items-center gap-2 text-xs sm:text-sm mt-auto"
      style={{ color: TEXT_COLOR }}
    >
      <span
        className="pb-0.5"
        style={{ borderBottom: '1px solid rgb(var(--c-text) / 0.3)' }}
      >
        Learn more
      </span>
      <ArrowRight size={14} style={{ transform: 'rotate(-45deg)' }} />
    </a>
  );
}

function TextCard({ index, number, title, items }) {
  return (
    <FeatureCardShell index={index} className="p-5 sm:p-6 flex flex-col gap-5 lg:h-full" style={{ backgroundColor: TILE_BG }}>
      <div className="flex items-start justify-between">
        <IconPlaceholder label={title} />
        <span className="text-xs font-mono" style={{ color: 'rgb(var(--c-text) / 0.45)' }}>{number}</span>
      </div>
      <h3 className="text-lg sm:text-xl font-normal leading-tight" style={{ color: TEXT_COLOR }}>
        {title}
      </h3>
      <ul className="flex flex-col gap-3">
        {items.map((it, i) => (
          <ChecklistItem key={i}>{it}</ChecklistItem>
        ))}
      </ul>
      <div className="mt-auto pt-4">
        <LearnMoreLink />
      </div>
    </FeatureCardShell>
  );
}

function VideoCard({ index }) {
  return (
    <FeatureCardShell index={index} className="lg:h-full min-h-[340px] bg-black">
      <LazyVideo
        className="absolute inset-0 w-full h-full object-cover"
        src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260406_133058_0504132a-0cf3-4450-a370-8ea3b05c95d4.mp4"
      />
      <div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/70 pointer-events-none" />
      <div className="absolute bottom-0 left-0 right-0 p-5 sm:p-6">
        <h3 className="text-xl sm:text-2xl md:text-3xl font-normal" style={{ color: 'rgb(var(--c-invert-text))', lineHeight: 1.05 }}>
          Your creative canvas.
        </h3>
      </div>
    </FeatureCardShell>
  );
}

/* ------------------ PEOPLE — MOBILE-ONLY HORIZONTAL CAROUSEL ------------------
   Mobile-only scroll-jacked horizontal carousel. The wrapper is N×100vh
   tall; inside, a `position: sticky` child pins to the viewport top for
   the duration of that scroll. The user's vertical scroll progress
   (0..1) maps to a positionFloat (0..N-1), which drives:
     - a horizontal translateX on the strip so portrait[positionFloat]
       sits in the optical centre of the viewport, neighbours bleeding
       in from both edges (Andrew→Chris→Ben→Kurtis as you scroll down)
     - a continuous opacity falloff: active portrait at 1.0, fully-off
       neighbours at 0.55 (matches the existing dim treatment)
     - a sharp opacity crossfade on the matching name + descriptor
       below, so the editorial copy swaps cleanly as each person
       crosses the centre
   No JS animation timer — every visual is a deterministic function of
   scroll position, so motion stays in lock-step with the user's finger
   and reverses cleanly on scroll-up.
   Desktop is untouched: this component is only rendered inside a
   `.sm:hidden` wrapper, which is `display:none` from the sm (640px)
   breakpoint up. The `-mx-5` lets the strip escape the section's
   horizontal padding so portraits can bleed to the actual viewport
   edges, not the section's content edge. */
function PeopleMobileCarousel({ people }) {
  const N = people.length;
  const wrapperRef = useRef2(null);
  const [progress, setProgress] = React.useState(0);
  /* entranceTop tracks the wrapper's getBoundingClientRect().top in px.
     Drives the entrance blur-fade-up reveal. We compute it here as
     React state (rather than mutating styles imperatively via a
     separate applyBlurFadeUp) because the carousel re-renders on every
     scroll tick (`progress` changes), and React would re-apply inline
     styles each render — clobbering any direct DOM mutations and
     breaking the reveal animation. Routing the reveal through React
     state means the styles flow through the normal render cycle and
     stay coherent. Initial value is large so first paint shows the
     elements in their hidden/lifted/blurred state until the first
     scroll tick measures real position. */
  const [entranceTop, setEntranceTop] = React.useState(99999);

  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    let raf = null;
    const tick = () => {
      const rect = el.getBoundingClientRect();
      // Track wrapper top for the entrance reveal (used in render).
      setEntranceTop(rect.top);
      const totalScrollable = el.offsetHeight - window.innerHeight;
      if (totalScrollable <= 0) {
        raf = null;
        return;
      }
      const scrolled = -rect.top;
      const p = Math.max(0, Math.min(1, scrolled / totalScrollable));
      setProgress(p);
      raf = null;
    };
    const onScroll = () => {
      if (raf == null) raf = requestAnimationFrame(tick);
    };
    // People-section snap uses `el.offsetHeight - window.innerHeight`
    // as totalScrollable; URL-bar collapse would jitter the mapping.
    const onResize = stableMobileResize(tick);
    tick();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  /* Snap-to-person scroll: when the user stops scrolling INSIDE the
     pin region, ease them to the nearest person's centred position.
     Uses the modern `scrollend` event when available (Chrome 114+,
     Safari 18+, all current mobile browsers) so the snap fires after
     momentum scrolling settles, never mid-flick. Falls back to a
     220ms debounced scroll listener for older engines.

     Why JS rather than CSS scroll-snap: the page itself is the scroll
     container (driven by Lenis), and applying mandatory CSS snap
     would conflict with Lenis's smoothing and disrupt scroll
     behavior site-wide. Targeting just this section's snap points
     in JS keeps the rest of the page untouched. */
  React.useEffect(() => {
    const trySnap = () => {
      const el = wrapperRef.current;
      if (!el) return;
      const totalScrollable = el.offsetHeight - window.innerHeight;
      if (totalScrollable <= 0) return;
      const rect = el.getBoundingClientRect();
      const scrolled = -rect.top;
      // Only snap inside the pin window; outside, leave scroll alone.
      if (scrolled < 0 || scrolled > totalScrollable) return;
      const p = scrolled / totalScrollable;
      const float = p * (N - 1);
      const nearest = Math.round(float);
      const targetP = nearest / (N - 1);
      const targetScrolled = targetP * totalScrollable;
      // Already within 2px of a snap point — don't ricochet on the
      // browser's own scrollend event right after we just snapped.
      if (Math.abs(scrolled - targetScrolled) < 2) return;
      const targetY = window.scrollY + (targetScrolled - scrolled);
      window.scrollTo({ top: targetY, behavior: 'smooth' });
    };

    if ('onscrollend' in window) {
      window.addEventListener('scrollend', trySnap, { passive: true });
      return () => window.removeEventListener('scrollend', trySnap);
    }
    let timer = null;
    const onScroll = () => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(trySnap, 220);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (timer) clearTimeout(timer);
    };
  }, [N]);

  /* Entrance reveal — computed from `entranceTop` (the wrapper's
     getBoundingClientRect().top) on every render. Because this routes
     through React's normal render cycle (state → JSX → DOM), the
     reveal styles never get clobbered by re-renders the way direct
     DOM mutations from a separate applyBlurFadeUp helper would.

     Timings mirror the desktop "BUILT BY OPERATORS" reveal:
       headline: 1.00 → 0.30   (matches desktop headlineWrapRef)
       strip:    0.70 → -0.05  (matches desktop gridWrapRef)
       name+role lags slightly so the cascade reads top→bottom.
     The strong ease-out (1-(1-t)^4) means the reveal needs to span
     a wide rect.top range or it will complete off-screen — these
     values place the bulk of the animation in scroll positions where
     the elements are actually in viewport, so the lift + blur is
     visible to the user as the section rises. */
  const vh = typeof window !== 'undefined' ? (window.innerHeight || 800) : 800;
  const computeReveal = (startVh, endVh) => {
    const start = vh * startVh;
    const end = vh * endVh;
    const span = start - end;
    if (span <= 0) return 1;
    const raw = (start - entranceTop) / span;
    const t = Math.max(0, Math.min(1, raw));
    return 1 - Math.pow(1 - t, 4);
  };
  const revealStyle = (t, dyPx, blurPx) => ({
    opacity: t,
    transform: `translate3d(0, ${((1 - t) * dyPx).toFixed(2)}px, 0)`,
    filter: `blur(${((1 - t) * blurPx).toFixed(2)}px)`,
    willChange: 'opacity, transform, filter',
  });
  /* Windows now mirror the DESKTOP BUILT BY OPERATORS reveal
     EXACTLY (they previously claimed to but didn't — the old
     1.00→0.30 / 0.70→-0.05 spans were ~2× wider and completed
     while the section was barely on screen, so the headline
     oozed in instead of popping). Desktop: headline 0.95→0.55,
     grid 0.85→0.40 (applyBlurFadeUp defaults). Same easeOutQuart
     curve `computeReveal` already uses. Tighter span = punchy
     pop; later completion = it lands while clearly in view.
     name/role lags grid slightly so the cascade reads top→bottom
     (headline → portraits → byline). */
  const headlineT = computeReveal(0.95, 0.55);
  const stripT    = computeReveal(0.85, 0.40);
  const nameRoleT = computeReveal(0.78, 0.30);

  const positionFloat = progress * (N - 1);
  // Each portrait is 76vw — wide enough that the active one dominates
  // the screen, with ~12vw of bleed on each side for the neighbours to
  // peek through. NO inter-portrait gap: the four portraits read as
  // one continuous bar across the screen, with only the outermost
  // corners rounded (Andrew's left, Kurtis's right) — inner edges
  // butt flush.
  const PORTRAIT_VW = 76;
  // Centre portrait[positionFloat] in the viewport. With gap = 0,
  // each step is exactly PORTRAIT_VW.
  //   stripTx = 50vw - (positionFloat * PORTRAIT_VW + PORTRAIT_VW/2)
  // At progress 0 (Andrew), stripTx = 12vw → Andrew sits centred with
  // 12vw to the left and Chris's left edge butting at viewport 88vw
  // (12vw of Chris peeks through). At progress 1 (Kurtis), stripTx
  // ≈ -216vw → Kurtis centred, the rest fully off-screen left.
  const stripTx = 50 - (positionFloat * PORTRAIT_VW + PORTRAIT_VW / 2);

  const RULE_COLOR = 'rgb(var(--c-text) / 0.16)';

  return (
    <div
      ref={wrapperRef}
      className="-mx-5"
      /* Wrapper height = 100vh sticky pin + (N-1) × VH_PER_TRANSITION
         of scroll travel. 46vh per transition (was 60, was 100): the
         dwell-plateau opacity means each portrait now holds full
         visibility for most of its turn, so the inter-person travel
         no longer needs to be long to avoid a murky middle — a
         shorter step makes the carousel feel snappy and removes the
         "long timeout between them". The JS snap still lands each
         person dead-centre. */
      style={{ height: `${100 + (N - 1) * 46}vh` }}
    >
      <div
        className="sticky top-0 overflow-hidden"
        style={{
          height: '100vh',
          width: '100vw',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          /* All spacing tokens are clamped against viewport height so
             the composition tightens on iPhone SE-class phones (568px
             tall) and breathes on iPhone 12+ phones (844px+). With
             flex justify-content:center, residual space distributes
             evenly above and below — keeps the carousel optically
             centred at every aspect. */
          gap: 'clamp(0.75rem, 2vh, 1.5rem)',
          /* paddingTop clears the fixed navbar pill (top-3 = 12px +
             ~36px pill ≈ 48px). clamp gives ~3rem on short phones
             and ~5rem on tall phones so the headline never feels
             crowded against the pill. */
          paddingTop: 'clamp(3rem, 6vh, 5rem)',
          paddingBottom: 'clamp(1rem, 2vh, 2rem)',
        }}
      >
        {/* Headline — pinned with the carousel so "BUILT BY OPERATORS"
            stays visible the whole time the carousel is locked. Uses
            the SAME clamp(2rem, 8vw, 7.5rem) as "Our Investments" and
            the desktop "BUILT BY OPERATORS" so title sizing reads as
            consistent across the whole page. On a 360px portrait phone
            this resolves to ~32px (8vw clamped at min 2rem). */}
        <h2
          className="font-extrabold text-center select-none uppercase px-5"
          style={{
            color: TEXT_COLOR,
            fontSize: 'clamp(2rem, 8vw, 7.5rem)',
            lineHeight: 0.95,
            letterSpacing: '-0.05em',
            textWrap: 'balance',
            flex: 'none',
            margin: 0,
            ...revealStyle(headlineT, 60, 18),
          }}
        >
          Our Team
        </h2>

        {/* Horizontal portrait strip — translates with scroll progress.
            Height clamps so the strip uses real estate proportionally:
            on iPhone SE (568px) it's ~205px, on iPhone 12 (844px) it's
            ~304px, capped at 360px on tall phones. Aspect-1/1 portraits
            inside take this height (so they're slightly landscape on
            very narrow screens, square on wider ones — the image
            object-cover absorbs the difference cleanly). */}
        <div className="relative" style={{ height: 'clamp(180px, 36vh, 360px)', flex: 'none', ...revealStyle(stripT, 60, 18) }}>
          <div
            className="absolute top-0 left-0 flex items-center"
            style={{
              transform: `translate3d(${stripTx}vw, 0, 0)`,
              willChange: 'transform',
              height: '100%',
            }}
          >
            {people.map((p, i) => {
              const distance = Math.abs(i - positionFloat);
              // DWELL-PLATEAU opacity. The old `max(0.35, 1 - d*1.3)`
              // peaked at full brightness ONLY at the exact integer
              // centre — so for most of the travel between two people
              // BOTH were dim (the "large amount of time where neither
              // picture has full visibility" the user reported).
              // Instead: hold the centred portrait at full opacity
              // across a DWELL band, then a short, sharp crossfade to
              // the dim floor. Net: each portrait reads as fully
              // present for the bulk of its turn, with a quick
              // hand-off — no murky dead zone in the middle.
              const DWELL = 0.32;  // ± band held at full opacity
              const FADE  = 0.20;  // crossfade width past the dwell
              const FLOOR = 0.32;  // resting dim for off-centre portraits
              let opacity;
              if (distance <= DWELL) {
                opacity = 1;
              } else {
                const f = Math.min(1, (distance - DWELL) / FADE);
                // easeInOutCubic on the crossfade so the hand-off is
                // crisp in the middle but lands softly at both ends.
                const e = f < 0.5
                  ? 4 * f * f * f
                  : 1 - Math.pow(-2 * f + 2, 3) / 2;
                opacity = Math.max(FLOOR, 1 - e * (1 - FLOOR));
              }
              // Outermost corners only: Andrew (i=0) gets left-side
              // corners, Kurtis (i=N-1) gets right-side corners,
              // everyone in between has square corners so the strip
              // reads as one connected bar. Order in CSS shorthand is
              // top-left top-right bottom-right bottom-left.
              const isFirst = i === 0;
              const isLast  = i === N - 1;
              const r = '20px';
              const borderRadius = `${isFirst ? r : '0'} ${isLast ? r : '0'} ${isLast ? r : '0'} ${isFirst ? r : '0'}`;
              return (
                <div
                  key={p.name}
                  style={{
                    width: `${PORTRAIT_VW}vw`,
                    height: '100%',
                    aspectRatio: '1 / 1',
                    flex: 'none',
                    opacity,
                    background: 'rgb(var(--c-invert-bg))',
                    borderRadius,
                    overflow: 'hidden',
                    willChange: 'opacity',
                  }}
                >
                  <img
                    src={p.photo}
                    alt={p.name}
                    draggable="false"
                    className="people-photo w-full h-full object-cover select-none pointer-events-none"
                    style={{
                      ...p.photoStyle,
                      transformOrigin: 'center top',
                    }}
                  />
                </div>
              );
            })}
          </div>
        </div>

        {/* Name + role — sharp crossfade tied to the active person.
            Height clamps so the row stays compact on tiny phones and
            doesn't dominate vertical real estate. */}
        <div className="relative px-5" style={{ height: 'clamp(2.5rem, 7vh, 3.25rem)', flex: 'none', ...revealStyle(nameRoleT, 60, 18) }}>
          {people.map((p, i) => {
            const distance = Math.abs(i - positionFloat);
            // Sharp crossfade: opacity hits 0 at distance ≥ 0.5, so two
            // bylines never read at half-strength simultaneously.
            const o = Math.max(0, 1 - distance * 2);
            return (
              <div
                key={p.name}
                className="absolute inset-0 text-center"
                style={{ opacity: o, pointerEvents: 'none' }}
              >
                <div
                  style={{
                    fontWeight: 700,
                    fontSize: '1.0625rem',
                    color: TEXT_COLOR,
                    letterSpacing: '-0.005em',
                  }}
                >
                  {p.name}
                </div>
                <div
                  className="mt-1 uppercase"
                  style={{
                    color: 'rgb(var(--c-text) / 0.45)',
                    fontSize: '0.6875rem',
                    letterSpacing: '0.18em',
                  }}
                >
                  {p.role}
                </div>
              </div>
            );
          })}
        </div>

        {/* Descriptor — all four cards stacked in a single CSS grid cell
            so the wrapper auto-sizes to the tallest, then opacity +
            ruleScale interpolate against scroll distance. The frame
            rules draw in/out continuously rather than animating on a
            timer, so the motion stays glued to the finger.
            mx-auto + maxWidth caps the line length on landscape mobile
            / wider phones so justified text doesn't stretch into a
            uncomfortable measure (>75 chars/line is the readability
            cliff). On a typical 360-430px wide phone, mx-5 still wins
            (the maxWidth doesn't kick in below 22rem); on a 600px+
            mobile landscape, the maxWidth keeps the column tight. */}
        <div
          style={{
            display: 'grid',
            flex: 'none',
            /* Padding gives the inner descriptor 20px of inset from
               viewport edges (matching every other element on the page).
               maxWidth caps line length on landscape mobile and tablet-
               edge phones so justified text never stretches into an
               uncomfortable >75-char measure. mx auto keeps the block
               centred when maxWidth kicks in on wider screens. */
            width: '100%',
            maxWidth: '32rem',
            marginLeft: 'auto',
            marginRight: 'auto',
            paddingLeft: '1.25rem',
            paddingRight: '1.25rem',
            boxSizing: 'border-box',
          }}
        >
          {people.map((p, i) => {
            const distance = Math.abs(i - positionFloat);
            const o = Math.max(0, 1 - distance * 2);
            const ruleScale = Math.max(0, 1 - distance * 2);
            return (
              <div
                key={p.name}
                style={{
                  gridArea: '1 / 1',
                  opacity: o,
                  pointerEvents: o > 0.5 ? 'auto' : 'none',
                }}
              >
                <div
                  style={{
                    height: 1,
                    background: RULE_COLOR,
                    transformOrigin: 'center',
                    transform: `scaleX(${ruleScale})`,
                    willChange: 'transform',
                  }}
                />
                {/* No tag/byline row here on mobile — the active
                    person's name + role already sits as a single
                    block beneath the portrait above, so repeating it
                    inside the framing rules would be redundant. The
                    rules just frame the body copy directly. */}
                <p
                  style={{
                    color: TEXT_COLOR,
                    /* Typography matched to the About panel prose
                       (sections.jsx:2189-2192) so the People descriptor
                       reads in the same editorial voice as the rest of
                       the body copy on the page. The previous 13-16px
                       sizing felt undersized next to the 17px name
                       directly above and broke the body-prose rhythm
                       established by the liquid-glass About slides.
                       Floor of 1.15rem (18.4px) is the same prose floor
                       used everywhere else on the page; the 2.05vw
                       middle stop only kicks in on large tablet+ widths
                       so on a typical 390-430px phone the floor wins. */
                    fontWeight: 500,
                    fontSize: 'clamp(1.15rem, 2.05vw, 1.65rem)',
                    lineHeight: 1.38,
                    letterSpacing: '-0.012em',
                    /* Centered body matches the desktop descriptor
                       treatment so the copy reads as a single editorial
                       voice across breakpoints. textWrap: balance
                       evens out line lengths so the block sits as a
                       symmetric pull-quote rather than a left-leaning
                       paragraph. */
                    textAlign: 'center',
                    textWrap: 'balance',
                    paddingTop: '0.875rem',
                    paddingBottom: '0.875rem',
                    margin: 0,
                  }}
                >
                  {p.description}
                </p>
                <div
                  style={{
                    height: 1,
                    background: RULE_COLOR,
                    transformOrigin: 'center',
                    transform: `scaleX(${ruleScale})`,
                    willChange: 'transform',
                  }}
                />
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}


/* ------------------------------- PEOPLE ------------------------------- */
function Features() {
  const people = [
    {
      name: 'Andrew Wilkinson',
      role: 'Co-Founder',
      photo: 'media/people/andrew.png?v=6',
      photoStyle: { objectPosition: '50% 0%' },
      description:
        'Co-founder of Tiny. Has personally committed enough acts of folly to fund the firm’s existence.',
    },
    {
      name: 'Chris Sparling',
      role: 'Co-Founder',
      photo: 'media/people/chris.png?v=7',
      photoStyle: {
        objectPosition: '50% 0%',
      },
      description:
        'Co-founder of Tiny. The reason most of the truly bad ideas don’t make it too far.',
    },
    {
      name: 'Ben Moore',
      role: 'President',
      photo: 'media/people/ben.png?v=11',
      photoStyle: { objectPosition: '50% 0%' },
      description:
        'Fifteen years with Andrew and Chris — from copywriter to the C-suite of a public company. He has sat on every side of the table, and backs every deal we make with his own money.',
    },
    {
      name: 'Kurtis Vallee',
      role: 'Chief Financial Officer',
      photo: 'media/people/kurtis.png?v=7',
      photoStyle: {
        objectPosition: '50% 0%',
        transform: 'translateY(-1.5%) scale(1.08)',
      },
      description:
        'Turns interesting conversations into clean deals, while also ensuring we don’t fly into the side of a mountain. Also puts his money where his mouth is, and cuts checks into every deal.',
    },
  ];

  // Resting state is NO selection (null) — nobody is singled out on
  // first paint. Hovering a tile makes it the active one (lifts it,
  // dims the rest, reveals its bio); leaving the portrait block
  // resets to null so every tile returns to the neutral state rather
  // than leaving the last-hovered person stuck active.
  const [hovered, setHovered] = React.useState(null);

  // ---- SCROLL-DRIVEN BLUR-FADE-UP REVEAL ----
  // As the hand catcher + falling figures recede out of view above
  // (peopleRect.top vh*1.0 → vh*0.5), this section's three blocks
  // blur-fade up from below in a clean cascade — headline first,
  // portraits second, descriptor third. Each layer is keyed off the
  // PEOPLE section's getBoundingClientRect().top via the shared
  // applyBlurFadeUp helper (passing `getRefTop`), so the cascade
  // synchronises with the hand recede above rather than each
  // element's own scroll position.
  //
  // Timings sit ~0.10vh earlier than the original pass so the words
  // unblur sooner — feels less like "waiting for them to settle"
  // and more like the next section is taking the baton smoothly.
  const headlineWrapRef = useRef2(null);
  const gridWrapRef     = useRef2(null);

  React.useEffect(() => {
    // Match the Investments reveal exactly so the lift+blur cascade
    // reads the same on both sections — same dyPx/blurPx (defaults
    // 60/18 from applyBlurFadeUp), same tight scroll range (~40vh),
    // and keyed off each element's own top (no getRefTop) so the
    // animation runs as the user scrolls the headline / grid into
    // view, not as the section as a whole moves. Tight range = punchy
    // pop-up rather than a slow gradual fade.
    const cleanups = [
      applyBlurFadeUp(headlineWrapRef.current, { startVh: 0.95, endVh: 0.55 }),
      applyBlurFadeUp(gridWrapRef.current,     { startVh: 0.85, endVh: 0.40 }),
    ];
    return () => cleanups.forEach((fn) => fn && fn());
  }, []);

  return (
    <section
      data-people-section
      /* No bg-surface — the page body is already cream, and adding an opaque
         rectangle here would paint over the hand catcher (z:0) and the
         falling figures (z:1) the moment Features enters the viewport,
         creating a hard horizontal cut at the section seam. The catch
         zone above gives Features all the breathing room it needs.
         Top padding is small (pt-8/12/16) — the headline + grid + descriptor
         fade in via the scroll-driven reveal above, so they don't need
         a big airy buffer before BUILT BY OPERATORS appears. */
      className="relative px-5 sm:px-8 md:px-10 pt-8 sm:pt-12 md:pt-16 pb-16 sm:pb-28 md:pb-36"
      style={{ zIndex: 2 }}
    >
      <div className="relative mx-auto max-w-7xl">
        {/* DESKTOP-ONLY headline — hidden on mobile because the
            PeopleMobileCarousel renders its own headline inside the
            pinned sticky region (so the title stays visible while the
            carousel is locked). The applyBlurFadeUp reveal still runs
            against this ref but mutates a display:none element on
            mobile, which is harmless. */}
        <div className="hidden sm:block">
        {/* Headline — first reveal layer, blur-fades up as the hand recedes above. */}
        <div ref={headlineWrapRef} style={REVEAL_INITIAL_STYLE}>
          <h2
            className="font-extrabold text-center select-none uppercase whitespace-nowrap"
            style={{
              color: TEXT_COLOR,
              fontSize: 'clamp(2rem, 8vw, 7.5rem)',
              lineHeight: 0.9,
              letterSpacing: '-0.05em',
            }}
          >
            Our Team
          </h2>
        </div>
        </div>

        {/* DESKTOP-ONLY layout below — hidden on mobile (<640px) where
            the new PeopleMobileCarousel takes over. `hidden sm:block`
            sets display:none below sm, display:block from sm up, so
            desktop layout is byte-identical to its previous form
            (block container is layout-neutral around block children). */}
        <div className="hidden sm:block">
        {/* Portraits + name row — second reveal layer, follows the headline. */}
        <div ref={gridWrapRef} style={REVEAL_INITIAL_STYLE}>
        {/* Unified portrait block — 4 tiles butted edge-to-edge, outer corners
            rounded, inner edges flush. No gaps.
            Hover dim/highlight uses 240ms ease-out (was 450ms). Hover should
            feel snappy — under 250ms reads as instant feedback, anything
            longer feels like the page is "thinking" about whether to
            respond. The tiles also gain a subtle :active scale so a tap
            registers as a press. */}
        <div
          className="team-photo-grid mt-10 sm:mt-14 md:mt-16 grid grid-cols-2 sm:grid-cols-4 overflow-hidden"
          style={{ borderRadius: 28 }}
          onMouseLeave={() => setHovered(null)}
        >
          {people.map((p, i) => {
            const isHovered = hovered === i;
            // At rest (nothing hovered) every tile sits untouched — no one
            // singled out. Hovering one keeps it bright and applies a cream
            // wash over the rest. The wash lives inside each tile instead of
            // lowering tile opacity, which avoids one-pixel seams between
            // adjacent portraits on subpixel/composited edges.
            const washOpacity = hovered === null || isHovered ? 0 : 0.45;
            return (
              <div
                key={p.name}
                className="people-tile relative cursor-pointer overflow-hidden"
                style={{
                  aspectRatio: '1 / 1',
                  background: 'rgb(var(--c-invert-bg))',
                  transition:
                    'transform 220ms cubic-bezier(0.23, 1, 0.32, 1)',
                  willChange: 'transform, opacity',
                }}
                onMouseEnter={() => setHovered(i)}
                aria-label={`${p.name}, ${p.role}`}
                tabIndex={0}
                onFocus={() => setHovered(i)}
              >
                <img
                  src={p.photo}
                  alt={p.name}
                  draggable="false"
                  className="people-photo absolute inset-0 w-full h-full object-cover select-none"
                  style={{
                    ...p.photoStyle,
                    transformOrigin: 'center top',
                  }}
                />
                <div
                  aria-hidden="true"
                  className="absolute inset-0 pointer-events-none"
                  style={{
                    background: 'rgb(var(--c-bg))',
                    opacity: washOpacity,
                    transition:
                      'opacity 240ms cubic-bezier(0.23, 1, 0.32, 1)',
                    willChange: 'opacity',
                  }}
                />
              </div>
            );
          })}
        </div>

        {/* Bubble box — 4-sided rounded outline that wraps the name row +
            description region. Border colour, weight, and radius match the
            Investment rows below so the page reads as one design system.
            The bubble's vertical edges run from the bottom of the portrait
            block above down to the bottom of the description, and the
            rounded corners are the same 28px the rest of the page uses. */}
        <div
          className="mt-5 sm:mt-6 overflow-hidden"
          style={{
            border: '1px solid rgb(var(--c-text) / 0.18)',
            borderRadius: 28,
          }}
        >
          {/* Name + role row — the bubble's RESTING content. Vertical
              padding is SYMMETRIC (equal top + bottom) so that, with the
              bio drawer collapsed to zero height below, the bubble reads
              as a deliberate, self-contained byline bar — not a header
              waiting for a body. Color/weight are HOVER-driven UI
              feedback and resolve quickly (220ms). The expanding bio
              drawer adds its OWN padding only while it is open, so at rest
              this centered bar is the entire box, top to bottom. */}
          <div className="grid grid-cols-2 sm:grid-cols-4 py-7 sm:py-8 md:py-9 px-4 sm:px-6 md:px-8">
            {people.map((p, i) => {
              const emphasized = hovered === i;
              return (
                <div key={p.name} className="text-center px-2">
                  <div
                    style={{
                      color: hovered === null ? TEXT_COLOR : (emphasized ? TEXT_COLOR : 'rgb(var(--c-text) / 0.5)'),
                      fontSize: 'clamp(0.9375rem, 1.1vw, 1.0625rem)',
                      /* Name is always bold so the under-picture row reads
                         as a proper byline at every state; hover affordance
                         is carried by the opacity/color shift, not weight. */
                      fontWeight: 700,
                      letterSpacing: '-0.005em',
                      transition:
                        'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
                    }}
                  >
                    {p.name}
                  </div>
                  <div
                    className="mt-1 uppercase"
                    style={{
                      color: 'rgb(var(--c-text) / 0.4)',
                      fontSize: 'clamp(0.6875rem, 0.75vw, 0.75rem)',
                      letterSpacing: '0.14em',
                    }}
                  >
                    {p.role}
                  </div>
                </div>
              );
            })}
          </div>

          {/* ── COLLAPSE-THE-VOID BIO DRAWER ────────────────────────────
              At rest (hovered === null) this whole region — hairline
              divider AND bio — is collapsed to height 0, so the bubble
              hugs the byline bar above as a complete, intentional bar
              with no empty cream below it. Hovering a person animates the
              drawer open to its natural height (Framer Motion height:auto
              → measured px), revealing the divider, then that person's
              bio; mouse-leave (handled on the portrait grid) collapses it
              back. Height + opacity + blur all ride the site's signature
              easing [0.23,1,0.32,1] so the open/close feels like the rest
              of the page rather than a generic accordion.

              The drawer holds the divider on the SAME element so the line
              only exists while open — no orphan hairline floating under
              the names at rest. Inside, the per-person bios are stacked
              absolutely and crossfade, so hovering a *different* person
              while already open swaps copy smoothly without re-collapsing.
              `aria-hidden` + a measured min-height keep the open state
              stable across the four bios (longest = Ben/Kurtis ~3 lines). */}
          <m2.div
            initial={false}
            animate={{
              height: hovered !== null ? 'auto' : 0,
              opacity: hovered !== null ? 1 : 0,
            }}
            transition={{
              height: { duration: 0.62, ease: [0.23, 1, 0.32, 1] },
              opacity: { duration: hovered !== null ? 0.42 : 0.3, ease: [0.23, 1, 0.32, 1] },
            }}
            style={{ overflow: 'hidden', willChange: 'height, opacity' }}
            aria-hidden={hovered === null}
          >
            {/* Hairline divider — lives inside the drawer so it appears
                only while a bio is open. Same colour/weight as the bubble
                border so the box reads as one design system. */}
            <div
              style={{
                height: 1,
                background: 'rgb(var(--c-text) / 0.18)',
              }}
            />

            {/* Bio stage — absolutely-stacked bios crossfade on hover.
                min-height is responsive so the drawer settles to one
                comfortable open height regardless of which bio is showing,
                avoiding a height "jump" when swapping between a short bio
                (Andrew/Chris, 1 line) and a longer one (Ben/Kurtis). */}
            <div className="relative min-h-[11.5rem] sm:min-h-[10.5rem] md:min-h-[9.5rem] lg:min-h-[8.5rem]">
              {people.map((p, i) => {
                const active = hovered === i;
                return (
                  <div
                    key={p.name}
                    className="absolute inset-0 flex items-center justify-center px-6 sm:px-10 md:px-14"
                    aria-hidden={!active}
                    style={{
                      opacity: active ? 1 : 0,
                      transition: 'opacity 280ms cubic-bezier(0.23, 1, 0.32, 1)',
                      pointerEvents: active ? 'auto' : 'none',
                      willChange: 'opacity',
                    }}
                  >
                    <p
                      className="mx-auto"
                      style={{
                        color: TEXT_COLOR,
                        fontSize: 'clamp(1.1875rem, 1.55vw, 1.4375rem)',
                        lineHeight: 1.5,
                        letterSpacing: '-0.008em',
                        /* Centered short editorial copy reads as a
                           confident byline under the centered name+role
                           row above. Center alignment + balanced wrap
                           keeps 1–3 line bios visually settled in the
                           drawer. Measure capped at 60rem. */
                        textAlign: 'center',
                        textWrap: 'balance',
                        paddingTop: '2rem',
                        paddingBottom: '2rem',
                        maxWidth: '60rem',
                        filter: active ? 'blur(0px)' : 'blur(14px)',
                        transform: active ? 'translateY(0)' : 'translateY(3px)',
                        transition:
                          'filter 640ms cubic-bezier(0.23, 1, 0.32, 1), transform 540ms cubic-bezier(0.23, 1, 0.32, 1)',
                        willChange: 'filter, transform',
                      }}
                    >
                      {p.description}
                    </p>
                  </div>
                );
              })}
            </div>
          </m2.div>
        </div>
        {/* /bubble box */}
        </div>
        {/* /gridWrapRef — closes the second reveal layer */}
        </div>
        {/* /hidden sm:block — closes the desktop-only layout */}

        {/* MOBILE-ONLY layout — scroll-jacked horizontal carousel.
            `sm:hidden` is `display:none` from the sm (640px) breakpoint
            up, so this branch never renders on desktop and cannot
            affect that layout. Below sm it replaces the desktop
            grid/byline/descriptor stack with a single pinned
            composition (see PeopleMobileCarousel above). */}
        <div className="sm:hidden">
          <PeopleMobileCarousel people={people} />
        </div>
      </div>
    </section>
  );
}

/* ------------------------------ INVESTMENTS ------------------------------ */
// Per-letter scramble. When `active` flips true, each letter cycles through
// random uppercase/digit glyphs on a stagger, then settles back to the
// original. Spaces and punctuation are skipped so word boundaries stay
// readable mid-scramble. Cleanup cancels the interval on unmount or when
// `active` flips off, so jumping between rows never leaves an orphan
// animation running on a row that is no longer hovered.
function ScrambleText({
  text,
  active,
  stagger = 18,
  duration = 260,
  frameInterval = 72,
  delay = 0,
}) {
  const [display, setDisplay] = React.useState(text);
  const intervalRef = useRef2(null);
  const timeoutRef = useRef2(null);

  React.useEffect(() => {
    const clearAll = () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
      }
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
    clearAll();

    if (!active) {
      setDisplay(text);
      return;
    }

    const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*+=?/<>';
    const targetChars = text.split('');
    const totalDuration = targetChars.length * stagger + duration;

    const begin = () => {
      const start = performance.now();
      const tick = () => {
        const elapsed = performance.now() - start;
        const next = targetChars.map((ch, i) => {
          if (!/[A-Za-z0-9]/.test(ch)) return ch;
          const charStart = i * stagger;
          const charEnd = charStart + duration;
          if (elapsed < charStart || elapsed >= charEnd) return ch;
          return CHARS[Math.floor(Math.random() * CHARS.length)];
        });
        setDisplay(next.join(''));
        if (elapsed >= totalDuration) {
          setDisplay(text);
          clearInterval(intervalRef.current);
          intervalRef.current = null;
        }
      };
      tick();
      intervalRef.current = setInterval(tick, frameInterval);
    };

    if (delay > 0) {
      timeoutRef.current = setTimeout(begin, delay);
    } else {
      begin();
    }

    return clearAll;
  }, [active, text, stagger, duration, frameInterval, delay]);

  return display;
}

// Stacked accordion-style list of investment categories. On hover, the row
// expands vertically, its background fills solid lavender, and a placeholder
// image panel appears on the right (ready to accept real imagery).
function InvestmentRow({ item, isActive, index, onHover }) {
  const isMobile = useIsMobile();
  // Active row inverts: warm near-black bg + cream text. rgb(var(--c-text)) is
  // the exact canvas color sampled from the BUILT BY OPERATORS portrait
  // backgrounds (andrew/ben/chris/kurtis), so the active row visually
  // ties back to the operator section above — one shared ink across
  // both inverted moments on the page.
  const INK_BG   = 'rgb(var(--c-text))';
  const CREAM_FG = 'rgb(var(--c-invert-text))';
  const href = `/investments?category=${item.slug}`;
  const descriptionLines = item.descriptionLines || [item.description];
  const titleLines = isActive && item.titleLines ? item.titleLines : [item.title];
  // Unified 650ms / cubic-bezier(0.23, 1, 0.32, 1) across every
  // property that changes when a row activates (height, bg, border,
  // image opacity + height, arrow bg + border, every inline color
  // transition further down). Single source of truth — change DUR
  // or EASE here and every property re-tunes in lockstep.
  //
  // easeOutQuint — the same curve already used 13× elsewhere in
  // sections.jsx for hover/interaction transitions, so the row
  // expansion shares the site's interaction-curve vocabulary
  // rather than introducing a foreign one. Front-loaded enough to
  // feel responsive to hover, with a long silky tail so the settle
  // reads as premium rather than abrupt.
  //
  // 650ms on the 112px height jump = ~170 px/s — the comfortable
  // glide range for accordion reveals. Tuned between two prior
  // attempts: 1200ms read as patient/sluggish, 420ms read as
  // snappy/choppy because at short durations easeOutQuint's
  // front-loading exposes itself as a "snap" rather than a glide
  // AND multi-row sweeps couldn't overlap gracefully (the
  // outgoing row collapsed before the new one had committed,
  // creating a perceptual jump). 650ms gives the outgoing and
  // incoming transitions enough overlap that a cursor sweep
  // across rows reads as one continuous gesture.
  const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';
  const DUR  = '650ms';
  const ROW_TRANSITION_DESKTOP =
    `height ${DUR} ${EASE},` +
    ` background-color ${DUR} ${EASE},` +
    ` border-color ${DUR} ${EASE}`;
  const ROW_TRANSITION_MOBILE =
    `background-color ${DUR} ${EASE},` +
    ` border-color ${DUR} ${EASE}`;
  const IMAGE_TRANSITION =
    `height ${DUR} ${EASE},` +
    ` opacity ${DUR} ${EASE}`;
  const ARROW_TRANSITION =
    `background-color ${DUR} ${EASE},` +
    ` border-color ${DUR} ${EASE}`;
  const COLOR_TRANSITION = `color ${DUR} ${EASE}`;

  // Mobile rows are uniform 152 px — keeping height constant prevents the
  // scroll-driven active detection from flickering when the active row
  // would otherwise grow and shift the geometry under the listener.
  const rowHeight = isMobile ? 152 : (isActive ? 190 : 78);
  const rowTransition = isMobile ? ROW_TRANSITION_MOBILE : ROW_TRANSITION_DESKTOP;

  // Touch screens fire onMouseEnter on tap — gating to (hover: hover)
  // keeps mobile scroll-driven activation from getting overridden by a
  // stray hover when the user taps a row.
  const handleMouseEnter = () => {
    if (isMobile) return;
    if (typeof window !== 'undefined' &&
        window.matchMedia &&
        !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    onHover(index);
  };

  return (
    <m2.a
      href={href}
      data-investment-row
      data-row-idx={index}
      onMouseEnter={handleMouseEnter}
      className="press relative block w-full cursor-pointer overflow-hidden"
      style={{
        height: rowHeight,
        borderRadius: isMobile ? 22 : 28,
        border: `1px solid rgb(var(--c-text) / ${isActive ? 0 : 0.22})`,
        background: isActive ? INK_BG : 'rgb(var(--c-text) / 0.03)',
        transition: rowTransition,
      }}
      aria-label={`View ${item.title} investments`}
    >
      {isMobile ? (
        /* === MOBILE LAYOUT ===
           Title up top (full width, smaller font), then a bottom row of
           VIDEO TILE | DESCRIPTION | ARROW. Constant 152 px height; the
           active state is communicated purely by the lavender background,
           brighter description, and the playing video tile. Scroll-driven
           activation is wired in Investments(). */
        <div className="relative h-full w-full px-4 py-3.5 flex flex-col gap-2.5">
          <h3
            className="font-extrabold select-none investment-title-mobile"
            style={{
              color: isActive ? CREAM_FG : TEXT_COLOR,
              fontSize: 'clamp(1.05rem, 4.6vw, 1.4rem)',
              letterSpacing: '-0.025em',
              lineHeight: 1.05,
              transition: COLOR_TRANSITION,
            }}
          >
            {titleLines.map((line, lineIndex) => (
              <React.Fragment key={`${item.slug}-mobile-title-${lineIndex}`}>
                {lineIndex > 0 && <br />}
                <span style={{ display: 'inline-block' }}>
                  <ScrambleText text={line} active={isActive} />
                </span>
              </React.Fragment>
            ))}
          </h3>

          <div className="flex items-stretch gap-3 flex-1 min-h-0">
            <div
              className="invest-thumb relative overflow-hidden flex-shrink-0"
              style={{
                aspectRatio: '1 / 1',
                borderRadius: 14,
                height: '100%',
                background: 'rgb(var(--c-text) / 0.08)',
              }}
            >
              {item.video ? (
                <LazyVideo
                  src={item.video}
                  paused={!isActive}
                  className="absolute inset-0 w-full h-full object-cover"
                  style={{ objectPosition: 'center', transform: 'scale(1.06)' }}
                />
              ) : item.image && (
                <img
                  src={item.image}
                  alt=""
                  className="absolute inset-0 w-full h-full object-cover"
                  style={{ objectPosition: 'center', transform: 'scale(1.06)' }}
                  draggable={false}
                />
              )}
            </div>

            <p
              className="uppercase flex-1 self-center"
              style={{
                color: isActive ? 'rgb(var(--c-invert-text) / 0.92)' : 'rgb(var(--c-muted))',
                fontSize: 10.5,
                letterSpacing: '0.06em',
                lineHeight: 1.5,
                minWidth: 0,
                overflowWrap: 'anywhere',
                transition: COLOR_TRANSITION,
              }}
            >
              {/* Join the pre-split lines into one string on mobile and let it
                  wrap naturally — the desktop line-break points are too wide
                  for the narrow phone column and were clipping mid-word. */}
              {(item.description || descriptionLines.join(' '))}
            </p>

            <div
              className="flex-shrink-0 self-center rounded-full flex items-center justify-center"
              style={{
                width: 40,
                height: 40,
                backgroundColor: isActive ? CREAM_FG : 'transparent',
                border: isActive
                  ? '1px solid transparent'
                  : '1px solid rgb(var(--c-text) / 0.35)',
                transition: ARROW_TRANSITION,
              }}
              aria-hidden="true"
            >
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d={isActive ? 'M7 17 L17 7 M17 7 H8 M17 7 V16' : 'M5 12 H19 M14 7 L19 12 L14 17'}
                  stroke={isActive ? INK_BG : TEXT_COLOR}
                  strokeWidth="1.75"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </div>
          </div>
        </div>
      ) : (
        /* === DESKTOP LAYOUT (unchanged from prior) === */
        <div className="relative h-full w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 md:gap-x-8 px-6 sm:px-8 md:px-10">
          <h3
            className="font-extrabold select-none"
            style={{
              color: isActive ? CREAM_FG : TEXT_COLOR,
              fontSize: 'clamp(1.5rem, 2.4vw, 2.25rem)',
              letterSpacing: '-0.03em',
              lineHeight: 1,
              minWidth: 0,
              transition: COLOR_TRANSITION,
            }}
          >
            {titleLines.map((line, lineIndex) => (
              <React.Fragment key={`${item.slug}-title-${lineIndex}`}>
                {lineIndex > 0 && <br />}
                <span style={{ display: 'inline-block' }}>
                  <ScrambleText text={line} active={isActive} />
                </span>
              </React.Fragment>
            ))}
          </h3>

          <div className="flex items-center gap-5 sm:gap-6 md:gap-8">
            <m2.div
              className="hidden md:block relative overflow-hidden flex-shrink-0"
              style={{
                aspectRatio: '1 / 1',
                borderRadius: '28px',
                height: isActive ? 176 : 78,
                opacity: isActive ? 1 : 0,
                background: 'rgb(var(--c-text) / 0.08)',
                transition: IMAGE_TRANSITION,
              }}
            >
              {item.video ? (
                <LazyVideo
                  src={item.video}
                  paused={!isActive}
                  className="absolute inset-0 w-full h-full object-cover"
                  style={{ objectPosition: 'center', transform: 'scale(1.1)' }}
                />
              ) : item.image && (
                <img
                  src={item.image}
                  alt=""
                  className="absolute inset-0 w-full h-full object-cover"
                  style={{ objectPosition: 'center', transform: 'scale(1.1)' }}
                  draggable={false}
                />
              )}
            </m2.div>

            <p
              className="text-right uppercase hidden sm:block"
              style={{
                color: isActive ? 'rgb(var(--c-invert-text) / 0.92)' : 'rgb(var(--c-muted))',
                fontSize: '11px',
                letterSpacing: '0.08em',
                lineHeight: 1.55,
                maxWidth: 285,
                transition: COLOR_TRANSITION,
              }}
            >
              {descriptionLines.map((line, lineIndex) => (
                <React.Fragment key={`${item.slug}-description-${lineIndex}`}>
                  {lineIndex > 0 && <br />}
                  <span style={{ whiteSpace: 'nowrap' }}>{line}</span>
                </React.Fragment>
              ))}
            </p>

            <div
              className="flex-shrink-0 rounded-full flex items-center justify-center"
              style={{
                width: 44,
                height: 44,
                backgroundColor: isActive ? CREAM_FG : 'transparent',
                border: isActive
                  ? '1px solid transparent'
                  : '1px solid rgb(var(--c-text) / 0.35)',
                transition: ARROW_TRANSITION,
              }}
            >
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d={isActive ? 'M7 17 L17 7 M17 7 H8 M17 7 V16' : 'M5 12 H19 M14 7 L19 12 L14 17'}
                  stroke={isActive ? INK_BG : TEXT_COLOR}
                  strokeWidth="1.75"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </div>
          </div>
        </div>
      )}
    </m2.a>
  );
}

function Investments() {
  // Slugs MUST match the investments page taxonomy (CAT_ORDER in
  // investments.html: funds / venture / businesses / public-equities /
  // real-estate). Any other value falls through to "all". The old
  // taxonomy (venture-funds / business-minority / business-majority /
  // philanthropy) is dead — those rows silently landed on the unfiltered
  // grid. Business minority + majority collapsed into one "Businesses"
  // row (one slug, no duplicate deep-link); philanthropy was removed
  // entirely. Titles/labels track the investments-page category labels.
  const items = [
    { title: 'VENTURE',          slug: 'venture',        description: 'Semi-random investments in founders we admire.',                            descriptionLines: ['Semi-random investments', 'in founders we admire.'],                          video: 'media/investments/15.mp4' },
    { title: 'BUSINESSES',       slug: 'businesses',     description: 'Passive investments in wonderful private businesses.',                       descriptionLines: ['Passive investments in', 'wonderful private businesses.'],                    video: 'media/investments/business-shop.mp4?v=12' },
    { title: 'PUBLIC EQUITIES',  slug: 'public-equities', titleLines: ['PUBLIC', 'EQUITIES'], description: 'Long-held positions in public companies.', descriptionLines: ['Long-held positions in', 'public companies.'],                                video: 'media/investments/14.mp4' },
    { title: 'FUNDS',            slug: 'funds',          description: 'We back managers we think highly of and leave them alone.',                   descriptionLines: ['We back managers we think highly of', 'and leave them alone.'],               video: 'media/investments/funds-manager.mp4?v=11' },
    { title: 'REAL ESTATE',      slug: 'real-estate',    description: 'We occasionally buy buildings, often to make a neighborhood better.',         descriptionLines: ['We occasionally buy buildings, often', 'to make a neighborhood better.'],      video: 'media/investments/13.mp4?v=2' },
  ];

  const [active, setActive] = React.useState(null);
  const isMobile = useIsMobile();

  // Blur-fade-up reveal — headline rises first, accordion list a beat
  // later. Both keyed off the section's own scroll position so they
  // enter as the user reaches them, matching the cascade in the
  // BUILT BY OPERATORS block above.
  const headlineRef = useRef2(null);
  const listRef     = useRef2(null);
  useBlurFadeUp(headlineRef, { startVh: 0.95, endVh: 0.55 });
  useBlurFadeUp(listRef,     { startVh: 0.85, endVh: 0.40 });

  // ---- HEADLINE FIT-TO-WIDTH ----
  // "Our Investments" is sized to span the exact same horizontal width
  // as the accordion rows below. Both live inside the same `max-w-7xl`
  // container, so we measure the container's content width, measure the
  // headline at a base font-size of 100px in a hidden span (same font /
  // weight / letter-spacing as the visible h2), then scale up.
  // Re-runs on resize and on fonts.ready so the layout resettles with
  // real font metrics rather than the system fallback.
  const titleContainerRef = useRef2(null);
  const titleMeasureRef   = useRef2(null);
  const [titleFontSize, setTitleFontSize] = React.useState(120);
  React.useEffect(() => {
    if (!titleContainerRef.current || !titleMeasureRef.current) return;
    const fit = () => {
      const cw = titleContainerRef.current?.getBoundingClientRect().width;
      const measureEl = titleMeasureRef.current;
      if (!cw || !measureEl) return;
      const naturalWidth = measureEl.getBoundingClientRect().width;
      if (naturalWidth > 0) {
        const computed = (cw / naturalWidth) * 100;
        // Clamp so the headline never goes absurdly small on tiny phones
        // (where px-5 padding eats most of the viewport) or absurdly
        // large on a giant 6K display.
        setTitleFontSize(Math.max(40, Math.min(260, computed)));
      }
    };
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(titleContainerRef.current);
    let cancelled = false;
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => { if (!cancelled) fit(); });
    }
    return () => { cancelled = true; ro.disconnect(); };
  }, []);

  // ---- SCROLL-DRIVEN ACTIVE ROW (MOBILE ONLY) ----
  // Touch devices have no hover, so the user can't activate a row by
  // pointing at it. Instead, the row whose vertical centre is closest
  // to the viewport's vertical centre becomes "active" — its lavender
  // wash lights up, its description brightens, and its video plays
  // (paused={!isActive} on the LazyVideo inside the row).
  //
  // Only updates `active` when at least one row is actually inside the
  // viewport — otherwise, scrolling above or below the section would
  // keep flipping the active to the topmost / bottommost row and start
  // a video the user can't see.
  React.useEffect(() => {
    if (!isMobile) return;
    const list = listRef.current;
    if (!list) return;

    let raf = 0;
    const tick = () => {
      const vh = window.innerHeight || 800;
      const vpCentre = vh * 0.5;
      const rows = list.querySelectorAll('[data-investment-row]');
      let bestIdx = -1;
      let bestDist = Infinity;
      rows.forEach((el, i) => {
        const r = el.getBoundingClientRect();
        // Skip rows entirely outside the viewport.
        if (r.bottom < 0 || r.top > vh) return;
        const rowCentre = r.top + r.height * 0.5;
        const dist = Math.abs(rowCentre - vpCentre);
        if (dist < bestDist) {
          bestDist = dist;
          bestIdx = i;
        }
      });
      if (bestIdx >= 0) {
        setActive((cur) => (cur === bestIdx ? cur : bestIdx));
      }
    };
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => { raf = 0; tick(); });
    };
    tick();
    // Re-tick after layout settles — the BUILT BY OPERATORS reveal
    // above + lazy-loaded videos can shift row positions for the
    // first second or so.
    const settle = [50, 200, 600, 1500].map((ms) => setTimeout(tick, ms));
    // Wrap so iOS URL-bar collapse doesn't flip the active section
    // mid-scroll (vh-based threshold math).
    const onResize = stableMobileResize(tick);
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      settle.forEach(clearTimeout);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [isMobile]);

  // pb-16 (mobile only — sm:py-28 overrides from 640px up) keeps the
  // Investments→Footer gap at 8rem on mobile, matching the
  // BBO→Investments gap above for consistent inter-section rhythm.
  // Desktop py values are untouched.
  return (
    <section className="relative bg-surface px-5 sm:px-8 md:px-10 pt-16 pb-16 sm:py-28 md:py-36">
      <div ref={titleContainerRef} className="relative mx-auto max-w-7xl">
        {/* Ultra-bold headline — single line, font-size computed in JS so the
            text spans EXACTLY the container width, matching the accordion
            rows below edge-to-edge. No text-center: at full container width
            both edges already align flush. */}
        <div ref={headlineRef} style={REVEAL_INITIAL_STYLE}>
          <h2
            className="font-extrabold text-center select-none uppercase whitespace-nowrap"
            style={{
              color: TEXT_COLOR,
              fontSize: 'clamp(2rem, 8vw, 7.5rem)',
              lineHeight: 0.9,
              letterSpacing: '-0.05em',
            }}
          >
            Our Investments
          </h2>
          {/* Hidden measurer — same font / weight / letter-spacing as the
              visible h2 above, fixed at base font-size 100. Used by the
              fit-to-width effect to compute the pixel font-size that makes
              the visible h2 span the container's content width. */}
          <span
            ref={titleMeasureRef}
            aria-hidden="true"
            className="font-extrabold uppercase whitespace-nowrap"
            style={{
              position: 'absolute',
              top: -9999,
              left: -9999,
              visibility: 'hidden',
              pointerEvents: 'none',
              fontSize: 100,
              lineHeight: 0.9,
              letterSpacing: '-0.05em',
            }}
          >
            Our Investments
          </span>
        </div>

        {/* Accordion list — sits below, tight gap between rows.
            Top margin scaled up (mt-12 sm:mt-16 md:mt-20) since the
            headline above is now a full-width display headline rather
            than a smaller centered one — the bigger title needs more
            air below it for balance. */}
        <div
          ref={listRef}
          className="mt-12 sm:mt-16 md:mt-20 flex flex-col gap-2 sm:gap-2"
          style={REVEAL_INITIAL_STYLE}
          onMouseLeave={() => {
            if (!isMobile) setActive(null);
          }}
        >
          {items.map((item, i) => (
            <InvestmentRow
              key={item.title}
              item={item}
              index={i}
              isActive={active === i}
              onHover={setActive}
            />
          ))}
        </div>

        {/* "View the full portfolio" CTA — clean editorial link sitting
            below the accordion list, deep-linking to the dedicated
            investments page. Reuses the page's existing `nav-link` /
            `nav-arrow` classes so the hover arrow-nudge (translateX
            on hover-capable devices, gated via the CSS media query
            already in index.html) is identical to the top-bar
            links — one consistent affordance across the page.
            `press-strong` adds the 0.97 scale-on-press feedback so
            taps feel responsive. Generous top margin (mt-12 / 16 /
            20) matches the rhythm separating the headline from the
            list above, so the CTA reads as a deliberate next-beat
            rather than a tail to the last row.
            Mobile uses `text-lg` (18px) instead of `text-base` (16px)
            so the CTA sits at the same scale as the accordion item
            titles above (clamp(1.05rem, 4.6vw, 1.4rem) ≈ 17-22px).
            16px would have read as visually quieter than the rows it
            follows — undermining its role as the "next step"
            affordance. The `gap-2.5` mobile (was `gap-2`) gives a
            touch more breath between the bumped-up label and the
            arrow so the inline-flex pair stays balanced. */}
        <div className="mt-12 sm:mt-16 md:mt-20 flex justify-center">
          <a
            href="/investments"
            className="nav-link press-strong group inline-flex items-center gap-2.5 text-lg md:text-xl font-bold"
            style={{ color: TEXT_COLOR }}
          >
            <span>View the full portfolio</span>
            <span
              aria-hidden="true"
              className="nav-arrow inline-block"
            >
              →
            </span>
          </a>
        </div>
      </div>
    </section>
  );
}

Object.assign(window, {
  Hero, About, Features, Investments, Navbar,
  // Reveal helpers shared with footer.jsx (separate Babel script scope).
  applyBlurFadeUp, useBlurFadeUp, REVEAL_INITIAL_STYLE,
});
