/* ============================================================
   brand.jsx — wordmark + Nav + Hero + Intro
   ============================================================ */

const { useEffect, useState, useRef } = React;

/* ============================================================
   Crewsive wordmark — custom SVG traced from the brand wordmark.

   Each letter is drawn as a filled polygon with 16-unit chamfered
   corners on every outer edge (top-left, top-right, bottom-right,
   bottom-left) so the type reads like a beveled wedge stencil.
   Reads CREWSIVE in caps.
   ============================================================ */
function CrewsiveWordmark({ height = 28, style, causticFill = false }) {
  // Letter strokes are built from a 130-unit cap height grid.
  // Outer chamfer = 16 units; inner stem chamfer = 4 units.
  //
  // `causticFill` — when true, the letterforms are filled with a
  // drifting linear gradient that matches the manifesto shader's
  // palette (base / mid / hot / peak in brand blue). An
  // animateTransform slowly translates the gradient so the bright
  // caustic bands sweep through the letters the same way the
  // shader's vertical columns drift through the manifesto bg.
  // Static gradients can't match a fragment shader pixel-for-pixel,
  // but the palette + slow drift carry the same visual signature.
  const fillAttr = causticFill ? "url(#crewsive-caustic)" : "currentColor";
  return (
    <span
      className="crewsive-wm-svg"
      style={{ display: "inline-block", lineHeight: 0, height: `${height}px`, ...style }}
      aria-label="Crewsive"
      role="img"
    >
      <svg
        viewBox="0 0 944 130"
        height={height}
        width="auto"
        fill={fillAttr}
        style={{ overflow: "visible" }}
      >
        {causticFill && (
          <defs>
            {/*
              White-dominant caustic palette. Previous version used
              the manifesto shader's exact dark-blue palette which
              made the wordmark mostly dark and hard to read against
              the dark page bg. This version preserves the same
              three-band caustic structure and the drift animation,
              but flips the luminance lead to white — the bright
              peak is pure #FFFFFF, the "trough" is a soft cool
              off-white, and the visible-blue tint only shows in
              the narrow caustic ridges. End result: legibility
              first, with the shader's shimmer still drifting
              through the letterforms.

                trough  #EAF1FB  — soft cool white (replaces base)
                mid     #F4F8FC  — barely-tinted near-white
                ridge   #B5D0EC  — soft brand-blue accent
                peak    #FFFFFF  — pure white at the caustic crest

              The gradient is wider than the wordmark (-300 → 1244
              vs viewBox 0 → 944) so the animateTransform can drift
              it ±250px without showing the gradient end-stops.
            */}
            <linearGradient
              id="crewsive-caustic"
              gradientUnits="userSpaceOnUse"
              x1="-300" y1="0" x2="1244" y2="0"
            >
              <stop offset="0%"   stopColor="#F4F8FC" />
              <stop offset="6%"   stopColor="#B5D0EC" />
              <stop offset="10%"  stopColor="#FFFFFF" />
              <stop offset="14%"  stopColor="#B5D0EC" />
              <stop offset="22%"  stopColor="#EAF1FB" />
              <stop offset="32%"  stopColor="#B5D0EC" />
              <stop offset="38%"  stopColor="#FFFFFF" />
              <stop offset="44%"  stopColor="#B5D0EC" />
              <stop offset="54%"  stopColor="#EAF1FB" />
              <stop offset="64%"  stopColor="#B5D0EC" />
              <stop offset="70%"  stopColor="#FFFFFF" />
              <stop offset="76%"  stopColor="#B5D0EC" />
              <stop offset="86%"  stopColor="#F4F8FC" />
              <stop offset="100%" stopColor="#EAF1FB" />
              <animateTransform
                attributeName="gradientTransform"
                type="translate"
                from="-250 0"
                to="250 0"
                dur="14s"
                repeatCount="indefinite"
                additive="replace"
              />
            </linearGradient>
          </defs>
        )}
        {/* C — chamfered on all four outer corners, open right */}
        <path d="M 16 0 L 84 0 L 100 16 L 100 24 L 28 24 L 24 28 L 24 92 L 28 96 L 100 96 L 100 104 L 84 120 L 16 120 L 0 104 L 0 16 Z" />
        {/* R — chamfered stem + bowl with angled leg */}
        <path d="M 132 0 L 200 0 L 216 16 L 216 56 L 200 72 L 184 72 L 216 120 L 188 120 L 156 72 L 140 72 L 140 120 L 132 120 L 116 104 L 116 16 Z M 140 24 L 140 48 L 192 48 L 192 24 Z" fillRule="evenodd" />
        {/* E — three bars with chamfered outer corners + angled bar ends */}
        <path d="M 248 0 L 316 0 L 332 16 L 332 24 L 256 24 L 256 48 L 312 48 L 320 56 L 320 62 L 312 70 L 256 70 L 256 96 L 332 96 L 332 104 L 316 120 L 248 120 L 232 104 L 232 16 Z" />
        {/* W — four diagonals, chamfered outer top corners */}
        <path d="M 360 0 L 376 0 L 388 80 L 408 0 L 432 0 L 452 80 L 464 0 L 480 0 L 492 16 L 466 120 L 442 120 L 420 36 L 398 120 L 374 120 L 348 16 Z" />
        {/* S — Z-flow with chamfers top-right + bottom-left + step joins */}
        <path d="M 524 0 L 596 0 L 612 16 L 608 24 L 540 24 L 536 28 L 536 44 L 540 48 L 596 48 L 612 64 L 612 104 L 596 120 L 524 120 L 508 104 L 512 96 L 580 96 L 584 92 L 584 76 L 580 72 L 524 72 L 508 56 L 508 16 Z" />
        {/* I — narrow capital with slab serifs, chamfered outer corners */}
        <path d="M 644 0 L 696 0 L 712 16 L 712 24 L 682 24 L 682 96 L 712 96 L 712 104 L 696 120 L 644 120 L 628 104 L 628 96 L 658 96 L 658 24 L 628 24 L 628 16 Z" />
        {/* V — chevron with chamfered outer top corners */}
        <path d="M 740 0 L 754 0 L 778 92 L 802 0 L 816 0 L 828 16 L 794 120 L 762 120 L 728 16 Z" />
        {/* E — repeat (matches first E exactly, shifted right) */}
        <path d="M 860 0 L 928 0 L 944 16 L 944 24 L 868 24 L 868 48 L 924 48 L 932 56 L 932 62 L 924 70 L 868 70 L 868 96 L 944 96 L 944 104 L 928 120 L 860 120 L 844 104 L 844 16 Z" />
      </svg>
    </span>
  );
}

/* ============================================================
   CausticTextSVG — accent text rendered as inline SVG with the
   same caustic gradient + drift animation the nav wordmark uses.
   The wordmark is SVG paths; this is SVG <text>. Both rely on
   the SAME mechanism: a linearGradient in <defs> with an
   <animateTransform> drifting it across userSpaceOnUse
   coordinates, and the fill element references that gradient
   via `fill="url(#id)"`.

   Why SVG instead of CSS background-clip: text — earlier
   attempts using background-clip + per-char wrappers failed
   because the per-character `.hw-char` elements use
   transform: translate3d(...) for their rise animation, and
   each transform creates a stacking context that breaks the
   parent's background-clip propagation. SVG <text> has no such
   issue: the gradient fill is native to SVG and works on any
   glyph rendering inside the SVG's coordinate system.

   Trade-off — this drops the per-character rise animation on
   words that use this component, since the rise mechanic
   depends on HTML span+CSS transforms. The remaining (non-em)
   words in each phrase still get the rise.

   Sizing — SVG <text> doesn't have intrinsic dimensions the way
   HTML text does, so we measure the text's actual bbox after
   first render and set the viewBox to match. CSS then sizes
   the SVG height to 1em and width to auto, which scales the
   measured viewBox proportionally — same baseline-to-cap ratio
   as the surrounding HTML serif text. */

const CAUSTIC_LIGHT_STOPS = [
  { offset: '0%',   color: '#F4F8FC' },
  { offset: '6%',   color: '#B5D0EC' },
  { offset: '10%',  color: '#FFFFFF' },
  { offset: '14%',  color: '#B5D0EC' },
  { offset: '22%',  color: '#EAF1FB' },
  { offset: '32%',  color: '#B5D0EC' },
  { offset: '38%',  color: '#FFFFFF' },
  { offset: '44%',  color: '#B5D0EC' },
  { offset: '54%',  color: '#EAF1FB' },
  { offset: '64%',  color: '#B5D0EC' },
  { offset: '70%',  color: '#FFFFFF' },
  { offset: '76%',  color: '#B5D0EC' },
  { offset: '86%',  color: '#F4F8FC' },
  { offset: '100%', color: '#EAF1FB' },
];
const CAUSTIC_DARK_STOPS = [
  { offset: '0%',   color: '#141D2E' },
  { offset: '6%',   color: '#8FB3E0' },
  { offset: '10%',  color: '#C2DBF7' },
  { offset: '14%',  color: '#8FB3E0' },
  { offset: '22%',  color: '#07090F' },
  { offset: '32%',  color: '#8FB3E0' },
  { offset: '38%',  color: '#C2DBF7' },
  { offset: '44%',  color: '#8FB3E0' },
  { offset: '54%',  color: '#07090F' },
  { offset: '64%',  color: '#8FB3E0' },
  { offset: '70%',  color: '#C2DBF7' },
  { offset: '76%',  color: '#8FB3E0' },
  { offset: '86%',  color: '#141D2E' },
  { offset: '100%', color: '#07090F' },
];

function CausticTextSVG({ text, palette = 'light' }) {
  /* Stable unique-per-instance ID — multiple instances on the
     page need distinct gradient IDs to avoid all converging on
     the same gradient. */
  const id = React.useMemo(
    () => 'caustic-' + Math.random().toString(36).slice(2, 9),
    []
  );
  const textRef = React.useRef(null);
  /* viewBox dimensions — start with a rough estimate sized by
     character count, then correct to the actual measured bbox
     once the text mounts. Height 130 mirrors the wordmark grid;
     y=100 places the text baseline at a similar position. */
  const [vb, setVb] = React.useState({
    w: Math.max(220, text.length * 60),
    h: 130,
  });
  React.useEffect(() => {
    if (!textRef.current) return;
    try {
      const b = textRef.current.getBBox();
      if (b && b.width > 0) {
        setVb({ w: Math.ceil(b.width + 12), h: 130 });
      }
    } catch (e) { /* getBBox can throw before paint */ }
  }, [text]);

  const stops = palette === 'dark' ? CAUSTIC_DARK_STOPS : CAUSTIC_LIGHT_STOPS;
  /* CSS width derived from viewBox aspect — keeps the SVG
     visually sized to match its text content at height: 0.95em. */
  const cssWidthEm = ((vb.w / vb.h) * 0.95).toFixed(3);

  return (
    <svg
      className={'caustic-text-svg caustic-text-svg--' + palette}
      viewBox={`0 0 ${vb.w} ${vb.h}`}
      preserveAspectRatio="xMinYMid meet"
      style={{
        display: 'inline-block',
        verticalAlign: 'baseline',
        height: '0.95em',
        width: cssWidthEm + 'em',
        overflow: 'visible',
      }}
      aria-label={text}
      role="img"
    >
      <defs>
        <linearGradient
          id={id}
          gradientUnits="userSpaceOnUse"
          x1={-Math.round(vb.w * 0.35)}
          y1="0"
          x2={Math.round(vb.w * 1.35)}
          y2="0"
        >
          {stops.map((s, i) => (
            <stop key={i} offset={s.offset} stopColor={s.color} />
          ))}
          <animateTransform
            attributeName="gradientTransform"
            type="translate"
            from={`${-Math.round(vb.w * 0.30)} 0`}
            to={`${Math.round(vb.w * 0.30)} 0`}
            dur="14s"
            repeatCount="indefinite"
            additive="replace"
          />
        </linearGradient>
      </defs>
      <text
        ref={textRef}
        x="0"
        y="105"
        fill={`url(#${id})`}
        fontFamily="inherit"
        fontSize="120"
        fontStyle="italic"
        fontWeight="400"
        letterSpacing="-2.6"
      >
        {text}
      </text>
    </svg>
  );
}

/* ============================================================
   Editorial menu — Jacob & Co–inspired full-screen takeover.
   Each primary item has its own bespoke ornament. A shared
   halo follows the active item to thread the four moods together.
   ============================================================ */

function ServicesOrnament() {
  return (
    <>
      <svg className="orn-svg orn-arc" viewBox="0 0 100 100" aria-hidden>
        <path d="M 100 0 A 100 100 0 0 1 0 100" strokeWidth="0.7" />
        <circle cx="100" cy="0" r="2.4" fill="currentColor" />
      </svg>
      <svg className="orn-svg orn-cross" viewBox="0 0 60 60" aria-hidden>
        <line x1="30" y1="0"  x2="30" y2="60" strokeWidth="0.7" />
        <line x1="0"  y1="30" x2="60" y2="30" strokeWidth="0.7" />
        <circle cx="30" cy="30" r="6" strokeWidth="0.7" />
      </svg>
      <span className="orn-rule" />
      <span className="orn-label orn-label--tl">DRAFT · SCALE 1:1</span>
      <span className="orn-label orn-label--br">+ BUILD</span>
    </>
  );
}

function WorkOrnament() {
  return (
    <>
      <span className="orn-frame" />
      <span className="orn-corner orn-corner--tl" />
      <span className="orn-corner orn-corner--tr" />
      <span className="orn-corner orn-corner--bl" />
      <span className="orn-corner orn-corner--br" />
      <span className="orn-label orn-label--tl">SELECTED · 01 / 04</span>
      <span className="orn-label orn-label--br">↗ CASE STUDIES</span>
    </>
  );
}

function ProcessOrnament() {
  return (
    <>
      <span className="orn-spine" />
      <div className="orn-node orn-node--1">
        <span className="orn-dot" aria-hidden />
        <span className="orn-num">01</span>
        <span className="orn-word">Discover</span>
      </div>
      <div className="orn-node orn-node--2">
        <span className="orn-dot" aria-hidden />
        <span className="orn-num">02</span>
        <span className="orn-word">Build</span>
      </div>
      <div className="orn-node orn-node--3">
        <span className="orn-dot" aria-hidden />
        <span className="orn-num">03</span>
        <span className="orn-word">Live</span>
      </div>
      <svg className="orn-svg orn-curve" viewBox="0 0 100 600" preserveAspectRatio="none" aria-hidden>
        <path d="M 95 30 Q 25 300 95 570" />
      </svg>
    </>
  );
}

function FaqOrnament() {
  return (
    <>
      <span className="orn-quote orn-quote--open" aria-hidden>&ldquo;</span>
      <span className="orn-quote orn-quote--close" aria-hidden>&rdquo;</span>
      <span className="orn-label orn-label--tr">CANDID · ANSWERS</span>
    </>
  );
}

// Menu now routes to the real destination + directory pages (the old
// in-page anchors #services/#work/#process pointed at sections that were
// removed in the rebuild). `cls` is kept in its original order so each
// ornament stays positioned where the CSS expects it.
const NAV_PRIMARY = [
  { label: 'SERVICES',   href: '/services.html',       cls: 'orn-services' },
  { label: 'FREE AUDIT', href: '/free-seo-audit.html', cls: 'orn-work'     },
  { label: 'FAQ',        href: '/faq.html',            cls: 'orn-process'  },
  { label: 'CONTACT',    href: '#work-with-me',        cls: 'orn-faq'      },
];

const NAV_ORNAMENTS = [ServicesOrnament, WorkOrnament, ProcessOrnament, FaqOrnament];

const NAV_SECONDARY = [
  { label: 'PRIVACY',           href: '/privacy.html' },
  { label: 'TERMS',             href: '/terms.html'   },
  { label: 'luke@crewsive.com', href: 'mailto:luke@crewsive.com' },
];

const HALO_OFFSETS = ['-21%', '-7%', '7%', '21%'];

function NavMenu({ open, onClose }) {
  const [hovered, setHovered] = useState(null);

  // Close on Escape
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  // Reset hover state when menu closes
  useEffect(() => { if (!open) setHovered(null); }, [open]);

  const hasHover = hovered !== null;
  const haloY = hasHover ? HALO_OFFSETS[hovered] : '0%';

  return (
    <div
      className={`nav-menu ${open ? 'nav-menu--open' : ''}`}
      role="dialog"
      aria-modal="true"
      aria-label="Main menu"
      aria-hidden={!open}
    >
      <div className="nav-menu__ambient" aria-hidden="true">
        <div className="nav-menu__ambient-text">built for operators.</div>
      </div>

      <div
        className={`menu-halo ${hasHover ? 'menu-halo--locked' : ''}`}
        style={{ '--halo-y': haloY }}
        aria-hidden="true"
      />

      {NAV_PRIMARY.map((item, i) => {
        const Orn = NAV_ORNAMENTS[i];
        return (
          <div
            key={item.cls}
            className={`ornament ${item.cls} ${hovered === i ? 'ornament--active' : ''}`}
            aria-hidden="true"
          >
            <Orn />
          </div>
        );
      })}

      <ul className={`nav-menu__primary ${hasHover ? 'nav-menu__primary--has-hover' : ''}`}>
        {NAV_PRIMARY.map((item, i) => (
          <li className="primary-row" key={item.label}>
            <a
              href={item.href}
              className={`primary-item ${hovered === i ? 'primary-item--active' : ''}`}
              onMouseEnter={() => setHovered(i)}
              onMouseLeave={() => setHovered(null)}
              onFocus={() => setHovered(i)}
              onBlur={() => setHovered(null)}
              onClick={onClose}
              tabIndex={open ? 0 : -1}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>

      <nav className="nav-menu__secondary" aria-label="Secondary">
        {NAV_SECONDARY.map((item, i) => (
          <React.Fragment key={item.label}>
            <a
              className="secondary-item"
              href={item.href}
              tabIndex={open ? 0 : -1}
              onClick={onClose}
            >
              {item.label}
            </a>
            {i < NAV_SECONDARY.length - 1 && (
              <span className="secondary-divider" aria-hidden="true" />
            )}
          </React.Fragment>
        ))}
      </nav>
    </div>
  );
}

/* ============================================================
   Top nav
   ============================================================ */
function Nav() {
  const [open, setOpen] = useState(false);

  // Lock body scroll while menu is open
  useEffect(() => {
    document.body.classList.toggle('menu-open', open);
  }, [open]);

  return (
    <>
      <nav className="nav">
        <a href="#top" className="brand" aria-label="Crewsive">
          <CrewsiveWordmark height={36} causticFill />
        </a>
        <div className="nav__right">
          <a href="#cta" className="cta">
            Join the crew
            <span aria-hidden>↗</span>
          </a>
          <button
            type="button"
            className="nav-trigger"
            aria-expanded={open}
            aria-label={open ? 'Close menu' : 'Open menu'}
            onClick={() => setOpen(o => !o)}
          >
            <span className="nav-trigger__line nav-trigger__line--top" aria-hidden />
            <span className="nav-trigger__line nav-trigger__line--bottom" aria-hidden />
            <span className="nav-trigger__close" aria-hidden>×</span>
          </button>
        </div>
      </nav>
      <NavMenu open={open} onClose={() => setOpen(false)} />
    </>
  );
}

/* ============================================================
   MobileJumpCta — persistent thumb-zone primary action on phones.
   Mobile drops the top nav menu + inline CTA, so without this the
   only path to convert is scrolling the whole ~7-viewport page to
   the email form. This pill rides in the bottom thumb zone (where
   most taps land), appears once the reader reaches the Manifesto
   (so the cinematic hero + cover/donut scrolljack scenes stay
   clean), and hides again when the real form scrolls into view so
   it never doubles that button. Desktop is unaffected — CSS gates
   the pill to <=720px and this effect bails on wider viewports.
   Anchors to #work-with-me, the email-capture section. */
function MobileJumpCta() {
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    if (!window.matchMedia('(max-width: 720px)').matches) return;
    const manifesto = document.querySelector('.manifesto');
    const submit = document.querySelector('.work-with-me__submit');
    if (!manifesto || !submit) return;
    /* `reached` latches once the Manifesto first scrolls in, so the
       pill doesn't flicker in the gap before the form. `buttonIn`
       hides it the instant the REAL submit button is on screen —
       the Manifesto is shorter than a viewport so the form section
       always peeks in behind it; keying off the actual button (not
       the whole section) is what gives the pill a real window and
       avoids doubling the button the user can already tap. */
    let reached = false;
    let buttonIn = false;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.target === manifesto && e.isIntersecting) reached = true;
        if (e.target === submit) buttonIn = e.isIntersecting;
      }
      setVisible(reached && !buttonIn);
    }, { threshold: 0 });
    io.observe(manifesto);
    io.observe(submit);
    return () => io.disconnect();
  }, []);
  return (
    <a
      href="#work-with-me"
      className={`mobile-jump-cta ${visible ? 'is-visible' : ''}`}
      aria-hidden={!visible}
      tabIndex={visible ? 0 : -1}
    >
      Join the crew
      <span aria-hidden>↗</span>
    </a>
  );
}

/* ============================================================
   ScrollProgress — thin top-edge progress hairline on phones.
   The page is ~7 viewports tall and dominated by pinned
   scrolljack, so the native scrollbar conveys almost no sense of
   position. This gives a quiet orientation cue. Passive scroll
   listener + rAF throttle (never blocks the scroll thread);
   updates a single `scaleX` so it stays on the compositor. Bails
   on desktop. Works under both Lenis and native scroll since it
   reads window.scrollY each frame. */
function ScrollProgress() {
  const fillRef = useRef(null);
  useEffect(() => {
    if (!window.matchMedia('(max-width: 720px)').matches) return;
    const fill = fillRef.current;
    if (!fill) return;
    let ticking = false;
    const update = () => {
      ticking = false;
      const max = document.documentElement.scrollHeight - window.innerHeight;
      const p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
      fill.style.transform = `scaleX(${p.toFixed(4)})`;
    };
    const onScroll = () => {
      if (!ticking) { ticking = true; requestAnimationFrame(update); }
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, []);
  return (
    <div className="scroll-progress" aria-hidden="true">
      <div className="scroll-progress__fill" ref={fillRef} />
    </div>
  );
}

/* ============================================================
   VelocityMarquee — an editorial keyword band that drifts slowly at
   rest and SURGES + leans (skew) with scroll velocity, reading
   window.__scrollVel from the velocity layer in index.html. This is
   the showcase for the momentum feel the owner loved: scroll fast
   and the strip accelerates and tilts into the motion, then eases
   back to a slow drift. Transform-only (compositor) rAF loop; static
   under reduced-motion. Content is the core offerings, tripled for a
   seamless wrap.
   ============================================================ */
function VelocityMarquee() {
  const rootRef = useRef(null);
  const trackRef = useRef(null);
  // Pauses the leftward drift while the user hovers/focuses the bar so
  // the now-clickable keyword links are easy to hit.
  const pausedRef = useRef(false);
  /* Latch the bar to a fixed strip under the nav once the user has
     scrolled past the sprout (the whole intro-cover-jack scrolls above
     the nav line). Once latched it never un-sticks — it stays pinned
     even when scrolling back to the top. Tracks the live nav height so
     it always sits just under the logo. */
  useEffect(() => {
    const root = rootRef.current;
    const icj = document.querySelector('.intro-cover-jack');
    const nav = document.querySelector('.nav');
    if (!root || !icj) return;
    const navH = () => (nav ? nav.offsetHeight : 64);
    const setTop = () => root.style.setProperty('--marquee-top', navH() + 'px');
    setTop();
    window.addEventListener('resize', setTop, { passive: true });
    let latched = false;
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        // boundingClientRect.bottom <= navH means the section has
        // scrolled fully above the nav line (we're past the sprout).
        if (!latched && e.boundingClientRect.bottom <= navH()) {
          latched = true;
          root.classList.add('is-stuck');
          io.disconnect();
        }
      }
    }, { threshold: 0 });
    io.observe(icj);
    return () => { io.disconnect(); window.removeEventListener('resize', setTop); };
  }, []);
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    let x = 0;
    let raf = 0;
    const BASE = 0.5;   // px/frame idle leftward drift
    const BOOST = 13;   // scroll-velocity contribution
    const loop = () => {
      const v = window.__scrollVel || 0;
      if (!pausedRef.current) x -= BASE + Math.abs(v) * BOOST;
      const oneRun = track.scrollWidth / 3;   // content is tripled
      if (oneRun && x <= -oneRun) x += oneRun;
      track.style.transform =
        `translate3d(${x.toFixed(2)}px,0,0) skewX(${(v * -3).toFixed(2)}deg)`;
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, []);
  // Each keyword links to its service FAQ page — the rotating header
  // doubles as a discovery nav into the AEO FAQ set.
  const items = [
    { label: 'AI Automations',       href: '/ai-automation-faq.html' },
    { label: 'Custom Software',      href: '/custom-software-faq.html' },
    { label: 'Beautiful Websites',   href: '/web-design-faq.html' },
    { label: 'SEO Rankings',         href: '/seo-services-faq.html' },
    { label: 'Ad Campaigns',         href: '/paid-advertising-faq.html' },
    { label: 'Funnels that Convert', href: '/marketing-funnels-faq.html' },
  ];
  // Tripled for the seamless wrap. Only the first copy is exposed to
  // assistive tech + keyboard; the duplicates are decorative.
  const run = [0, 1, 2].flatMap((copy) => items.map((it) => ({ ...it, copy })));
  const pause = () => { pausedRef.current = true; };
  const resume = () => { pausedRef.current = false; };
  return (
    <nav
      className="velo-marquee"
      ref={rootRef}
      aria-label="Explore service FAQs"
      onMouseEnter={pause}
      onMouseLeave={resume}
      onFocusCapture={pause}
      onBlurCapture={resume}
    >
      <div className="velo-marquee__track" ref={trackRef}>
        {run.map((it, i) => (
          <span className="velo-marquee__item" key={i}>
            <a
              className="velo-marquee__text"
              href={it.href}
              aria-hidden={it.copy > 0 ? 'true' : undefined}
              tabIndex={it.copy > 0 ? -1 : undefined}
            >{it.label}</a>
            <span className="velo-marquee__sep" aria-hidden="true">✦</span>
          </span>
        ))}
      </div>
    </nav>
  );
}

/* ============================================================
   Preloader — first-visit "curtain." The Crewsive wordmark fixed at
   the top (exactly where the nav logo lands) FILLS left-to-right with
   its caustic gradient as a load progress indicator: a dim ghost copy
   sits under a caustic-filled copy that's clipped from the left,
   revealed by load progress. Dismissed on the REAL window load event
   (a timed loader on a fast page just inflates LCP), with a min-
   display so it doesn't flash and a hard fallback so it never traps.
   Once per session; skipped under reduced-motion. On exit the dark
   overlay cross-fades out — the filled preloader logo and the nav's
   own caustic logo coincide, so it reads as one continuous mark.
   ============================================================ */
function Preloader() {
  const skip = (() => {
    try { if (sessionStorage.getItem('crewsive_preloaded')) return true; } catch (e) {}
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  })();
  const [done, setDone] = useState(skip);
  const [exiting, setExiting] = useState(false);
  const [count, setCount] = useState(0);

  useEffect(() => {
    if (skip) return;
    document.body.classList.add('is-preloading');
    let raf = 0;
    let loaded = false;
    const start = performance.now();
    const COUNT_DUR = 1100;
    const tick = (now) => {
      const p = Math.min(1, (now - start) / COUNT_DUR);
      const target = loaded ? 100 : Math.round(p * 90);
      setCount((c) => (target > c ? target : c));
      if (!loaded || count < 100) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    let finished = false;
    const finish = () => {
      if (finished) return;
      finished = true;
      loaded = true;
      setCount(100);
      // Hold "100" a beat, play the curtain exit, then unmount.
      setTimeout(() => setExiting(true), 360);
      setTimeout(() => {
        try { sessionStorage.setItem('crewsive_preloaded', '1'); } catch (e) {}
        document.body.classList.remove('is-preloading');
        setDone(true);
      }, 360 + 780);
    };

    if (document.readyState === 'complete') {
      setTimeout(finish, 650);              // min display so it doesn't flash
    } else {
      window.addEventListener('load', () => setTimeout(finish, 250), { once: true });
    }
    const fallback = setTimeout(finish, 3500);   // never trap the user

    return () => {
      cancelAnimationFrame(raf);
      clearTimeout(fallback);
      document.body.classList.remove('is-preloading');
    };
    // eslint-disable-next-line
  }, []);

  if (done) return null;
  return (
    <div className={`preloader ${exiting ? 'preloader--out' : ''}`} aria-hidden="true">
      {/* Two stacked copies of the SAME wordmark at the nav-logo
          position. Ghost = dim outline (empty state); fill = caustic
          gradient, clipped from the right by load progress so it
          reveals left-to-right. */}
      <div className="preloader__logo" style={{ '--p': count + '%' }}>
        <span className="preloader__logo-ghost"><CrewsiveWordmark height={36} /></span>
        <span className="preloader__logo-fill"><CrewsiveWordmark height={36} causticFill /></span>
      </div>
    </div>
  );
}

/* ============================================================
   HeroMeta — top-level fixed strip carrying the location +
   live local time. Used to live inside the hero shelf and
   scroll out with it; pulled up to App-level so it stays
   pinned at the top of the viewport across the hero AND the
   IntroCoverJack white stage, then fades to 0 as the circle
   bloom finishes covering the viewport. mix-blend-mode:difference
   keeps the strip readable across the dark hero, the white
   stage, and the post-inversion dark stage without manual
   color swaps.
   ============================================================ */
function HeroMeta() {
  const [now, setNow] = useState(() => new Date());
  const ref = useRef(null);

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 60000);
    return () => clearInterval(id);
  }, []);

  /* Scroll-driven fade. Sample raw progress through the
     IntroCoverJack pin distance and fade the strip from 1 → 0
     as the circle bloom completes (FADE_END matches CIRCLE_END
     in IntroCoverJack — 0.46 of the 560vh pin). Stays at full
     opacity anywhere else on the page. */
  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const el = ref.current;
    if (!el) return;
    const wrap = document.querySelector('.intro-cover-jack');

    const FADE_START = 0.40;
    const FADE_END   = 0.46;

    let raf = 0;
    const apply = () => {
      raf = 0;
      if (!wrap) { el.style.opacity = '1'; return; }
      const r = wrap.getBoundingClientRect();
      const sd = wrap.offsetHeight - window.innerHeight;
      if (sd <= 0) { el.style.opacity = '1'; return; }
      const scrolled = Math.max(0, Math.min(sd, -r.top));
      const raw = scrolled / sd;
      let opacity;
      if (raw <= FADE_START) opacity = 1;
      else if (raw >= FADE_END) opacity = 0;
      else opacity = 1 - (raw - FADE_START) / (FADE_END - FADE_START);
      el.style.opacity = opacity.toFixed(3);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(apply); };
    apply();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });

  return (
    <div className="hero-meta" ref={ref} aria-hidden="true">
      <span aria-hidden />
      <span>{time} PT</span>
    </div>
  );
}

/* ============================================================
   Rolldown — vaan-group-style per-character vertical swap.
   Each character is its own inline-block span with a staggered
   transition-delay (via the --i custom property), so on hover
   the original label rolls up and out one letter at a time
   while the replacement copy rolls in from below the same way.
   Real spaces are preserved between words so multi-line copy
   wraps naturally; the space slot also counts toward the
   stagger so there's no rhythm break across word boundaries.
   ============================================================ */
function splitChars(text) {
  let i = 0;
  const out = [];
  const words = text.split(' ');
  words.forEach((word, wi) => {
    const charSpans = Array.from(word).map((ch, ci) => (
      <span key={`c${ci}`} className="char" style={{ "--i": i++ }}>
        {ch}
      </span>
    ));
    // Wrap each word so its chars stay together — line-wrapping
    // can only happen at the real spaces BETWEEN words.
    out.push(
      <span key={`w${wi}`} className="word">{charSpans}</span>
    );
    if (wi < words.length - 1) {
      out.push(' ');
      i++; // keep stagger continuous across the space
    }
  });
  return out;
}

function Rolldown({ label, detail, size = 'sm' }) {
  return (
    <div className={`rolldown rolldown--${size}`} role="listitem" tabIndex={0}>
      {/* label sits in a clipped frame so its rolling chars get hidden
          when they leave the row's bounds */}
      <span className="rolldown__label-clip">
        <span className="rolldown__label" aria-label={label}>
          {splitChars(label)}
        </span>
      </span>
      {/* detail is absolutely positioned over the label position and
          allowed to overflow downward — the row never resizes */}
      <span className="rolldown__detail" aria-hidden="true">
        {splitChars(detail)}
      </span>
    </div>
  );
}

/* ============================================================
   Hero — current (v2, 2026-05-07).

   Copy grounded in voice-of-customer research run 2026-05-07.
   Lead with the universal mission ("I help businesses grow"),
   first-person, with the offer named in the sub line.

   The previous hero (cascade animation + headless-i + i-ball
   choreography that arced from "business" to land on "Together",
   plus the three-pillar Together / To Your Data / To Your
   Customers rolldowns) is fully archived at
   `archive/hero-v1-2026-05-07.md` along with revival instructions.

   The cascade per-character animation is preserved here — every
   .hw-char still gets a --ci index and animates in. The i-ball
   choreography and rolldown push-wave delays are gone (no
   destination to land on without rolldowns), but `Rolldown`,
   `splitChars`, and all related CSS classes remain in place so
   the form can be revived later without rebuilding it.
   ============================================================ */
function Hero() {
  const headlineRef = useRef(null);
  const howTagRef = useRef(null);
  const hintRef = useRef(null);

  /* "how?" tag entrance — armed ~1.2s after mount so it lands
     just as the headline's tail letters are settling, then the
     row fades in and its own .hw-char chars rise via the shared
     mask machinery. */
  useEffect(() => {
    const tag = howTagRef.current;
    if (!tag) return;
    void tag.offsetHeight;
    const id = setTimeout(() => {
      tag.classList.add('is-armed');
      tag.classList.add('revealed');
    }, 1200);
    return () => clearTimeout(id);
  }, []);

  /* Lock-in + post-reveal scroll hint. First hover/focus locks the
     rolled-up paragraph open for the session. ~1.4s after that lock
     fires, if the user hasn't scrolled, a "check it out ↓" hint
     fades in below the paragraph. The first real scroll fades the
     hint back out — its job is to point users to the rest of the
     page, not linger once they've taken the cue. */
  useEffect(() => {
    const tag = howTagRef.current;
    const hint = hintRef.current;
    if (!tag) return;

    let revealTimer = 0;
    let scrollOff = null;
    /* Higher than the headline's exit threshold (10vh) so the
       hint stays visible while the user begins scrolling. The
       counter-translate in the parallax handler keeps it pinned
       to the same viewport position the whole time. */
    const SCROLL_THRESHOLD = 380;

    const showHintIfStill = () => {
      if (!hint) return;
      if (window.scrollY > SCROLL_THRESHOLD) return;
      hint.classList.add('is-shown');
      const onScroll = () => {
        if (window.scrollY > SCROLL_THRESHOLD) {
          hint.classList.remove('is-shown');
          window.removeEventListener('scroll', onScroll);
          scrollOff = null;
        }
      };
      window.addEventListener('scroll', onScroll, { passive: true });
      scrollOff = onScroll;
    };

    const lock = () => {
      tag.classList.add('is-locked');
      revealTimer = setTimeout(showHintIfStill, 1400);
    };
    tag.addEventListener('mouseenter', lock, { once: true });
    tag.addEventListener('focus', lock, { once: true, capture: true });

    return () => {
      tag.removeEventListener('mouseenter', lock);
      tag.removeEventListener('focus', lock, true);
      if (revealTimer) clearTimeout(revealTimer);
      if (scrollOff) window.removeEventListener('scroll', scrollOff);
    };
  }, []);

  /* Headline reveal — adds `.revealed` to the <h1> on the second
     paint frame so the initial off-screen state lands as a real
     paint before the transition kicks in. Per-letter timing and
     rotation are baked into inline CSS variables (--cd, --cr) at
     render time; the `.revealed` class flips them to their resting
     values via a long expoOut curve. */
  useEffect(() => {
    const h1 = headlineRef.current;
    if (!h1) return;
    // Force a layout read so the initial off-screen state is
    // committed as a real paint, then flip the class. Browsers
    // throttle rAF in unfocused tabs, so a plain microtask + a
    // forced reflow is more reliable than chained rAF here.
    void h1.offsetHeight;
    const id = setTimeout(() => h1.classList.add('revealed'), 0);
    return () => clearTimeout(id);
  }, []);

  /* Headline scroll-exit — once the user starts to scroll past a
     small threshold, each character drops back below its line
     mask with a snappy curve, mirroring the entrance in reverse.
     Stagger is tighter than the entrance (15ms/char) so the exit
     feels like a single coordinated gesture. Restored on scroll
     back to top so the entrance animation can replay. */
  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const h1 = headlineRef.current;
    if (!h1) return;
    const chars = h1.querySelectorAll('.hw-char');
    if (!chars.length) return;
    chars.forEach(c => { c.dataset.entryDelay = c.style.transitionDelay || ''; });

    const STEP_MS = 15;
    /* Exit threshold — wait until the user has scrolled ~20% of
       a viewport before dropping the headline. Anything smaller
       (we tried 8px first) made the exit fire on the merest
       trackpad twitch and felt jumpy. Tied to viewport height so
       the trigger feels proportionally the same on a 13" laptop
       and a 4K desktop. */
    const exitThreshold = () => Math.max(60, window.innerHeight * 0.10);
    let exiting = false;
    let raf = 0;
    const apply = () => {
      raf = 0;
      const shouldExit = window.scrollY > exitThreshold();
      if (shouldExit && !exiting) {
        exiting = true;
        chars.forEach((c, i) => {
          c.style.transitionDelay = `${i * STEP_MS}ms`;
          c.classList.add('is-exit');
        });
      } else if (!shouldExit && exiting) {
        exiting = false;
        chars.forEach(c => {
          c.classList.remove('is-exit');
          c.style.transitionDelay = c.dataset.entryDelay || '';
        });
      }
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(apply); };
    apply();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  /* Scroll-jack motion. As the user scrolls through the first
     100vh of the page, translate the shelf upward by an equal
     amount and apply gentle parallax to the how-tag for depth.
     The headline is intentionally excluded — it has its own
     per-letter exit (see useEffect above) and parallax on top of
     that would compound transforms and look chaotic. The meta-strip
     used to live inside this transform and parallax in tandem with
     the shelf, but it's now a top-level fixed `<HeroMeta />` that
     persists through the IntroCoverJack stage and fades out with
     the circle bloom — see HeroMeta below.
     Reduced-motion skips this — RAF-throttled, no React re-renders. */
  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const shelf = document.querySelector('.hero-jack__shelf');
    if (!shelf) return;
    const howtag = document.querySelector('.hero-howtag');
    const hint   = document.querySelector('.hero-howtag__hint');

    const PARALLAX_HOWTAG = 0.32;

    let raf = 0;
    const apply = () => {
      raf = 0;
      const vh = window.innerHeight;
      const t = Math.min(Math.max(window.scrollY, 0), vh);

      shelf.style.transform = `translate3d(0, ${-t}px, 0)`;
      if (howtag) howtag.style.transform = `translate3d(0, ${-t * PARALLAX_HOWTAG}px, 0)`;
      /* Hint is a child of .hero-howtag and inherits both the
         shelf's −t transform and the howtag's extra −t·0.32. We
         counter-translate by exactly that combined amount so the
         hint stays anchored to its starting viewport position
         while everything around it parallaxes. The translateX(-50%)
         is preserved here because JS inline transform overrides
         the CSS's translateX(-50%) centering. */
      if (hint)   hint.style.transform   = `translate(-50%, ${t * (1 + PARALLAX_HOWTAG)}px)`;
    };
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(apply);
    };
    apply();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <header className="hero" id="top">
      <div className="hero__content">
        {/* meta-strip lives at top-level now (see <HeroMeta />) so it
            persists through the IntroCoverJack white stage and fades
            out with the circle bloom rather than scrolling out with
            the hero shelf. */}
        <div className="stage">
          {(() => {
            // Per-letter Ouro-style entrance: each .hw-char gets a
            // randomized duration (--cd), small rotation (--cr),
            // and staggered delay, baked in via inline styles.
            // Seed-based PRNG keeps the values stable across the
            // minute-tick re-render of the parent Hero.
            const heroWords = [
              { t: 'I' },
              { t: 'help' },
              { t: 'businesses', em: true },
              { t: 'grow.', em: true },
            ];
            const seedRand = (n) => {
              const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
              return x - Math.floor(x);
            };
            let ci = 0;
            return (
              <h1 className="serif" ref={headlineRef}>
                {heroWords.map((w, wi) => (
                  <React.Fragment key={wi}>
                    <span className={'hw-word' + (w.em ? ' em' : '')}>
                      {Array.from(w.t).map((ch, chi) => {
                        const i = ci++;
                        const r1 = seedRand(i * 3 + 1);
                        const r2 = seedRand(i * 3 + 2);
                        const r3 = seedRand(i * 3 + 3);
                        const dur = (0.82 + r1 * 0.45).toFixed(2);   // 0.82s – 1.27s
                        const rot = (r2 * 4 - 2).toFixed(1);          // -2deg – +2deg
                        const delay = Math.round(60 + r3 * 320 + i * 18); // 60–380ms + per-letter walk
                        return (
                          <span
                            key={chi}
                            className="hw-char"
                            style={{
                              '--ci': i,
                              '--cd': `${dur}s`,
                              '--cr': `${rot}deg`,
                              transitionDelay: `${delay}ms`,
                            }}
                          >
                            {ch}
                          </span>
                        );
                      })}
                    </span>
                    {wi < heroWords.length - 1 ? ' ' : ''}
                  </React.Fragment>
                ))}
              </h1>
            );
          })()}

          {(() => {
            // Secondary "how?" tag — rises in like the hero (per
            // letter, masked, randomized timing). On hover, the
            // word slides up and out and the paragraph rises into
            // the same visual line, exactly like the original
            // Crewsive "Together" rolldown. After the first hover,
            // a `.is-locked` class on the parent keeps the
            // paragraph visible for the rest of the session.
            //
            // The detail copy is a placeholder — swap it for the
            // real answer to "how?".
            const howWord = 'how?';
            const detailText = 'Custom-built websites, SEO, automations, software, ad systems, and reporting — end-to-end, by one person.';
            const seedRand = (n) => {
              const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
              return x - Math.floor(x);
            };
            const detailWords = detailText.split(' ');
            let detIdx = 0;
            return (
              <div className="hero-howtag" ref={howTagRef} tabIndex={0}>
                <div className="hero-howtag__rd">
                  <span className="hero-howtag__label">
                    <span className="hw-word hero-howtag__label-word">
                      {Array.from(howWord).map((ch, i) => {
                        const r1 = seedRand((i + 100) * 3 + 1);
                        const r2 = seedRand((i + 100) * 3 + 2);
                        const r3 = seedRand((i + 100) * 3 + 3);
                        const dur = (0.85 + r1 * 0.40).toFixed(2);
                        const rot = (r2 * 4 - 2).toFixed(1);
                        const delay = Math.round(40 + r3 * 220 + i * 22);
                        return (
                          <span
                            key={i}
                            className="hw-char"
                            style={{
                              '--cd': `${dur}s`,
                              '--cr': `${rot}deg`,
                              transitionDelay: `${delay}ms`,
                            }}
                          >
                            {ch}
                          </span>
                        );
                      })}
                    </span>
                  </span>
                  <span className="hero-howtag__detail" aria-hidden="true">
                    {detailWords.map((word, wi) => (
                      <React.Fragment key={wi}>
                        <span className="hh-det-word">
                          {Array.from(word).map((ch, ci) => (
                            <span
                              key={ci}
                              className="hh-det-char"
                              style={{ '--i': detIdx++ }}
                            >
                              {ch}
                            </span>
                          ))}
                        </span>
                        {wi < detailWords.length - 1 ? ' ' : ''}
                      </React.Fragment>
                    ))}
                  </span>
                  <div className="hero-howtag__hint" ref={hintRef} aria-hidden="true">
                    <span className="hero-howtag__hint-text">Check it out</span>
                    <svg
                      className="hero-howtag__hint-arrow"
                      viewBox="0 0 12 32"
                      width="12"
                      height="32"
                      aria-hidden="true"
                    >
                      {/* Thin stem + chevron, drafted-line aesthetic to
                         match the wordmark's chamfered geometry. */}
                      <line x1="6" y1="0" x2="6" y2="24" stroke="currentColor" strokeWidth="1.25" />
                      <polyline
                        points="1,20 6,26 11,20"
                        stroke="currentColor"
                        strokeWidth="1.25"
                        fill="none"
                        strokeLinecap="square"
                        strokeLinejoin="miter"
                      />
                    </svg>
                  </div>
                </div>
              </div>
            );
          })()}
        </div>
      </div>
    </header>
  );
}

/* ============================================================
   Intro — intentionally blank white card. Sits behind the hero
   shelf during the hero-jack lift; its only job right now is to
   be the clean white surface that the dark Hero card rises off
   of, and that the IntroCoverJack scroll-jack reveals.
   ============================================================ */
function Intro() {
  return <section className="intro" aria-hidden="true" />;
}

/* ============================================================
   IntroCoverJack — scroll-jacked transition that comes
   immediately after the hero-jack. A dark card covers a little
   more than the left third of the viewport. As the user scrolls
   through the sticky zone, the dark card translates left until
   it's fully off-screen, leaving the blank white page behind.
   The right side of the viewport stays white the entire time
   because the cover only spans ~37% of the width.
   ============================================================ */
function IntroCoverJack() {
  const wrapRef = useRef(null);

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    const cover = wrap.querySelector('.intro-cover-jack__cover');
    if (!cover) return;
    const phrase = wrap.querySelector('.intro-cover-jack__phrase');
    const phraseChars = phrase ? phrase.querySelectorAll('.hw-char') : [];
    // Cache each char's original entry delay so we can restore it
    // after an exit. Exit uses a tighter, staggered timing.
    phraseChars.forEach((c) => { c.dataset.entryDelay = c.style.transitionDelay || ''; });
    const circle = wrap.querySelector('.intro-cover-jack__circle');
    /* Second cascade — "Hi, I'm Luke." rises in *after* the user
       has scrolled most of the way into the inverted circle. The
       cascade itself reuses the shared `.hw-word` / `.hw-char`
       rise machinery; we just toggle `.revealed` on its parent. */
    const hi = wrap.querySelector('.intro-cover-jack__hi');
    /* Cache HI chars + their original entry-cascade delays so the
       reverse (scroll-up past HI_EXIT_AT) can restore them
       cleanly. Mirrors the phraseChars caching above. */
    const hiChars = hi ? hi.querySelectorAll('.hw-char') : [];
    hiChars.forEach((c) => { c.dataset.entryDelay = c.style.transitionDelay || ''; });
    /* Fourth cascade — "I help businesses get more customers and
       streamline workflows with custom tailored solutions" rises
       mid donut→sprout morph and holds across the entire sprout stage.
       No per-char exit animation — the line stays put until the
       pin releases and the whole stage scrolls out as a unit. */
    const love = wrap.querySelector('.intro-cover-jack__love');
    const loveChars = love ? love.querySelectorAll('.hw-char') : [];
    loveChars.forEach((c) => { c.dataset.entryDelay = c.style.transitionDelay || ''; });
    let loveRevealed = false;
    let hiRevealed = false;
    let hiExiting = false;
    let phraseRevealed = false;
    /* exitMode tracks which kind of fall we're showing right now:
         null  = at rest (cascade-in done, chars at baseline)
         'fwd' = forward, scroll-driven inline-transform fall — chars
                 follow scroll position so they "rain down" in lockstep
                 with the circle bloom rather than firing as a one-shot
                 timed CSS transition
         'bwd' = backward, legacy class-based stagger fall when the user
                 scrolls back above REVEAL_AT after having revealed once
       Mode-switch transitions cleanly clear the previous mode's state
       (inline transform + transition for fwd, .is-exit class +
       transitionDelay for bwd). */
    let exitMode = null;
    const EXIT_STEP_MS = 15;

    /* Tunable thresholds — fractions of sticky-scroll progress
       (raw 0 → 1 across the stage's pin distance).
         REVEAL_AT             cascade-in begins
         COVER_START..         dark cover slides off the left
           ..COVER_END
         PHRASE_EXIT_AT..      scroll-driven per-char drop-out;
           ..PHRASE_EXIT_END   completes well before the circle
                               finishes so the headline is gone
                               by mid-bloom
         CIRCLE_START..        white-difference circle grows from
           ..CIRCLE_END          center, inverting white→black +
                                 dark→light
       No parallax on the phrase — a `transform` on it would
       create a stacking context that traps the italic accent's
       z-index inside, defeating the circle's blue-stays-blue
       carve-out. */
    /* Wrapper is 400vh = 300vh of pin scroll. Setup phase (cover
       slide + phrase + circle bloom) runs as before (0.04 → 0.46).
       Dots area is now donut → sprout only (hammer + answer copy
       removed). Timeline:
         0.04–0.33  cover slide + phrase reveal/exit + circle bloom
         0.34–0.43  donut SPAWN (Hi-Luke rises with it)
         0.43–0.52  donut HOLD continues — total donut hold = 0.18
         0.52–0.58  donut → sprout morph (Hi exits at 0.52;
                    Love rises mid-morph at 0.55)
         0.58–1.00  sprout HOLD — "I help businesses get more
                    customers and streamline workflows with custom
                    tailored solutions" holds across the entire
                    stage; per-char .is-exit drop fires only at
                    0.97, as the user is actively scrolling out
                    — same gesture the hero headline uses. */
    const REVEAL_AT       = 0.04;
    const COVER_START     = 0.12;
    const COVER_END       = 0.23;
    const PHRASE_EXIT_AT  = 0.25;
    const PHRASE_EXIT_END = 0.33;
    const CIRCLE_START    = 0.25;
    /* Circle bloom finishes exactly as the donut stage settles so
       the inverted area is at full size before the HI dwell. */
    const CIRCLE_END      = 0.46;
    /* Trigger the "Hi, I'm Luke." cascade once the circle has
       bloomed enough that the centered headline lands inside the
       inverted area — otherwise the dark text would briefly read
       as dark-on-white outside the circle's edge. */
    const HI_REVEAL_AT    = 0.34;
    /* HI exits exactly at the donut → sprout morph start, so the
       text leaves as the dots begin rearranging into the leaf. */
    const HI_EXIT_AT      = 0.52;
    const HI_EXIT_STEP_MS = 15;
    /* Love copy rises mid donut → sprout morph (0.52..0.58),
       lands in place as the dots resettle into the sprout, then
       holds across the long sprout stage. Per-char DROPS below
       the line mask at LOVE_EXIT_AT as the user begins scrolling
       out of the pinned section — same .is-exit gesture the hero
       headline uses on first scroll. */
    const LOVE_REVEAL_AT      = 0.55;
    let raf = 0;
    const apply = () => {
      raf = 0;
      const r = wrap.getBoundingClientRect();
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      /* On phones the cover fills the whole viewport (it shares
         the screen with the phrase on desktop). So slide the
         cover off a touch sooner on mobile to give the phrase
         ("That's what I build for.") behind it a real visible
         beat before its own exit at PHRASE_EXIT_AT. */
      const isMobile = vw <= 720;
      const coverEnd = isMobile ? 0.18 : COVER_END;
      /* On phones the cover is FULL-SCREEN, so the phrase must
         stay hidden until the cover has completely cleared
         (coverEnd = 0.18) — otherwise it cascades in behind the
         cover and is just "already there" when the cover wipes
         off. Reveal a hair after the cover clears, hold through
         the middle, then drop out by ~0.34 (just before the
         donut / "Hi, I'm Luke." rises at HI_REVEAL_AT). On
         desktop the cover is only the left panel and the phrase
         sits beside it, so it can reveal early — keep originals. */
      const revealAt      = isMobile ? 0.19 : REVEAL_AT;
      const phraseExitAt  = isMobile ? 0.40 : PHRASE_EXIT_AT;
      const phraseExitEnd = isMobile ? 0.46 : PHRASE_EXIT_END;
      /* The phrase now holds much longer on mobile (0.19 → 0.40 vs
         0.30). To avoid colliding with the donut/circle, shift the
         WHOLE post-phrase timeline (Hi, sprout, circle) later by the
         same amount — relative spacing is preserved, so the
         choreography stays intact, just packed into the back half of
         the pin. Desktop keeps every original value (POST = 0). */
      const POST         = isMobile ? 0.20 : 0;
      const circleStart  = isMobile ? 0.40 : CIRCLE_START;
      const circleEnd    = CIRCLE_END + POST;
      const hiRevealAt   = HI_REVEAL_AT + POST;
      const hiExitAt     = HI_EXIT_AT + POST;
      const loveRevealAt = LOVE_REVEAL_AT + POST;
      const stickyDistance = Math.max(1, wrap.offsetHeight - vh);
      const scrolled = Math.max(0, Math.min(stickyDistance, -r.top));
      const raw = scrolled / stickyDistance;

      // Phrase reveal / exit.
      //   beforeRev (first time)    : not revealed, no exit
      //   normal window             : revealed, no exit
      //   pastFwd (raw≥PHRASE_EXIT) : revealed, scroll-driven fwd fall
      //   beforeRev (after reveal)  : revealed, class-based bwd fall
      if (phrase && phraseChars.length) {
        const beforeRev = raw <= revealAt;
        const inFwdExit = raw >= phraseExitAt;

        if (!beforeRev && !phraseRevealed) {
          phraseRevealed = true;
          phrase.classList.add('revealed');
        }

        if (inFwdExit) {
          // Leaving bwd mode — strip its class state.
          if (exitMode === 'bwd') {
            phraseChars.forEach((c) => {
              c.classList.remove('is-exit');
              c.style.transitionDelay = c.dataset.entryDelay || '';
            });
          }
          // Entering fwd mode — kill CSS transitions so the inline
          // transform we set on each frame tracks scroll directly.
          if (exitMode !== 'fwd') {
            phraseChars.forEach((c) => { c.style.transition = 'none'; });
            exitMode = 'fwd';
          }
          /* Scroll-driven per-char fall. Each char gets its own
             segment of the exit window:
               start    = (i / N) * STAGGER_SPREAD
               duration = FALL_DURATION
             So the leftmost char begins falling at exitProg ≈ 0
             and the rightmost finishes around exitProg ≈ 1.
             Quadratic ease-in approximates gravity. The vertical
             distance (1.35em + 16px) matches the hero's `.is-exit`
             rule so the fall feels like the same gesture. */
          const STAGGER_SPREAD = 0.55;
          const FALL_DURATION  = 0.45;
          const exitWin = phraseExitEnd - phraseExitAt;
          const exitProg = Math.min(1, (raw - phraseExitAt) / exitWin);
          const N = phraseChars.length;
          phraseChars.forEach((c, i) => {
            const start = (i / Math.max(1, N - 1)) * STAGGER_SPREAD;
            const cp = Math.max(0, Math.min(1, (exitProg - start) / FALL_DURATION));
            const eased = cp * cp;
            c.style.transform = `translate3d(0, calc(${eased.toFixed(3)} * (1.35em + 16px)), 0.02px)`;
          });
        } else if (beforeRev && phraseRevealed) {
          // Strip fwd-mode inline state if we just left it.
          if (exitMode === 'fwd') {
            phraseChars.forEach((c) => {
              c.style.transform = '';
              c.style.transition = '';
            });
          }
          // Legacy backward fall — staggered class-based one-shot.
          if (exitMode !== 'bwd') {
            phraseChars.forEach((c, i) => {
              c.style.transitionDelay = `${i * EXIT_STEP_MS}ms`;
              c.classList.add('is-exit');
            });
            exitMode = 'bwd';
          }
        } else {
          // At rest — clear whichever mode we were just in.
          if (exitMode === 'fwd') {
            phraseChars.forEach((c) => {
              c.style.transform = '';
              c.style.transition = '';
            });
          }
          if (exitMode === 'bwd') {
            phraseChars.forEach((c) => {
              c.classList.remove('is-exit');
              c.style.transitionDelay = c.dataset.entryDelay || '';
            });
          }
          exitMode = null;
        }
      }

      // Cover slide. `coverEnd` is pulled earlier on mobile (see
      // top of apply) so the full-screen cover clears in time for
      // the phrase behind it to register before it exits.
      const cp = raw <= COVER_START ? 0
               : raw >= coverEnd    ? 1
               : (raw - COVER_START) / (coverEnd - COVER_START);
      cover.style.transform = `translate3d(${(-cp * 100).toFixed(2)}%, 0, 0)`;

      // "Hi, I'm Luke." reveal — toggle .revealed on the headline
      // when scroll crosses HI_REVEAL_AT. The cascade-in animation
      // uses the same shared CSS as every other rise group.
      if (hi) {
        if (raw > hiRevealAt && !hiRevealed) {
          hiRevealed = true;
          hi.classList.add('revealed');
        } else if (raw <= hiRevealAt && hiRevealed) {
          hiRevealed = false;
          hi.classList.remove('revealed');
        }

        /* Love copy reveal + per-char DROP exit — rises mid donut →
           sprout morph as the dots resolve into the leaf, holds
           through the long sprout stage, then drops below the
           .hw-word line mask once the user begins scrolling out of
           the pinned section. Mirrors Hi-Luke's exit: `.is-exiting`
           on the wrapper lifts the bottom clip so chars stay visible
           while falling, and `.is-exit` on each char drives the
           staggered drop. */
        if (love) {
          if (raw > loveRevealAt && !loveRevealed) {
            loveRevealed = true;
            love.classList.add('revealed');
          } else if (raw <= loveRevealAt && loveRevealed) {
            loveRevealed = false;
            love.classList.remove('revealed');
          }

        }

        /* Exit cascade — class-based fall, same gesture the hero
           h1 uses on first scroll. We add `.is-exiting` to the
           wrapper (which lifts the .hw-word bottom clip in CSS so
           chars remain visible while falling) and `.is-exit` to
           each char with a staggered transition-delay so they drop
           one-by-one. The donut already tracks the chars' bounding
           rects per frame, so collision happens automatically as
           they translate down through it. */
        const shouldHiExit = raw > hiExitAt && hiRevealed;
        if (shouldHiExit && !hiExiting) {
          hiExiting = true;
          hi.classList.add('is-exiting');
          hiChars.forEach((c, i) => {
            c.style.transitionDelay = `${i * HI_EXIT_STEP_MS}ms`;
            c.classList.add('is-exit');
          });
        } else if (!shouldHiExit && hiExiting) {
          hiExiting = false;
          hi.classList.remove('is-exiting');
          hiChars.forEach((c) => {
            c.classList.remove('is-exit');
            c.style.transitionDelay = c.dataset.entryDelay || '';
          });
        }
      }

      // Circle inversion. White overlay clipped to a circle that
      // grows from center; with mix-blend-mode: difference, the
      // area inside the circle inverts the white stage and dark
      // text simultaneously. Eased with ease-out quad so the
      // arrival feels softer than the departure.
      if (circle) {
        const t = raw <= circleStart ? 0
                : raw >= circleEnd   ? 1
                : (raw - circleStart) / (circleEnd - circleStart);
        const eased = 1 - (1 - t) * (1 - t);
        const maxR = Math.hypot(vw, vh) / 2 + 80;
        const radius = eased * maxR;
        circle.style.clipPath = `circle(${radius.toFixed(1)}px at 50% 50%)`;
      }
    };
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(apply);
    };
    apply();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  /* Desktop auto-snap REMOVED (2026-05-28). The old "sticky auto-pull
     to dwell points" effect lerped the page to the next rest state
     when you paused mid-transition — which read as the page scrolling
     by itself and fought the wheel. Section pacing now comes from even
     scroll-distance-per-stage (CSS section heights); momentum + native
     scroll handle the rest. This also removes a permanent rAF loop. */

  return (
    <div className="intro-cover-jack" ref={wrapRef}>
      <div className="intro-cover-jack__stage">
        {(() => {
          // Right-side phrase that cascades in like the hero h1.
          // Same `.hw-word` / `.hw-char` machinery so the existing
          // mask + per-char rise CSS does the heavy lifting; the
          // `.revealed` class is added by the scroll handler once
          // the user enters the sticky zone.
          const phraseWords = [
            { t: 'That’s' },
            { t: 'what' },
            { t: 'I' },
            { t: 'build', em: true },
            { t: 'for.', em: true },
          ];
          const seedRand = (n) => {
            const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
            return x - Math.floor(x);
          };
          let ci = 0;
          return (
            <h2 className="intro-cover-jack__phrase serif">
              <span className="intro-cover-jack__phrase-inner">
              {phraseWords.map((w, wi) => (
                <React.Fragment key={wi}>
                  <span className={'hw-word' + (w.em ? ' em' : '')}>
                    {Array.from(w.t).map((ch, chi) => {
                      const i = ci++;
                      const r1 = seedRand((i + 200) * 3 + 1);
                      const r2 = seedRand((i + 200) * 3 + 2);
                      const r3 = seedRand((i + 200) * 3 + 3);
                      const dur = (0.82 + r1 * 0.45).toFixed(2);
                      const rot = (r2 * 4 - 2).toFixed(1);
                      const delay = Math.round(60 + r3 * 320 + i * 18);
                      return (
                        <span
                          key={chi}
                          className="hw-char"
                          style={{
                            '--ci': i,
                            '--cd': `${dur}s`,
                            '--cr': `${rot}deg`,
                            transitionDelay: `${delay}ms`,
                          }}
                        >
                          {ch}
                        </span>
                      );
                    })}
                  </span>
                  {wi < phraseWords.length - 1 ? ' ' : ''}
                </React.Fragment>
              ))}
              </span>
            </h2>
          );
        })()}
        <div className="intro-cover-jack__cover">
          <h2 className="intro-cover-jack__headline serif">
            When the actual work isn’t the problem, the <em>business side</em> is.
          </h2>
        </div>
        {(() => {
          // Second cascade — "Hi, I'm Luke." Lives behind the
          // circle (z=auto) so the difference blend inverts its
          // dark ink to light inside the inverted area; "Luke."
          // is italicized + lifted above the circle so the brand
          // accent stays accent-blue regardless of the inversion.
          const hiWords = [
            { t: 'Hi,' },
            { t: 'I’m' },
            { t: 'Luke.', em: true },
          ];
          const seedRand = (n) => {
            const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
            return x - Math.floor(x);
          };
          let ci = 0;
          return (
            <h2 className="intro-cover-jack__hi serif" aria-hidden="true">
              <span className="intro-cover-jack__hi-inner">
                {hiWords.map((w, wi) => (
                  <React.Fragment key={wi}>
                    <span className={'hw-word' + (w.em ? ' em' : '')}>
                      {Array.from(w.t).map((ch, chi) => {
                        const i = ci++;
                        const r1 = seedRand((i + 400) * 3 + 1);
                        const r2 = seedRand((i + 400) * 3 + 2);
                        const r3 = seedRand((i + 400) * 3 + 3);
                        const dur = (0.82 + r1 * 0.45).toFixed(2);
                        const rot = (r2 * 4 - 2).toFixed(1);
                        const delay = Math.round(60 + r3 * 320 + i * 18);
                        return (
                          <span
                            key={chi}
                            className="hw-char"
                            style={{
                              '--ci': i,
                              '--cd': `${dur}s`,
                              '--cr': `${rot}deg`,
                              transitionDelay: `${delay}ms`,
                            }}
                          >
                            {ch}
                          </span>
                        );
                      })}
                    </span>
                    {wi < hiWords.length - 1 ? ' ' : ''}
                  </React.Fragment>
                ))}
              </span>
            </h2>
          );
        })()}
        {(() => {
          // Third cascade — "I help businesses get more customers
          // and streamline workflows with custom tailored solutions"
          // Rises mid donut→sprout morph and holds across the
          // entire sprout stage. No per-char exit animation — the
          // line stays put until the pin releases and the whole
          // stage scrolls out as a unit.
          // Regular ink at z-auto so the difference-blend circle
          // inverts it; italic accents lifted above the dot canvas
          // so they stay brand-blue.
          // The closing phrase "custom tailored solutions" carries the
          // .em accent — that's the brand promise. All other words
          // paint white via .intro-cover-jack__love's base color.
          const loveWords = [
            { t: 'I' },
            { t: 'help' },
            { t: 'businesses' },
            { t: 'get' },
            { t: 'more' },
            { t: 'customers' },
            { t: 'and' },
            { t: 'streamline' },
            { t: 'workflows' },
            { t: 'with' },
            { t: 'custom', em: true },
            { t: 'tailored', em: true },
            { t: 'solutions', em: true },
          ];
          const seedRand = (n) => {
            const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
            return x - Math.floor(x);
          };
          let ci = 0;
          return (
            <h2 className="intro-cover-jack__love serif" aria-hidden="true">
              <span className="intro-cover-jack__love-inner">
                {loveWords.map((w, wi) => (
                  <React.Fragment key={wi}>
                    <span className={'hw-word' + (w.em ? ' em' : '')}>
                      {Array.from(w.t).map((ch, chi) => {
                        const i = ci++;
                        const r1 = seedRand((i + 1200) * 3 + 1);
                        const r2 = seedRand((i + 1200) * 3 + 2);
                        const r3 = seedRand((i + 1200) * 3 + 3);
                        const dur = (0.82 + r1 * 0.45).toFixed(2);
                        const rot = (r2 * 4 - 2).toFixed(1);
                        const delay = Math.round(40 + r3 * 200 + i * 10);
                        return (
                          <span
                            key={chi}
                            className="hw-char"
                            style={{
                              '--ci': i,
                              '--cd': `${dur}s`,
                              '--cr': `${rot}deg`,
                              transitionDelay: `${delay}ms`,
                            }}
                          >
                            {ch}
                          </span>
                        );
                      })}
                    </span>
                    {wi < loveWords.length - 1 ? ' ' : ''}
                  </React.Fragment>
                ))}
              </span>
            </h2>
          );
        })()}
        <div className="intro-cover-jack__circle" aria-hidden="true" />
      </div>
    </div>
  );
}

/* ============================================================
   Manifesto — the first "normal website" section the page
   scrolls into after the IntroCoverJack ends. Calm, dark,
   editorial; uses the same tokens as the rest of the site
   so it reads as a graceful continuation, not a new chapter.
   Layout:
     • Eyebrow + section number (mono uppercase)
     • Big serif statement (no italic accents — that visual
       move was used hard in the scrolljack; this section is
       the quieter follow-through)
     • Three-card services strip — AI, sites, discovery
     • Closing CTA row with email + scroll-to-top
   ============================================================ */
function Manifesto() {
  return (
    <section className="manifesto" id="cta">
      <div className="manifesto__inner">
        <h2 className="manifesto__statement serif">I build:</h2>

        {/* The four core offerings — paired in a 2x2 grid. These
            are the front-of-house outputs people see and buy. */}
        <ul className="manifesto__boxes">
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              Profitable ad campaigns
            </span>
            <a className="manifesto__box-link" href="/paid-advertising.html" aria-label="Profitable ad campaigns — paid advertising"></a>
          </li>
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              SEO rankings that crush your competitors
            </span>
            <a className="manifesto__box-link" href="/seo-services.html" aria-label="SEO rankings that crush your competitors — SEO services"></a>
          </li>
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              Beautiful websites that sell
            </span>
            <a className="manifesto__box-link" href="/web-design.html" aria-label="Beautiful websites that sell — web design"></a>
          </li>
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              Funnels that convert
            </span>
            <a className="manifesto__box-link" href="/marketing-funnels.html" aria-label="Funnels that convert — marketing funnels"></a>
          </li>
        </ul>

        {/* Transitional line — sets up the back-of-house category
            (AI + custom software) that visitors don't usually see
            but that powers everything above. */}
        <p className="manifesto__aside">
          But there's more that goes on behind the scenes.
        </p>

        {/* Back-of-house offerings — the engine room. Same tile
            language as the front-of-house grid above, just two
            wider tiles instead of four. */}
        <ul className="manifesto__boxes manifesto__boxes--two">
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              AI automations
            </span>
            <a className="manifesto__box-link" href="/ai-automation.html" aria-label="AI automations"></a>
          </li>
          <li className="manifesto__box">
            <span className="manifesto__box-text">
              Custom software
            </span>
            <a className="manifesto__box-link" href="/custom-software.html" aria-label="Custom software"></a>
          </li>
        </ul>

        {/* Closing tagline — the catch-all promise. Italic serif at
            the same scale as the transitional aside so the two
            "plain text" beats read as a matched pair bracketing
            the back-of-house tiles. */}
        <p className="manifesto__tagline">
          If you do it on a computer, I can automate it.
        </p>
      </div>
    </section>
  );
}

Object.assign(window, { CrewsiveWordmark, Nav, HeroMeta, Hero, Intro, IntroCoverJack, Manifesto });
