// app.jsx — composes the racetrack portfolio.
// Owns: scroll listener, path generation, car position/tangent, checkpoint
// progress, navlink active state, typed hero, and the Tweaks panel.

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

/* ───────── Tweak defaults ───────── */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accent": "#E63946",
  "carStyle": "sports",
  "trackStyle": "asphalt",
  "trackWidth": 56,
  "showSpeedo": true,
  "showProgressGlow": true,
  "dark": true
} /*EDITMODE-END*/;

const ACCENT_OPTIONS = ["#E63946", "#1E40AF", "#10B981", "#F59E0B", "#111827", "#7C3AED"];

/* ───────── helpers ───────── */
function easeInOut(t) {return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;}

/* Find the point on a path closest to a given y (used for checkpoint placement).
   path: SVGPathElement; targetY in path coords. Returns {x, y, d}. */
function findPointAtY(path, targetY, samples = 240) {
  const L = path.getTotalLength();
  let best = { x: 0, y: 0, d: 0, diff: Infinity };
  for (let i = 0; i <= samples; i++) {
    const d = i / samples * L;
    const p = path.getPointAtLength(d);
    const diff = Math.abs(p.y - targetY);
    if (diff < best.diff) best = { x: p.x, y: p.y, d, diff };
  }
  // refine: search ±2 segment around best
  const seg = L / samples;
  const start = Math.max(0, best.d - seg);
  const end = Math.min(L, best.d + seg);
  const refSamples = 60;
  for (let i = 0; i <= refSamples; i++) {
    const d = start + (end - start) * i / refSamples;
    const p = path.getPointAtLength(d);
    const diff = Math.abs(p.y - targetY);
    if (diff < best.diff) best = { x: p.x, y: p.y, d, diff };
  }
  return best;
}

/* ───────── Hero ───────── */
const TYPE_WORDS = ["engineer", "coder", "creator", "innovator"];

function TypedWord() {
  const [idx, setIdx] = useState(0);
  const [text, setText] = useState("");
  const [phase, setPhase] = useState("typing"); // typing | hold | deleting

  useEffect(() => {
    const target = TYPE_WORDS[idx];
    let timer;
    if (phase === "typing") {
      if (text.length < target.length) {
        timer = setTimeout(() => setText(target.slice(0, text.length + 1)), 70);
      } else {
        timer = setTimeout(() => setPhase("deleting"), 1200);
      }
    } else if (phase === "deleting") {
      if (text.length > 0) {
        timer = setTimeout(() => setText(target.slice(0, text.length - 1)), 38);
      } else {
        setPhase("typing");
        setIdx((i) => (i + 1) % TYPE_WORDS.length);
      }
    }
    return () => clearTimeout(timer);
  }, [text, phase, idx]);

  return <span className="typer">{text || "\u00A0"}</span>;
}

function HeroSection() {
  return (
    <section className="section hero" id="hero" data-screen-label="Hero">
      <div className="hero-card">
        <div className="eyebrow">Mina Mikhail / Portfolio</div>
        <h1 className="hero-name">
          Hi, I&rsquo;m <em>Mina</em>.<br />I build &amp; race ideas.
        </h1>
        <div className="typer-row">
          <span>I&rsquo;m a&nbsp;</span>
          <TypedWord />
        </div>
        <div className="hero-meta">
          <span><span className="dot" /> Toronto, ON</span>
          <span>COMPUTER SCIENCE STUDENT</span>
          <span>RESEARCHER</span>
        </div>
      </div>
    </section>);

}

function FinishSection() {
  return (
    <section className="section finish" id="finish" data-screen-label="Finish">
      <div className="checker" aria-hidden="true" />
      <h2>You&rsquo;ve crossed the line.</h2>
      <p>Thanks for taking the lap. If you want to talk, race, or build — reach out.</p>
      <a className="contact" href="mailto:minamikhail504@gmail.com">
        Get in touch →
      </a>
    </section>);

}

/* ───────── Track + Car ───────── */
const SECTIONS = [
{ id: "hero", label: "START" },
{ id: "experience", label: "EXPERIENCE" },
{ id: "projects", label: "PROJECTS" },
{ id: "achievements", label: "WINS" },
{ id: "skills", label: "PIT STOP" },
{ id: "finish", label: "FINISH" }];


function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Refs
  const stageRef = useRef(null);
  const pathRef = useRef(null);
  const carRef = useRef(null);

  // Measurements that drive path generation
  const [stageSize, setStageSize] = useState({ w: 0, h: 0 });
  const [sectionTops, setSectionTops] = useState({}); // {id: yPx}
  // Y of the .finish .checker element's CENTER in document coordinates — the
  // exact pixel the path should END on so the car parks ON the finish line,
  // BEFORE the "you've crossed the line" text. Computed in measure().
  const [finishLineY, setFinishLineY] = useState(null);

  // Velocity state — only read by the speedometer. Pushed from the scroll
  // rAF via a throttled setState (~10 Hz) so the rest of App doesn't re-render
  // on every scroll frame.
  const velocityRef = useRef(0);
  const [velocity, setVelocity] = useState(0);
  const lastVelocityPushRef = useRef(0);
  // Read 100vh from CSS via a hidden probe. On iOS Safari, `window.innerHeight`
  // jitters as the URL bar shows/hides during scroll, which would constantly
  // re-compute the path waypoints and visibly shift the track underneath the
  // user. Reading CSS `100vh` resolves to the LARGE viewport height that stays
  // stable across that animation, so the layout doesn't reflow on every scroll.
  const getStableVh = useCallback(() => {
    if (typeof window === "undefined") return 800;
    const probe = document.createElement("div");
    probe.style.cssText = "position:absolute;visibility:hidden;height:100vh;width:1px;top:-9999px;left:-9999px;pointer-events:none;";
    document.body.appendChild(probe);
    const h = probe.offsetHeight || window.innerHeight;
    document.body.removeChild(probe);
    return h;
  }, []);
  const [vh, setVh] = useState(() => (typeof window !== "undefined" ? window.innerHeight : 800));

  // Pre-sampled path points: { d, x, y } for each step along the SVG path.
  const [pathSamples, setPathSamples] = useState([]);
  /* Refs used by the scroll-handler rAF to update the DOM directly. Going via
     React state (setCar / setCheckpoints) every scroll frame caused visible
     lag on Safari and Chrome both — the reconcile + commit pipeline can't keep
     up with 60 Hz scroll events. Direct DOM updates are pixel-synchronous.
     React state is still used for things that legitimately change at scroll
     boundaries (active nav section), but throttled to fire only on change. */
  const pathSamplesRef = useRef([]);
  const vhRef = useRef(800);
  const totalLenRef = useRef(0);
  const progressPathRef = useRef(null);
  const checkpointsRef = useRef([]);

  // Checkpoints + progress
  const [checkpoints, setCheckpoints] = useState([]); // [{id, label, x, y, dist, passed}]
  const [activeSection, setActiveSection] = useState("hero");

  // CSS variables driven by tweaks
  useEffect(() => {
    const root = document.documentElement;
    root.style.setProperty("--accent", t.accent);
    root.style.setProperty("--track-w", `${t.trackWidth}px`);
    root.setAttribute("data-theme", t.dark ? "dark" : "light");
  }, [t.accent, t.trackWidth, t.dark]);

  /* Theme persistence via localStorage — independent of tweaks editmode state so
     it survives navigation to other pages (case studies) and back. The theme
     written here is also read on coptic.html / wordle.html so toggling on either
     side stays consistent. */
  useEffect(() => {
    try {
      const stored = localStorage.getItem("mm-theme");
      if (stored === "dark" && !t.dark) setTweak("dark", true);else
      if (stored === "light" && t.dark) setTweak("dark", false);
    } catch {}
    // run once on mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  useEffect(() => {
    try {localStorage.setItem("mm-theme", t.dark ? "dark" : "light");} catch {}
  }, [t.dark]);

  /* Remember scroll position across navigations (e.g. into a project case-study page
     and back). On mount, restore the saved scrollY once the document is tall enough
     to actually accommodate it — measurements settle progressively as images/video
     load, so we poll for up to ~5s. On scroll, persist current scrollY (throttled).
     Disables the browser's own scroll restoration so it doesn't fight us. */
  useEffect(() => {
    if ("scrollRestoration" in window.history) {
      window.history.scrollRestoration = "manual";
    }
    const KEY = "mm-portfolio-scrollY";
    let saved = 0;
    try {saved = parseFloat(localStorage.getItem(KEY) || "0") || 0;} catch {}

    if (saved > 0) {
      // Wait until the page is tall enough for the saved position to land somewhere
      // real. Poll for ~5s; bail early once we reach the target.
      const start = performance.now();
      const tryRestore = () => {
        const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
        if (maxScroll >= saved - 4) {
          window.scrollTo(0, saved);
          return;
        }
        if (performance.now() - start < 5000) {
          requestAnimationFrame(tryRestore);
        } else {
          // Give up — scroll to whatever's reachable so we're not stranded at 0.
          window.scrollTo(0, Math.min(saved, maxScroll));
        }
      };
      requestAnimationFrame(tryRestore);
    }

    // Throttle scroll persistence to ~6/s so we don't hammer localStorage.
    let persistRaf = 0;
    let lastPersist = 0;
    const onScrollPersist = () => {
      if (persistRaf) return;
      persistRaf = requestAnimationFrame(() => {
        persistRaf = 0;
        const now = performance.now();
        if (now - lastPersist < 160) return;
        lastPersist = now;
        try {localStorage.setItem(KEY, String(window.scrollY));} catch {}
      });
    };
    window.addEventListener("scroll", onScrollPersist, { passive: true });
    // Also persist on pagehide (covers SPA-style navigations & tab close).
    const onLeave = () => {
      try {localStorage.setItem(KEY, String(window.scrollY));} catch {}
    };
    window.addEventListener("pagehide", onLeave);
    return () => {
      window.removeEventListener("scroll", onScrollPersist);
      window.removeEventListener("pagehide", onLeave);
      cancelAnimationFrame(persistRaf);
    };
  }, []);

  /* Measure stage + section tops. Re-measure on resize, font load, video metadata,
     and filter changes (DOM mutation observer). */
  useEffect(() => {
    if (!stageRef.current) return;
    const stage = stageRef.current;

    const measure = () => {
      const r = stage.getBoundingClientRect();
      const tops = {};
      SECTIONS.forEach(({ id }) => {
        const el = document.getElementById(id);
        if (el) tops[id] = el.offsetTop;
      });
      // Compute stage height from the natural content flow (last section's
      // bottom), NOT stage.scrollHeight — scrollHeight includes the
      // absolutely-positioned track SVG, whose height we set from this same
      // measurement. That feedback loop means once the SVG renders tall, it
      // pins scrollHeight at the old value even after the window shrinks. By
      // reading the finish section's offsetTop + offsetHeight we measure only
      // the in-flow content.
      const finishEl = document.getElementById(SECTIONS[SECTIONS.length - 1].id);
      const contentH = finishEl ?
      finishEl.offsetTop + finishEl.offsetHeight :
      stage.scrollHeight;
      setStageSize({ w: r.width, h: contentH });
      setSectionTops(tops);
      // Pin the path's end to the .checker element's vertical center so
      // the car parks ON the finish line (the checkered band), BEFORE the
      // "you've crossed the line" text.
      const checkerEl = finishEl ? finishEl.querySelector(".checker") : null;
      if (checkerEl && finishEl) {
        // offsetTop is relative to .finish (position:relative), and
        // finishEl.offsetTop is in the same coordinate system as the rest
        // of `tops` (relative to .stage). Add them for document Y.
        const checkerCenter =
          finishEl.offsetTop + checkerEl.offsetTop + checkerEl.offsetHeight / 2;
        setFinishLineY((prev) =>
          prev == null || Math.abs(prev - checkerCenter) > 2 ? checkerCenter : prev
        );
      }
      // Use the stable large-viewport height instead of window.innerHeight so the
      // path doesn't jitter as iOS Safari's URL bar collapses/expands.
      const stable = getStableVh();
      setVh((prev) => (Math.abs(prev - stable) > 2 ? stable : prev));
    };

    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(stage);
    window.addEventListener("resize", measure);

    // DOM mutations from filter changes
    const mo = new MutationObserver(() => {
      // debounce
      clearTimeout(window.__measureT);
      window.__measureT = setTimeout(measure, 60);
    });
    mo.observe(stage, { childList: true, subtree: true });

    // delayed pass for media
    const t1 = setTimeout(measure, 600);
    const t2 = setTimeout(measure, 1600);
    const t3 = setTimeout(measure, 3200);

    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(measure);
    }

    return () => {
      ro.disconnect();
      mo.disconnect();
      window.removeEventListener("resize", measure);
      clearTimeout(t1);clearTimeout(t2);clearTimeout(t3);
    };
  }, []);

  /* Scroll handler: a CONTINUOUS rAF loop that polls window.scrollY every
     frame, instead of waiting for scroll events. Safari (especially during
     rubber-band overscroll and momentum scroll) composes scrolling on a
     separate thread and does NOT always fire a scroll event per frame, so
     an event-driven rAF would miss bounce frames — the visible result was
     the car staying put while the page bounced, then snapping when the
     next event arrived. The cache check still skips the DOM write when
     the rounded transform values are unchanged, so the loop is effectively
     free when the page is idle. */
  useEffect(() => {
    let raf = 0;
    let lastY = window.scrollY;
    let lastT = performance.now();
    let smoothedV = 0;

    // Cached last-applied rounded values so we can skip DOM writes when the
    // numbers haven't changed — Safari re-rasterises rotated layers on every
    // tiny angle change, producing visible shiver. Rounding + skip-if-equal
    // collapses sub-pixel/sub-degree noise into stable frames.
    let lastTransformPx = NaN;
    let lastTransformPy = NaN;
    let lastTransformAngle = NaN;

    function applyCarAtScroll(rawSy) {
      const samples = pathSamplesRef.current;
      const carEl = carRef.current;
      if (!samples.length || !carEl) return;
      // Clamp scrollY to the real scroll range so iOS rubber-band overscroll
      // (where scrollY momentarily exceeds maxScroll) doesn't push the lookup
      // past the path end and visibly snap the car off the asphalt.
      const maxScroll = Math.max(
        0,
        (document.documentElement.scrollHeight || 0) - window.innerHeight
      );
      const sy = Math.max(0, Math.min(rawSy, maxScroll));
      const liveVh = window.innerHeight;
      // Clamp targetY into the actual sampled Y range so the car can NEVER
      // be asked for a point outside the path — defense against any
      // remaining edge mismatch between live vh and the stable vh the path
      // was generated with.
      const yMin = samples[0].y;
      const yMax = samples[samples.length - 1].y;
      const rawTargetY = sy + liveVh * 0.5;
      const targetY = Math.max(yMin, Math.min(yMax, rawTargetY));

      // Binary search — samples are guaranteed monotonic-non-decreasing in Y
      // (see the sampler below).
      let lo = 0, hi = samples.length - 1;
      while (lo < hi - 1) {
        const mid = (lo + hi) >> 1;
        if (samples[mid].y < targetY) lo = mid;
        else hi = mid;
      }
      const yA = samples[lo].y, yB = samples[hi].y;
      const denom = yB - yA;
      const tt = denom <= 0 ? 0 : Math.max(0, Math.min(1, (targetY - yA) / denom));
      const pd = samples[lo].d + (samples[hi].d - samples[lo].d) * tt;

      // Tangent: read from a small arc-length window around the current
      // sample. Using 6 samples (~0.4% of total length at N=1500) smooths
      // out jitter from densely-sampled adjacent points.
      const wPrev = samples[Math.max(lo - 3, 0)];
      const wNext = samples[Math.min(hi + 3, samples.length - 1)];
      const angle = Math.atan2(wNext.y - wPrev.y, wNext.x - wPrev.x) * 180 / Math.PI;

      // Round to whole pixels + whole degrees. The path is nearly straight
      // for most of its length, so quantising the rotation locks it to
      // a stable angle → no per-frame re-rasterisation on Safari.
      const pxR = Math.round(samples[lo].x + (samples[hi].x - samples[lo].x) * tt);
      // .car is position:fixed, so Y is in VIEWPORT coordinates. Compute it
      // as (path.y - rawScrollY): during normal scroll this simplifies to
      // exactly liveVh/2 (constant — no DOM write, no per-frame Y churn,
      // no lag). During rubber-band overscroll where targetY is clamped to
      // the path range but rawSy is outside [0, maxScroll], this naturally
      // shifts the car to sit on the clamped path point, keeping it glued
      // to the asphalt instead of separating from it.
      const pyDoc = samples[lo].y + (samples[hi].y - samples[lo].y) * tt;
      const pyR = Math.round(pyDoc - rawSy);
      const angleR = Math.round(angle);
      if (
        pxR !== lastTransformPx ||
        pyR !== lastTransformPy ||
        angleR !== lastTransformAngle
      ) {
        lastTransformPx = pxR;
        lastTransformPy = pyR;
        lastTransformAngle = angleR;
        carEl.style.transform = `translate3d(${pxR}px, ${pyR}px, 0) rotate(${angleR}deg)`;
      }

      const progEl = progressPathRef.current;
      const total = totalLenRef.current;
      if (progEl && total > 0) {
        const dashFill = Math.min(total, Math.max(0, pd));
        progEl.setAttribute("stroke-dasharray", `${dashFill} ${total}`);
      }

      const cps = checkpointsRef.current;
      for (let i = 0; i < cps.length; i++) {
        const cp = cps[i];
        const shouldBePassed = pd >= cp.d - 4;
        if (shouldBePassed !== cp._passed && cp._el) {
          cp._passed = shouldBePassed;
          cp._el.classList.toggle("passed", shouldBePassed);
        }
      }

      let bestActive = "hero";
      const tops = sectionTopsRef.current;
      for (const { id } of SECTIONS) {
        const top = tops[id];
        if (top != null && rawTargetY >= top) bestActive = id;
      }
      if (bestActive !== activeSectionRef.current) {
        activeSectionRef.current = bestActive;
        setActiveSection(bestActive);
      }
    }

    function tick() {
      raf = requestAnimationFrame(tick);
      const y = window.scrollY;
      const now = performance.now();
      const dt = Math.max(1, now - lastT);
      const delta = Math.abs(y - lastY);
      const instV = delta / (dt / 1000);
      const isTeleport = delta > 600 && dt < 80;
      if (!isTeleport) {
        smoothedV = smoothedV * 0.78 + instV * 0.22;
      }
      lastY = y;
      lastT = now;
      velocityRef.current = smoothedV;
      if (now - lastVelocityPushRef.current > 100) {
        lastVelocityPushRef.current = now;
        setVelocity(smoothedV);
      }
      applyCarAtScroll(y);
    }

    // Pause the rAF loop while the tab is hidden — there's no point reading
    // scrollY when nothing can be visible.
    const onVisibility = () => {
      if (document.hidden) {
        if (raf) { cancelAnimationFrame(raf); raf = 0; }
      } else if (!raf) {
        raf = requestAnimationFrame(tick);
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    // Initial position
    applyCarAtScroll(window.scrollY);
    raf = requestAnimationFrame(tick);

    const decay = setInterval(() => {
      smoothedV *= 0.85;
      velocityRef.current = smoothedV;
      setVelocity((v) => {
        const nv = v * 0.85;
        return nv < 0.5 ? 0 : nv;
      });
    }, 200);

    return () => {
      document.removeEventListener("visibilitychange", onVisibility);
      if (raf) cancelAnimationFrame(raf);
      clearInterval(decay);
    };
  }, []);

  /* Build SVG path d-string from current measurements. */
  const pathD = useMemo(() => {
    const { w, h } = stageSize;
    if (!w || !h) return "";

    // Track starts a viewport-half below page top and ends a viewport-half
    // above page bottom — so when the car is pinned to vertical viewport center
    // it lines up with the track start at scroll=0 and the track end at scroll=max.
    const trackStart = vh * 0.5;
    const trackEnd = h - vh * 0.5;

    const hero = Math.max(trackStart, sectionTops.hero ?? trackStart);
    const exp = sectionTops.experience ?? h * 0.22;
    const proj = sectionTops.projects ?? h * 0.42;
    const ach = sectionTops.achievements ?? h * 0.60;
    const skills = sectionTops.skills ?? h * 0.78;
    const fin = sectionTops.finish ?? h - 200;
    // The path ends ON the finish line (the checkered band inside the
    // .finish section), NOT at the bottom of the viewport. If we haven't
    // measured the .checker yet, fall back to trackEnd.
    const end = finishLineY != null ? finishLineY : trackEnd;

    const wp = [
    // Straight right-side track end-to-end — x stays in 0.82–0.86 with only
    // a tiny natural drift. No final curve to center; the path simply ends
    // at the finish line's vertical position (the .checker), so the car
    // parks ON the finish line at the same X as the rest of the track.
    { x: 0.84, y: trackStart },
    { x: 0.84, y: hero + (exp - hero) * 0.40 },
    { x: 0.84, y: hero + (exp - hero) * 0.92 },

    { x: 0.86, y: exp + (proj - exp) * 0.30 },
    { x: 0.84, y: exp + (proj - exp) * 0.70 },

    { x: 0.84, y: proj + (ach - proj) * 0.30 },
    { x: 0.86, y: proj + (ach - proj) * 0.70 },

    { x: 0.84, y: ach + (skills - ach) * 0.30 },
    { x: 0.84, y: ach + (skills - ach) * 0.70 },

    { x: 0.86, y: skills + (fin - skills) * 0.30 },
    { x: 0.84, y: skills + (fin - skills) * 0.70 },

    // Finish line — same X as the rest of the track, just stopped at
    // the .checker's Y so the car parks ON the line and doesn't go past.
    { x: 0.84, y: end }];


    const pts = wp.map((p) => ({ x: p.x * w, y: p.y }));
    return buildSmoothPath(pts, 0.55);
  }, [stageSize, sectionTops, vh, finishLineY]);

  /* Whenever path changes, compute checkpoint positions (one per section). */
  useEffect(() => {
    if (!pathRef.current || !pathD) return;
    const path = pathRef.current;
    const total = path.getTotalLength();
    const cps = SECTIONS.map(({ id, label }) => {
      const top = sectionTops[id];
      if (top == null) return null;
      // For 'hero' use a point near the start of path (top section); for 'finish' use end.
      let p;
      if (id === "hero") {
        const q = path.getPointAtLength(8);
        p = { x: q.x, y: q.y, d: 8 };
      } else if (id === "finish") {
        const q = path.getPointAtLength(total - 12);
        p = { x: q.x, y: q.y, d: total - 12 };
      } else {
        // Find point near section top
        p = findPointAtY(path, top + 60);
      }
      return { id, label, x: p.x, y: p.y, d: p.d };
    }).filter(Boolean);
    setCheckpoints(cps);
    // Mirror into the ref the scroll-handler rAF reads. Each entry gets a
    // mutable `_el` (assigned by the JSX ref callback below) and `_passed`
    // (last-applied state, so we only toggle the CSS class on real changes).
    checkpointsRef.current = cps.map((cp) => ({ ...cp, _el: null, _passed: false }));
  }, [pathD, sectionTops]);

  /* Drive car position: car is locked to vertical viewport center, so we find
     the point on the track whose y matches scrollY + vh/2 and steal its x +
     tangent angle. This makes the car visually stationary while the track
     scrolls past underneath it. */
  /* Pre-sample the path whenever its d-string changes. 200 samples is plenty
     for visual smoothness while keeping the per-scroll work O(log N) instead
     of O(N × getPointAtLength). */
  useEffect(() => {
    if (!pathRef.current || !pathD) { setPathSamples([]); return; }
    const path = pathRef.current;
    const total = path.getTotalLength();
    // Dense sampling — at N=1500 each segment is ~3px of arc length on a typical
    // 4500px-long track. Binary-search interpolation between adjacent samples
    // is then sub-pixel accurate, so the car stays glued to the asphalt even
    // through tight S-curves.
    const N = 1500;
    const samples = new Array(N + 1);
    for (let i = 0; i <= N; i++) {
      const d = (i / N) * total;
      const p = path.getPointAtLength(d);
      samples[i] = { d, x: p.x, y: p.y };
    }
    // FORCE strictly-monotonic Y. Catmull-Rom smoothing can let the curve
    // dip a few pixels backward in Y on tight bends; if that happens, the
    // binary search in applyCarAtScroll could land on the wrong segment and
    // briefly snap the car to a different part of the track. Clamping each
    // Y to >= previous Y guarantees the search is exact.
    for (let i = 1; i < samples.length; i++) {
      if (samples[i].y < samples[i - 1].y) {
        samples[i] = { d: samples[i].d, x: samples[i].x, y: samples[i - 1].y };
      }
    }
    setPathSamples(samples);
    pathSamplesRef.current = samples;
    totalLenRef.current = total;
    if (carRef.current && samples.length) {
      const { p, angle } = findCarPositionForY(samples, window.scrollY + window.innerHeight * 0.5);
      // .car is position:fixed → Y is viewport. Subtract scrollY so the
      // initial paint lands on the same viewport center as steady-state.
      const py = Math.round(p.y - window.scrollY);
      carRef.current.style.transform = `translate3d(${Math.round(p.x)}px, ${py}px, 0) rotate(${Math.round(angle)}deg)`;
    }
  }, [pathD]);

  // Mirror vh + sectionTops + activeSection into refs so the scroll-handler rAF
  // can read the latest values without re-binding listeners.
  useEffect(() => { vhRef.current = vh; }, [vh]);
  const sectionTopsRef = useRef({});
  useEffect(() => { sectionTopsRef.current = sectionTops; }, [sectionTops]);
  const activeSectionRef = useRef("hero");

  /* Pre-sample the path whenever its d-string changes. Same routine used
     inside the scroll rAF to position the car — we just call it once here
     to seed the DOM transform when the path changes. */
  function findCarPositionForY(samples, y) {
    let lo = 0, hi = samples.length - 1;
    while (lo < hi - 1) {
      const mid = (lo + hi) >> 1;
      if (samples[mid].y < y) lo = mid;
      else hi = mid;
    }
    const pickIdx = Math.abs(samples[lo].y - y) < Math.abs(samples[hi].y - y) ? lo : hi;
    const p = samples[pickIdx];
    const prev = samples[Math.max(pickIdx - 1, 0)];
    const next = samples[Math.min(pickIdx + 1, samples.length - 1)];
    const angle = Math.atan2(next.y - prev.y, next.x - prev.x) * 180 / Math.PI;
    return { p, angle };
  }

  /* DELETED: the previous setCar / setCheckpoints effect that ran on every
     scrollY change. Both are now driven directly from the scroll rAF above.
     This was the source of the post-scroll re-render lag. */

  /* Custom JS-driven smooth scroll. Safari's native `scrollIntoView({behavior:
     "smooth"})` is supported (Safari 15.4+) but its easing curve feels jerky
     compared to Chrome's, and on older Safari it falls back to an instant
     jump. Rolling our own rAF tween gives identical, smooth motion in every
     browser — and it cooperates with the car's continuous rAF loop instead
     of fighting the compositor for control of the scroll. */
  const scrollTo = useCallback((id) => {
    const el = document.getElementById(id);
    if (!el) return;
    const startY = window.scrollY;
    const maxScroll = Math.max(
      0,
      document.documentElement.scrollHeight - window.innerHeight
    );
    const rawTarget = el.getBoundingClientRect().top + window.scrollY;
    const targetY = Math.max(0, Math.min(rawTarget, maxScroll));
    const delta = targetY - startY;
    if (Math.abs(delta) < 1) return;
    // Distance-proportional duration, clamped — short hops feel snappy,
    // long jumps don't feel sluggish.
    const duration = Math.min(900, Math.max(450, Math.abs(delta) * 0.45));
    const startT = performance.now();
    // ease-out cubic: snappy start, gentle landing.
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    function step(now) {
      const elapsed = now - startT;
      const t = Math.min(elapsed / duration, 1);
      window.scrollTo(0, startY + delta * ease(t));
      if (t < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  }, []);

  const CarSVG = window.CAR_VARIANTS[t.carStyle] || window.SportsCar;

  const totalLen = pathRef.current ? pathRef.current.getTotalLength() : 0;
  // The scroll-handler rAF writes the live dash-array directly to
  // .track-progress via progressPathRef. We just need a sensible initial value
  // here; the rAF overwrites on the first scroll frame.
  const initialDash = `0 ${totalLen}`;
  // Lap number corresponds to the current section (1–4 across hero / experience /
  // projects / finish) so the top-right counter advances as the car enters each new
  // section on the map.
  const sectionIndex = Math.max(0, SECTIONS.findIndex((s) => s.id === activeSection));
  const lap = sectionIndex + 1;
  const totalLaps = SECTIONS.length;

  /* Mobile burger menu — independent state, closes on link click / Esc / route
     change. The menu locks page scroll while open. */
  const [menuOpen, setMenuOpen] = useState(false);
  useEffect(() => {
    if (!menuOpen) return;
    const onKey = (e) => {if (e.key === "Escape") setMenuOpen(false);};
    window.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;
    };
  }, [menuOpen]);
  const goTo = useCallback((id) => {
    setMenuOpen(false);
    // small delay so the close animation can play before scroll yanks the page
    window.setTimeout(() => scrollTo(id), 80);
  }, [scrollTo]);

  return (
    <>
      {/* top chrome */}
      <header className="chrome">
        <div className="brand">
          <span className="brand-dot" />
          <span>MM / Lap {String(lap).padStart(2, "0")} / {String(totalLaps).padStart(2, "0")}</span>
        </div>
        <nav className="navlinks" aria-label="Sections">
          {SECTIONS.map(({ id, label }) =>
          <button
            key={id}
            className={activeSection === id ? "active" : ""}
            onClick={() => scrollTo(id)}>
            
              {label}
            </button>
          )}
          <button
            className="theme-toggle"
            aria-label={t.dark ? "Switch to light mode" : "Switch to dark mode"}
            title={t.dark ? "Light mode" : "Dark mode"}
            onClick={() => setTweak("dark", !t.dark)}>
            
            {t.dark ?
            // sun
            <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <circle cx="12" cy="12" r="4" />
                <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
              </svg> :

            // moon
            <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
              </svg>
            }
          </button>
        </nav>

        {/* Mobile-only burger button — visible only when desktop navlinks are
              hidden via the responsive @media rule. */}
        <button
          className={`burger ${menuOpen ? "open" : ""}`}
          aria-label={menuOpen ? "Close menu" : "Open menu"}
          aria-expanded={menuOpen}
          aria-controls="mobile-menu"
          onClick={() => setMenuOpen((o) => !o)}>
          
          <span className="burger-bar" />
          <span className="burger-bar" />
          <span className="burger-bar" />
        </button>
      </header>

      {/* Mobile menu overlay — a racing-paddock pit-board style panel. Slides
            down from the top with staggered link entry; backdrop blurs the page.
            The aria-hidden flip lets screen readers skip when closed. */}
      <div
        id="mobile-menu"
        className={`mobile-menu ${menuOpen ? "open" : ""}`}
        aria-hidden={!menuOpen}
        role="dialog"
        aria-label="Site navigation">
        
        <div className="mobile-menu-backdrop" onClick={() => setMenuOpen(false)} />
        <div className="mobile-menu-panel">
          <div className="mobile-menu-strip" aria-hidden="true" />
          <div className="mobile-menu-head">
            <div className="mobile-menu-lap">
              <span className="mm-lap-label">Lap</span>
              <span className="mm-lap-num">{String(lap).padStart(2, "0")}<span className="mm-lap-sep">/</span>{String(totalLaps).padStart(2, "0")}</span>
            </div>
            <button
              className="mobile-menu-theme"
              aria-label={t.dark ? "Switch to light mode" : "Switch to dark mode"}
              onClick={() => setTweak("dark", !t.dark)}>
              
              {t.dark ?
              <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <circle cx="12" cy="12" r="4" />
                  <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
                </svg> :

              <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
                </svg>
              }
            </button>
          </div>
          <nav className="mobile-menu-nav" aria-label="Sections">
            {SECTIONS.map(({ id, label }, i) => {
              const active = activeSection === id;
              return (
                <button
                  key={id}
                  className={`mobile-menu-link ${active ? "active" : ""}`}
                  onClick={() => goTo(id)}
                  style={{ "--stagger": `${i * 60}ms` }}>
                  
                  <span className="mobile-menu-num">{String(i + 1).padStart(2, "0")}</span>
                  <span className="mobile-menu-label">{label}</span>
                  <span className="mobile-menu-flag" aria-hidden="true" />
                </button>);

            })}
          </nav>
          <div className="mobile-menu-foot">
            <span className="mm-tag">MM / Portfolio · Race Day</span>
          </div>
        </div>
      </div>

      {/* stage */}
      <div className="stage" ref={stageRef}>
        {/* SVG TRACK */}
        <svg
          className="track-svg"
          width={stageSize.w}
          height={stageSize.h}
          aria-hidden="true">
          
          {/* faint progress glow trailing the car */}
          {t.showProgressGlow && pathD &&
          <path
            ref={progressPathRef}
            className="track-progress"
            d={pathD}
            strokeDasharray={initialDash} />

          }
          {/* shoulder */}
          {pathD && t.trackStyle !== "minimal" &&
          <path className="track-shoulder" d={pathD} />
          }
          {/* asphalt */}
          {pathD &&
          <path
            ref={pathRef}
            className="track-asphalt"
            d={pathD}
            style={t.trackStyle === "minimal" ? {
              stroke: "transparent"
            } : {}} />

          }
          {/* invisible measurement path (always rendered so we can sample) */}
          {pathD && t.trackStyle === "minimal" &&
          <path ref={pathRef} d={pathD} fill="none" stroke="transparent" />
          }
          {/* dashed centerline */}
          {pathD && (t.trackStyle === "asphalt" || t.trackStyle === "minimal") &&
          <path
            className="track-dash"
            d={pathD}
            style={t.trackStyle === "minimal" ? {
              stroke: "var(--ink-mute)",
              strokeDasharray: "10 10",
              opacity: 0.55
            } : {}} />

          }
          {/* dotted-only style */}
          {pathD && t.trackStyle === "dotted" &&
          <path
            d={pathD}
            fill="none"
            stroke="var(--ink-soft)"
            strokeWidth="3"
            strokeDasharray="2 12"
            strokeLinecap="round"
            opacity="0.7" />

          }
        </svg>

        {/* CHECKPOINT FLAGS */}
        {checkpoints.map((cp, idx) =>
        <div
          key={cp.id}
          ref={(el) => {
            // Plumb the live DOM node into the ref the scroll rAF reads, so it
            // can toggle .passed without re-rendering React.
            const arr = checkpointsRef.current;
            const match = arr.find((c) => c.id === cp.id);
            if (match) match._el = el;
          }}
          className={`checkpoint ${cp.id === "hero" || cp.id === "finish" ? "cp-edge" : "cp-mid"}`}
          style={{ left: cp.x, top: cp.y - 24 }}>
          
            <div className="flag-pole" />
            <div className="flag-flag" />
            <div className="label">{cp.label}</div>
          </div>
        )}

        {/* CAR */}
        {pathD &&
        <div
          className="car"
          ref={carRef}>
          
            <div className="car-inner">
              <CarSVG accent={t.accent} headlights={t.dark} />
            </div>
          </div>
        }

        {/* CONTENT */}
        <HeroSection />
        <ExperienceSection />
        <ProjectsSection />
        <AchievementsSection />
        <SkillsSection />
        <FinishSection />
      </div>

      {/* speedometer */}
      {t.showSpeedo &&
      <Speedo velocity={velocity} lap={lap} totalLaps={totalLaps} />
      }

      {/* Tweaks */}
      <TweaksPanel title="Tweaks">
        <TweakSection label="Car" />
        <TweakRadio
          label="Style"
          value={t.carStyle}
          options={[
          { value: "sports", label: "Sports" },
          { value: "f1", label: "F1" },
          { value: "kart", label: "Kart" }]
          }
          onChange={(v) => setTweak("carStyle", v)} />
        

        <TweakSection label="Track" />
        <TweakRadio
          label="Style"
          value={t.trackStyle}
          options={[
          { value: "asphalt", label: "Asphalt" },
          { value: "minimal", label: "Dashed" },
          { value: "dotted", label: "Dotted" }]
          }
          onChange={(v) => setTweak("trackStyle", v)} />
        
        <TweakSlider
          label="Track width"
          value={t.trackWidth}
          min={20}
          max={80}
          step={2}
          unit="px"
          onChange={(v) => setTweak("trackWidth", v)} />
        
        <TweakToggle
          label="Progress glow"
          value={t.showProgressGlow}
          onChange={(v) => setTweak("showProgressGlow", v)} />
        

        <TweakSection label="Theme" />
        <TweakColor
          label="Accent"
          value={t.accent}
          options={ACCENT_OPTIONS}
          onChange={(v) => setTweak("accent", v)} />
        
        <TweakToggle
          label="Dark mode"
          value={t.dark}
          onChange={(v) => setTweak("dark", v)} />
        

        <TweakSection label="HUD" />
        <TweakToggle
          label="Speedometer"
          value={t.showSpeedo}
          onChange={(v) => setTweak("showSpeedo", v)} />
        
      </TweaksPanel>
    </>);

}

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