/* ────────────────────────────────────────────────────────────
   Section 5 - Roadmap (Scroll Steps pattern)
   Inspired by the Framer "Scroll Steps" component:
     - LEFT (sticky): media pane that swaps images as you scroll.
     - RIGHT (scroll): vertical list of milestone steps. Each step has
       a pulsing brass dot when active, connected by a hairline.
     - Active step is whichever is closest to the viewport's vertical
       center. Image crossfades. Dot pulses. Line above the active
       step is filled brass, below is hairline.
   Background: trajectory PNG at very low opacity, behind the section.
   ──────────────────────────────────────────────────────────── */

/* Abbreviate full month names so the mobile timeframe stays compact
   ("April–June 2026" -> "Apr–Jun 2026"). Quarter dates pass through. */
const __axAbbrDate = (d) =>
  d.replace(
    /January|February|March|April|May|June|July|August|September|October|November|December/g,
    (mo) => mo.slice(0, 3)
  );

/* ────────────────────────────────────────────────────────────
   Section 5 - Roadmap, MOBILE dial (≤768px).
   The section pins while the user scrolls; page-scroll progress
   drives a calibrated tick "tuning dial" and crossfades the active
   milestone. The desktop Scroll-Steps layout is untouched: this
   block is display:none above 768px, and the desktop grid is
   display:none at/below it. No imagery - the dial is pure CSS/JS;
   PtParticles (already at the section root) supplies the ambient field.
   ──────────────────────────────────────────────────────────── */
function RoadmapDial({ ms }) {
  const N = ms.length;

  // Dial geometry - fixed px constants (a ruler the dial scrolls through).
  const SUB = 6;                 // minor subdivisions per milestone interval
  const TICKGAP = 20;            // px between adjacent ticks
  const SPAN = SUB * TICKGAP;    // 120 - px per milestone interval
  const FALL = 280;              // falloff radius (length / opacity / tilt)
  const BASE_W = 54;             // CSS base width of every tick

  const [active, setActive] = React.useState(0);

  const trackRef = React.useRef(null);
  const dialRef = React.useRef(null);
  const tickRefs = React.useRef([]);
  const flagRef = React.useRef(null);
  const lastActiveRef = React.useRef(0);

  // Tick descriptors: minor ticks + one major per milestone, with
  // LEAD intervals of lead-in/lead-out so the ruler always fills the
  // visible spine top-to-bottom (no "empty" regions at af extremes).
  // The dial element wears the same fade mask as the spine so the
  // outermost ticks dissolve cleanly at top + bottom.
  const LEAD_MOBILE = 4;
  const ticks = React.useMemo(() => {
    const arr = [];
    const steps = ((N - 1) + LEAD_MOBILE - (-LEAD_MOBILE)) * SUB;
    for (let k = 0; k <= steps; k++) {
      const mp = -LEAD_MOBILE + k / SUB;
      const nearest = Math.round(mp);
      const major = (k % SUB === 0) && nearest >= 0 && nearest <= N - 1;
      arr.push({ mp, major, m: major ? nearest : -1 });
    }
    return arr;
  }, [N]);

  // Dial stylesheet - injected once.
  React.useEffect(() => {
    if (document.getElementById("__roadmap-dial-css")) return;
    const s = document.createElement("style");
    s.id = "__roadmap-dial-css";
    s.textContent = `
      .ax-rm-mobile{ display:none; }
      @media (max-width:768px){
        .ax-rm-desktop{ display:none !important; }
        .ax-rm-mobile{ display:block; }
      }
      .ax-rm-mobile{ position:relative; height:300svh; margin-top:var(--sp-fluid-loose); margin-bottom:-64px; }
      .ax-rm-stage{ position:sticky; top:0; height:100svh; overflow:hidden; }

      .ax-rm-dialcol{
        position:absolute; left:0; top:0; bottom:0; width:116px;
        perspective:560px; pointer-events:none; z-index:1;
      }
      .ax-rm-focal{
        position:absolute; left:0; right:-20px; top:50%; height:92px;
        transform:translateY(-50%);
        background:radial-gradient(ellipse at 50% 50%,
          rgba(212,162,76,0.11), rgba(212,162,76,0) 70%);
      }
      .ax-rm-spine{
        position:absolute; left:56px; top:0; bottom:0; width:1px;
        background:rgba(237,234,226,0.16);
        -webkit-mask-image:linear-gradient(180deg,transparent,#000 20%,#000 80%,transparent);
                mask-image:linear-gradient(180deg,transparent,#000 20%,#000 80%,transparent);
      }
      .ax-rm-dial{
        position:absolute; left:56px; top:0; bottom:0; width:80px;
        transform-style:preserve-3d; pointer-events:none;
        /* Same fade mask as the spine so the outermost ticks
           dissolve at top + bottom of the rail. The mask requires
           a non-zero box to compute against - hence the explicit
           width (children are absolute, so width doesn't affect
           layout). */
        -webkit-mask-image:linear-gradient(180deg,transparent,#000 20%,#000 80%,transparent);
                mask-image:linear-gradient(180deg,transparent,#000 20%,#000 80%,transparent);
      }
      .ax-rm-tick{
        position:absolute; left:0; top:0; width:54px; height:1.5px;
        background:rgba(237,234,226,0.46);
        transform-origin:0% 50%; will-change:transform,opacity;
      }
      .ax-rm-tick.major{
        height:2px; background:rgba(212,162,76,0.6);
        transition:background var(--motion-fast) ease,
                   box-shadow var(--motion-fast) ease,
                   height var(--motion-fast) ease;
      }
      .ax-rm-tick.major.on{
        height:2.5px; background:#D4A24C;
        box-shadow:0 0 13px rgba(212,162,76,0.6);
      }
      .ax-rm-reticle{
        position:absolute; left:43px; top:calc(50% - 20px);
        width:13px; height:13px;
        border:0 solid rgba(212,162,76,0.62);
        border-top-width:1px; border-left-width:1px;
      }
      .ax-rm-flag{
        position:absolute; left:0; width:54px; top:50%;
        transform:translateY(-50%); text-align:right;
      }
      .ax-rm-count{
        display:flex; align-items:baseline; justify-content:flex-end; gap:3px;
      }
      .ax-rm-num{
        font-family:var(--font-mono); font-weight:500; font-size:18px;
        line-height:1; color:#D4A24C;
      }
      .ax-rm-den{ font-family:var(--font-mono); font-size:9px; color:#6F7178; }
      .ax-rm-flag.ax-blink{ animation:axRmFlag 340ms cubic-bezier(0.16,1,0.3,1); }
      @keyframes axRmFlag{
        from{ opacity:0; transform:translateY(-50%) translateX(-6px); }
        to{   opacity:1; transform:translateY(-50%) translateX(0); }
      }

      .ax-rm-content{ position:absolute; inset:0; z-index:2; }
      .ax-rm-panel{
        position:absolute; inset:0; padding:0 18px 0 116px;
        display:flex; flex-direction:column; justify-content:center;
        opacity:0; pointer-events:none;
        transition:opacity var(--motion-base) cubic-bezier(0.16,1,0.3,1);
      }
      .ax-rm-panel.on{ opacity:1; }
      .ax-rm-status{
        font-family:var(--font-mono); font-size:10px; letter-spacing:0.18em;
        text-transform:uppercase; color:#A8A8A2;
        display:flex; flex-wrap:wrap; align-items:center; gap:7px;
        margin-bottom:14px;
      }
      .ax-rm-status .dot{
        width:5px; height:5px; border-radius:50%; background:#D4A24C; flex:none;
        animation:axRmPulse 1.9s ease-in-out infinite;
      }
      .ax-rm-status .live{ color:#D4A24C; }
      .ax-rm-status .sep{ color:#6F7178; }
      @keyframes axRmPulse{
        0%{ box-shadow:0 0 0 0 rgba(212,162,76,0.55); }
        70%{ box-shadow:0 0 0 9px rgba(212,162,76,0); }
        100%{ box-shadow:0 0 0 0 rgba(212,162,76,0); }
      }
      .ax-rm-label{
        font-family:var(--font-display); font-weight:500;
        font-size:clamp(1.5rem, 1.18rem + 1.4vw, 2rem);
        line-height:1.2; letter-spacing:-0.012em; color:#EDEAE2; margin:0;
      }
      .ax-rm-body{
        font-family:var(--font-body); font-size:14px; line-height:1.62;
        color:#A8A8A2; margin:14px 0 0;
      }
    `;
    document.head.appendChild(s);
  }, []);

  // Scroll-driven dial - mobile only. Tick transforms are imperative
  // (per-frame); the active milestone is React state (drives the flag,
  // the content crossfade and the active tick highlight).
  React.useEffect(() => {
    if (!window.matchMedia || !window.matchMedia("(max-width:768px)").matches) return;
    const track = trackRef.current;
    const dialEl = dialRef.current;
    if (!track || !dialEl) return;

    const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
    const HALFPI = Math.PI / 2;

    const compute = () => {
      const vh = window.innerHeight || 800;
      const pinnable = track.offsetHeight - vh;
      const scrolled = clamp(-track.getBoundingClientRect().top, 0, pinnable);
      const af = (pinnable > 0 ? scrolled / pinnable : 0) * (N - 1);
      const nextActive = Math.round(af);
      const dialC = dialEl.clientHeight / 2;

      for (let i = 0; i < ticks.length; i++) {
        const t = ticks[i];
        const el = tickRefs.current[i];
        if (!el) continue;
        const d = (t.mp - af) * SPAN;
        const norm = clamp(Math.abs(d) / FALL, 0, 1);
        const lc = Math.cos(norm * HALFPI);
        const len = 6 + 24 * lc + (t.major ? 11 * lc : 0);
        const ang = -clamp(d / FALL, -1, 1) * 30;
        el.style.transform =
          "translateY(" + (dialC + d) + "px) scaleX(" +
          (len / BASE_W).toFixed(3) + ") rotateX(" + ang.toFixed(1) + "deg)";
        el.style.opacity = (1 - norm * 0.82).toFixed(3);
      }

      if (nextActive !== lastActiveRef.current) {
        lastActiveRef.current = nextActive;
        setActive(nextActive);
      }
    };

    compute();
    let scheduled = false;
    const onScroll = () => {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(() => { scheduled = false; compute(); });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [ticks, N]);

  // Re-trigger the counter blink whenever the active milestone changes.
  React.useEffect(() => {
    const f = flagRef.current;
    if (!f) return;
    f.classList.remove("ax-blink");
    void f.offsetWidth;
    f.classList.add("ax-blink");
  }, [active]);

  return (
    <div className="ax-rm-mobile" ref={trackRef}>
      <div className="ax-rm-stage">
        <div className="ax-rm-content">
          {ms.map((m, i) => (
            <div key={m.n} className={"ax-rm-panel" + (i === active ? " on" : "")}>
              <div className="ax-rm-status">
                <span className="dot" />
                {i === 0 && <span className="live">Underway</span>}
                {i === 0 && <span className="sep">/</span>}
                <span>{__axAbbrDate(m.date)}</span>
              </div>
              <h3 className="ax-rm-label">{m.label}</h3>
              <p className="ax-rm-body">{m.body}</p>
            </div>
          ))}
        </div>

        <div className="ax-rm-dialcol">
          <div className="ax-rm-focal" />
          <div className="ax-rm-spine" />
          <div className="ax-rm-dial" ref={dialRef}>
            {ticks.map((t, i) => (
              <div
                key={i}
                ref={(el) => (tickRefs.current[i] = el)}
                className={
                  "ax-rm-tick" +
                  (t.major ? " major" : "") +
                  (t.major && t.m === active ? " on" : "")
                }
              />
            ))}
          </div>
          <span className="ax-rm-reticle" />
          <div className="ax-rm-flag" ref={flagRef}>
            <div className="ax-rm-count">
              <span className="ax-rm-num">{ms[active].n}</span>
              <span className="ax-rm-den">/ 0{N}</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ────────────────────────────────────────────────────────────
   Section 5 - Roadmap, DESKTOP ruler (>768px).
   Replaces the old hairline rail + pulsing dots in the right
   column with the calibrated tick ruler from the mobile dial:
   a sticky 100vh column (negative bottom margin so it doesn't
   push the step list) that dials as the steps scroll past. The
   read-head sits at viewport center; the active milestone's
   major tick goes bright brass. The sticky image pane and the
   step content are untouched.
   ──────────────────────────────────────────────────────────── */
function RoadmapRuler({ ms, stepRefs, anchorRefs }) {
  const N = ms.length;

  // Ruler geometry - fixed px constants, scaled up from the mobile dial.
  const SUB = 6;                 // minor subdivisions per milestone interval
  const TICKGAP = 28;            // px between adjacent ticks
  const SPAN = SUB * TICKGAP;    // 168 - px per milestone interval
  const FALL = 340;              // falloff radius (length / opacity / tilt)
  const BASE_W = 80;             // CSS base width of every tick

  const [active, setActive] = React.useState(0);
  const dialRef = React.useRef(null);
  const tickRefs = React.useRef([]);
  const lastActiveRef = React.useRef(0);

  // Tick descriptors: minor ticks + one major per milestone, with
  // LEAD intervals of lead-in/lead-out so the ruler always fills the
  // visible spine top-to-bottom (no "empty" regions at af extremes).
  // The dial element wears the same vertical fade mask as the spine
  // so the outermost ticks dissolve cleanly at the edges.
  const LEAD_DESKTOP = 4;
  const ticks = React.useMemo(() => {
    const arr = [];
    const steps = ((N - 1) + LEAD_DESKTOP - (-LEAD_DESKTOP)) * SUB;
    for (let k = 0; k <= steps; k++) {
      const mp = -LEAD_DESKTOP + k / SUB;
      const nearest = Math.round(mp);
      const major = (k % SUB === 0) && nearest >= 0 && nearest <= N - 1;
      arr.push({ mp, major, m: major ? nearest : -1 });
    }
    return arr;
  }, [N]);

  // Ruler stylesheet - injected once.
  React.useEffect(() => {
    if (document.getElementById("__roadmap-ruler-css")) return;
    const s = document.createElement("style");
    s.id = "__roadmap-ruler-css";
    s.textContent = `
      .ax-rmd-rail{
        position:sticky; top:0; height:100vh; margin-bottom:-100vh;
        width:0; pointer-events:none; perspective:900px;
      }
      .ax-rmd-focal{
        position:absolute; left:-26px; width:150px; top:50%; height:120px;
        transform:translateY(-50%);
        background:radial-gradient(ellipse at 38% 50%,
          rgba(212,162,76,0.12), rgba(212,162,76,0) 70%);
      }
      .ax-rmd-spine{
        position:absolute; left:9px; top:8%; bottom:8%; width:1px;
        background:rgba(237,234,226,0.16);
        -webkit-mask-image:linear-gradient(180deg,transparent,#000 16%,#000 84%,transparent);
                mask-image:linear-gradient(180deg,transparent,#000 16%,#000 84%,transparent);
      }
      .ax-rmd-dial{
        position:absolute; left:9px; top:0; bottom:0; width:120px;
        transform-style:preserve-3d; pointer-events:none;
        /* Same fade mask as the spine so the outermost ticks
           dissolve at the top + bottom of the rail. The mask
           requires a non-zero box to compute against - hence the
           explicit width (children are absolute, so width doesn't
           affect layout). */
        -webkit-mask-image:linear-gradient(180deg,transparent,#000 16%,#000 84%,transparent);
                mask-image:linear-gradient(180deg,transparent,#000 16%,#000 84%,transparent);
      }
      .ax-rmd-tick{
        position:absolute; left:0; top:0; width:80px; height:1.5px;
        background:rgba(237,234,226,0.42);
        transform-origin:0% 50%; will-change:transform,opacity;
      }
      .ax-rmd-tick.major{
        height:2px; background:rgba(212,162,76,0.55);
        transition:background var(--motion-fast) ease,
                   box-shadow var(--motion-fast) ease,
                   height var(--motion-fast) ease;
      }
      .ax-rmd-tick.major.on{
        height:3px; background:#D4A24C;
        box-shadow:0 0 16px rgba(212,162,76,0.6);
      }
    `;
    document.head.appendChild(s);
  }, []);

  // Scroll-driven dial - desktop only. af is interpolated from the
  // step centers (the step list scrolls; the ruler stays put and
  // dials). Tick transforms are imperative; the active milestone is
  // React state, driving the bright-brass active tick.
  React.useEffect(() => {
    if (window.matchMedia && window.matchMedia("(max-width:768px)").matches) return;
    const dialEl = dialRef.current;
    if (!dialEl) return;

    const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
    const HALFPI = Math.PI / 2;

    const compute = () => {
      const steps = stepRefs.current;
      if (!steps || steps.length < N) return;
      const playhead = (window.innerHeight || 800) * 0.5;

      const centers = [];
      for (let i = 0; i < N; i++) {
        // Prefer the eyebrow anchor ("Milestone XX") - that's the
        // element the user expects each major tick to line up with.
        // Fall back to the whole step block's center if no anchor
        // ref was supplied.
        const a = anchorRefs && anchorRefs.current && anchorRefs.current[i];
        const el = a || steps[i];
        if (!el) return;
        const r = el.getBoundingClientRect();
        // For the eyebrow, use its baseline rather than its box
        // center - that's where the tick should kiss the text.
        centers.push(a ? (r.top + r.height / 2) : (r.top + r.height / 2));
      }

      // Pre-compute per-segment gaps + average gap. We use the actual
          // segment a tick falls inside to position it, so major ticks
          // sit dead-center on their milestone's content (regardless
          // of the step's height or the section's overall pacing).
          // FALL is derived from the average gap so a tick is bright
          // for roughly half a gap before fading - independent of the
          // pixel pacing of the page.
          const gapStart = (N >= 2) ? (centers[1] - centers[0]) : 600;
          const gapEnd = (N >= 2) ? (centers[N - 1] - centers[N - 2]) : 600;
          let totalGap = 0;
          for (let i = 0; i < N - 1; i++) totalGap += centers[i + 1] - centers[i];
          const avgGap = (N >= 2) ? (totalGap / (N - 1)) : 600;
          const fall = avgGap * 0.55;

      let af;
      if (playhead <= centers[0]) af = 0;
      else if (playhead >= centers[N - 1]) af = N - 1;
      else {
        af = 0;
        for (let i = 0; i < N - 1; i++) {
          if (playhead >= centers[i] && playhead <= centers[i + 1]) {
            af = i + (playhead - centers[i]) / (centers[i + 1] - centers[i]);
            break;
          }
        }
      }
      const nextActive = Math.round(af);
      const dialC = dialEl.clientHeight / 2;

      for (let i = 0; i < ticks.length; i++) {
        const t = ticks[i];
        const el = tickRefs.current[i];
        if (!el) continue;

        // Position each tick at its TRUE milestone-content y, then
        // express that as an offset from the read-head (playhead).
        // For tick positions inside the step range we interpolate
        // between adjacent step centers; outside the range we
        // extrapolate using the closest segment's gap, so the
        // ruler keeps a believable cadence at the edges.
        const mp = t.mp;
        let yAbs;
        if (mp <= 0) {
          yAbs = centers[0] + mp * gapStart;
        } else if (mp >= N - 1) {
          yAbs = centers[N - 1] + (mp - (N - 1)) * gapEnd;
        } else {
          const fi = Math.floor(mp);
          const frac = mp - fi;
          yAbs = centers[fi] + frac * (centers[fi + 1] - centers[fi]);
        }
        const d = yAbs - playhead;

        const norm = clamp(Math.abs(d) / fall, 0, 1);
        const lc = Math.cos(norm * HALFPI);
        const len = 8 + 34 * lc + (t.major ? 17 * lc : 0);
        const ang = -clamp(d / fall, -1, 1) * 26;
        el.style.transform =
          "translateY(" + (dialC + d) + "px) scaleX(" +
          (len / BASE_W).toFixed(3) + ") rotateX(" + ang.toFixed(1) + "deg)";
        el.style.opacity = (1 - norm * 0.8).toFixed(3);
      }

      if (nextActive !== lastActiveRef.current) {
        lastActiveRef.current = nextActive;
        setActive(nextActive);
      }
    };

    compute();
    let scheduled = false;
    const onScroll = () => {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(() => { scheduled = false; compute(); });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [ticks, N, stepRefs, anchorRefs]);

  return (
    <div className="ax-rmd-rail" aria-hidden="true">
      <div className="ax-rmd-spine" />
      <div className="ax-rmd-dial" ref={dialRef}>
        {ticks.map((t, i) => (
          <div
            key={i}
            ref={(el) => (tickRefs.current[i] = el)}
            className={
              "ax-rmd-tick" +
              (t.major ? " major" : "") +
              (t.major && t.m === active ? " on" : "")
            }
          />
        ))}
      </div>
    </div>
  );
}

function SectionRoadmap() {
  const ms = [
  {
    n: "01",
    label: "6-Month Testing + Field Validation",
    date: "April–June 2026",
    img: "assets/imagery/milestone-01.png",
    body: "Characterize laser–rock interaction across geo-materials. Define the operating envelope for power density, spot size, and rate of penetration. Validate stability at depth and demonstrate controlled debris management. Convert physics into the first real design inputs for the encapsulated system."
  },
  {
    n: "02",
    label: "Advanced Subsystem Development",
    date: "Q3 2026",
    img: "assets/imagery/milestone-02.png",
    body: "Translate test data into a full encapsulated system architecture. Define the multi-diode array configuration, thermal management approach, and mechanical envelope. Complete bill of materials and establish supply chain for prototype build."
  },
  {
    n: "03",
    label: "Integrated Prototype Ready",
    date: "Q2 2027",
    img: "assets/imagery/milestone-03.png",
    body: "First fully integrated encapsulated laser drilling system assembled and bench-validated. All subsystems - power delivery, cooling, optics, debris management - operating as a unified downhole architecture for the first time."
  },
  {
    n: "04",
    label: "Field Test-Ready Prototype",
    date: "Q1 2028",
    img: "assets/imagery/milestone-04.png",
    body: "System validated to commercial drilling depths in controlled conditions. Performance data available across rock types. Deployment partnerships established. System ready for real-world field demonstration."
  },
  {
    n: "05",
    label: "Market Launch & Beyond",
    date: "Q4 2028",
    img: "assets/imagery/milestone-05.png",
    body: "First commercial DaaS contracts executed. Axion establishes itself as the new cost benchmark for deep geothermal drilling. Fleet scaling begins."
  }];


  const sectionRef = React.useRef(null);
  const bgRef = React.useRef(null);
  const stepRefs = React.useRef([]);
  const anchorRefs = React.useRef([]);
  const mediaRef = React.useRef(null);
  const [active, setActive] = React.useState(0);
  // Track the previously-active milestone so the outgoing image
  // can keep running its 400ms fade-out while the incoming image
  // runs its 600ms fade-in (with the 100ms overlap delay from §8
  // of the motion brief). Without this, only the incoming image
  // animates and the outgoing one snaps to opacity:0.
  const prevActiveRef = React.useRef(0);
  React.useEffect(() => {
    prevActiveRef.current = active;
  }, [active]);

  // Cursor-tracking 3D tilt + rim highlight. We mutate CSS variables
  // directly (no React state) so the transform updates per pointer
  // event without re-rendering the section.
  const onPointerMove = React.useCallback((e) => {
    const el = mediaRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const px = e.clientX - r.left;
    const py = e.clientY - r.top;
    const mx = (px / r.width) * 2 - 1;  // -1..1
    const my = (py / r.height) * 2 - 1; // -1..1
    el.style.setProperty("--mx", mx.toFixed(3));
    el.style.setProperty("--my", my.toFixed(3));
    el.style.setProperty("--px", px + "px");
    el.style.setProperty("--py", py + "px");
  }, []);
  const onPointerEnter = React.useCallback(() => {
    const el = mediaRef.current;
    if (!el) return;
    el.classList.add("is-hov");
    el.style.setProperty("--hov", "1");
  }, []);
  const onPointerLeave = React.useCallback(() => {
    const el = mediaRef.current;
    if (!el) return;
    el.classList.remove("is-hov");
    el.style.setProperty("--hov", "0");
    el.style.setProperty("--mx", "0");
    el.style.setProperty("--my", "0");
  }, []);

  React.useEffect(() => {
    const compute = () => {
      const vh = window.innerHeight || 800;
      const playhead = vh * 0.5; // viewport center
      let bestIdx = 0;
      let bestDist = Infinity;
      stepRefs.current.forEach((el, i) => {
        if (!el) return;
        const r = el.getBoundingClientRect();
        const center = r.top + r.height / 2;
        const dist = Math.abs(center - playhead);
        if (dist < bestDist) {bestDist = dist;bestIdx = i;}
      });
      setActive(bestIdx);

      // Very light parallax for the trajectory background. We translate
      // the bg element by a small fraction of the section's offset
      // from viewport center - capped so it never drifts more than
      // ~40px from rest. Reads as atmosphere, not motion.
      const sec = sectionRef.current;
      const bg = bgRef.current;
      if (sec && bg) {
        const sr = sec.getBoundingClientRect();
        const sectionCenter = sr.top + sr.height / 2;
        const delta = (sectionCenter - vh / 2) * -0.06; // ~6% parallax depth
        const clamped = Math.max(-40, Math.min(40, delta));
        bg.style.transform = `translate3d(0, ${clamped.toFixed(1)}px, 0)`;
      }
    };
    compute();
    let scheduled = false;
    const onScroll = () => {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(() => {scheduled = false;compute();});
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  // Inject keyframes once for the pulsing dot.
  React.useEffect(() => {
    if (document.getElementById("__roadmap-pulse-css")) return;
    const s = document.createElement("style");
    s.id = "__roadmap-pulse-css";
    s.textContent = `
      @keyframes axRoadmapPulse {
        0%   { box-shadow: 0 0 0 0    rgba(212,162,76,0.55); }
        70%  { box-shadow: 0 0 0 14px rgba(212,162,76,0);    }
        100% { box-shadow: 0 0 0 0    rgba(212,162,76,0);    }
      }
      /* Scroll-Steps media pane.
         Subtle hover treatment - a very small 3D tilt (max ~2°) and
         a faint warm rim that tracks the cursor. The earlier draft
         was overcooked; this dials everything back to "you'd notice
         it if you were looking for it." */
      .ax-roadmap-media {
        --mx: 0; --my: 0;
        --px: 50%; --py: 50%;
        --hov: 0;
        transform-style: preserve-3d;
        transform: perspective(1200px)
                   rotateY(calc(var(--mx) * 2deg))
                   rotateX(calc(var(--my) * -2deg))
                   translateZ(0);
        /* motion-instant for the cursor-tracking transform (direct
           pointer feedback); motion-fast for border/shadow polish. */
        transition: transform var(--motion-instant) cubic-bezier(0.25, 1, 0.5, 1),
                    border-color var(--motion-fast) cubic-bezier(0.5, 1, 0.89, 1),
                    box-shadow var(--motion-fast) cubic-bezier(0.5, 1, 0.89, 1);
        will-change: transform;
      }
      .ax-roadmap-media.is-hov {
        /* No border-color shift, no brass glow - just a slightly
           deeper drop shadow so the pane lifts a hair off the page. */
        box-shadow: 0 18px 40px -28px rgba(0,0,0,0.6);
      }
      .ax-roadmap-img {
        /* Default: fully hidden, no transition. The active/leaving
           classes below override per the brief's §8 swap pattern.
           This way only the two relevant images animate; the rest
           stay quietly at opacity:0 with no work to do. */
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
        display: block;
        opacity: 0;
        transform: scale(1.02);
        will-change: opacity, transform;
      }
      /* Incoming - the new active milestone. motion-base content
         reveal; the cross-fade overlap (100ms delay) is preserved. */
      .ax-roadmap-img.is-active {
        opacity: 1;
        transform: scale(1.00);
        transition:
          opacity var(--motion-base) cubic-bezier(0.16, 1, 0.3, 1) 100ms,
          transform var(--motion-base) cubic-bezier(0.16, 1, 0.3, 1) 100ms,
          filter var(--motion-fast) cubic-bezier(0.5, 1, 0.89, 1);
      }
      /* Outgoing - motion-fast (300ms) so it clears ahead of the
         incoming reveal and the swap feels continuous. */
      .ax-roadmap-img.is-leaving {
        opacity: 0;
        transform: scale(0.98);
        transition:
          opacity var(--motion-fast) cubic-bezier(0.25, 1, 0.5, 1),
          transform var(--motion-fast) cubic-bezier(0.25, 1, 0.5, 1);
      }
      .ax-roadmap-media.is-hov .ax-roadmap-img.is-active {
        filter: brightness(1.02);
      }
      /* Cursor-tracking rim/specular highlight - heavily damped. */
      .ax-roadmap-rim {
        position: absolute;
        inset: 0;
        pointer-events: none;
        opacity: calc(var(--hov) * 0.5);
        /* motion-fast - hover feedback. */
        transition: opacity var(--motion-fast) cubic-bezier(0.5, 1, 0.89, 1);
        background:
          radial-gradient(
            220px circle at var(--px) var(--py),
            rgba(237,234,226,0.08),
            rgba(237,234,226,0.02) 40%,
            rgba(237,234,226,0) 65%
          );
        mix-blend-mode: screen;
      }
      /* Inner edge highlight - barely-there warm inset on hover. */
      .ax-roadmap-edge {
        position: absolute;
        inset: 0;
        pointer-events: none;
        box-shadow: inset 0 0 0 1px rgba(237,234,226,calc(var(--hov) * 0.10));
        /* motion-fast - hover feedback. */
        transition: box-shadow var(--motion-fast) cubic-bezier(0.5, 1, 0.89, 1);
      }
    `;
    document.head.appendChild(s);
  }, []);

  return (
    <div ref={sectionRef} style={{ position: "relative", isolation: "isolate", background: "#0D0F12" }}>
      {/* Parallax clip layer - wraps ONLY the background grid so we
          can clip its overscan without breaking position:sticky
          for the milestone media pane (sticky cannot resolve inside
          an overflow:hidden ancestor). */}
      <div aria-hidden="true" style={{
        position: "absolute",
        inset: 0,
        zIndex: 0,
        overflow: "hidden",
        pointerEvents: "none"
      }}>
      {/* Drafting-paper grid - same treatment as Section 3 (The Solve)
          so the two sections share a visual ground. Subtle parallax:
          we translate the grid layer by a small fraction of the
          section's offset from viewport center so it drifts as the
          user scrolls. The transform is mutated on the bg element via
          a scroll listener (no React state) so it updates per-frame
          without re-rendering the section. */}
      <div
        ref={bgRef}
        aria-hidden="true"
        style={{
          position: "absolute",
          /* Overscan vertically so the parallax translate never
             reveals an edge. The parent wrapper has overflow:hidden
             so this never bleeds into adjacent sections. */
          top: "-80px",
          bottom: "-80px",
          left: 0,
          right: 0,
          zIndex: 0,
          pointerEvents: "none",
          backgroundImage: `
            linear-gradient(to right, rgba(237,234,226,0.06) 1px, transparent 1px),
            linear-gradient(to bottom, rgba(237,234,226,0.06) 1px, transparent 1px)
          `,
          backgroundSize: "48px 48px",
          maskImage: "radial-gradient(ellipse at center, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, rgba(0,0,0,0.2) 100%)",
          WebkitMaskImage: "radial-gradient(ellipse at center, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, rgba(0,0,0,0.2) 100%)",
          willChange: "transform"
        }}
      />
      </div>
      {/* Ambient particle field - sits behind everything in this
          section. The sticky image pane on the left is given an
          explicit z-index above this layer so the particles read
          as atmosphere behind it. */}
      {typeof PtParticles !== "undefined" && (
        <PtParticles style={{ zIndex: 0 }} density={0.0024} />
      )}
      <SectionFrame index="05" plate="V." dispatch="Commercialization roadmap · § 05" bg="transparent">
        <SectionEyebrow index="05" label="The roadmap" />

        <SectionHeading size="lg">
          Commercialization in under<br />
          three years.
        </SectionHeading>

        <SectionDeck max={900}>
          The team behind Axion has spent careers building hardware that works
          under pressure, on deadline, in environments where failure isn't an
          option. That operating instinct is baked into our roadmap.
        </SectionDeck>

        {/* Scroll-Steps layout - sticky media pane on the LEFT sits
                                                 flush against the scroller line. The image is responsive
                                                 (clamped 320–500px) and tracks the cursor with a 3D tilt
                                                 + brass rim highlight while hovered. */}
        <div className="ax-rm-desktop" style={{
          marginTop: 96,
          display: "grid",
          gridTemplateColumns: "minmax(320px, 500px) 1fr",
          columnGap: 56,
          alignItems: "start"
        }}>
          {/* LEFT - sticky image pane, justified to the right edge of
                                                   its column so it sits right next to the timeline rail.
                                                   ImageSettle gives the pane its first-time entrance per
                                                   §8 of the motion brief - opacity 0→1, scale 1.06→1.00,
                                                   y +40→0 over 1200ms when the section enters. After that
                                                   first run it never re-fires, so the per-milestone swap
                                                   pattern below has clean ownership of opacity/transform. */}
          {/* Sticky pane - vertically centered in the viewport.
              We use a CSS variable (--ax-img-half) so the centering
              math is in one place: the wrapper's `top` offset is
              `calc(50vh - <half image height>)`, which puts the
              image's vertical midpoint at viewport-center without
              needing a translateY(-50%) (which would visually pull
              the image above its grid cell's top edge and let it
              overlap the section H1 above the grid).
              The inner ImageSettle keeps its entrance choreography
              on its own element; no transform conflict. */}
          <div style={{
            "--ax-img-half": "clamp(160px, 18vw, 250px)",
            position: "sticky",
            top: "calc(50vh - var(--ax-img-half))",
            alignSelf: "start",
            zIndex: 2
          }}>
          <ImageSettle style={{
            display: "flex",
            justifyContent: "flex-end"
          }}>
            <div
              ref={mediaRef}
              className="ax-roadmap-media"
              onPointerMove={onPointerMove}
              onPointerEnter={onPointerEnter}
              onPointerLeave={onPointerLeave}
              style={{
                position: "relative",
                width: "clamp(320px, 36vw, 500px)",
                aspectRatio: "1 / 1",
                border: "1px solid rgba(237,234,226,0.14)",
                background: "#0F1114",
                overflow: "hidden"
              }}>
              {ms.map((m, i) => {
                // Each image is one of three states:
                //   active  - current milestone (incoming if just changed)
                //   leaving - the one we just navigated away from
                //   idle    - hidden, no transition cost
                const isActive = active === i;
                const isLeaving = !isActive && prevActiveRef.current === i;
                const cls =
                  "ax-roadmap-img" +
                  (isActive ? " is-active" : "") +
                  (isLeaving ? " is-leaving" : "");
                return (
                  <img
                    key={m.n}
                    className={cls}
                    src={m.img}
                    alt=""
                    aria-hidden={!isActive}
                  />
                );
              })}
              {/* Cursor-tracked rim highlight (above images, below caption). */}
              <div className="ax-roadmap-rim" aria-hidden="true" />
              <div className="ax-roadmap-edge" aria-hidden="true" />
              {/* Soft vignette for legibility of the overlay caption. */}
              <div aria-hidden="true" style={{
                position: "absolute", inset: 0,
                background: "linear-gradient(180deg, rgba(15,17,20,0) 55%, rgba(15,17,20,0.85) 100%)",
                pointerEvents: "none"
              }} />
              {/* Caption overlay - milestone counter + active label. */}
              <div style={{
                position: "absolute",
                left: 20, right: 20, bottom: 20,
                display: "flex",
                justifyContent: "space-between",
                alignItems: "flex-end",
                gap: 16,
                fontFamily: "var(--font-mono)",
                fontSize: 11,
                letterSpacing: "0.20em",
                textTransform: "uppercase",
                color: "#A8A8A2",
                pointerEvents: "none"
              }}>
                <div>
                  <div style={{ color: "#D4A24C" }}>Milestone {ms[active].n} / 05</div>
                  <div style={{ color: "#EDEAE2", marginTop: 6 }}>{ms[active].date}</div>
                </div>
                <div style={{ textAlign: "right" }}>
                  <div>Plate V · Roadmap</div>
                </div>
              </div>
            </div>
          </ImageSettle>
          </div>

          {/* RIGHT - step list. Each step sits in a tall block so the
                                                   sticky image holds while the user reads it. */}
          <div style={{ position: "relative", zIndex: 2 }}>
            <RoadmapRuler ms={ms} stepRefs={stepRefs} anchorRefs={anchorRefs} />

            {ms.map((m, i) => {
              const isActive = active === i;
              // Desktop-only entry stagger — matches the Section 6
              // team-card pattern exactly: ImageSettle's default
              // primitive (opacity 0→1, y +40→0, scale 1.06→1.00,
              // 1200ms ease-out-expo) with an 80ms × index delay
              // pulled from the same --stagger-base token. Disabled
              // on mobile because the mobile path uses the
              // scroll-driven RoadmapDial below — adding entry
              // stagger on top of the dial would muddy that
              // interaction. (The desktop grid is also display:none
              // under 768px, so this is belt + braces.)
              const isMobile =
                typeof window !== "undefined" &&
                window.matchMedia &&
                window.matchMedia("(max-width:768px)").matches;
              return (
                <ImageSettle
                  key={m.n}
                  delay={i * (window.ax ? window.ax.stagger("base") : 80)}
                  disabled={isMobile}>
                <div
                  ref={(el) => stepRefs.current[i] = el}
                  style={{
                    position: "relative",
                    paddingLeft: 140,
                    paddingTop: i === 0 ? 8 : 64,
                    paddingBottom: i === ms.length - 1 ? 8 : 64,
                    minHeight: "60vh",
                    display: "flex",
                    flexDirection: "column",
                    justifyContent: "center"
                  }}>
                  
                  {/* Active milestone is marked by the calibrated ruler. */}

                  <div
                    data-role="eyebrow"
                    ref={(el) => anchorRefs.current[i] = el}
                    style={{
                    fontFamily: "var(--font-mono)",
                    fontSize: 11,
                    letterSpacing: "0.22em",
                    textTransform: "uppercase",
                    color: isActive ? "#D4A24C" : "#6F7178",
                    display: "flex",
                    alignItems: "center",
                    gap: 10,
                    transition: "color var(--motion-fast) cubic-bezier(0.76, 0, 0.24, 1)"
                  }}>
                    {/* Pulsing brass dot - mirrors the mobile dial's
                        status indicator. Reserves its 6px box at all
                        times (visibility:hidden when inactive) so the
                        eyebrow text doesn't shift horizontally as the
                        active milestone moves through the list. */}
                    <span aria-hidden="true" style={{
                      width: 6,
                      height: 6,
                      borderRadius: "50%",
                      background: "#D4A24C",
                      flex: "none",
                      visibility: isActive ? "visible" : "hidden",
                      animation: isActive
                        ? "axRoadmapPulse 1.8s cubic-bezier(0.76, 0, 0.24, 1) infinite"
                        : "none"
                    }} />
                    <span>Milestone {m.n}{i === 0 ? " \u00b7 Underway" : ""}</span>
                  </div>
                  <h3 data-reveal-headline style={{
                    fontFamily: "var(--font-display)",
                    fontSize: 30,
                    fontWeight: 500,
                    lineHeight: 1.18,
                    letterSpacing: "-0.012em",
                    color: isActive ? "#EDEAE2" : "rgba(237,234,226,0.55)",
                    margin: "12px 0 0",
                    transition: "color var(--motion-fast) cubic-bezier(0.76, 0, 0.24, 1)"
                  }}>
                    {/* Pattern A reveal - the H3 rises from below
                        line-by-line on entry, matching the section
                        headline's intro animation. */}
                    <span className="ax-headline-mask">
                      <span className="ax-headline-line">{m.label}</span>
                    </span>
                  </h3>
                  <div data-role="body" style={{
                    fontFamily: "var(--font-mono)",
                    fontSize: 11,
                    letterSpacing: "0.16em",
                    textTransform: "uppercase",
                    color: isActive ? "#A8A8A2" : "#6F7178",
                    marginTop: 10,
                    transition: "color var(--motion-fast) cubic-bezier(0.76, 0, 0.24, 1)"
                  }}>
                    {m.date}
                  </div>
                  <p style={{
                    fontFamily: "var(--font-body)",
                    fontSize: 15,
                    lineHeight: 1.6,
                    color: isActive ? "#A8A8A2" : "rgba(168,168,162,0.55)",
                    maxWidth: 560,
                    marginTop: 16,
                    transition: "color var(--motion-fast) cubic-bezier(0.76, 0, 0.24, 1)"
                  }}>
                    {m.body}
                  </p>
                </div>
                </ImageSettle>);

            })}
          </div>
        </div>

        <RoadmapDial ms={ms} />
      </SectionFrame>
    </div>);

}

window.SectionRoadmap = SectionRoadmap;