/* ============================================================
   Kevin Charboneau — Portfolio · sections
   Work (pinned vertical carousel) · About · Contact
   ============================================================ */
const { useState: useStateS, useEffect: useEffectS, useRef: useRefS } = React;

/* ============================================================
   WORK — pinned vertical carousel
   ============================================================ */
const STRIP_GAP = 14; // px between film-strip frames

/* Film-strip frame media: plays the project video when it's the focused frame,
   pauses (showing the poster frame) otherwise. Optional `start` sets the
   in-point (seconds). We use a media-fragment URL (#t=) because the preview
   server doesn't honor range-seeks, so setting currentTime is unreliable;
   loading the fragment URL reliably begins (and re-loops) at the in-point. */
function FrameVideo({ src, poster, active, start = 0, noLoop = false }) {
  const ref = useRefS(null);
  const [inView, setInView] = useStateS(false);
  const [near, setNear] = useStateS(false);   // approaching viewport → begin preload
  const url = start ? `${src}#t=${start}` : src;
  // Defer the download: only fetch the clip once the Work strip is approaching
  // the viewport (generous rootMargin gives it lead time to be ready by the
  // time the user reaches Selected Work). Keeps project videos off the critical
  // path so the hero paints fast.
  useEffectS(() => {
    const v = ref.current; if (!v) return;
    const io = new IntersectionObserver(
      (ents) => { if (ents.some((e) => e.isIntersecting)) { setNear(true); io.disconnect(); } },
      { rootMargin: "400px 0px 400px 0px", threshold: 0 }
    );
    io.observe(v);
    return () => io.disconnect();
  }, []);
  // Only consider the clip "visible" once the work stage is actually on screen,
  // so it never plays on initial load (Work sits below the fold) — only once
  // the user scrolls and this project comes into focus.
  useEffectS(() => {
    const v = ref.current; if (!v) return;
    const io = new IntersectionObserver(
      (ents) => ents.forEach((e) => setInView(e.isIntersecting && e.intersectionRatio >= 0.5)),
      { threshold: [0, 0.5, 1] }
    );
    io.observe(v);
    return () => io.disconnect();
  }, []);
  // Once near, load the #t= fragment so the paused frame shown IS the in-point
  // frame (no poster placeholder, and the clip visibly "starts" at the in-point).
  useEffectS(() => {
    const v = ref.current; if (!v || !near) return;
    try { v.load(); } catch (e) {}
  }, [url, near]);
  useEffectS(() => {
    const v = ref.current; if (!v || !near) return;
    const shouldPlay = active && inView;
    const play = () => { const p = v.play(); if (p && p.catch) p.catch(() => {}); };
    const onLoaded = () => play();
    // Re-applying the #t= fragment via load() reliably lands at the in-point,
    // unlike setting currentTime (the preview server can't range-seek). Used
    // for cold-start (play fired before the clip reached the in-point) and loop.
    const reloadAndPlay = () => { v.addEventListener("loadeddata", onLoaded, { once: true }); try { v.load(); } catch (e) { play(); } };
    const onEnded = () => { if (!noLoop) reloadAndPlay(); };
    v.addEventListener("ended", onEnded);
    if (shouldPlay) {
      if (!start || v.currentTime >= start - 0.3) play();
      else reloadAndPlay();
    } else { v.pause(); }
    return () => { v.removeEventListener("ended", onEnded); v.removeEventListener("loadeddata", onLoaded); };
  }, [active, inView, url, near]);
  return (
    <video ref={ref} className="work-frame__img" src={near ? url : undefined}
      muted playsInline preload={near ? "auto" : "none"} tabIndex={-1} aria-hidden="true" />
  );
}

/* Feature media takeover — rolling over a feature that has a clip lets its
   video take over the whole Work stage, with a subtle progress bar + X to
   close. Persists until dismissed (Esc, X, or switching projects). */
function FeatureTakeover({ data, onClose }) {
  const ref = useRefS(null);
  const [pct, setPct] = useStateS(0);
  const [idx, setIdx] = useStateS(0);
  const clips = data.clips && data.clips.length ? data.clips : [data.src];
  const multi = clips.length > 1;
  const src = clips[idx];
  const isImg = /\.(webp|png|jpe?g|gif|avif)$/i.test(src);
  const go = (d) => setIdx((i) => (i + d + clips.length) % clips.length);
  useEffectS(() => {
    const v = ref.current; if (!v || isImg) { setPct(0); return; }
    setPct(0);
    const onTime = () => { if (v.duration) setPct((v.currentTime / v.duration) * 100); };
    v.addEventListener("timeupdate", onTime);
    try { v.currentTime = 0; } catch (e) {}
    const p = v.play(); if (p && p.catch) p.catch(() => {});
    return () => v.removeEventListener("timeupdate", onTime);
  }, [src, isImg]);
  useEffectS(() => {
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      else if (multi && e.key === "ArrowRight") go(1);
      else if (multi && e.key === "ArrowLeft") go(-1);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, multi, clips.length]);
  return (
    <div className="feat-take" role="dialog" aria-modal="true" aria-label={data.name + " preview"} onClick={onClose}>
      <button className="feat-take__close" onClick={onClose} aria-label="Close preview"><Icon.x className="ico" /></button>
      {multi ? (
        <button className="feat-take__nav feat-take__nav--prev" onClick={(e) => { e.stopPropagation(); go(-1); }} aria-label="Previous clip"><Icon.arrowL className="ico" /></button>
      ) : null}
      {multi ? (
        <button className="feat-take__nav feat-take__nav--next" onClick={(e) => { e.stopPropagation(); go(1); }} aria-label="Next clip"><Icon.arrowR className="ico" /></button>
      ) : null}
      <div className="feat-take__card" onClick={(e) => e.stopPropagation()}>
        {isImg
          ? <img key={src} className="feat-take__video" src={src} alt={data.name} />
          : <video key={src} ref={ref} className="feat-take__video" src={src} playsInline autoPlay loop preload="auto" />}
        {isImg ? null : <div className="feat-take__bar"><span style={{ width: pct + "%" }} /></div>}
      </div>
      <div className="feat-take__caption">
        <span className="feat-take__name">{data.name}</span>
        {multi ? <span className="feat-take__count">{String(idx + 1).padStart(2, "0")} / {String(clips.length).padStart(2, "0")}</span> : null}
      </div>
    </div>
  );
}

function WorkDetailBody({ p, txt, setFeat }) {
  return (
    <React.Fragment>
      <div className="wd__eyebrow">
        <span>{txt(p.slug, "role", p.role || p.org)}</span>
        <span>{txt(p.slug, "year", p.year)}</span>
      </div>
      <h3 className="wd__title">{txt(p.slug, "title", p.title)}</h3>
      <p className="wd__desc">{txt(p.slug, "blurb", p.blurb).split("\n").map((ln, i) => {const bold = t => t.split(/(\*\*[^*]+\*\*)/).map((s, k) => s.startsWith("**") && s.endsWith("**") ? <strong key={k}>{s.slice(2, -2)}</strong> : s); return <React.Fragment key={i}>{i > 0 && <br />}{ln.split(/(__[^_]+__)/).map((seg, si) => seg.startsWith("__") && seg.endsWith("__") ? <span key={si} style={{color:"var(--paper,#fff)"}}>{bold(seg.slice(2, -2))}</span> : bold(seg))}</React.Fragment>;})}</p>
      {p.kind === "before" ? (
        <div className="wd__firms">
          <div className="wd__firms-row wd__firms-row--head">
            <span className="wd__firms-cell wd__firms-head-co">Company</span>
            <span className="wd__firms-cell wd__firms-head-role">Role</span>
          </div>
          {(p.companies || []).map((c, ci) => (
            <div className="wd__firms-row" key={ci}>
              <span className="wd__firms-cell wd__firms-cell--logo">
                <image-slot id={"firm-logo-" + (c.logo ? c.logo.split("/").pop() : ci)} shape="rect" fit="contain" src={c.logo || undefined}
                  placeholder=""></image-slot>
              </span>
              <span className="wd__firms-cell wd__firms-cell--name">{c.name}{c.sub ? <span className="wd__firms-sub">{c.sub}</span> : null}</span>
              <span className="wd__firms-cell wd__firms-cell--muted">{c.role}</span>
            </div>
          ))}
        </div>
      ) : (
        <React.Fragment>
          {p.features && p.features.length ? (
            <div className="wd__feat">
              <span className="eyebrow">Key features I led</span>
              <div className="wd__feat-list">
                {p.features.map((f, fi) => (
                  <div className={"wd__feat-item" + (f.media ? " wd__feat-item--media" : "")} key={fi}
                       onClick={f.media ? () => setFeat({ clips: [].concat(f.media), name: f.name }) : undefined}
                       role={f.media ? "button" : undefined} tabIndex={f.media ? 0 : undefined}
                       aria-label={f.media ? "Preview " + f.name : undefined}>
                    <span className="wd__feat-n">{String(fi + 1).padStart(2, "0")}</span>
                    <span className="wd__feat-name">{f.name.split(/(__[^_]+__)/).map((seg, si) => seg.startsWith("__") && seg.endsWith("__") ? <span key={si} style={{color:"var(--paper,#fff)"}}>{seg.slice(2, -2)}</span> : seg)}</span>
                    {f.media ? <Icon.play className="wd__feat-play" /> : null}
                  </div>
                ))}
              </div>
            </div>
          ) : p.hideAch ? null : (() => {
            const ach = (p.achievements && p.achievements.length) ? p.achievements : ACHIEVEMENT_PLACEHOLDERS;
            const isPh = !(p.achievements && p.achievements.length);
            return (
              <div className="wd__ach">
                <span className="eyebrow">Achievements</span>
                <div className={"wd__ach-list" + (isPh ? " is-placeholder" : "")}>
                  {ach.map((a, i) => (
                    <div className="wd__ach-item" key={i}>
                      <div className="wd__ach-metric">{a.metric || "\u2014"}</div>
                      <div className="wd__ach-label">{a.label || "Add an achievement"}</div>
                    </div>
                  ))}
                </div>
              </div>
            );
          })()}
        </React.Fragment>
      )}
    </React.Fragment>
  );
}

function Work({ tw, onActive }) {
  const TW = tw || {};
  const txt = (slug, f, fb) => TW["wk_" + slug + "_" + f] ?? fb;
  const trackRef = useRefS(null);
  const stripRef = useRefS(null);
  const stripTrackRef = useRefS(null);
  const detailRef = useRefS(null);
  const [active, setActive] = useStateS(0);
  const [feat, setFeat] = useStateS(null);
  const N = PROJECTS.length;
  // Close any open feature preview when the focused project changes.
  useEffectS(() => { setFeat(null); }, [active]);
  const PER = 0.45; // viewport-heights of scroll per project (tight — ~one swipe each)

  useEffectS(() => {
    const track = trackRef.current;
    if (!track) return;
    let raf = 0;
    const compute = () => {
      raf = 0;
      if (window.matchMedia("(max-width: 860px)").matches) { setActive(0); return; }
      const rect = track.getBoundingClientRect();
      const vh = window.innerHeight;
      const dist = track.offsetHeight - vh;
      const scrolled = Math.min(Math.max(-rect.top, 0), dist);
      const p = dist > 0 ? scrolled / dist : 0;
      const sPos = p * (N - 1);          // continuous strip position 0..N-1
      let idx = Math.round(sPos);
      idx = Math.min(Math.max(idx, 0), N - 1);
      setActive(idx);
      if (onActive) onActive(idx);
      // Lock the focused frame to the exact centre of the strip viewport;
      // CSS transition animates the swap when the active index changes.
      const stTrack = stripTrackRef.current, view = stripRef.current;
      if (stTrack && view && stTrack.children.length) {
        const fh = stTrack.children[0].offsetHeight;
        // Sit the focused frame at 1/4 down the slack (rather than centred) so
        // the in-focus block rides higher and the empty band above it halves.
        const topOff = Math.max(0, (view.offsetHeight - fh) * 0.25 - 24);
        const ty = topOff - idx * (fh + STRIP_GAP);
        stTrack.style.transform = `translateY(${ty.toFixed(1)}px)`;
        // Top-align the detail text with the focused frame's top.
        if (detailRef.current) detailRef.current.style.paddingTop = topOff.toFixed(1) + "px";
      }
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    onScroll();
    const t = setTimeout(onScroll, 120); // re-position once frames have laid out
    return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); clearTimeout(t); if (raf) cancelAnimationFrame(raf); };
  }, [N]);

  const jumpTo = (idx) => {
    const track = trackRef.current; if (!track) return;
    const vh = window.innerHeight;
    const dist = track.offsetHeight - vh;
    const top = track.getBoundingClientRect().top + window.scrollY;
    const targetP = N > 1 ? idx / (N - 1) : 0;
    window.scrollTo({ top: Math.round(top + targetP * dist), behavior: "smooth" });
  };

  const trackStyle = { height: `calc(100svh + ${(N * PER * 100).toFixed(0)}svh)` };

  // Per-section type settings (apply across all projects via CSS vars on #work)
  const num = (k, d) => { const v = TW[k]; return v == null ? d : v; };
  const typeStyle = {
    "--wk-eyebrow-scale": num("wk_ty_eyebrow_size", 100) / 100,
    "--wk-eyebrow-tr": num("wk_ty_eyebrow_tr", 0.14) + "em",
    "--wk-meta-scale": num("wk_ty_meta_size", 100) / 100,
    "--wk-meta-tr": num("wk_ty_meta_tr", 0.1) + "em",
    "--wk-title-scale": num("wk_ty_title_size", 100) / 100,
    "--wk-title-weight": num("wk_ty_title_weight", 800),
    "--wk-title-lh": num("wk_ty_title_lh", 1.02),
    "--wk-title-tr": num("wk_ty_title_tr", -0.02) + "em",
    "--wk-desc-scale": num("wk_ty_desc_size", 100) / 100,
    "--wk-desc-lh": num("wk_ty_desc_lh", 1.5),
    "--wk-tags-scale": num("wk_ty_tags_size", 100) / 100,
    "--wk-tags-tr": num("wk_ty_tags_tr", 0.1) + "em",
    "--wk-cta-scale": num("wk_ty_cta_size", 100) / 100,
    "--wk-cta-tr": num("wk_ty_cta_tr", 0.1) + "em",
  };

  return (
    <section className="work" id="work" style={typeStyle}>
      <div className="work-track" ref={trackRef} style={trackStyle}>
        <div className="work-stage">
          <div className="work-stage__head">
            <div className="work-stage__eyebrow">{TW.wk_sectionLabel ?? "Selected Work"}</div>
            <div className="work-stage__count"><b>{String(active + 1).padStart(2, "0")}</b> / {String(N).padStart(2, "0")}</div>
          </div>

          <div className="work-body">
            <div className="work-detail" ref={detailRef}>
              {PROJECTS.map((p, i) => (
                <div className={"wd" + (i === active ? " is-active" : "") + (p.kind === "before" ? " wd--before" : "") + (p.features && p.features.length ? " wd--feat" : "")} key={p.slug} aria-hidden={i !== active}>
                  <WorkDetailBody p={p} txt={txt} setFeat={setFeat} />
                </div>
              ))}
            </div>

            <div className="work-strip" ref={stripRef}>
              <div className="work-strip__track" ref={stripTrackRef}>
                {PROJECTS.map((p, i) => (
                  <div className={"work-frame" + (i === active ? " is-active" : "") + (p.kind === "before" ? " work-frame--before" : "")} key={p.slug}
                       onClick={() => (i === active ? null : jumpTo(i))}
                       role={i === active ? undefined : "button"} aria-label={i === active ? undefined : "Go to " + p.title}>
                    {p.kind === "before"
                      ? (p.img ? <img className="work-frame__img" src={p.img} alt={p.title} loading="lazy" /> : <div className="work-frame__before"><span className="eyebrow">Earlier career</span><span className="work-frame__before-t">Before Meta</span></div>)
                      : p.video
                      ? <FrameVideo src={p.video} poster={p.img} active={i === active} start={p.videoStart || 0} noLoop={p.noLoop} />
                      : <img className="work-frame__img" src={p.img} alt={p.title} loading={i < 3 ? "eager" : "lazy"} />}
                  </div>
                ))}
              </div>
              <div className="work-strip__fade work-strip__fade--top" />
              <div className="work-strip__fade work-strip__fade--bot" />
            </div>
          </div>
          {feat ? <FeatureTakeover data={feat} onClose={() => setFeat(null)} /> : null}
        </div>
      </div>

      {/* Mobile stacked list */}
      <div className="work-mobile">
        {PROJECTS.map((p, i) => (
          <div className="wm-card" key={p.slug}>
            {(p.video || p.img) && (
              <div className="wm-card__media">{p.video
                ? <FrameVideo src={p.video} poster={p.img} active={true} start={p.videoStart || 0} noLoop={p.noLoop} />
                : <img src={p.img} alt={p.title} loading="lazy" />}</div>
            )}
            <div className="wm-card__detail">
              <WorkDetailBody p={p} txt={txt} setFeat={setFeat} />
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

/* ============================================================
   ABOUT
   ============================================================ */
const ABOUT_INTRO = "11+ years as a high-performer at Meta, 6 years as a design manager. 3 years as Internship program Director for Facebook and Reality Labs.\n\nGround-breaking design impact at the dawn of 2 computing revolutions (mobile and AI wearables).\n\nFrom sole designer at 3 startups to __design leader for Meta's highest-profile bets__.";

const ABOUT_POINTS = [
  { icon: "rocket", h: "I've built successful new product categories from 0 → 1",
    b: "Across startups and big tech, enterprise and consumer products, starting with zero customers or a billion. I've learned hard lessons and racked up massive wins building in uncharted domains." },
  { icon: "penTool", h: "I lead through craft — always raising the bar",
    b: "Well crafted experiences build trust. Users read visual quality as product quality. I sweat the details users never consciously notice but always feel. I stay close to customers; advocate for them relentlessly." },
  { icon: "sparkles", h: "I'm an AI optimist, but we have to steer it towards good",
    b: "AI has supercharged how I leverage my extensive industry experience, unlocking my creativity and speed to turn ideas into reality." },
  { icon: "compass", h: "I thrive in the mess",
    b: "I bring calm to chaos. Nothing works as planned when you're building something that's never been done before. I keep my eyes on the goal but happy to take ANY road that gets us there. I seamlessly switch altitudes — framing a 3-year vision in the morning, refining a micro-interaction in the afternoon." },
  { icon: "whistle", h: "I'm a true player / coach",
    b: "I move fluidly between IC craft and design leadership. I lead by doing: hands in the work, prototypes over decks, and a quality bar I set by example." },
  { icon: "razor", h: "I sharpen strategy",
    b: "I'm most valuable in ambiguity. When the problem has a spark but stays undefined, I create clarity and momentum. Every breakthrough starts with honestly diagnosing the real problem and challenging our assumptions — even if sitting with ambiguity is uncomfortable." },
  { icon: "swap", h: "I influence without authority",
    b: "I build genuine partnerships with engineering and product — shared goals, shared trade-offs, shared wins — not a service-desk relationship. I'm a translator: between user needs and business goals, between design intent and engineering constraint, between vision and this sprint." },
  { icon: "sprout", h: "I build a culture where great work comes from",
    b: "I coach and enable designers to do the best work of their careers — through purpose, accountability and empowerment. I advocate for safe spaces to go deep, be thoughtful and to challenge the status quo. Seeking certainty kills originality." },
];

function About() {
  return (
    <section className="section about" id="about">
      <div className="wrap">
        <div className="sec-head reveal">
          <div className="sec-head__label">About</div>
        </div>
        <div className="about__grid">
          <div className="about__portrait reveal">
            <image-slot id="kc-portrait" shape="rounded" radius="12" src="assets/kc-portrait.jpg"
              placeholder="Drop a portrait — 4:5 works best"></image-slot>
            <div className="about__id">
              <div className="about__name">Kevin Charboneau</div>
              <div className="about__loc">Seattle, WA</div>
              <a className="about__mail" href="mailto:kcharboneau@gmail.com"></a>
            </div>
          </div>
          <div className="about__main">
            <h2 className="about__lead reveal" data-d="1">
              18 years designing at the <span className="hl-sweep">scale of billions</span> and creating <em className="hl-sweep">market-defining</em> products.
            </h2>
            <p className="about__intro reveal" data-d="2" style={{fontSize:"18px"}}>{ABOUT_INTRO.split("\n").map((ln, i) => <React.Fragment key={i}>{i > 0 && <br />}{ln.split(/(__[^_]+__)/).map((seg, si) => seg.startsWith("__") && seg.endsWith("__") ? <span key={si} style={{color:"var(--paper,#fff)"}}>{seg.slice(2, -2)}</span> : seg)}</React.Fragment>)}</p>
            <div className="about__points">
              {ABOUT_POINTS.map((pt, i) => {
                const Ic = Icon[pt.icon];
                return (
                  <div className="about__point reveal" data-d={(i % 4) + 1} key={i}>
                    <span className="about__point-ic" aria-hidden="true">{Ic && <Ic />}</span>
                    <span className="about__point-c">
                      <span className="about__point-h">{pt.h}</span>
                      <span className="about__point-b">{pt.b}</span>
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ============================================================
   CONTACT / FOOTER
   ============================================================ */
const SOCIALS = [
  ["LinkedIn", "in/kevin-charboneau", "https://www.linkedin.com/in/kevin-charboneau/", "linkedin"],
  ["Email", "kcharboneau@gmail.com", "mailto:kcharboneau@gmail.com", "mail"],
];

/* Square pixel sparkles that spawn as a TRACER along the cursor path — same
   glowing white pixel as the hero, but emitted at the pointer and fading out.
   Sits behind the contact content (z-index 0), pointer-events none. */
function ContactSparkles() {
  const canvasRef = useRefS(null);
  useEffectS(() => {
    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 (matches the hero sparkle).
    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
      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="contact__sparkles" ref={canvasRef} aria-hidden="true" />;
}

function Contact() {
  return (
    <footer className="section contact" id="contact">
      <ContactSparkles />
      <div className="wrap">
        <div className="contact__grid">
          <div className="contact__lead reveal">
            <h2 className="contact__title">Let's build<br />what's <span className="ice">next.</span></h2>
          </div>
          <div className="contact__links reveal" data-d="1">
            {SOCIALS.map(([n, h, url, ic]) => {
              const Ic = Icon[ic];
              return (
                <a className="contact__link" key={n} href={url} target={url.startsWith("mailto:") ? undefined : "_blank"} rel="noreferrer">
                  <span className="contact__link-l">{Ic && <Ic className="ico" />}{n}</span>
                  <span className="h">{h} <Icon.arrowUR style={{ width: 13, height: 13 }} /></span>
                </a>
              );
            })}
          </div>
        </div>
        <div className="foot-base">
          <span>© 2026 Kevin Charboneau</span>
          <span>Product Design · Available for select work</span>
        </div>
      </div>
    </footer>
  );
}

/* ============================================================
   CASE STUDY PAGE
   ============================================================ */
/* Placeholder rows shown until a project defines its own `features`
   array in port-data.jsx. Shape: { name, role, impact }. */
const FEATURE_PLACEHOLDERS = [
  { name: "Feature name", role: "Led", impact: "Add impact" },
  { name: "Feature name", role: "Designed", impact: "Add impact" },
  { name: "Feature name", role: "Led", impact: "Add impact" },
  { name: "Feature name", role: "Co-designed", impact: "Add impact" },
  { name: "Feature name", role: "Designed", impact: "Add impact" },
];

/* Placeholder achievements shown until a project defines its own
   `achievements` array in port-data.jsx. Shape: { metric, label }. */
const ACHIEVEMENT_PLACEHOLDERS = [
  { metric: "00", label: "Achievement — add a result or recognition here." },
  { metric: "00", label: "Achievement — add a result or recognition here." },
];

function Achievements({ items }) {
  const rows = (items && items.length ? items : ACHIEVEMENT_PLACEHOLDERS);
  const isPlaceholder = !(items && items.length);
  return (
    <div className={"cs__ach" + (isPlaceholder ? " is-placeholder" : "")}>
      {rows.map((a, i) => (
        <div className="cs__ach-item" key={i}>
          <div className="cs__ach-metric">{a.metric || "—"}</div>
          <div className="cs__ach-label">{a.label || "Add an achievement"}</div>
        </div>
      ))}
    </div>
  );
}

/* Top-level Achievements section for the home page. */
function AchievementsSection() {
  return (
    <section className="section achievements" id="achievements">
      <div className="wrap">
        <div className="sec-head reveal">
          <div className="sec-head__label">Achievements</div>
        </div>
        <div className="reveal" data-d="1">
          <Achievements items={undefined} />
        </div>
      </div>
    </section>
  );
}

function FeatureTable({ items }) {
  const rows = (items && items.length ? items : FEATURE_PLACEHOLDERS);
  const isPlaceholder = !(items && items.length);
  return (
    <div className={"cs__ftable" + (isPlaceholder ? " is-placeholder" : "")}>
      <div className="cs__ftrow cs__ftrow--head">
        <span className="cs__ftcell cs__ftcell--n">#</span>
        <span className="cs__ftcell">Feature</span>
        <span className="cs__ftcell">Role</span>
        <span className="cs__ftcell">Impact</span>
      </div>
      {rows.map((f, i) => (
        <div className="cs__ftrow" key={i}>
          <span className="cs__ftcell cs__ftcell--n">{String(i + 1).padStart(2, "0")}</span>
          <span className="cs__ftcell cs__ftcell--feat">{f.name || "Feature name"}</span>
          <span className="cs__ftcell cs__ftcell--muted">{f.role || "—"}</span>
          <span className="cs__ftcell cs__ftcell--muted">{f.impact || "—"}</span>
        </div>
      ))}
    </div>
  );
}

Object.assign(window, { Work, About, Contact, ContactSparkles, AchievementsSection });
