/* ============================================================
   Kevin Charboneau — Portfolio · app shell
   Nav · Hero (parallax) · hash router · page-transition overlay · Tweaks
   ============================================================ */
const { useState, useEffect, useLayoutEffect, useRef, useCallback } = React;

/* ---- Tweak defaults ---- */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "transition": "curtain",
  "motion": "medium",
  "displayFont": "DM Sans",
  "ramp": "balanced",
  "accent": "#e0f4ff"
}/*EDITMODE-END*/;

/* Per-project Work text — defaults pulled from PROJECTS; overrides persist as
   they're edited (host adds keys to the EDITMODE block on first change). */
const WORK_TEXT_DEFAULTS = (() => {
  const d = { wk_sectionLabel: "Selected Work", wk_cta: "View case study" };
  PROJECTS.forEach((p) => {
    d["wk_" + p.slug + "_org"] = p.org;
    d["wk_" + p.slug + "_year"] = p.year;
    d["wk_" + p.slug + "_kicker"] = p.kicker;
    d["wk_" + p.slug + "_title"] = p.title;
    d["wk_" + p.slug + "_blurb"] = p.blurb;
    d["wk_" + p.slug + "_tags"] = (p.tags || []).join(", ");
  });
  // Per-section type settings (global across projects)
  Object.assign(d, {
    wk_ty_title_size: 100, wk_ty_title_weight: 800, wk_ty_title_lh: 1.02, wk_ty_title_tr: -0.02,
    wk_ty_eyebrow_size: 100, wk_ty_eyebrow_tr: 0.14,
    wk_ty_meta_size: 100, wk_ty_meta_tr: 0.1,
    wk_ty_desc_size: 100, wk_ty_desc_lh: 1.5,
    wk_ty_tags_size: 100, wk_ty_tags_tr: 0.1,
    wk_ty_cta_size: 100, wk_ty_cta_tr: 0.1,
  });
  return d;
})();

const MOTION_SCALE = { subtle: 0.5, medium: 1, bold: 1.7 };
const RAMP = {
  tight:    { lhHead: "1.0",  lhSnug: "1.05", trHead: "-0.028em" },
  balanced: { lhHead: "1.06", lhSnug: "1.12", trHead: "-0.02em" },
  airy:     { lhHead: "1.14", lhSnug: "1.22", trHead: "-0.012em" },
};
const FONT_STACK = {
  "DM Sans": "'DM Sans', system-ui, sans-serif",
  "Cal Sans": "'Cal Sans', 'DM Sans', sans-serif",
};

/* ---- hash routing ---- */
function parseHash() {
  const h = (location.hash || "").replace(/^#/, "");
  return { name: "home", anchor: h && !h.startsWith("/") ? h : null };
}

/* ============================================================
   NAV
   ============================================================ */
/* ---- Brand hover: the "KC" monogram unfurls into the full
   "Kevin Charboneau" wordmark, revealing one letter at a time, left→right.
   At rest we show the clean KC monogram; on hover it cross-fades to the
   wordmark (K to K, in place) as the rest of the name types in.
   Opens on hover/focus, collapses on leave. ---- */
const BRAND_REST = 85.8;   // right clip-inset (%) that leaves only the wordmark's K showing
// Reveal targets, one per letter, as right clip-inset %: e v i n · C h a r b o n (eau)
const BRAND_STOPS = [77.5, 69.2, 65.1, 53.8, 35.5, 28.4, 22.5, 17.8, 10.7, 5.9, 2.4, 0];
function openBrand(el) {
  const kc = el.querySelector(".brand__kc");
  if (!kc || kc.classList.contains("is-open")) return;
  kc.classList.add("is-open");
  const word = kc.querySelector(".brand__word");
  if (kc.__rev) { clearInterval(kc.__rev); kc.__rev = null; }
  if (!word) return;
  word.style.clipPath = `inset(0 ${BRAND_REST}% 0 0)`;   // start on the K, aligned to the fading monogram
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    word.style.clipPath = "inset(0 0 0 0)";
    return;
  }
  let i = 0;
  const tick = () => {
    word.style.clipPath = `inset(0 ${BRAND_STOPS[i]}% 0 0)`;
    if (++i >= BRAND_STOPS.length) { clearInterval(kc.__rev); kc.__rev = null; }
  };
  kc.__rev = setInterval(tick, 32);
}
function closeBrand(el) {
  const kc = el.querySelector(".brand__kc");
  if (!kc) return;
  if (kc.__rev) { clearInterval(kc.__rev); kc.__rev = null; }
  kc.classList.remove("is-open");
  const word = kc.querySelector(".brand__word");
  if (word) word.style.clipPath = `inset(0 ${BRAND_REST}% 0 0)`;
}

function Nav({ route, onNav, onHome }) {
  const [hidden, setHidden] = useState(false);
  const [active, setActive] = useState("");
  const lastY = useRef(0);

  useEffect(() => {
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const y = window.scrollY;
        setHidden(y > 120); // stay hidden once scrolled down (no scroll-up reveal)
        lastY.current = y;
        if (route.name === "home") {
          const ids = ["work", "about", "contact"];
          let cur = "";
          for (const id of ids) {
            const el = document.getElementById(id);
            if (el && el.getBoundingClientRect().top < window.innerHeight * 0.4) cur = id;
          }
          setActive(cur);
        }
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [route.name]);

  const goSection = (id) => {
    if (route.name !== "home") { onHome(id); return; }
    document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
  };

  return (
    <nav className={"nav" + (hidden ? " is-hidden" : "")}>
      <div className="brand" onClick={() => onHome()} role="button" tabIndex={0} aria-label="Kevin Charboneau — home"
        onMouseEnter={(e) => openBrand(e.currentTarget)} onMouseLeave={(e) => closeBrand(e.currentTarget)}
        onFocus={(e) => openBrand(e.currentTarget)} onBlur={(e) => closeBrand(e.currentTarget)}>
        <span className="brand__kc" aria-hidden="true">
          <InlineSvg className="brand__mono" src="assets/port/kc-default.svg" />
          <InlineSvg className="brand__word" src="assets/port/kc-hover.svg" />
        </span>
      </div>
      <div className="nav__links">
        <button className={"nav__link" + (active === "work" ? " is-active" : "")} onClick={() => goSection("work")}>Work</button>
        <button className={"nav__link" + (active === "about" ? " is-active" : "")} onClick={() => goSection("about")}>About</button>
        <button className={"nav__link" + (active === "contact" ? " is-active" : "")} onClick={() => goSection("contact")}>Contact</button>
      </div>
    </nav>
  );
}

/* ============================================================
   HERO SPARKLES  — square pixel sparkles that spawn as a TRACER
   along the cursor path (glowing white pixel emitted at the
   pointer and fading out). Sits below the grain and every hero
   element (z-index 0), pointer-events none.
   ============================================================ */
function HeroSparkles() {
  const canvasRef = useRef(null);
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);

    // Pre-rendered glowing square pixel sprite.
    const SPRITE = 64, C = SPRITE / 2, STAR_R = 9;
    const sprite = document.createElement("canvas");
    sprite.width = sprite.height = Math.round(SPRITE * dpr);
    const sc = sprite.getContext("2d");
    sc.setTransform(dpr, 0, 0, dpr, 0, 0);
    sc.translate(C, C);
    sc.shadowColor = "rgba(255,255,255,0.9)";
    sc.shadowBlur = STAR_R * 2.4;
    sc.imageSmoothingEnabled = false;
    const sq = STAR_R * 1.15;
    sc.fillStyle = "#fff";
    sc.fillRect(-sq / 2, -sq / 2, sq, sq);

    let w = 0, h = 0, raf = 0;
    let particles = [];
    let lastSpawn = 0;   // throttle emission (ms) so the trail is sparser
    const mouse = { x: 0, y: 0, has: false, px: 0, py: 0 };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      w = rect.width; h = rect.height;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const spawn = (x, y, speed) => {
      // fewer sparks, and slower drift, than the contact tracer
      const n = Math.min(2, 1 + Math.round(speed / 40));
      for (let i = 0; i < n; i++) {
        const ang = Math.random() * Math.PI * 2;
        const sp = Math.random() * 0.22 + 0.04;
        particles.push({
          x: x + (Math.random() - 0.5) * 8,
          y: y + (Math.random() - 0.5) * 8,
          vx: Math.cos(ang) * sp,
          vy: Math.sin(ang) * sp - 0.04,   // slight upward bias
          life: 1,
          decay: (Math.random() * 0.008 + 0.005) / 1.5,   // last 50% longer
          r: Math.random() * 1.1 + 0.5,
          tw: Math.random() * Math.PI * 2,
          tws: Math.random() * 0.05 + 0.02,
        });
      }
      if (particles.length > 320) particles.splice(0, particles.length - 320);
    };

    const step = () => {
      ctx.clearRect(0, 0, w, h);
      for (let i = particles.length - 1; i >= 0; i--) {
        const p = particles[i];
        p.x += p.vx; p.y += p.vy;
        p.vy += 0.0018;          // faint gravity
        p.vx *= 0.99; p.vy *= 0.99;
        p.life -= p.decay;
        p.tw += p.tws;
        if (p.life <= 0) { particles.splice(i, 1); continue; }
        const a = Math.max(0, p.life) * (0.65 + Math.sin(p.tw) * 0.35);
        const R1 = p.r * 2.6 * 1.25;   // +25% pixel size
        const size = (R1 / STAR_R) * SPRITE * (0.5 + p.life * 0.5);
        ctx.globalAlpha = Math.max(0, a);
        ctx.drawImage(sprite, p.x - size / 2, p.y - size / 2, size, size);
      }
      ctx.globalAlpha = 1;
      raf = requestAnimationFrame(step);
    };

    const onMove = (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left, y = e.clientY - rect.top;
      if (x < 0 || y < 0 || x > w || y > h) return;
      const now = performance.now();
      const speed = mouse.has ? Math.hypot(x - mouse.px, y - mouse.py) : 0;
      mouse.px = x; mouse.py = y; mouse.has = true;
      if (now - lastSpawn < 46) return;   // emit at most ~22x/sec (+20%)
      lastSpawn = now;
      spawn(x, y, speed);
    };

    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    if (!reduced) {
      window.addEventListener("mousemove", onMove, { passive: true });
      raf = requestAnimationFrame(step);
    }
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      window.removeEventListener("mousemove", onMove);
    };
  }, []);
  return <canvas className="hero__sparkles" ref={canvasRef} aria-hidden="true" />;
}

/* ============================================================
   HERO  (parallax wordmark + glasses)
   ============================================================ */
function Hero() {
  const wmRef = useRef(null);
  const glRef = useRef(null);
  const heroRef = useRef(null);
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const marks = Array.from(document.querySelectorAll(".hero [data-wm]"));
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const y = window.scrollY;
        const m = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--motion")) || 1;
        if (y < window.innerHeight * 1.2) {
          const tf = `translateX(-50%) translateY(calc(-50% - 20px + ${(y * 0.18 * m).toFixed(1)}px))`;
          marks.forEach((el) => { el.style.transform = tf; });
          if (glRef.current) glRef.current.style.transform = `translateY(${(y * 0.06 * m).toFixed(1)}px) scale(${1 + Math.min(y, 600) * 0.00012 * m})`;
        }
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, []);

  return (
    <header className="hero" id="top" ref={heroRef}>
      <HeroSparkles />
      <div className="hero__grain" />

      {/* Wordmark, behind the glasses */}
      <div className="hero__wordmark" data-wm ref={wmRef}>
        <div className="wm-wipe"><InlineSvg src="assets/port/kevin-display.svg" label="KEViN" /></div>
      </div>

      {/* Glasses — soft settle-in on load, parallax on scroll */}
      <div className="hero__glasses-wrap">
        <img className="hero__glasses" ref={glRef} src="assets/port/hero-glasses.png" alt="Meta Ray-Ban Display glasses with on-lens AR interface" />
      </div>

      <div className="hero__caption">
        <div className="hero__role">AI Design Leader</div>
        <div className="hero__sub">Startup scrappy, big-tech results</div>
      </div>
    </header>
  );
}

/* ============================================================
   STEP SCROLL  —  one gesture = one section step
   Stops: hero → each Work project → About → Contact.
   Wheel / keyboard / swipe advance exactly one stop and animate to it.
   Native scroll-snap is disabled while active (we own the snapping).
   Disabled on mobile (<861px), reduced-motion, and off the home route.
   ============================================================ */
function useStepScroll(routeName) {
  useEffect(() => {
    // Disabled: the custom wheel/touch/keyboard scroll-jacking below kept
    // breaking in ways that were hard to reproduce and fix reliably (an
    // animation that could stall mid-step and leave the page unresponsive
    // to further scroll input). Falling back to plain native scrolling —
    // still shaped by the CSS scroll-snap-type on <html> — removes that
    // whole class of bug entirely.
    return;
    // eslint-disable-next-line no-unreachable
    if (routeName !== "home") return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const mq = window.matchMedia("(min-width: 861px)");
    const root = document.documentElement;
    let animating = false;
    let cooldownUntil = 0;
    let touchY = 0;
    let watchdog = 0;
    let scrollEndHandler = null;

    // Belt-and-suspenders cleanup: cancels the fallback timer and detaches the
    // scrollend listener a step attached, so neither fires again after the
    // step is considered done (whether it finished normally or was force-cleared).
    const clearScrollWatch = () => {
      if (scrollEndHandler) { window.removeEventListener("scrollend", scrollEndHandler); scrollEndHandler = null; }
      clearTimeout(watchdog);
    };
    // If the tab backgrounds mid-step, or `scrollend` never fires for some
    // reason, `animating` could otherwise stay true forever, permanently
    // swallowing all further wheel/keyboard/touch scroll input. These two
    // safety nets guarantee `animating` always clears.
    const unstick = () => { clearScrollWatch(); animating = false; cooldownUntil = 0; };
    const onVisibility = () => { if (document.visibilityState === "visible") unstick(); };
    document.addEventListener("visibilitychange", onVisibility);

    const prevSnap = root.style.scrollSnapType;

    // Stops: hero → each Work project → About top. Contact is NOT a step — once
    // About is reached the page hands back to natural scrolling (About + Contact).
    const getStops = () => {
      const vh = window.innerHeight;
      const stops = [0];
      const track = document.querySelector(".work-track");
      const N = PROJECTS.length;
      if (track) {
        const top = track.getBoundingClientRect().top + window.scrollY;
        const dist = Math.max(0, track.offsetHeight - vh);
        for (let i = 0; i < N; i++) {
          stops.push(Math.round(top + (N > 1 ? i / (N - 1) : 0) * dist));
        }
      }
      const about = document.getElementById("about");
      if (about) stops.push(aboutHeadTop());
      return stops;
    };

    // About's box top (where the section begins) vs. the About HEADER's pinned
    // position (box top + the section's top padding). The header target is the
    // snap/step destination so the header lands flush at the top of the window;
    // the box top defines the free zone so the WHOLE section — including its top
    // padding — scrolls natively without a jerky step handoff near the top.
    const aboutBoxTop = () => {
      const about = document.getElementById("about");
      return about ? about.getBoundingClientRect().top + window.scrollY : Infinity;
    };
    const aboutHeadTop = () => {
      const about = document.getElementById("about");
      if (!about) return Infinity;
      const pt = parseFloat(getComputedStyle(about).paddingTop) || 0;
      return Math.round(about.getBoundingClientRect().top + window.scrollY + pt);
    };
    const inFreeZone = () => window.scrollY >= aboutBoxTop() - 2;        // About box & below
    const atAboutTop = () => window.scrollY <= aboutHeadTop() + 8;       // About header at/near top

    // Snap off while stepping (mid-track Work stops aren't snap edges); restore
    // the original proximity snap once we're in the About/Contact free zone.
    const applySnap = () => {
      root.style.scrollSnapType = (mq.matches && !inFreeZone()) ? "none" : prevSnap;
    };
    applySnap();

    const nearest = (stops, y) => {
      let best = 0, bd = Infinity;
      stops.forEach((s, i) => { const d = Math.abs(s - y); if (d < bd) { bd = d; best = i; } });
      return best;
    };

    // Native compositor-driven smooth scroll instead of a hand-rolled
    // requestAnimationFrame loop: rAF callbacks get throttled or fully paused
    // by the browser whenever the tab isn't the focused/visible one (alt-tab,
    // a notification stealing focus, a second monitor, etc.), which stalled a
    // JS-driven loop mid-animation and left the page parked at a random,
    // non-stop scroll position. `window.scrollTo({behavior:"smooth"})` is
    // owned and completed by the browser itself, so it isn't subject to that.
    const animateTo = (y) => {
      const startY = window.scrollY;
      const dy = y - startY;
      if (Math.abs(dy) < 2) return;
      animating = true;
      // Rough distance-scaled estimate, used only to size the fallback below —
      // actual native smooth-scroll pacing is chosen by the browser.
      const dur = Math.min(680, Math.max(380, Math.abs(dy) * 0.42));
      clearScrollWatch();
      scrollEndHandler = () => {
        scrollEndHandler = null;
        clearTimeout(watchdog);
        animating = false;
        cooldownUntil = performance.now() + 160;
        applySnap();
      };
      window.addEventListener("scrollend", scrollEndHandler, { once: true });
      // Fallback for browsers without `scrollend` (or if it's ever missed) —
      // same safety net as before, just retargeted at the native scroll.
      watchdog = setTimeout(unstick, dur + 500);
      window.scrollTo({ top: y, behavior: "smooth" });
    };

    const stepBy = (dir) => {
      if (animating || performance.now() < cooldownUntil) return;
      const stops = getStops();
      const cur = nearest(stops, window.scrollY);
      const next = Math.min(stops.length - 1, Math.max(0, cur + dir));
      if (next === cur) return;
      animateTo(stops[next]);
    };
    const stepTo = (edge) => {
      if (animating || performance.now() < cooldownUntil) return;
      const stops = getStops();
      animateTo(stops[edge === "end" ? stops.length - 1 : 0]);
    };

    // Bail out (let native scroll happen) over the Tweaks panel or feature modal.
    const overChrome = (t) =>
      (t && t.closest && t.closest(".twk-panel")) || document.querySelector(".feat-take");

    const onWheel = (e) => {
      if (!mq.matches) return;
      if (overChrome(e.target)) return;
      if (inFreeZone() && !animating) {
        // Downward: native — read the list, continue into Contact.
        if (e.deltaY >= 0) return;
        // Upward: native through the list; only once the About header is back at
        // the top of the window does one more scroll step up to the last project.
        if (!atAboutTop()) return;
      }
      e.preventDefault();
      const now = performance.now();
      // While a step is animating or settling, swallow the event and push the
      // cooldown out — this absorbs trackpad/inertia momentum so one physical
      // flick moves exactly one stop instead of skipping the next.
      if (animating || now < cooldownUntil) { cooldownUntil = now + 160; return; }
      if (Math.abs(e.deltaY) < 2) return;
      stepBy(e.deltaY > 0 ? 1 : -1);
    };
    const onKey = (e) => {
      if (!mq.matches) return;
      const t = e.target;
      const tag = t && t.tagName;
      if (t && (t.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT")) return;
      if (t && t.closest && t.closest(".twk-panel")) return;
      if (document.querySelector(".feat-take")) return;
      const down = e.key === "ArrowDown" || e.key === "PageDown" || e.key === " " || e.key === "Spacebar";
      const up = e.key === "ArrowUp" || e.key === "PageUp";
      // Let About/Contact scroll natively with the keyboard too.
      if (inFreeZone()) {
        if (down) return;
        if (up && !atAboutTop()) return;
      }
      if (down) { e.preventDefault(); stepBy(1); }
      else if (up) { e.preventDefault(); stepBy(-1); }
      else if (e.key === "Home") { e.preventDefault(); stepTo("home"); }
      else if (e.key === "End") { e.preventDefault(); stepTo("end"); }
    };
    const onTouchStart = (e) => { touchY = e.touches[0].clientY; };
    const onTouchMove = (e) => {
      if (!mq.matches || overChrome(e.target)) return;
      if (inFreeZone()) return; // natural scrolling through About/Contact
      e.preventDefault(); // block native scroll; we drive the step on touchend
    };
    const onTouchEnd = (e) => {
      if (!mq.matches || overChrome(e.target)) return;
      const dy = touchY - (e.changedTouches[0] ? e.changedTouches[0].clientY : touchY);
      if (inFreeZone()) {
        if (dy >= 0) return;              // swipe up (scroll down) — native
        if (!atAboutTop()) return;        // still list above — native
      }
      if (Math.abs(dy) > 40) stepBy(dy > 0 ? 1 : -1);
    };
    const onScrollSnap = () => { if (!animating) applySnap(); };

    window.addEventListener("wheel", onWheel, { passive: false });
    window.addEventListener("keydown", onKey);
    window.addEventListener("touchstart", onTouchStart, { passive: true });
    window.addEventListener("touchmove", onTouchMove, { passive: false });
    window.addEventListener("touchend", onTouchEnd, { passive: true });
    window.addEventListener("scroll", onScrollSnap, { passive: true });
    mq.addEventListener("change", applySnap);
    return () => {
      window.removeEventListener("wheel", onWheel);
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("touchstart", onTouchStart);
      window.removeEventListener("touchmove", onTouchMove);
      window.removeEventListener("touchend", onTouchEnd);
      window.removeEventListener("scroll", onScrollSnap);
      mq.removeEventListener("change", applySnap);
      document.removeEventListener("visibilitychange", onVisibility);
      clearScrollWatch();
      root.style.scrollSnapType = prevSnap;
    };
  }, [routeName]);
}

/* ============================================================
   APP
   ============================================================ */
function App() {
  const [t, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...WORK_TEXT_DEFAULTS, kc_crop_zoom: 1, kc_crop_x: 0, kc_crop_y: 0 });
  const [route, setRoute] = useState(parseHash());
  const [activeIdx, setActiveIdx] = useState(0); // focused Work project (for text editing)
  const [ov, setOv] = useState(null); // { style, phase }
  const busy = useRef(false);
  const homeScrollY = useRef(0);   // scroll position to restore when returning home
  const pendingScroll = useRef(0); // scroll target to apply after the next route lays out

  /* After a route change, restore the intended scroll position once the new
     view's layout exists (the Work track is multi-viewport, so we wait a frame
     before scrolling so the target position — and carousel project — is valid). */
  useLayoutEffect(() => {
    const y = pendingScroll.current;
    const root = document.documentElement;
    const prev = root.style.scrollBehavior;
    requestAnimationFrame(() => { requestAnimationFrame(() => {
      // Force instant (the move happens while the transition overlay covers the
      // screen) — otherwise scroll-behavior:smooth animates a long visible scroll.
      root.style.scrollBehavior = "auto";
      window.scrollTo(0, y);
      root.style.scrollBehavior = prev;
    }); });
  }, [route.name, route.slug]);

  /* apply tweaks to CSS vars */
  useEffect(() => {
    const r = document.documentElement;
    r.style.setProperty("--motion", String(MOTION_SCALE[t.motion] ?? 1));
    r.style.setProperty("--font-display", FONT_STACK[t.displayFont] || FONT_STACK["DM Sans"]);
    const rp = RAMP[t.ramp] || RAMP.balanced;
    r.style.setProperty("--lh-head", rp.lhHead);
    r.style.setProperty("--lh-snug", rp.lhSnug);
    r.style.setProperty("--tr-head", rp.trHead);
    r.style.setProperty("--ice", t.accent || "#e0f4ff");
  }, [t.motion, t.displayFont, t.ramp, t.accent]);

  /* apply portrait crop to the image-slot */
  useEffect(() => {
    const el = document.getElementById("kc-portrait");
    if (!el) return;
    const apply = () => { if (el.setCrop) el.setCrop(t.kc_crop_zoom, t.kc_crop_x, t.kc_crop_y); };
    apply();
    // Re-apply shortly after mount in case the image hasn't decoded yet.
    const id = setTimeout(apply, 120);
    return () => clearTimeout(id);
  }, [t.kc_crop_zoom, t.kc_crop_x, t.kc_crop_y, route.name]);

  useReveal(route.name + (route.slug || ""));
  useStepScroll(route.name);

  /* sync to hash on back/forward */
  useEffect(() => {
    const onHash = () => { if (!busy.current) setRoute(parseHash()); };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);

  const reduced = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const transitionTo = useCallback((nextRoute, hash, scrollTarget = 0) => {
    if (busy.current) return;
    const style = t.transition || "curtain";
    const DUR = style === "fade" ? 280 : 520;
    pendingScroll.current = scrollTarget;
    if (reduced) {
      busy.current = true;
      if (hash !== undefined) location.hash = hash;
      setRoute(nextRoute);
      requestAnimationFrame(() => { busy.current = false; });
      return;
    }
    busy.current = true;
    setOv({ style, phase: "cover" });
    setTimeout(() => {
      if (hash !== undefined) location.hash = hash;
      setRoute(nextRoute);
      setOv({ style, phase: "reveal" });
      setTimeout(() => { setOv(null); busy.current = false; }, DUR + 30);
    }, DUR);
  }, [t.transition, reduced]);

  const goHome = (anchor) => {
    // Back with no anchor restores the exact prior scroll position; an anchor
    // (nav Work/About/Contact) scrolls to that section instead.
    transitionTo({ name: "home", anchor: anchor || null }, "", anchor ? 0 : homeScrollY.current);
    if (anchor) {
      setTimeout(() => document.getElementById(anchor)?.scrollIntoView({ behavior: "smooth" }), 620);
    }
  };

  /* Work text editing — targets whichever project is currently in focus */
  const editP = PROJECTS[Math.min(Math.max(activeIdx, 0), PROJECTS.length - 1)];
  const wk = (f) => "wk_" + editP.slug + "_" + f;

  return (
    <React.Fragment>
      <Nav route={route} onHome={goHome} />
      <main>
        <Hero />
        <Work tw={t} onActive={setActiveIdx} />
        <About />
        <Contact />
      </main>

      {ov && (
        <div className={`pt pt--${ov.style} ${ov.phase}`} aria-hidden="true">
          {ov.style === "curtain" && <div className="pt__mark"><InlineSvg src="assets/port/kevin-display.svg" /></div>}
        </div>
      )}

      <TweaksPanel title="Tweaks">
        <TweakSection label="Motion" />
        <TweakRadio label="Page transition" value={t.transition}
          options={["curtain", "cover", "fade"]}
          onChange={(v) => setTweak("transition", v)} />
        <TweakRadio label="Scroll motion" value={t.motion}
          options={["subtle", "medium", "bold"]}
          onChange={(v) => setTweak("motion", v)} />
        <TweakSection label="Type ramp" />
        <TweakRadio label="Display font" value={t.displayFont}
          options={["DM Sans", "Cal Sans"]}
          onChange={(v) => setTweak("displayFont", v)} />
        <TweakRadio label="Rhythm" value={t.ramp}
          options={["tight", "balanced", "airy"]}
          onChange={(v) => setTweak("ramp", v)} />
        <TweakSection label="Accent" />
        <TweakColor label="Ink" value={t.accent}
          options={["#e0f4ff", "#c2d6e1", "#a9c7dc", "#9ec2a8"]}
          onChange={(v) => setTweak("accent", v)} />
        <TweakSection label="Work — content" />
        <TweakRow label="Editing" value={editP.title} />
        <TweakText label="Title" value={t[wk("title")]}
          onChange={(v) => setTweak(wk("title"), v)} />
        <TweakRow label="Description">
          <textarea className="twk-field"
            style={{ height: 70, padding: "6px 8px", lineHeight: 1.4, resize: "vertical", fontFamily: "inherit" }}
            value={t[wk("blurb")]} onChange={(e) => setTweak(wk("blurb"), e.target.value)} />
        </TweakRow>
        <TweakText label="Organization" value={t[wk("org")]}
          onChange={(v) => setTweak(wk("org"), v)} />
        <TweakText label="Year" value={t[wk("year")]}
          onChange={(v) => setTweak(wk("year"), v)} />
        <TweakText label="Tags (comma-separated)" value={t[wk("tags")]}
          onChange={(v) => setTweak(wk("tags"), v)} />
        <TweakSection label="Work — labels" />
        <TweakText label="Section eyebrow" value={t.wk_sectionLabel}
          onChange={(v) => setTweak("wk_sectionLabel", v)} />
        <TweakText label="CTA button" value={t.wk_cta}
          onChange={(v) => setTweak("wk_cta", v)} />
        <TweakSection label="Type · Title" />
        <TweakSlider label="Size" value={t.wk_ty_title_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_title_size", v)} />
        <TweakRadio label="Weight" value={t.wk_ty_title_weight} options={[600, 700, 800]}
          onChange={(v) => setTweak("wk_ty_title_weight", v)} />
        <TweakSlider label="Line height" value={t.wk_ty_title_lh} min={0.9} max={1.4} step={0.01}
          onChange={(v) => setTweak("wk_ty_title_lh", v)} />
        <TweakSlider label="Tracking" value={t.wk_ty_title_tr} min={-0.05} max={0.05} step={0.002} unit="em"
          onChange={(v) => setTweak("wk_ty_title_tr", v)} />
        <TweakSection label="Type · Eyebrow" />
        <TweakSlider label="Size" value={t.wk_ty_eyebrow_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_eyebrow_size", v)} />
        <TweakSlider label="Tracking" value={t.wk_ty_eyebrow_tr} min={0} max={0.3} step={0.005} unit="em"
          onChange={(v) => setTweak("wk_ty_eyebrow_tr", v)} />
        <TweakSection label="Type · Meta (org · year)" />
        <TweakSlider label="Size" value={t.wk_ty_meta_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_meta_size", v)} />
        <TweakSlider label="Tracking" value={t.wk_ty_meta_tr} min={0} max={0.3} step={0.005} unit="em"
          onChange={(v) => setTweak("wk_ty_meta_tr", v)} />
        <TweakSection label="Type · Description" />
        <TweakSlider label="Size" value={t.wk_ty_desc_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_desc_size", v)} />
        <TweakSlider label="Line height" value={t.wk_ty_desc_lh} min={1.1} max={2} step={0.01}
          onChange={(v) => setTweak("wk_ty_desc_lh", v)} />
        <TweakSection label="Type · Tags" />
        <TweakSlider label="Size" value={t.wk_ty_tags_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_tags_size", v)} />
        <TweakSlider label="Tracking" value={t.wk_ty_tags_tr} min={0} max={0.3} step={0.005} unit="em"
          onChange={(v) => setTweak("wk_ty_tags_tr", v)} />
        <TweakSection label="Type · CTA button" />
        <TweakSlider label="Size" value={t.wk_ty_cta_size} min={70} max={160} unit="%"
          onChange={(v) => setTweak("wk_ty_cta_size", v)} />
        <TweakSlider label="Tracking" value={t.wk_ty_cta_tr} min={0} max={0.3} step={0.005} unit="em"
          onChange={(v) => setTweak("wk_ty_cta_tr", v)} />
        <TweakSection label="About — portrait crop" />
        <TweakSlider label="Zoom" value={t.kc_crop_zoom} min={1} max={4} step={0.02} unit="×"
          onChange={(v) => setTweak("kc_crop_zoom", v)} />
        <TweakSlider label="Horizontal" value={t.kc_crop_x} min={-1} max={1} step={0.02}
          onChange={(v) => setTweak("kc_crop_x", v)} />
        <TweakSlider label="Vertical" value={t.kc_crop_y} min={-1} max={1} step={0.02}
          onChange={(v) => setTweak("kc_crop_y", v)} />
      </TweaksPanel>
    </React.Fragment>
  );
}

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