/* ============================================================
   extras.jsx — Process, Hungry, FAQ, CTA, Footer
   ============================================================ */

function Process() {
  const steps = [
    {
      n: "01",
      t: <>Listen — <em>find the leaks</em>.</>,
      p: "A working session to map the operation. Where revenue gets lost, where the team is stuck, where the tools are fighting them.",
      d: "Week 1 · 2 sessions",
    },
    {
      n: "02",
      t: <>Build — <em>working software</em> in week one.</>,
      p: "First usable build by Friday. Real data, real workflows, deployed where the team will use it.",
      d: "Week 1 · live build",
    },
    {
      n: "03",
      t: <>Iterate — <em>weekly</em>, with you.</>,
      p: "Every week, we ship. We sit with the team using it, watch where it bends, and harden it.",
      d: "Weeks 2–6",
    },
    {
      n: "04",
      t: <>Hand off — <em>or stay on</em>.</>,
      p: "Documented, tested, transferable. Stay with us on a retainer for ongoing growth, or take the keys.",
      d: "Week 6+",
    },
  ];
  return (
    <section className="process" id="process">
      <div className="shell">
        <div className="section-head">
          <div className="num">Process</div>
          <h2>Four steps. <em>No surprises.</em></h2>
        </div>
        <div className="wrap">
          <div />
          <div>
            {steps.map((s, i) => (
              <div className="step" key={i}>
                <div className="num">{s.n}</div>
                <div>
                  <h4 className="serif">{s.t}</h4>
                  <div className="duration">{s.d}</div>
                </div>
                <p>{s.p}</p>
              </div>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

function Hungry() {
  return (
    <section className="hungry">
      <div className="shell">
        <div className="section-head">
          <div className="num">A note</div>
          <h2>New &amp; <em>hungry</em>.</h2>
        </div>
        <div className="grid">
          <div className="left">
            <h3 className="serif">
              We&rsquo;re young. That&rsquo;s the <em>edge</em>, not the asterisk.
            </h3>
            <div className="sig"><CrewsiveWordmark height={36} /></div>
            <div className="stamp">a quiet promise</div>
          </div>
          <div className="right">
            <p>
              We started Crewsive because the businesses we cared about were
              being held back by software they didn&rsquo;t deserve. Generic dashboards.
              Theme-y websites. Ad agencies that vanished after onboarding.
            </p>
            <p>
              <em>We answer fast. We build close. We stay invested.</em> Every
              client gets a founder, not a ticket queue.
            </p>
            <div className="promises">
              <div className="row">
                <div className="n">01</div>
                <div className="b">We answer in <em>hours</em>, not days.</div>
              </div>
              <div className="row">
                <div className="n">02</div>
                <div className="b">Working software in <em>week one</em>. Always.</div>
              </div>
              <div className="row">
                <div className="n">03</div>
                <div className="b">We care more about <em>your numbers</em> than ours.</div>
              </div>
              <div className="row">
                <div className="n">04</div>
                <div className="b">We don&rsquo;t deliver, you <em>don&rsquo;t pay</em>.</div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

function FAQ() {
  const items = [
    {
      q: "I'm not technical. Will I be able to use what you build?",
      a: "Yes — that's the whole point. We design the interface around your team's existing workflow, not a generic admin panel. If your operations manager can use a CRM, they can use what we build.",
    },
    {
      q: "What does 'custom AI automation' actually mean?",
      a: "It means an agent that plans and executes multi-step work — reconciling books, triaging inbox, generating reports, routing leads — wired into your existing tools. You get a custom control panel to start, monitor, and review every run.",
    },
    {
      q: "How fast can we start seeing results?",
      a: "Week one. Our first build is always working software you can put in front of your team by Friday. We iterate every week after that.",
    },
    {
      q: "Do you do all three services, or can I pick one?",
      a: "Pick whichever moves the needle. Most clients start with one — usually the AI automation or the reporting software — then add the site and the discovery work once the foundation is solid.",
    },
    {
      q: "What does it cost?",
      a: "Project-based, scoped to outcomes. Discovery (SEO/AEO/Meta) is a monthly retainer.",
    },
    {
      q: "Do you sub the work out?",
      a: "No. Every line of code, every pixel, every campaign — built by us. Founder-led, no agency middlemen.",
    },
  ];
  return (
    <section className="faq" id="faq">
      <div className="shell">
        <div className="section-head">
          <div className="num">FAQ</div>
          <h2>The honest answers.</h2>
        </div>
        <div className="list">
          {items.map((it, i) => (
            <details key={i} open={i === 0}>
              <summary>
                <span>{it.q}</span>
                <span className="icon">+</span>
              </summary>
              <div className="a">{it.a}</div>
            </details>
          ))}
        </div>
      </div>
    </section>
  );
}

function CTA({ email }) {
  return (
    <section className="cta" id="cta">
      <div className="bg" />
      <div className="inner">
        <div className="eyebrow"><span className="dot" />Ready when you are</div>
        <h2 className="serif">
          Ready to seamlessly connect <em>all parts of your business?</em>
        </h2>
        <p>
          One conversation. We&rsquo;ll find the highest-leverage system to build
          first, scope it together, and have working software in your hands
          by next Friday.
        </p>
        <div className="actions">
          <a href={`mailto:${email}?subject=Joining%20the%20crew`} className="btn-big">
            Join the crew <span className="arr">↗</span>
          </a>
          <a href={`mailto:${email}`} className="btn-big ghost">
            {email}
          </a>
        </div>
      </div>
    </section>
  );
}

function Footer({ email }) {
  return (
    <footer className="foot">
      <span className="brand-foot">
        <CrewsiveWordmark height={22} />
        <span className="yr">© {new Date().getFullYear()}</span>
      </span>
      <span className="links">
        <a href={`mailto:${email}`}>{email}</a>
      </span>
      <span>Built in-house</span>
    </footer>
  );
}

function SeoAuditPopup() {
  const [open, setOpen] = React.useState(false);
  const [sent, setSent] = React.useState(false);
  const [form, setForm] = React.useState({
    firstName: "",
    company: "",
    website: "",
    email: "",
    industry: "",
  });

  React.useEffect(() => {
    if (typeof window === "undefined") return;
    if (window.localStorage.getItem("seo-popup-dismissed-v4") === "1") return;

    let cancelled = false;
    let observer = null;
    let scrollFallback = null;

    function show() {
      if (cancelled) return;
      setOpen(true);
      cleanup();
    }
    function cleanup() {
      if (observer) observer.disconnect();
      if (scrollFallback) window.removeEventListener("scroll", scrollFallback);
    }

    function attach() {
      // Trigger once the visitor scrolls to the Manifesto section.
      const target = document.querySelector(".manifesto");
      if (target && "IntersectionObserver" in window) {
        observer = new IntersectionObserver((entries) => {
          if (entries.some((e) => e.isIntersecting)) show();
        }, { rootMargin: "0px 0px -30% 0px" });
        observer.observe(target);
      }
      // Belt-and-suspenders fallback — fire once the Manifesto's top
      // scrolls ~30% into the viewport (mirrors the IO rootMargin).
      scrollFallback = () => {
        const m = document.querySelector(".manifesto");
        if (m && m.getBoundingClientRect().top < window.innerHeight * 0.7) show();
      };
      window.addEventListener("scroll", scrollFallback, { passive: true });
      scrollFallback();
    }

    // Defer until the rest of the page (loaded via in-browser Babel) mounts
    const initTimer = setTimeout(attach, 400);
    return () => {
      cancelled = true;
      clearTimeout(initTimer);
      cleanup();
    };
  }, []);

  function dismiss() {
    setOpen(false);
    try { window.localStorage.setItem("seo-popup-dismissed-v4", "1"); } catch (e) {}
  }

  function update(k) {
    return (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
  }

  function submit(e) {
    e.preventDefault();
    const subject = encodeURIComponent("Free SEO audit request");
    const body = encodeURIComponent(
      `First name: ${form.firstName}\nCompany: ${form.company}\nWebsite: ${form.website}\nEmail: ${form.email}\nIndustry: ${form.industry}`
    );
    window.location.href = `mailto:luke@crewsive.com?subject=${subject}&body=${body}`;
    setSent(true);
    setTimeout(dismiss, 1800);
  }

  if (!open) return null;

  return (
    <aside className="seo-popup" role="dialog" aria-labelledby="seo-popup-title">
      <button className="seo-popup__close" onClick={dismiss} aria-label="Close">
        ×
      </button>
      <h3 id="seo-popup-title" className="seo-popup__title serif">
        Your SEO <em>sucks.</em>
      </h3>
      <div className="seo-popup__kicker">Get a free audit.</div>

      {sent ? (
        <div className="seo-popup__sent">
          <div className="seo-popup__sent-mark">✓</div>
          <div>
            <strong>On its way.</strong>
            <span>Check your email — we&rsquo;ll reply within two business days.</span>
          </div>
        </div>
      ) : (
        <form className="seo-popup__form" onSubmit={submit}>
          <label className={`seo-popup__field${form.firstName ? " has-value" : ""}`}>
            <span>First name</span>
            <input
              type="text"
              required
              value={form.firstName}
              onChange={update("firstName")}
              autoComplete="given-name"
            />
          </label>
          <label className={`seo-popup__field${form.company ? " has-value" : ""}`}>
            <span>Company</span>
            <input
              type="text"
              required
              value={form.company}
              onChange={update("company")}
              autoComplete="organization"
            />
          </label>
          <label className={`seo-popup__field${form.website ? " has-value" : ""}`}>
            <span>Website</span>
            <input
              type="url"
              required
              placeholder=""
              value={form.website}
              onChange={update("website")}
              autoComplete="url"
            />
          </label>
          <label className={`seo-popup__field${form.email ? " has-value" : ""}`}>
            <span>Company email</span>
            <input
              type="email"
              required
              value={form.email}
              onChange={update("email")}
              autoComplete="email"
            />
          </label>
          <label className={`seo-popup__field has-value`}>
            <span>Industry</span>
            <select required value={form.industry} onChange={update("industry")}>
              <option value="" disabled>Select one…</option>
              <option>E-commerce</option>
              <option>SaaS / software</option>
              <option>Professional services</option>
              <option>Local / brick &amp; mortar</option>
              <option>Health &amp; wellness</option>
              <option>Real estate</option>
              <option>Manufacturing / trades</option>
              <option>Other</option>
            </select>
          </label>
          <button type="submit" className="seo-popup__submit">
            Get audit <span className="arr">→</span>
          </button>
          <p className="legal-fineprint legal-fineprint--popup">
            By requesting an audit you agree to our{" "}
            <a href="/privacy.html" target="_blank" rel="noopener">Privacy Policy</a>,{" "}
            <a href="/terms.html" target="_blank" rel="noopener">Terms of Use</a>, and{" "}
            <a href="/cookies.html" target="_blank" rel="noopener">Cookie Recipe</a>.
          </p>
        </form>
      )}
    </aside>
  );
}

/* ============================================================
   DotDonut — concentric-rings particle ring. Mirrors the Ouro
   Labs second-headline donut technique: a flat 2D pattern of
   nested circles whose radii sinusoidally pulse to fake a 3D
   ribbon, with per-particle spring physics so the ring feels
   alive when the cursor sweeps through it.

   Per frame, for every dot:
     1. drift its angle slowly (whole ring slowly rotates)
     2. compute a target radius rEff = base_r + amp · edgeFade ·
        sin(2·angle + twistPhase − radNorm·π). twistPhase advances
        every frame, so the wave travels around the ring.
     3. spring the dot's position toward (cx + cos·rEff,
        cy + sin·rEff) with a small force; damp velocity 6%/frame.
     4. if the cursor is MOVING (mouse speed > 0) and within MRAD,
        push the dot away with a smooth (1 − d/MRAD)² falloff
        scaled by mouse speed. Stationary cursor = no push.
     5. fill alpha = 0.15..1 driven by sin(phase) — that's what
        gives the "front-of-ribbon bright, back-of-ribbon dim"
        depth illusion without any actual 3D math.

   Spawns in once `.intro-cover-jack__hi` gets `.revealed`, fades
   out when it leaves. 2D canvas, no libraries.
   ============================================================ */
function DotDonut() {
  const canvasRef = React.useRef(null);

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

    /* Mount the canvas inside the IntroCoverJack stage. The stage
       has `isolation: isolate` + a white BG; placed inside, the
       canvas sits at z=4 — above the difference-blend circle (z=3)
       so the dots stay blue, below the italic "Luke." accent (z=5)
       so the headline reads on top. */
    const stage = document.querySelector('.intro-cover-jack__stage');
    if (!stage) return;
    stage.appendChild(canvas);

    const ctx = canvas.getContext('2d');
    const dpr = Math.min(window.devicePixelRatio || 1, 2);

    /* Tunables — match Ouro's `second-hl-particles` constants
       (see hero-intro.js on ouro-labs.com). */
    const SPACING       = 5;        // px between dots, both within and across rings
    const DOT_R         = 1.18;     // dot radius in px (drawn as filled circle)
    const SPRING_FORCE  = 0.0032;   // pull-back-to-target stiffness
    const DAMP          = 0.94;     // velocity damping per frame
    const ANGLE_DRIFT   = 0.001;    // radians/frame — whole ring slowly rotates
    const TWIST_FREQ    = 2;        // # of full waves around the circumference
    const TWIST_INC     = 0.012;    // twistPhase increment per frame
    const TWIST_AMP_F   = 0.22;     // wave amplitude as fraction of ring thickness
    const M_FORCE       = 28;       // mouse repulsion magnitude (× mouse speed) — softer than Ouro's 62 so dots stay closer to the ring
    /* Phase thresholds — kept in sync with brand.jsx. Setup runs
       0.04 → 0.46 as before; dots area is now donut → sprout only.
       Single morph (0.52 → 0.58) hands off to a long sprout hold
       that runs out the rest of the pin. Wrapper is 400vh = 300vh
       of pin scroll (was 660vh / 560vh — cut by ~46% so visitors
       can scroll into the manifesto section much sooner). */
    const HI_REVEAL_AT  = 0.34;
    const SPAWN_END     = 0.43;
    const SPAWN_EASE    = 0.10;

    /* Oregon morph DISABLED — the dots no longer pass through the
       Oregon silhouette stage. MORPH_START/END are pushed past 1
       so morphTarget stays at 0 forever, which keeps the shape
       chain at donut and lets the sprout target drive the
       donut → sprout transition directly. */
    const MORPH_START   = 99;
    const MORPH_END     = 99;
    const MORPH_EASE    = 0.18;

    /* Donut → sprout morph — 0.06 wide, starts at the end of the
       0.18-wide donut hold (donut hold 0.34 → 0.52, morph 0.52 →
       0.58). */
    const SPROUT_START  = 0.52;
    const SPROUT_END    = 0.58;

    /* Oregon outline — closed polygon (normalized; origin top-left,
       x = east, y = south). Traced from a real Oregon silhouette so
       the morphed dot field reads as the state, not a generic
       rectangle. Aspect ~1.18 : 1 (real Oregon's apparent aspect on
       most projections). Captures every feature that distinguishes
       Oregon at small scale:

         1. **Astoria peninsula spike** at the NW — small but iconic.
         2. **Wavy Columbia River top** — multiple distinct northward
            bumps (dips in y here).
         3. **Sloped upper-right** — the border leaves the Columbia
            around x≈0.66 and angles SE down to the NE corner at
            y≈0.22 (clearly lower than the NW corner).
         4. **Major Snake River bite** — east border indents ~17%
            inward in the upper portion, then returns to the right
            edge. This is one of Oregon's clearest tells.
         5. **Mostly-straight east edge** below the bite, with subtle
            jaggedness, down to a full SE corner.
         6. **Straight south** at the 42nd parallel.
         7. **Subtle Pacific coast** with shallow scalloped headlands. */
    /* Oregon outline — based on actual state geography, not stylized.
       Aspect 1 : 0.787 = 1.27, matching real Oregon. The state is
       essentially a rectangle: only modest top variation from the
       Columbia River, a gentle ~8% drop from NW to NE corner, and a
       very subtle Snake River notch (Hells Canyon) on the east. The
       previous attempts kept exaggerating these features into
       cartoon proportions; this version trusts the real shape. */
    const OREGON_OUTLINE_NORM = [
      // NW corner area (Astoria) — small peninsula spike
      [0.000, 0.030],
      [0.030, 0.005],
      [0.060, 0.000],   // Astoria tip
      [0.090, 0.020],
      [0.130, 0.045],
      // Columbia River — gentle wavy top, slowly trending south
      [0.180, 0.055],
      [0.240, 0.040],
      [0.300, 0.060],
      [0.360, 0.045],
      [0.420, 0.055],
      [0.480, 0.045],
      [0.540, 0.058],
      [0.600, 0.052],
      [0.660, 0.062],
      [0.720, 0.060],
      [0.780, 0.070],
      [0.840, 0.075],
      [0.910, 0.080],
      [1.000, 0.085],   // NE corner — only slightly lower than NW
      // East edge (Snake River) — mostly straight with subtle Hells
      // Canyon notch around y≈0.40
      [0.985, 0.180],
      [0.985, 0.280],
      [0.970, 0.360],
      [0.945, 0.420],   // Hells Canyon — Snake bends west (~5.5% inward)
      [0.965, 0.475],
      [0.985, 0.540],
      [0.990, 0.620],
      [0.995, 0.710],
      [1.000, 0.787],   // SE corner
      // South edge — straight 42nd parallel
      [0.000, 0.787],   // SW corner
      // West edge — Pacific coast, mostly straight with subtle bumps
      [0.018, 0.715],
      [0.005, 0.640],
      [0.025, 0.560],
      [0.000, 0.480],
      [0.030, 0.400],   // Cape Blanco area
      [0.005, 0.320],
      [0.020, 0.240],
      [0.000, 0.160],
      [0.015, 0.090],
    ];

    /* Sprout outline — single closed polygon (CW) modeled after a
       hand-drawn seedling: an ASYMMETRIC pair of leaves emerging
       from a curved stem with a rounded bulb at the bottom (the
       seed/root). Left leaf is taller and tilted up-left; right
       leaf is shorter and angles up-right closer to horizontal —
       same shape language as the reference image. The leaves'
       inner edges converge at a V-pinch around (0.48, 0.42),
       which fakes the visual gap between leaves above the joint
       (a simple closed polygon can't carry a real hole, but the
       pinch reads as one). Below the pinch the polygon merges
       into a single curved stem, then flares back out into the
       bulb at the bottom.

       Normalized 0..1, origin top-left, x east, y south. */
    const SPROUT_OUTLINE_NORM = [
      // V-pinch — top of the joint between the two leaves
      [0.48, 0.42],
      // LEFT leaf upper edge — pinch UP and around to the outer tip
      [0.44, 0.30],
      [0.38, 0.18],
      [0.30, 0.10],
      [0.20, 0.04],   // crown (highest point of left leaf)
      [0.10, 0.06],
      [0.03, 0.14],
      [0.01, 0.22],   // LEFT leaf outer TIP
      // LEFT leaf lower edge — back DOWN-RIGHT to the stem joint
      [0.06, 0.32],
      [0.16, 0.42],
      [0.28, 0.48],
      [0.40, 0.50],   // joint LEFT
      // Stem LEFT edge — curving down with a slight S
      [0.44, 0.58],
      [0.45, 0.68],
      [0.43, 0.78],
      [0.40, 0.86],
      [0.38, 0.92],
      // Bulb (rounded base / seed)
      [0.40, 0.97],
      [0.48, 1.00],
      [0.58, 1.00],
      [0.65, 0.96],
      [0.66, 0.91],
      // Stem RIGHT edge — back UP with mirrored S curve
      [0.60, 0.84],
      [0.56, 0.76],
      [0.55, 0.66],
      [0.55, 0.58],
      [0.56, 0.50],   // joint RIGHT
      // RIGHT leaf lower edge — joint UP-RIGHT to the outer tip
      [0.66, 0.48],
      [0.78, 0.46],
      [0.88, 0.42],
      [0.95, 0.36],   // RIGHT leaf outer TIP
      // RIGHT leaf upper edge — back DOWN-LEFT to the V-pinch
      [0.92, 0.30],
      [0.82, 0.24],
      [0.70, 0.22],
      [0.58, 0.24],
      [0.52, 0.32],
    ];

    /* Sprout scale relative to the donut's outer diameter. 0.65 =
       longer dim spans 1.3·baseR (about 65% of the donut diameter),
       big enough that the seedling silhouette reads clearly without
       the leaves blowing past Oregon's footprint. */
    const SPROUT_SIZE_F = 0.65;

    /* Sprout brightness — matched to the donut. Previously the
       sprout phase dimmed (alpha × 0.55) and lifted (L 0.72 → 0.58,
       C 0.22 → 0.18) to soften the seedling silhouette against
       the donut's additive bloom. With the bloom now handled by
       the source-over composite further down, no compensation is
       needed and the sprout reads at the donut's full luminance.
       SPROUT_DIM_F kept as a variable in case future shapes want
       to dim independently — just zeroed here. */
    const SPROUT_DIM_F = 0;
    const DOT_L_BASE    = 0.72;
    const DOT_L_SPROUT  = 0.72;
    const DOT_C_BASE    = 0.22;
    const DOT_C_SPROUT  = 0.22;

    /* Wave amplitude on Oregon — kept very subtle so the silhouette
       reads as Oregon first, gently animated second. 0.10 ≈ 3.7px
       crest at this canvas size (~1% of the polygon's height) — a
       barely-there shimmer rather than a flag-flap. */
    const OREGON_WAVE_F = 0.10;
    /* Wavelength of the L-to-R Oregon wave as a fraction of the
       donut's outer radius. ~1.2 means a single long crest sweeps
       across the silhouette (vs. several short crests at 0.85),
       which fits a subtle, calm motion. */
    const OREGON_WAVE_LEN_F = 1.2;
    /* How many times faster the L-to-R wave advances vs. the donut's
       rotational twist. 1.5 is a slow, deliberate sweep — fast enough
       to read as motion, slow enough to feel meditative. */
    const OREGON_WAVE_SPEED = 1.5;

    let w = 0, h = 0, cx = 0, cy = 0;
    let baseR = 0, innerR = 0, ringSpan = 0, twistAmp = 0;
    let mrad = 100;
    let particles = [];
    let twistPhase = 0;

    function setup() {
      w = canvas.clientWidth || window.innerWidth;
      h = canvas.clientHeight || window.innerHeight;
      canvas.width = Math.floor(w * dpr);
      canvas.height = Math.floor(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cx = w / 2;
      cy = h / 2;

      const core = Math.min(w, h);
      baseR = core * 0.36;
      /* 124.558/367 ≈ 0.339 — matches Ouro's inner/outer ratio. */
      innerR = baseR * (124.558 / 367);
      ringSpan = baseR - innerR || 1;
      twistAmp = ringSpan * TWIST_AMP_F;
      /* MRAD scales with viewport but is clamped so the cursor
         influence area feels consistent across screen sizes. */
      mrad = Math.max(120, Math.min(280, Math.round(core * 0.52)));

      /* Build the particle set: nested rings from innerR → baseR,
         each with circumference/SPACING dots. Particles keep their
         own (x, y, vx, vy) so the spring physics has a state to
         relax toward over multiple frames. */
      particles = [];
      const numRings = Math.max(2, Math.round(ringSpan / SPACING));
      for (let ri = 0; ri <= numRings; ri++) {
        const r = innerR + (ri * ringSpan) / numRings;
        const numPts = Math.max(6, Math.round((2 * Math.PI * r) / SPACING));
        for (let pi = 0; pi < numPts; pi++) {
          const a = (pi / numPts) * Math.PI * 2;
          particles.push({
            angle: a,
            radius: r,
            x: cx + Math.cos(a) * r,
            y: cy + Math.sin(a) * r,
            vx: 0, vy: 0,
          });
        }
      }

      /* Oregon polygon in canvas coords — bbox-centered on (cx, cy)
         and uniformly scaled so the longer dimension matches the
         donut's outer diameter (= 2·baseR). That's the "roughly
         the same size" the brief calls for; aspect is preserved so
         the silhouette doesn't squish. */
      let pminX = Infinity, pmaxX = -Infinity;
      let pminY = Infinity, pmaxY = -Infinity;
      for (let i = 0; i < OREGON_OUTLINE_NORM.length; i++) {
        const [x, y] = OREGON_OUTLINE_NORM[i];
        if (x < pminX) pminX = x;
        if (x > pmaxX) pmaxX = x;
        if (y < pminY) pminY = y;
        if (y > pmaxY) pmaxY = y;
      }
      const pw = pmaxX - pminX || 1;
      const ph = pmaxY - pminY || 1;
      const pscale = (baseR * 2) / Math.max(pw, ph);
      const pmidX = pminX + pw / 2;
      const pmidY = pminY + ph / 2;
      const oregonPoly = OREGON_OUTLINE_NORM.map(([x, y]) => [
        cx + (x - pmidX) * pscale,
        cy + (y - pmidY) * pscale,
      ]);

      /* Generate Oregon home positions — sample a uniform grid (cell
         = donut SPACING) over the polygon's bbox and keep cells
         whose center sits inside the polygon. This gives a dense,
         even fill of the state's interior — radial-only mapping
         from the previous attempt collapsed Oregon's near-square
         aspect into a slightly-warped donut, hiding the silhouette.
         A grid fill puts dots in the actual shape so corners + the
         Snake River notch read as Oregon, not a circle. */
      let oBmnX = Infinity, oBmxX = -Infinity;
      let oBmnY = Infinity, oBmxY = -Infinity;
      for (let i = 0; i < oregonPoly.length; i++) {
        const [x, y] = oregonPoly[i];
        if (x < oBmnX) oBmnX = x;
        if (x > oBmxX) oBmxX = x;
        if (y < oBmnY) oBmnY = y;
        if (y > oBmxY) oBmxY = y;
      }
      const pointInOregon = (x, y) => {
        // Standard ray-cast point-in-polygon (Crossing-number test).
        let inside = false;
        for (let i = 0, j = oregonPoly.length - 1; i < oregonPoly.length; j = i++) {
          const [xi, yi] = oregonPoly[i];
          const [xj, yj] = oregonPoly[j];
          if (((yi > y) !== (yj > y)) &&
              (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
            inside = !inside;
          }
        }
        return inside;
      };
      /* Deterministic mulberry32 — used both for sub-cell jitter
         (breaks the grid pattern so rows aren't visible) and for
         the assignment shuffle below. Same seed each setup so the
         scatter is stable across resizes. */
      let _oseed = 0x1d2c5b3a >>> 0;
      const _orand = () => {
        _oseed = (_oseed + 0x6D2B79F5) | 0;
        let t = Math.imul(_oseed ^ (_oseed >>> 15), 1 | _oseed);
        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
      const oregonPoints = [];
      const halfCell = SPACING / 2;
      const JITTER = SPACING * 0.45; // ±~2.25 px around grid cell center
      for (let yy = oBmnY + halfCell; yy < oBmxY; yy += SPACING) {
        for (let xx = oBmnX + halfCell; xx < oBmxX; xx += SPACING) {
          if (pointInOregon(xx, yy)) {
            const jx = xx + (_orand() * 2 - 1) * JITTER;
            const jy = yy + (_orand() * 2 - 1) * JITTER;
            oregonPoints.push([jx, jy]);
          }
        }
      }
      /* Shuffle so when the donut morphs into Oregon the dots don't
         sweep across the polygon in grid order — each particle
         picks up a scattered home and the morph reads as a swarm
         forming the silhouette rather than a top-left → bottom-
         right scan. */
      for (let i = oregonPoints.length - 1; i > 0; i--) {
        const j = (_orand() * (i + 1)) | 0;
        const tmp = oregonPoints[i];
        oregonPoints[i] = oregonPoints[j];
        oregonPoints[j] = tmp;
      }

      /* Assign each particle a unique Oregon home. The L-to-R flag
         wave only needs the cartesian (oregonX, oregonY) — phase
         comes from x position, displacement is vertical, no polar
         coords required. */
      for (let pi = 0; pi < particles.length; pi++) {
        const p = particles[pi];
        const op = oregonPoints[pi % Math.max(1, oregonPoints.length)];
        p.oregonX = op ? op[0] : p.x;
        p.oregonY = op ? op[1] : p.y;
      }

      /* Sprout polygon — same scaling pipeline as Oregon, but sized
         relative to the donut's outer diameter (smaller; see
         SPROUT_SIZE_F). Centered on (cx, cy). */
      let sminX = Infinity, smaxX = -Infinity;
      let sminY = Infinity, smaxY = -Infinity;
      for (let i = 0; i < SPROUT_OUTLINE_NORM.length; i++) {
        const [x, y] = SPROUT_OUTLINE_NORM[i];
        if (x < sminX) sminX = x;
        if (x > smaxX) smaxX = x;
        if (y < sminY) sminY = y;
        if (y > smaxY) smaxY = y;
      }
      const sw = smaxX - sminX || 1;
      const sh = smaxY - sminY || 1;
      const sscale = (baseR * SPROUT_SIZE_F * 2) / Math.max(sw, sh);
      const smidX = sminX + sw / 2;
      const smidY = sminY + sh / 2;
      const sproutPoly = SPROUT_OUTLINE_NORM.map(([x, y]) => [
        cx + (x - smidX) * sscale,
        cy + (y - smidY) * sscale,
      ]);

      /* Sprout home positions — same uniform-grid + jitter sampling
         as the Oregon polygon, then shuffled so the morph from
         Oregon → Sprout reads as a swarm collapsing into a sprout
         rather than a directional sweep. */
      let sBmnX = Infinity, sBmxX = -Infinity;
      let sBmnY = Infinity, sBmxY = -Infinity;
      for (let i = 0; i < sproutPoly.length; i++) {
        const [x, y] = sproutPoly[i];
        if (x < sBmnX) sBmnX = x;
        if (x > sBmxX) sBmxX = x;
        if (y < sBmnY) sBmnY = y;
        if (y > sBmxY) sBmxY = y;
      }
      const pointInSprout = (x, y) => {
        let inside = false;
        for (let i = 0, j = sproutPoly.length - 1; i < sproutPoly.length; j = i++) {
          const [xi, yi] = sproutPoly[i];
          const [xj, yj] = sproutPoly[j];
          if (((yi > y) !== (yj > y)) &&
              (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
            inside = !inside;
          }
        }
        return inside;
      };
      let _sseed = 0x4f7a2c91 >>> 0;
      const _srand = () => {
        _sseed = (_sseed + 0x6D2B79F5) | 0;
        let t = Math.imul(_sseed ^ (_sseed >>> 15), 1 | _sseed);
        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
      const sproutPoints = [];
      const sproutJitter = SPACING * 0.45;
      for (let yy = sBmnY + halfCell; yy < sBmxY; yy += SPACING) {
        for (let xx = sBmnX + halfCell; xx < sBmxX; xx += SPACING) {
          if (pointInSprout(xx, yy)) {
            const jx = xx + (_srand() * 2 - 1) * sproutJitter;
            const jy = yy + (_srand() * 2 - 1) * sproutJitter;
            sproutPoints.push([jx, jy]);
          }
        }
      }
      for (let i = sproutPoints.length - 1; i > 0; i--) {
        const j = (_srand() * (i + 1)) | 0;
        const tmp = sproutPoints[i];
        sproutPoints[i] = sproutPoints[j];
        sproutPoints[j] = tmp;
      }
      /* Sprout has fewer cells than Oregon (smaller silhouette).
         Particles whose index exceeds sproutPoints.length wrap
         around and share homes; with the jitter above the doubling
         is invisible. */
      for (let pi = 0; pi < particles.length; pi++) {
        const p = particles[pi];
        const sp = sproutPoints[pi % Math.max(1, sproutPoints.length)];
        p.sproutX = sp ? sp[0] : p.x;
        p.sproutY = sp ? sp[1] : p.y;
      }

    }
    setup();
    window.addEventListener('resize', setup);

    /* Mouse — tracked in canvas-local coords. Previous-frame
       position is kept so we can compute pointer SPEED, which
       is what gates the repulsion (no movement = no push). */
    const mouse = { x: -9999, y: -9999, px: -9999, py: -9999, spd: 0 };
    function onMouse(e) {
      const r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left;
      mouse.y = e.clientY - r.top;
    }
    function onLeave() {
      mouse.x = mouse.y = mouse.px = mouse.py = -9999;
    }
    window.addEventListener('mousemove', onMouse, { passive: true });
    window.addEventListener('mouseleave', onLeave, { passive: true });

    /* Pixel-perfect text collision — render "Hi, I'm Luke." into
       an offscreen canvas using each character's actual font &
       position, then sample the alpha channel per particle. Dots
       inside an opaque (= part of a letter) pixel get pushed to
       the nearest empty pixel and bounce off the inward velocity.

       Mask only covers the text's bounding box + COLLISION_PAD on
       each side, so the offscreen canvas is much smaller than the
       full donut canvas → cheaper getImageData. Cached across
       frames and only rebuilt when char positions actually change
       (during the cascade-in or on resize); once the headline
       settles, the mask is bit-stable so dots near the letters
       don't jitter from per-frame rendering variance. */
    /* HI is the only headline cascade with dot collision now —
       it lives at the centre during the donut phase. Dots get
       pushed out of the way of each letter via the mask query
       below.

       We also cache each char's headline ancestor (`charParents`)
       so the mask rebuild can skip chars whose headline hasn't yet
       had `.revealed` flipped on. Without that filter, dots would
       be pushed away from the chars' pre-reveal position (1.5em
       below baseline, opacity 0) before any letter is visible to
       the user. With it, collision activates exactly as the cascade
       starts, and `getBoundingClientRect` (called per frame inside
       rebuildTextMask) makes the mask follow each char as it rises
       — and again as HI later falls during its exit. */
    /* Love copy sits ABOVE the dot canvas (z:5 in CSS) and is
       intentionally excluded from the collision query — the user
       wants the love line to read as a clean overlay in front of
       the sprout, without the dot-field carving a halo around
       each letter. */
    const charEls = Array.from(
      document.querySelectorAll('.intro-cover-jack__hi .hw-char')
    );
    const charParents = charEls.map((c) =>
      c.closest('.intro-cover-jack__hi')
    );
    const off = document.createElement('canvas');
    const offCtx = off.getContext('2d', { willReadFrequently: true });
    let offData = null;       // Uint8ClampedArray of mask pixels
    let offW = 0, offH = 0;   // mask dimensions in physical pixels
    let offX = 0, offY = 0;   // mask origin in canvas-local CSS px
    /* Padding around the glyph bbox in the offscreen mask. Needs to
       exceed half the buffer-stroke width below so the wider halo
       isn't clipped at the offscreen-canvas edge. */
    const COLLISION_PAD = 26;
    /* Cache key — array of [left, top, width, height] integers
       per char in canvas-local CSS px, so the rebuild only fires
       when the rounded layout actually shifts. */
    let lastMaskKey = null;

    function rebuildTextMask() {
      if (!charEls.length) {
        offData = null;
        lastMaskKey = null;
        return;
      }

      /* Build a frame-stable cache key from the rounded canvas-
         local rect of each char. If nothing rounds differently
         since last frame, the mask hasn't visually changed —
         keep the cached `offData` and bail.

         Skip chars whose headline ancestor doesn't yet have
         `.revealed`. That keeps the collision dormant before the
         cascade begins (so dots aren't pushed away from invisible
         pre-cascade chars hiding 1.5em below baseline) and turns
         on automatically the frame `.revealed` is added. */
      const cr = canvas.getBoundingClientRect();
      const key = [];
      for (let i = 0; i < charEls.length; i++) {
        const parent = charParents[i];
        if (!parent || !parent.classList.contains('revealed')) continue;
        /* Skip chars in their exit state. HI's `.is-exit` translates
           the char 60vh down through the field, leaving it invisible
           (opacity 0) but `getBoundingClientRect` still reports its
           translated position. Without this filter the collision mask
           would carve a horizontal halo through the dot field along
           each exited char's current Y. */
        if (charEls[i].classList.contains('is-exit')) continue;
        const r = charEls[i].getBoundingClientRect();
        if (r.width === 0 || r.height === 0) continue;
        key.push(
          Math.round(r.left - cr.left),
          Math.round(r.top - cr.top),
          Math.round(r.width),
          Math.round(r.height)
        );
      }
      if (lastMaskKey && key.length === lastMaskKey.length) {
        let same = true;
        for (let i = 0; i < key.length; i++) {
          if (key[i] !== lastMaskKey[i]) { same = false; break; }
        }
        if (same) return; // mask still valid, skip rebuild
      }
      lastMaskKey = key;

      // Tight bounding box of all glyph rects, in canvas-local CSS px.
      // Same `.revealed` filter as the cache-key loop above — chars
      // whose headline hasn't cascaded in yet contribute nothing.
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      const charInfo = [];
      for (let i = 0; i < charEls.length; i++) {
        const parent = charParents[i];
        if (!parent || !parent.classList.contains('revealed')) continue;
        const el = charEls[i];
        const cl = el.classList;
        if (cl.contains('is-exit') || cl.contains('is-roll-up')) continue;
        const r = el.getBoundingClientRect();
        if (r.width === 0 || r.height === 0) continue;
        const lx = r.left - cr.left;
        const ly = r.top - cr.top;
        const rx = r.right - cr.left;
        const ry = r.bottom - cr.top;
        if (lx < minX) minX = lx;
        if (ly < minY) minY = ly;
        if (rx > maxX) maxX = rx;
        if (ry > maxY) maxY = ry;
        const cs = getComputedStyle(el);
        charInfo.push({
          text: el.textContent,
          x: lx,
          midY: (ly + ry) / 2,
          font: `${cs.fontStyle} ${cs.fontWeight} ${parseFloat(cs.fontSize)}px ${cs.fontFamily}`,
        });
      }
      if (!charInfo.length) {
        offData = null;
        return;
      }

      offX = minX - COLLISION_PAD;
      offY = minY - COLLISION_PAD;
      const wCss = Math.ceil(maxX - minX) + COLLISION_PAD * 2;
      const hCss = Math.ceil(maxY - minY) + COLLISION_PAD * 2;
      offW = Math.max(1, Math.floor(wCss * dpr));
      offH = Math.max(1, Math.floor(hCss * dpr));
      if (off.width !== offW) off.width = offW;
      if (off.height !== offH) off.height = offH;

      offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
      offCtx.clearRect(0, 0, wCss, hCss);
      offCtx.textBaseline = 'middle';
      offCtx.lineJoin = 'round';
      offCtx.lineCap = 'round';
      /* Two-zone alpha mask:
           • Stroke at α = 0.5, lineWidth 20 → ~10px halo on every
             side of each glyph (alpha ≈ 127 there). Tight enough
             that the dot field hugs the letterforms, loose enough
             that there's still a clear breathing margin.
           • Fill at α = 1.0 on top → the actual glyph (alpha 255).
         Letter pixels = 255, halo pixels ≈ 127, outside = 0.
         Draw-time thresholds: dots fade across α 30..100 (set in
         the homeFade lerp below) and stop drawing at α > 100;
         gradient push (α > 200) only fires deep inside the glyph
         itself. */
      offCtx.lineWidth = 20;
      for (let i = 0; i < charInfo.length; i++) {
        const c = charInfo[i];
        offCtx.font = c.font;
        offCtx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
        offCtx.strokeText(c.text, c.x - offX, c.midY - offY);
        offCtx.fillStyle = '#000';
        offCtx.fillText(c.text, c.x - offX, c.midY - offY);
      }

      offData = offCtx.getImageData(0, 0, offW, offH).data;
    }

    /* sampleAlpha — returns the mask's alpha at canvas-local
       (x, y), or 0 if outside the mask. Hot-path: called per
       particle per frame, so kept branchless and indexed directly. */
    function sampleAlpha(x, y) {
      if (!offData) return 0;
      const ix = ((x - offX) * dpr) | 0;
      const iy = ((y - offY) * dpr) | 0;
      if (ix < 0 || ix >= offW || iy < 0 || iy >= offH) return 0;
      return offData[(iy * offW + ix) * 4 + 3];
    }

    /* Spawn driver — read scroll position directly from the
       IntroCoverJack wrapper. Once scroll progress crosses
       HI_REVEAL_AT, `spawnTarget` ramps from 0 → 1 over the
       remainder of the pin distance, so the dots fade in
       *procedurally* with the user's scroll rather than as a
       fixed-duration animation. */
    const wrap = document.querySelector('.intro-cover-jack');
    let spawnTarget = 0;
    let morphTarget = 0;
    let sproutTarget = 0;
    let scrollRaf = 0;
    function updateSpawn() {
      scrollRaf = 0;
      if (!wrap) { spawnTarget = 1; morphTarget = 0; sproutTarget = 0; return; }
      const r = wrap.getBoundingClientRect();
      const stickyDistance = Math.max(1, wrap.offsetHeight - window.innerHeight);
      const scrolled = Math.max(0, Math.min(stickyDistance, -r.top));
      const raw = scrolled / stickyDistance;
      /* Mobile shifts the WHOLE post-phrase choreography later by
         POST (see brand.jsx — the phrase holds longer on phones, so
         Hi/sprout/circle are pushed into the back half of the pin).
         The dot canvas MUST apply the same shift or it desyncs from
         the text: without it the ring spawns before the cover/circle
         has covered the screen and the phrase has dropped out, and
         the donut morphs to the sprout during "Hi, I'm Luke." instead
         of when the "I help businesses…" line appears. */
      const isMobile     = window.innerWidth <= 720;
      const POST         = isMobile ? 0.20 : 0;
      const hiRevealAt   = HI_REVEAL_AT + POST;
      const spawnEnd     = SPAWN_END + POST;
      const sproutStart  = SPROUT_START + POST;
      const sproutEnd    = SPROUT_END + POST;
      spawnTarget = Math.max(
        0,
        Math.min(1, (raw - hiRevealAt) / (spawnEnd - hiRevealAt))
      );
      morphTarget = Math.max(
        0,
        Math.min(1, (raw - MORPH_START) / (MORPH_END - MORPH_START))
      );
      sproutTarget = Math.max(
        0,
        Math.min(1, (raw - sproutStart) / (sproutEnd - sproutStart))
      );
    }
    function onScroll() {
      if (scrollRaf) return;
      scrollRaf = requestAnimationFrame(updateSpawn);
    }
    updateSpawn();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });

    let opacity = 0;
    let morph = 0;
    let sprout = 0;
    let raf;

    function frame() {
      raf = requestAnimationFrame(frame);

      // Ease opacity + morph + sprout toward scroll-driven targets.
      // The shape chain is layered:
      //   morph   0 = donut, 1 = Oregon
      //   sprout  0 = current (donut/Oregon mix), 1 = sprout
      // Each later stage overrides the prior shape's target as it
      // ramps in, so the chain reads as donut → Oregon → sprout
      // without intermediate flicker. (Oregon currently disabled
      // — MORPH_START/END pushed past 1 — so the effective chain
      // is donut → sprout.)
      opacity += (spawnTarget - opacity) * SPAWN_EASE;
      morph   += (morphTarget - morph) * MORPH_EASE;
      sprout  += (sproutTarget - sprout) * MORPH_EASE;

      // Pointer speed for this frame (Ouro: spd = clamp(d / 12, 0, 1) * 0.85).
      const dxm = mouse.x - mouse.px;
      const dym = mouse.y - mouse.py;
      mouse.spd =
        mouse.x === -9999 || mouse.px === -9999
          ? 0
          : Math.min(1, Math.sqrt(dxm * dxm + dym * dym) / 12) * 0.85;
      mouse.px = mouse.x;
      mouse.py = mouse.y;

      ctx.clearRect(0, 0, w, h);
      if (opacity <= 0.005 || !particles.length) return;

      twistPhase += TWIST_INC;
      rebuildTextMask();
      /* Slightly brighter blue + additive ('lighter') compositing
         so overlapping dots in the dense ribbon bands accumulate
         into a luminous glow instead of just stacking opaquely.
         Reset to source-over after the loop so the rest of the
         canvas paints normally. */
      // Lerp dot color across the chain — all stages stay on the
       // same brand-blue hue (245). Donut/Oregon at full L/C, sprout
       // dims to a muted blue, then the evergreen-tree stage lifts
       // L back up so the silhouette reads as the brightest, biggest
       // climax of the chain rather than a darker version of the sprout.
       const dotL = DOT_L_BASE + (DOT_L_SPROUT - DOT_L_BASE) * sprout;
       const dotC = DOT_C_BASE + (DOT_C_SPROUT - DOT_C_BASE) * sprout;
       ctx.fillStyle = `oklch(${dotL.toFixed(3)} ${dotC.toFixed(3)} 245)`;
      // `lighter` (additive) bloom is what makes the donut + Oregon
      // ribbon glow on overlapping dots. Once `sprout` ramps past
      // 0.5 the cluster becomes tight enough that the additive
      // bloom dominates the silhouette — switch to `source-over`
      // alpha blending so overlapping sprout dots stop stacking
      // into a bright halo.
      ctx.globalCompositeOperation = sprout > 0.5 ? 'source-over' : 'lighter';

      /* Tunables for the pixel-perfect letter collision. Push and
         bounce both lowered vs. earlier — at the original 2.5 / 0.4
         the dots near letter edges visibly vibrated each frame as
         the mask shifted under the cascade animation. The smaller
         step + softer reflection lets dots glide out of the way
         instead of bouncing. */
      const GRAD_SAMPLE = 5;     // px offset for finite-difference gradient
      const GRAD_PUSH   = 1.6;   // px/frame outward step (was 2.5)
      const COLL_BOUNCE = 0.18;  // % of inward velocity reflected (was 0.4)

      /* Per-frame Oregon-wave constants — pulled out of the particle
         loop. Wavenumber sets the spatial period (k = 2π / wavelength)
         and the amplitude folds OREGON_WAVE_F into a single multiply. */
      const oregonWaveK   = (Math.PI * 2) / Math.max(1, baseR * OREGON_WAVE_LEN_F);
      const oregonWaveAmp = twistAmp * OREGON_WAVE_F;

      /* Phone tilt — slide the whole dot field a few px toward the
         tilt direction (reads the shared window.__tiltX/Y published by
         index.html). 0 on desktop / no sensor, so the field stays put
         there. Applied at draw time so physics + mask are untouched. */
      const tiltDX = (window.__tiltX || 0) * 14;
      const tiltDY = (window.__tiltY || 0) * 14;

      for (let i = 0; i < particles.length; i++) {
        const p = particles[i];
        p.angle -= ANGLE_DRIFT;

        /* Donut target — pulse the radius by a sine wave that
           travels around the ring. edgeFade (0 at the inner/outer
           edges, 1 at the middle) keeps the wave from distorting
           the ring's boundaries. */
        const radNorm = (p.radius - innerR) / ringSpan;
        const edgeFade = Math.sin(radNorm * Math.PI);
        const donutPhase = TWIST_FREQ * p.angle + twistPhase - radNorm * Math.PI;
        const donutWave = twistAmp * edgeFade * Math.sin(donutPhase);
        const cosA = Math.cos(p.angle);
        const sinA = Math.sin(p.angle);
        const rDonut = p.radius + donutWave;
        const donutOx = cx + cosA * rDonut;
        const donutOy = cy + sinA * rDonut;

        /* Oregon target — particle has a fixed scatter inside the
           state silhouette (set in setup). The wave is no longer
           rotational like the donut's; instead the phase advances
           with x position so the crest travels left-to-right across
           the silhouette, and the displacement is vertical — like
           a flag flapping in a breeze. The crest moves along x as
           `twistPhase` advances each frame. */
        const oregonPhase = (p.oregonX - cx) * oregonWaveK
                          + twistPhase * OREGON_WAVE_SPEED;
        const oregonWaveY = oregonWaveAmp * Math.sin(oregonPhase);
        const oregonOx = p.oregonX;
        const oregonOy = p.oregonY + oregonWaveY;

        /* Sprout target — particles collapse from Oregon's broad
           scatter into the seedling silhouette. Reuses the same
           x-driven flag-wave so the sprout shimmers in the same
           voice as Oregon, just over a smaller footprint. */
        const sproutPhase = (p.sproutX - cx) * oregonWaveK
                          + twistPhase * OREGON_WAVE_SPEED;
        const sproutWaveY = oregonWaveAmp * 0.6 * Math.sin(sproutPhase);
        const sproutOx = p.sproutX;
        const sproutOy = p.sproutY + sproutWaveY;

        // Donut → Oregon → sprout chain. Each lerp value
        // overrides the prior shape's target as it ramps in.
        const oregonStageOx = donutOx + (oregonOx - donutOx) * morph;
        const oregonStageOy = donutOy + (oregonOy - donutOy) * morph;
        const ox = oregonStageOx + (sproutOx - oregonStageOx) * sprout;
        const oy = oregonStageOy + (sproutOy - oregonStageOy) * sprout;

        // Spring toward target — no letter remapping, the draw-time
        // skip below removes the offending dots so they can't pile up.
        p.vx += (ox - p.x) * SPRING_FORCE;
        p.vy += (oy - p.y) * SPRING_FORCE;

        /* Mouse repulsion — only when pointer is moving (mouse.spd
           > 0). Smooth falloff: force ∝ (1 − d/MRAD)² × spd. */
        const mdx = p.x - mouse.x;
        const mdy = p.y - mouse.y;
        const md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md > 0 && md < mrad && mouse.spd > 0) {
          const mr = 1 - md / mrad;
          const k = mr * mr * M_FORCE * mouse.spd;
          p.vx += (mdx / md) * k;
          p.vy += (mdy / md) * k;
        }

        p.vx *= DAMP;
        p.vy *= DAMP;
        p.x += p.vx;
        p.y += p.vy;

        /* Dynamic collision via alpha gradient — fires only when
           the dot is inside the actual glyph (α > 200), not the
           softer buffer zone (α ≈ 127). Sample the 4 cardinal
           neighbors to build a finite-difference gradient pointing
           toward less ink, push outward, and bounce the inward
           velocity component. Smooth, no discrete snap. */
        if (sampleAlpha(p.x, p.y) > 200) {
          const aR = sampleAlpha(p.x + GRAD_SAMPLE, p.y);
          const aL = sampleAlpha(p.x - GRAD_SAMPLE, p.y);
          const aB = sampleAlpha(p.x, p.y + GRAD_SAMPLE);
          const aT = sampleAlpha(p.x, p.y - GRAD_SAMPLE);
          // Gradient pointing OUT of the letter (toward less alpha).
          const gx = aL - aR;
          const gy = aT - aB;
          const glen = Math.sqrt(gx * gx + gy * gy);
          if (glen > 0) {
            const nx = gx / glen;
            const ny = gy / glen;
            p.x += nx * GRAD_PUSH;
            p.y += ny * GRAD_PUSH;
            // Reflect inward velocity component along (nx, ny).
            const vn = p.vx * nx + p.vy * ny;
            if (vn < 0) {
              const f = -(1 + COLL_BOUNCE) * vn;
              p.vx += f * nx;
              p.vy += f * ny;
            }
          }
        }

        /* Fade dots whose home falls inside or near a letter. The
           home is lerped between donut and Oregon by `morph` so it
           tracks whichever shape the dot is closer to. We sample
           the alpha mask at that home and translate the value into
           an opacity multiplier (`homeFade`):
             • α ≤ 30   : fully visible (homeFade = 1)
             • α 30..100: tight linear fade — only a few px of
                           anti-aliased edge between the dot field
                           and the halo, so the transition zone is
                           narrow enough that there's no shimmer
                           band of half-visible dots flickering
                           with the cascade
             • α > 100  : fully hidden — covers the whole α≈127
                           halo plus the glyph itself
           Earlier we used 30..220 with skip at 220, which left a
           wide band of half-opacity dots throughout the halo —
           those dots' opacities updated each frame as the mask
           rebuild produced microscopic alpha differences, reading
           as glitch. */
        const donutBaseX  = cx + cosA * p.radius;
        const donutBaseY  = cy + sinA * p.radius;
        const baseStageX  = donutBaseX + (p.oregonX - donutBaseX) * morph;
        const baseStageY  = donutBaseY + (p.oregonY - donutBaseY) * morph;
        const baseX = baseStageX + (p.sproutX - baseStageX) * sprout;
        const baseY = baseStageY + (p.sproutY - baseStageY) * sprout;
        const homeAlpha = sampleAlpha(baseX, baseY);
        if (homeAlpha > 100) continue;
        const homeFade = homeAlpha > 30
          ? 1 - (homeAlpha - 30) / 70
          : 1;

        /* Depth-as-alpha — donut rides the rotational wave (front of
           the ribbon bright, back recedes). Oregon and sprout both use
           a tight 0.85..1.0 clamp on their L-to-R phase so each
           silhouette reads as one even shape with only a subtle pulse
           rather than vertical brightness stripes. */
        const t01Donut = Math.sin(donutPhase) * 0.5 + 0.5;
        const t01Oregon = 0.85 + 0.15 * (Math.sin(oregonPhase) * 0.5 + 0.5);
        const t01Sprout = 0.85 + 0.15 * (Math.sin(sproutPhase) * 0.5 + 0.5);
        const t01Stage1 = t01Donut + (t01Oregon - t01Donut) * morph;
        const t01 = t01Stage1 + (t01Sprout - t01Stage1) * sprout;
        const depth = 0.15 + 0.85 * (0.5 - 0.5 * Math.cos(t01 * Math.PI));

        // Sprout dimming. Donut/Oregon render at full brightness
        // (sprout = 0); as sprout ramps to 1 the field collapses
        // into the seedling and dims slightly so the shape reads
        // as a single calm silhouette rather than a busy swarm.
        const sproutDim  = 1 - sprout  * SPROUT_DIM_F;
        ctx.globalAlpha = depth * opacity * homeFade * sproutDim;
        ctx.beginPath();
        ctx.arc(p.x + tiltDX, p.y + tiltDY, DOT_R, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
      ctx.globalCompositeOperation = 'source-over';
    }
    raf = requestAnimationFrame(frame);

    return () => {
      cancelAnimationFrame(raf);
      if (scrollRaf) cancelAnimationFrame(scrollRaf);
      window.removeEventListener('resize', setup);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      window.removeEventListener('mousemove', onMouse);
      window.removeEventListener('mouseleave', onLeave);
    };
  }, []);

  return <canvas ref={canvasRef} className="dot-donut" aria-hidden="true" />;
}

/* ============================================================
   ManifestoShaderBG — full-section WebGL fragment-shader
   background that lives behind the "I build:" manifesto. No
   third-party libraries: one fullscreen quad, one fragment
   shader, ~50 lines of GLSL. Reads u_time / u_mouse / u_res
   and paints a slowly-domain-warped fbm field in the site's
   dark editorial blue palette, with a soft accent glow that
   trails the cursor.

   Mounted imperatively (matches the DotDonut pattern above):
   the component renders nothing in React's tree; the canvas
   is appended as the first child of `.manifesto` so the CSS
   in styles.css can simply absolutely-position it at inset:0
   behind `.manifesto__inner` (which is lifted to z:1).

   Behavior notes:
     • Respects prefers-reduced-motion: bails entirely and
       leaves the section's flat var(--bg) showing.
     • Caps DPR at 1.5 — the field is low-frequency so the
       extra pixels of a 2x retina render are wasted GPU.
     • IntersectionObserver pause: when the section scrolls
       offscreen the rAF loop short-circuits, so the shader
       costs nothing while you're reading the hero.
     • Pointer is lerped (6%/frame) so abrupt cursor jumps
       read as a smooth current rather than a hard snap.
     • On unmount we drop the GL context via WEBGL_lose_context
       — important when the section ever gets re-rendered.
   ============================================================ */
function ManifestoShaderBG({ target = '.manifesto', canvasClassName = 'manifesto__shader-bg' } = {}) {
  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const section = document.querySelector(target);
    if (!section) return;

    const canvas = document.createElement('canvas');
    canvas.className = canvasClassName;
    canvas.setAttribute('aria-hidden', 'true');
    section.insertBefore(canvas, section.firstChild);

    const gl = canvas.getContext('webgl', {
      antialias: false,
      alpha: false,
      premultipliedAlpha: false,
      powerPreference: 'low-power',
    });
    if (!gl) { canvas.remove(); return; }

    /* Vertex shader — single fullscreen triangle-strip quad in
       clip space. Nothing interesting happens here; all the
       visual work is in the fragment shader below. */
    const VERT = `
      attribute vec2 a_pos;
      void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
    `;

    /* Fragment shader — vertical-glass caustics, modeled after
       Dropship's hero shader (Unicorn Studio). The "ribbed glass
       / light through a prism" look is built from four moves
       stacked in this order:

         1. Rotate UV by ~12° so streaks have a diagonal flow
            instead of standing dead vertical (vertical alone
            reads as a CSS gradient — the tilt is what sells it
            as a real refraction).
         2. Squash the X axis hard (×0.16). The same noise func
            now produces features that are ~6× taller than wide,
            which gives you tall vertical ribbons instead of
            round blobs.
         3. Caustic ridges — feed the noise through
              ridge(n) = pow(1 - 2·|n - 0.5|, k)
            to convert smooth gradients into sharp bright bands
            wherever the noise was crossing 0.5. Sum two layers
            at different scales so the bands feel organic, not
            mechanical.
         4. Chromatic dispersion — sample the second ridge layer
            at three slightly different rotations per RGB channel.
            The bright edges pick up a faint prism rainbow, which
            is what makes the surface read as glass rather than
            painted light.

       Motion is almost entirely time-driven (slow scroll on the
       UV). Mouse influence is intentionally subtle — a small
       drift on the field's center — because that's what the
       Dropship version does: real interactivity would compete
       with the manifesto copy, but glacial motion reads as
       alive without pulling the eye. */
    const FRAG = `
      precision highp float;
      uniform vec2  u_res;
      uniform vec2  u_mouse;
      uniform float u_time;
      // 0..1 — drives the resolve-into-focus effect as the
      // manifesto comes into view. Wired from a scroll listener
      // in JS below. Smoothed with cubic ease for a soft handoff.
      uniform float u_reveal;
      // World-space scroll offset applied to the noise sampling,
      // so the field appears to drift upward at a fraction of
      // scroll speed — gentle parallax depth cue.
      uniform float u_scroll;

      float hash(vec2 p) {
        return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
      }
      float noise(vec2 p) {
        vec2 i = floor(p), f = fract(p);
        vec2 u = f * f * (3.0 - 2.0 * f);
        return mix(mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
                   mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
      }
      float fbm(vec2 p) {
        float v = 0.0;
        float a = 0.5;
        for (int i = 0; i < 5; i++) {
          v += a * noise(p);
          p *= 2.02;
          a *= 0.5;
        }
        return v;
      }

      // Caustic ridge — turns a smooth 0..1 noise value into a
      // sharp bright band centered on 0.5. Higher k = thinner,
      // brighter ridges. This is the core "light beam" shape.
      float ridge(float n, float k) {
        float r = 1.0 - 2.0 * abs(n - 0.5);
        return pow(max(r, 0.0), k);
      }

      // 2D rotation matrix — used to tilt the UV field so the
      // ribbons run on a slight diagonal instead of straight up.
      mat2 rot(float a) {
        float c = cos(a), s = sin(a);
        return mat2(c, -s, s, c);
      }

      void main() {
        vec2 uv = gl_FragCoord.xy / u_res;

        // Aspect-corrected, centered, tilted UV. Subtracting 0.5
        // first puts the rotation pivot at the section center;
        // multiplying x by the aspect ratio keeps the streaks
        // straight (otherwise they'd shear on wide screens).
        vec2 p = (uv - 0.5) * vec2(u_res.x / u_res.y, 1.0);
        p = rot(0.21) * p;            // ~12° tilt

        // Very subtle mouse drift — moves the entire field by at
        // most a few percent of a noise unit, so cursor proximity
        // gently shifts which ribbons are brightest without ever
        // tracking the pointer directly. Matches Dropship's near-
        // zero pointer reactivity.
        vec2 mDrift = (u_mouse - 0.5) * 0.10;

        // Scroll parallax — shifts the noise sample upward at a
        // fraction of scroll speed, so the columns appear to drift
        // past the surface plane. The minus sign flips the direction
        // so scrolling DOWN moves columns UP, like passing scenery.
        vec2 sDrift = vec2(0.0, -u_scroll * 0.18);

        // Per-column reveal phase. We sample a slow noise on the
        // pixel's x-position (no y), so adjacent columns get
        // slightly different phase values but the variation is
        // smooth — no hard column boundaries. The phase sits in
        // 0..0.55, meaning the latest-revealing columns wait
        // until u_reveal crosses 0.55 before they begin ramping
        // in. Each column then takes 0.45 of u_reveal to finish,
        // and all columns are fully resolved by u_reveal = 1.0.
        // The result: as you scroll, columns bleed through one
        // by one (some at 0.2, some at 0.5, some at 0.8) instead
        // of all four resolving in lockstep.
        float phase       = noise(vec2(p.x * 1.7 + 13.0, 0.0)) * 0.55;
        float localReveal = smoothstep(phase, phase + 0.45, u_reveal);

        // Reveal-driven sharpness modulation. As each column's
        // localReveal climbs, that column sharpens from soft glow
        // (k=2.5) to crisp ridge (k=7.5). This is what makes the
        // effect read as "resolving into focus" instead of just
        // fading in opacity — and with localReveal varying per
        // pixel across x, different columns sharpen at different
        // scroll positions.
        float k1 = mix(2.5, 7.5, localReveal);
        float k2 = mix(2.0, 5.5, localReveal);

        // Two caustic layers. Anisotropy lives in ONE place — the
        // fbm sample multiplier. x-frequency ≈ 3 means ~3 ridges
        // across the section width; y-frequency 0.18 makes each
        // ridge ~5× taller than wide, so they read clearly as
        // vertical columns rather than diagonal slashes. Two
        // layers drift in opposite directions so the columns
        // slowly weave instead of marching in lockstep.
        float t  = u_time * 0.05;
        float n1 = fbm(p * vec2(3.0, 0.18) + vec2( t * 1.0,  t * 0.4) + mDrift + sDrift);
        float n2 = fbm(p * vec2(2.1, 0.14) + vec2(-t * 0.7,  t * 0.6) - mDrift + sDrift);
        float r1 = ridge(n1, k1);
        float r2 = ridge(n2, k2);
        float bands = r1 * 1.25 + r2 * 0.70;

        // Bloom halo — re-sample the same field with a much lower
        // ridge sharpness (k = 1.8), then add a dim contribution
        // that's brightest where the crisp ridges are brightest.
        // Halo strength scales with localReveal so it only blooms
        // for columns that have already started resolving.
        float hHalo = ridge(n1, 1.8) + ridge(n2, 1.6) * 0.7;
        float halo  = hHalo * localReveal * 0.42;

        // Chromatic dispersion — re-sample layer 2 at three small
        // rotations for R/G/B. Dispersion strength scales with
        // localReveal so the prism rainbow appears column-by-
        // column as each one resolves.
        float dispAmt = 0.028 * (0.4 + 0.6 * localReveal);
        vec2 pr = rot( dispAmt) * p;
        vec2 pb = rot(-dispAmt) * p;
        float rR = ridge(fbm(pr * vec2(2.1, 0.14) + vec2(-t * 0.7, t * 0.6) - mDrift + sDrift), k2);
        float rB = ridge(fbm(pb * vec2(2.1, 0.14) + vec2(-t * 0.7, t * 0.6) - mDrift + sDrift), k2);
        vec3 disp = vec3(rR, r2, rB) * 0.32 * (0.5 + 0.5 * localReveal);

        // Palette — sampled from the site's actual brand tokens
        // so the columns read as part of the system, not a
        // foreign visual effect:
        //   base  ≈ --bg          (deep blue-black)
        //   mid   ≈ --bg-3        (slightly lifted)
        //   hot   ≈ --accent      (brand blue at ridge luminance)
        //   peak  ≈ --accent-2    (lighter brand variant, used
        //                          only at the brightest ridge
        //                          tips for specular punch)
        vec3 base = vec3(0.028, 0.038, 0.062);
        vec3 mid  = vec3(0.080, 0.115, 0.180);
        vec3 hot  = vec3(0.560, 0.700, 0.880);
        vec3 peak = vec3(0.760, 0.860, 0.970);

        vec3 col = mix(base, mid, smoothstep(-0.7, 0.7, p.x * 0.6 + p.y * 0.3));
        // Two-stage band mix with per-column reveal-scaled
        // intensity. Each column lifts at its own pace via
        // localReveal so the four columns "switch on" in sequence
        // as you scroll into the section.
        float bandsLift = bands * (0.45 + 0.55 * localReveal);
        col = mix(col, hot,  clamp(bandsLift,        0.0, 1.0));
        col = mix(col, peak, clamp(bandsLift - 1.0,  0.0, 1.0) * 0.7);
        // Add the bloom halo on top, additive — bright peaks
        // get a wider, softer glow that radiates outward.
        col += hot * halo * 0.35;
        col += disp;

        // Very light vignette — just darkens the four corners a
        // touch so the manifesto copy never sits on a peak. Kept
        // mild so it doesn't kill the columns themselves.
        float vig = smoothstep(1.25, 0.55, length(uv - 0.5));
        col *= 0.90 + 0.15 * vig;

        // Film grain — tiny per-frame dither to kill the soft
        // banding that always shows on near-black gradients.
        float g = (hash(uv * u_res + u_time) - 0.5) * 0.012;
        col += g;

        gl_FragColor = vec4(col, 1.0);
      }
    `;

    function compile(type, src) {
      const sh = gl.createShader(type);
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        console.error('[ManifestoShaderBG] shader error:', gl.getShaderInfoLog(sh));
        gl.deleteShader(sh);
        return null;
      }
      return sh;
    }
    const vs = compile(gl.VERTEX_SHADER, VERT);
    const fs = compile(gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) { canvas.remove(); return; }

    const prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error('[ManifestoShaderBG] program link error:', gl.getProgramInfoLog(prog));
      canvas.remove();
      return;
    }
    gl.useProgram(prog);

    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
      gl.STATIC_DRAW
    );
    const aPos = gl.getAttribLocation(prog, 'a_pos');
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    const uRes    = gl.getUniformLocation(prog, 'u_res');
    const uMouse  = gl.getUniformLocation(prog, 'u_mouse');
    const uTime   = gl.getUniformLocation(prog, 'u_time');
    const uReveal = gl.getUniformLocation(prog, 'u_reveal');
    const uScroll = gl.getUniformLocation(prog, 'u_scroll');

    const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    let w = 0, h = 0;
    function resize() {
      const r = canvas.getBoundingClientRect();
      w = Math.max(1, Math.floor(r.width  * dpr));
      h = Math.max(1, Math.floor(r.height * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    }
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);

    /* Cursor in normalized 0..1 section-local space. We lerp
       toward the target every frame so a jittery mouse reads
       as a smooth current rather than a snappy follow. */
    let mx = 0.5, my = 0.5;
    let tmx = 0.5, tmy = 0.5;
    function onMove(e) {
      const r = canvas.getBoundingClientRect();
      // Skip if the cursor isn't anywhere near this section —
      // saves a getBoundingClientRect-driven layout thrash on
      // every mousemove anywhere on the page.
      if (e.clientY < r.top - 200 || e.clientY > r.bottom + 200) return;
      tmx = (e.clientX - r.left) / r.width;
      tmy = 1.0 - (e.clientY - r.top) / r.height; // shader uses bottom-left origin
    }
    window.addEventListener('pointermove', onMove, { passive: true });

    /* Pause when the section is offscreen — there's nothing to
       look at, so spending GPU here just heats the laptop. The
       rootMargin needs to be large enough that we still render
       the bled-up portion of the canvas while it's hanging in
       the IntroCoverJack space above the manifesto. */
    let visible = true;
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
    }, { rootMargin: '90% 0px 120px 0px' });
    io.observe(section);

    /* Reveal/scroll state — recomputed each frame from the
       section's position relative to the viewport, then lerped
       so frame-to-frame jitter doesn't show up in the shader.

       revealTarget logic:
         • When the manifesto top is BELOW the viewport bottom by
           more than ~80vh, we're well above the section — reveal=0.
         • As the manifesto top crosses up through the viewport
           toward 0, reveal climbs to 1.
         • Once the manifesto is at or above the viewport, reveal
           stays clamped at 1.
       The 80vh window matches the canvas's upward bleed in CSS,
       so the shader's resolve-into-focus and the mask fade-in
       finish at the same scroll position. */
    let reveal       = 0;
    let scrollOffset = 0;
    function computeReveal() {
      const r       = section.getBoundingClientRect();
      const vh      = window.innerHeight || 800;
      const fadeLen = vh * 0.80;     // window over which reveal ramps 0→1
      // r.top is distance from viewport top to section top. When
      // r.top is +fadeLen, section is one fade-length below the
      // viewport top (reveal=0). When r.top is 0 the section is
      // sitting at the top (reveal=1). Negative r.top = scrolled
      // past, clamped to 1.
      const raw   = 1.0 - Math.max(0, Math.min(1, r.top / fadeLen));
      // Cubic ease-in-out for a calmer ramp than the linear raw.
      const eased = raw * raw * (3 - 2 * raw);
      // Track the total scroll the section has experienced —
      // used by the parallax uniform. Normalized to viewport
      // heights so values stay in a comfortable range.
      scrollOffset = -r.top / vh;
      return eased;
    }

    const start = performance.now();
    let raf = 0;
    function tick(now) {
      raf = requestAnimationFrame(tick);
      if (!visible) return;
      // On touch, the shared tilt provider (index.html) publishes
      // window.__tiltX/Y; steer the same drift target the cursor uses
      // on desktop. Undefined on desktop, so the cursor's onMove wins.
      if (typeof window.__tiltX === 'number') {
        tmx = 0.5 + window.__tiltX * 0.5;
        tmy = 0.5 - window.__tiltY * 0.5;
      }
      mx += (tmx - mx) * 0.06;
      my += (tmy - my) * 0.06;
      // Recompute the reveal target every frame and lerp toward
      // it. Lerp factor is high enough (0.18) that the value
      // tracks Lenis's smooth-scrolled scroll position closely,
      // but soft enough that the reveal can't outpace the visual
      // — no popping when scrolling fast.
      const target = computeReveal();
      reveal += (target - reveal) * 0.18;
      gl.uniform2f(uRes, w, h);
      gl.uniform2f(uMouse, mx, my);
      gl.uniform1f(uTime, (now - start) * 0.001);
      gl.uniform1f(uReveal, reveal);
      gl.uniform1f(uScroll, scrollOffset);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
    raf = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      window.removeEventListener('pointermove', onMove);
      const lose = gl.getExtension('WEBGL_lose_context');
      if (lose) lose.loseContext();
      canvas.remove();
    };
  }, [target, canvasClassName]);

  return null;
}

/* ============================================================
   WorkWithMe — final black section after the manifesto. Single
   focused conversion moment: large "Work with me." headline,
   Formspree-backed email capture, closing tagline.

   The form posts JSON to the same Formspree endpoint the
   crewsive-landing site uses (xreyorov), so submissions land
   in the same inbox without standing up a backend.
   ============================================================ */
function WorkWithMe() {
  const [email, setEmail] = React.useState("");
  const [status, setStatus] = React.useState("idle"); // idle | loading | success | error
  const [message, setMessage] = React.useState("");

  async function handleSubmit(e) {
    e.preventDefault();
    if (!email) return;
    setStatus("loading");
    try {
      const res = await fetch("https://formspree.io/f/xreyorov", {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "application/json" },
        body: JSON.stringify({ email, source: "crewsive-portfolio / work-with-me" }),
      });
      const data = await res.json().catch(() => ({}));
      if (res.ok) {
        setStatus("success");
        setMessage("You're on the list. I'll be in touch.");
        setEmail("");
      } else {
        setStatus("error");
        setMessage(data.error || "Something went wrong. Try again?");
      }
    } catch (err) {
      setStatus("error");
      setMessage("Something went wrong. Try again?");
    }
  }

  return (
    <section className="work-with-me" id="work-with-me">
      <div className="work-with-me__inner">
        <h2 className="work-with-me__title serif">
          Work with <em>me.</em>
        </h2>

        <p className="work-with-me__subhead serif">
          and take your business <em>to the next level.</em>
        </p>


        <form className="work-with-me__form" onSubmit={handleSubmit} noValidate>
          <label className="work-with-me__field" htmlFor="wwm-email">
            <span className="work-with-me__label">Your email</span>
            <input
              id="wwm-email"
              type="email"
              required
              placeholder="you@yourcompany.com"
              value={email}
              onChange={(e) => {
                setEmail(e.target.value);
                if (status !== "idle") setStatus("idle");
              }}
              autoComplete="email"
              spellCheck={false}
            />
          </label>
          <button
            type="submit"
            className={`work-with-me__submit is-${status}`}
            disabled={status === "loading" || status === "success"}
          >
            {status === "loading" ? "Sending…"
              : status === "success" ? (
                <>
                  <span className="work-with-me__check" aria-hidden>
                    <svg viewBox="0 0 24 24" width="16" height="16">
                      <path d="M4 12.5l5 5L20 6.5" fill="none" stroke="currentColor"
                        strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                  </span>
                  You{"’"}re in
                </>
              ) : (
                <>Join the crew <span aria-hidden>→</span></>
              )}
          </button>
        </form>

        {status === "success" && (
          <p className="work-with-me__msg work-with-me__msg--ok">{message}</p>
        )}
        {status === "error" && (
          <p className="work-with-me__msg work-with-me__msg--err">{message}</p>
        )}

        <p className="legal-fineprint">
          By joining you agree to our{" "}
          <a href="/privacy.html">Privacy Policy</a>,{" "}
          <a href="/terms.html">Terms of Use</a>, and{" "}
          <a href="/cookies.html">Cookie Recipe</a>.
        </p>
      </div>
    </section>
  );
}

/* ============================================================
   LiquidGlassPills — real lens-style refraction on the manifesto
   editorial trio (.manifesto__statement, .manifesto__aside,
   .manifesto__tagline).

   Why this exists: the default backdrop-filter:blur(...) glass
   trick reads as "frosted shower door" — pure scattering, no
   lensing. Apple's actual Liquid Glass material *bends* the
   background like a curved lens, with a tiny prismatic fringe
   at the perimeter. The trick is using an SVG displacement map
   that's a procedural convex-edge height field — NOT
   feTurbulence (which gives random wavy distortion, the
   universal tell of fake glass).

   Algorithm (kube.io / nikdelvin technique, distilled):

     1. Per-pill displacement map: 50%-grey base + a horizontal
        red gradient + a vertical green gradient (both
        mix-blend-mode:screen) + a blurred 50%-grey inner rect.
        The R channel encodes horizontal displacement, G encodes
        vertical. The blurred inner rect creates a "flat center,
        curved edge" falloff so the lens reads convex.
     2. Apply three feDisplacementMaps to SourceGraphic, one per
        RGB channel at slightly different scales — this produces
        chromatic aberration at the rim (the rainbow fringe).
        Screen-blend the three channels back together.
     3. Bind the resulting filter into the pill's
        backdrop-filter via a CSS custom property (--lens),
        so the SVG bends what's *behind* the pill, not the
        pill's own text content.

   Sizing: the displacement map MUST match the pill's pixel
   dimensions or the curvature lands at the wrong place. A
   ResizeObserver per pill regenerates the filter whenever the
   layout shifts (font reflow, viewport resize, etc.).

   Limitations: `backdrop-filter: url(#...)` is Chromium-only as
   of 2026-05. Safari/Firefox fall back to plain backdrop-blur —
   they get a clean frosted look, not the lens. That's the
   current pure-CSS ceiling; a WebGL/WebGPU shader could close
   the gap everywhere but requires a much bigger build.
   ============================================================ */
/* SDF-based displacement map — ported from shuding/liquid-glass
   (the WWDC25 Shu Ding demo, widely considered the best web port).

   Why this replaces my previous gradient-based map: the agent's
   raw-source pull surfaced that Shu Ding's demo uses zero
   chromatic aberration and a single feDisplacementMap whose
   source image is built per-pixel from a signed-distance
   function on a rounded rect. The SDF gives a smooth convex
   curvature that gradients can't, and dropping the CA removes
   the rainbow-fringe artifact the user called out as "sucks".

   Algorithm:
     1. For each pixel, compute its UV in the pill (0..1).
     2. Run a rounded-rect SDF centered at (0.5, 0.5) with the
        Shu Ding-published shape params (0.3, 0.2, 0.6). The
        function returns NEGATIVE values inside the shape and
        POSITIVE values outside; magnitude grows with distance.
     3. Map the distance through two smoothsteps to produce a
        displacement scalar in [0, 1] — full displacement
        inside the SDF's interior, smooth falloff to zero
        outside, near the pill rim.
     4. Sample position becomes (offset * scaled + 0.5, ...).
        When scaled=1 → no displacement; when scaled=0 →
        sample center. The lens "pulls" edge pixels inward.
     5. Encode dx/dy as R/G in a canvas (8-bit per channel,
        centered at 128). Convert canvas to data URL.
     6. feDisplacementMap's `scale` attribute is the actual
        maxScale we found, divided by DPI — auto-computed from
        the map's range, NOT a hand-picked strength constant.
        This is why Shu Ding's lens is so clean: the map and
        the scale agree exactly. */
function _smoothStep(a, b, t) {
  let x = (t - a) / (b - a);
  if (x < 0) x = 0; else if (x > 1) x = 1;
  return x * x * (3 - 2 * x);
}
function _roundedRectSDF(x, y, w, h, r) {
  const qx = Math.abs(x) - w + r;
  const qy = Math.abs(y) - h + r;
  const cornerX = Math.max(qx, 0);
  const cornerY = Math.max(qy, 0);
  return Math.min(Math.max(qx, qy), 0) + Math.sqrt(cornerX * cornerX + cornerY * cornerY) - r;
}

function buildPillFilter(id, pillW, pillH) {
  /* Cap DPI at 2 — the displacement map is monochrome
     low-frequency content so extra pixel density past 2x is
     wasted GPU. */
  const dpi = Math.min(window.devicePixelRatio || 1, 2);
  const w = Math.max(8, Math.round(pillW * dpi));
  const h = Math.max(8, Math.round(pillH * dpi));

  /* Shu Ding's published SDF shape parameters. These are
     normalized 0..1 within the pill bounding box. The inner
     SDF rect (0.3 × 0.2 with radius 0.6) is INSIDE the pill —
     the gap between SDF interior and pill perimeter is where
     the lens curvature lives. */
  const sdfW = 0.3, sdfH = 0.2, sdfR = 0.6;

  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d');
  const imgData = ctx.createImageData(w, h);
  const data = imgData.data;

  const raw = new Float32Array(w * h * 2);
  let maxScale = 0;
  let ri = 0;

  for (let py = 0; py < h; py++) {
    const uvY = py / h;
    const iy = uvY - 0.5;
    for (let px = 0; px < w; px++) {
      const uvX = px / w;
      const ix = uvX - 0.5;
      const dist = _roundedRectSDF(ix, iy, sdfW, sdfH, sdfR);
      const disp = _smoothStep(0.8, 0, dist - 0.15);
      const scaled = _smoothStep(0, 1, disp);
      const newX = ix * scaled + 0.5;
      const newY = iy * scaled + 0.5;
      const dx = newX * w - px;
      const dy = newY * h - py;
      const adx = Math.abs(dx);
      const ady = Math.abs(dy);
      if (adx > maxScale) maxScale = adx;
      if (ady > maxScale) maxScale = ady;
      raw[ri++] = dx;
      raw[ri++] = dy;
    }
  }
  maxScale *= 0.5;
  if (maxScale === 0) maxScale = 1;

  let di = 0, rii = 0;
  for (let p = 0; p < w * h; p++) {
    const dx = raw[rii++];
    const dy = raw[rii++];
    const r = dx / maxScale + 0.5;
    const g = dy / maxScale + 0.5;
    data[di++] = Math.max(0, Math.min(255, r * 255));
    data[di++] = Math.max(0, Math.min(255, g * 255));
    data[di++] = 0;
    data[di++] = 255;
  }
  ctx.putImageData(imgData, 0, 0);
  const dataUrl = canvas.toDataURL();
  const scale = maxScale / dpi;

  /* Minimal filter chain — exactly what Shu Ding ships:
     feImage as the displacement source, single feDisplacementMap.
     `filterUnits="userSpaceOnUse"` with explicit x/y/w/h
     matching the pill is more deterministic than the
     percentage-based filter region most tutorials use; the
     latter clips edge displacement and is a common "lens
     looks like shit" cause flagged by the source-dive. */
  return (
    `<filter id="${id}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB" x="0" y="0" width="${pillW}" height="${pillH}">` +
      `<feImage href="${dataUrl}" xlink:href="${dataUrl}" x="0" y="0" width="${pillW}" height="${pillH}" preserveAspectRatio="none" result="map"/>` +
      `<feDisplacementMap in="SourceGraphic" in2="map" scale="${scale}" xChannelSelector="R" yChannelSelector="G"/>` +
    `</filter>`
  );
}

function LiquidGlassPills() {
  React.useEffect(() => {
    const host = document.getElementById('liquid-glass-defs');
    if (!host) return;
    const defs = host.querySelector('defs') || host;

    /* .manifesto__statement is no longer in the pill list —
       per the user brief ("no pill on the word I build"), the
       statement renders as plain serif text, not a capsule.
       The CSS rule for .manifesto__statement::before sets
       content: none, so even if a stale --lens custom property
       were assigned it would have no surface to apply to. */
    const pills = document.querySelectorAll(
      '.manifesto__aside, .manifesto__tagline, .manifesto__box'
    );
    if (!pills.length) return;

    const SVG_NS = 'http://www.w3.org/2000/svg';
    const observers = [];
    const ids = [];
    /* Per-pill tracking object — the rAF ripple loop modulates
       feDisplacementMap.scale on each, around its baseScale.
       Pointer to the live DOM element so we don't have to
       querySelector every frame. */
    const filterMeta = [];

    pills.forEach((pill, i) => {
      const id = `liquid-pill-${i + 1}`;
      ids.push(id);
      pill.style.setProperty('--lens', `url(#${id})`);

      let raf = 0;
      const update = () => {
        raf = 0;
        const r = pill.getBoundingClientRect();
        const w = Math.max(40, Math.round(r.width));
        const h = Math.max(20, Math.round(r.height));

        /* Parse the new <filter> as proper SVG (not HTML) and
           swap it into the shared <defs>. Using DOMParser with
           the image/svg+xml MIME keeps the elements in the SVG
           namespace; insertAdjacentHTML on an SVG node would
           drop into HTML-parsing rules in some browsers and
           silently break attributes like xChannelSelector. */
        const wrapper = new DOMParser().parseFromString(
          `<svg xmlns="${SVG_NS}" xmlns:xlink="http://www.w3.org/1999/xlink">${buildPillFilter(id, w, h)}</svg>`,
          'image/svg+xml'
        );
        const newFilter = wrapper.querySelector('filter');
        if (!newFilter) return;

        const existing = defs.querySelector(`#${id}`);
        if (existing) existing.remove();
        const imported = document.importNode(newFilter, true);
        defs.appendChild(imported);

        /* Cache the live <feDisplacementMap> and its baseline
           scale so the ripple loop can modulate around it
           cheaply (no DOM queries per frame). On resize this
           runs again and the entry is rebuilt. */
        const dispEl = imported.querySelector('feDisplacementMap');
        if (dispEl) {
          const baseScale = parseFloat(dispEl.getAttribute('scale')) || 0;
          const existingMeta = filterMeta.find((m) => m.id === id);
          if (existingMeta) {
            existingMeta.dispEl = dispEl;
            existingMeta.baseScale = baseScale;
          } else {
            filterMeta.push({ id, dispEl, baseScale });
          }
        }
      };

      const onResize = () => {
        if (raf) cancelAnimationFrame(raf);
        raf = requestAnimationFrame(update);
      };
      update();
      const ro = new ResizeObserver(onResize);
      ro.observe(pill);
      observers.push({ ro, raf: () => raf && cancelAnimationFrame(raf) });
    });

    /* ── Scroll-coupled ripple ─────────────────────────────────
       The lens displacement strength modulates around its base
       value as a sinusoidal ripple, energized by scroll
       velocity and damped exponentially when scrolling stops.

       Originally driven by a `scroll` event listener, but the
       Lenis smooth-scroll library on this site can swallow or
       reshape native scroll events (depending on its mode),
       leaving the ripple inert. The robust approach is to
       poll `window.scrollY` once per rAF frame inside the
       ripple loop itself — a single property read, immune to
       whatever Lenis does to event flow. The loop kicks
       itself off via a kickstart listener (wheel/touchmove/
       keydown) and runs until the page is idle for ~1s.

       prefers-reduced-motion: bail entirely. */
    let energy = 0;
    let phase = 0;
    let rippleRaf = 0;
    let lastTickT = 0;
    let lastScrollY = window.scrollY;
    let idleFrames = 0;
    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    function tickRipple(now) {
      const dt = lastTickT ? Math.min(0.1, (now - lastTickT) / 1000) : 1 / 60;
      lastTickT = now;

      /* Per-frame scrollY poll — detects scroll regardless of
         whether Lenis fires scroll events. Whatever the actual
         delta is, we feed it through tanh to bound the energy
         injection at saturation. The 0.05 coefficient is tuned
         for a casual wheel tick (~10-20px/frame under Lenis)
         to land around 0.3-0.5 energy after one frame. */
      const curY = window.scrollY;
      const dy = curY - lastScrollY;
      lastScrollY = curY;
      if (dy !== 0) {
        energy = Math.min(1.0, energy + Math.abs(Math.tanh(dy * 0.05)) * 0.9);
        idleFrames = 0;
      } else {
        idleFrames++;
      }

      /* Phase advances at ~5 rad/s (just under 1 Hz). Energy
         decays exponentially — half-life ~250ms feels like
         glass, not jelly. */
      phase += dt * 5;
      energy *= Math.pow(0.18, dt);

      /* Peak amplitude × 0.28 = ~28% scale boost at max energy.
         The first iteration of this used 0.12 which was below
         the perceptual threshold against a dark backdrop —
         the user couldn't see the ripple at all. 0.28 makes
         the modulation visible without crossing into jello. */
      const ripple = Math.sin(phase) * energy * 0.28;
      const mod = 1 + ripple;

      for (let i = 0; i < filterMeta.length; i++) {
        const m = filterMeta[i];
        if (m.dispEl) m.dispEl.setAttribute('scale', (m.baseScale * mod).toFixed(2));
      }

      /* Continue while there's audible energy OR the page is
         still scrolling actively. Bail out after ~60 idle
         frames (1s) with no energy — at that point everything
         is settled and the GPU should rest. */
      if (energy > 0.01 || idleFrames < 60) {
        rippleRaf = requestAnimationFrame(tickRipple);
      } else {
        rippleRaf = 0;
        lastTickT = 0;
        idleFrames = 0;
        /* Snap everyone to baseline so we don't leave the last
           sub-threshold ripple value lingering on the filters. */
        for (let i = 0; i < filterMeta.length; i++) {
          const m = filterMeta[i];
          if (m.dispEl) m.dispEl.setAttribute('scale', m.baseScale.toFixed(2));
        }
      }
    }

    function kickstart() {
      if (reduceMotion) return;
      idleFrames = 0;
      if (!rippleRaf) {
        lastTickT = 0;
        rippleRaf = requestAnimationFrame(tickRipple);
      }
    }
    /* Multiple kickstart triggers because Lenis may swallow the
       native scroll event, but the user's input device (wheel,
       trackpad, touch, arrow keys) still fires its primary
       event reliably. Any one of these starting the loop is
       enough — the loop self-sustains via polling once running. */
    window.addEventListener('scroll',    kickstart, { passive: true });
    window.addEventListener('wheel',     kickstart, { passive: true });
    window.addEventListener('touchmove', kickstart, { passive: true });
    window.addEventListener('keydown',   kickstart, { passive: true });

    return () => {
      observers.forEach((o) => { o.ro.disconnect(); o.raf(); });
      window.removeEventListener('scroll',    kickstart);
      window.removeEventListener('wheel',     kickstart);
      window.removeEventListener('touchmove', kickstart);
      window.removeEventListener('keydown',   kickstart);
      if (rippleRaf) cancelAnimationFrame(rippleRaf);
      ids.forEach((id) => {
        const node = defs.querySelector(`#${id}`);
        if (node) node.remove();
      });
    };
  }, []);

  return null;
}

Object.assign(window, { Process, Hungry, FAQ, CTA, Footer, SeoAuditPopup, DotDonut, ManifestoShaderBG, WorkWithMe, LiquidGlassPills });
