// Portfolio — single-file React app
const { useState, useEffect, useRef, useCallback, useMemo } = React;

// ─── Tweaks ────────────────────────────────────────────────────────────────
const TWEAKS_DEFAULTS = /*EDITMODE-BEGIN*/{
  "bluAccent": "#007AFF",
  "rossoAccent": "#FF4040",
  "checkers": true,
  "heroSize": 64,
  "ctaWeight": "italic-bold",
  "showHand": true,
  "wavePalette": "refined"
} /*EDITMODE-END*/;

const FOLDER_PATH = "M 0 10 C 0 4.477 4.477 0 10 0 L 44.639 0 C 47.42 0 50.075 1.158 51.967 3.195 L 52.533 3.805 C 54.425 5.842 57.08 7 59.861 7 L 119 7 C 124.523 7 129 11.477 129 17 L 129 81 C 129 86.523 124.523 91 119 91 L 10 91 C 4.477 91 0 86.523 0 81 L 0 10 Z";

// ─── Theme palettes ────────────────────────────────────────────────────────
const THEMES = {
  blu: {
    key: "blu",
    label: "Work Portfolio",
    heroBg: "#000",
    portfolioBg: "#0E0E0E",
    portfolioFg: "#fff",
    heroFg: "#fff",
    softFg: "rgba(255,255,255,.55)",
    softerFg: "rgba(255,255,255,.35)",
    rule: "rgba(255,255,255,.08)",
    chipBg: "rgba(255,255,255,.08)",
    chipBorder: "rgba(255,255,255,.14)",
    handBg: "#0A1834",
    handTint: "#fff",
    barColor: "rgba(255,255,255,.7)",
    dotInactive: "rgba(255,255,255,.25)",
    ctaCard: "#0E0E0E",
    btnTextOnAccent: "#fff",
    btnTextOnOutline: "#fff",
    btnOutlineBorder: "rgba(255,255,255,.5)"
  },
  rosso: {
    key: "rosso",
    label: "Side Quests",
    heroBg: "#ff4438",
    portfolioBg: "#FFFFFF",
    portfolioFg: "#0E0E0E",
    heroFg: "#0E0E0E",
    softFg: "rgba(0,0,0,.55)",
    softerFg: "rgba(0,0,0,.35)",
    rule: "rgba(0,0,0,.08)",
    chipBg: "rgba(0,0,0,.045)",
    chipBorder: "rgba(0,0,0,.12)",
    handBg: "#E5DED5",
    handTint: "#0E0E0E",
    barColor: "rgba(255,64,64,.85)",
    dotInactive: "rgba(0,0,0,.18)",
    ctaCard: "#FFFFFF",
    btnTextOnAccent: "#fff",
    btnTextOnOutline: "#0E0E0E",
    btnOutlineBorder: "rgba(0,0,0,.85)"
  }
};

// ─── Tag palette ───────────────────────────────────────────────────────────
const TAGS = {
  art: { label: "Art Direction", bg: "transparent", color: "#0E0E0E", border: "1px solid rgba(0,0,0,.35)" },
  motion: { label: "Motion", bg: "#C983FF", color: "#0E0E0E" },
  visual: { label: "Visual", bg: "#AEE9FF", color: "#0E0E0E" },
  illu: { label: "Illustration", bg: "#FF8B61", color: "#0E0E0E" },
  ai: { label: "Ai", bg: "linear-gradient(135deg, #FFDDF6, #D2E7FF)", color: "#0E0E0E" },
  web: { label: "Web Design", bg: "#59A9FF", color: "#0E0E0E" },
  ux: { label: "UI/UX", bg: "#B2FD90", color: "#0E0E0E" },
  game: { label: "Game Design", bg: "#FDD57B", color: "#0E0E0E" }
};

// ─── Projects ──────────────────────────────────────────────────────────────
const PROJECTS = {
  blu: [
  // Tones & tags taken from /Sito/about-blu + /Sito/experience-blu Figma
  { name: "CapaToast", img: "assets/capatoast/megatoast-board.jpg", video: "assets/capatoast/menu-v2.mp4", tone: "#F4C400", fit: "cover", tags: ["motion", "visual", "illu"], year: "2023 — 2024",
    subtitle: "Food chain",
    description: "Capatoast is the first toasteria chain in Italy. Founded in 2014, it turned the classic toast into a complete meal, offering \"mega toasts\" made with large artisan bread (up to 15×19 cm) and filled with high-quality ingredients. It is present across Italy with dozens of stores, often located in shopping centers or in the historic centers of major cities.",
    description2: null,
    gallery: [
      { type: "image-group",
        images: [
          "assets/capatoast/store-yellow.jpg",
          "assets/capatoast/cagliari.png",
          "assets/capatoast/storefront-padova.jpg",
          "assets/capatoast/store-granroma.jpg",
          "assets/capatoast/store-globo.jpg"
        ],
        caption: "Stores · Italia",
        ratio: "16 / 9" },
      { type: "quote", size: 27, content: "CapaToast had a strong, recognizable visual identity, but no language for movement.\n\nThe brand lived in its visuals, not in motion." },
      { type: "text", content: "Together with the agency Fatelardo, I built the brand's first motion system: the way the logo, the products and the campaigns come to life on every screen." },
      { type: "text", content: "The animation style was suggested by the brand itself, almost naturally. CapaToast's letters have an irregular, almost bouncing rhythm, so the motion became choppy, built on just a few frames, recognizable in an instant." },
      { type: "video", url: "assets/capatoast/logo-loop.mp4", caption: "Logo animation · loop" },
      { type: "video", url: "assets/capatoast/menu-v2.mp4", caption: "Brand" },
      { type: "quote", content: "A stepped, jerky rhythm that fits an ironic and direct brand." },
      { type: "text", content: "From there, the system extended to everything else: menu boards, in-store screens, ledwalls, totems and social." },
      { type: "video", url: "assets/capatoast/logo-intro.mp4", caption: "Social" },
      { type: "video", url: "assets/capatoast/v06.mp4", caption: "Reels & stories · social" },
      { type: "video", url: "assets/capatoast/v07b.mp4", caption: "In-store screens" },
      { type: "video", url: "assets/capatoast/limited-v2.mp4", caption: "Limited edition · campaign" },
      { type: "video", url: "assets/capatoast/v08.mp4", caption: "In-store screen" },
      { type: "video", url: "assets/capatoast/v09.mp4", caption: "Store set-up" },
      { type: "video", url: "assets/capatoast/v10.mp4", caption: "Dual screens" },
      { type: "video", url: "assets/capatoast/v11.mp4", caption: "In-store screens" },
      { type: "text", content: "Toastino, the mascot, was designed and animated with the same logic as the brand the same choppy timing, the same attitude." },
      { type: "video", url: "assets/capatoast/v13.mp4", caption: "Toastino · reel" },
      { type: "video", url: "assets/capatoast/v14.mp4", caption: "Toastino · in-store screens" },
      { type: "video", url: "assets/capatoast/v15.mp4", caption: "Toastino · in-store screen" },
      { type: "video", url: "assets/capatoast/v16.mp4", caption: "Toastino · dual screens" },
      { type: "video", url: "assets/capatoast/grazie.mp4", caption: "Grazie · closing" },
    ] },
  { name: "Museo Maradona", embed: "maradona", video: "assets/maradona/cover.mp4", img: "assets/museo-maradona.jpg", tone: "#59A9FF", fit: "cover", tags: ["art", "motion", "visual", "illu"], year: "2024",
    subtitle: "Visual identity for a museum dedicated to Diego Maradona",
    description: "Identità visiva per uno spazio espositivo permanente dedicato alla figura di Diego Armando Maradona. Il progetto mette in dialogo iconografia popolare napoletana e linguaggio museale, restituendo all'archivio fotografico una grammatica visiva contemporanea.",
    description2: "Il sistema di segnaletica e i materiali di accompagnamento traducono la passione tifoide in un'esperienza curatoriale. Tipografia ampia, illustrazioni in tratto pulito e palette desaturata fanno da contrappunto alla potenza dei materiali storici.",
    gallery: 2 },
  { name: "Very Cool Italian Gestures", embed: "gestures", video: "assets/gestures/cover.mp4", img: null, tone: "#2E9E5B", tags: ["art", "motion", "visual", "illu"], year: "2024",
    subtitle: "An illustrated dictionary of Italian gestures",
    description: "Un dizionario illustrato dei gesti italiani più riconoscibili, animati e raccolti in una pubblicazione editoriale. Ogni gesto è raccontato con un breve loop motion e una didascalia in inglese/italiano per il pubblico internazionale.",
    description2: "Il progetto unisce illustrazione vettoriale e motion design per dare vita ai gesti come fossero piccoli pittogrammi animati. Una palette rossa e nera tiene insieme la collezione.",
    gallery: 2 },
  { name: "Archive", archive: true, embed: "archive", img: null, tone: "#59A9FF", solid: true, tags: [], year: "",
    subtitle: "Archive",
    description: "",
    gallery: 2 }],

  rosso: [
  { name: "QuestRoller", img: null, tone: "#FF4438", solid: true, tags: ["illu", "ux", "game", "art"], year: "2026",
    subtitle: "A dice-rolling mini-game living in the red home",
    description: "",
    gallery: 2 },
  { name: "Nintendo DSi Homebrew GifCam", img: null, tone: "#C7C7C7", solid: true, tags: ["visual"], year: "2025",
    subtitle: "Homebrew camera app for Nintendo DSi",
    description: "",
    gallery: 2 }]
};

// ─── Experience (used by both themes) ──────────────────────────────────────
const WORK_HISTORY = [
{ co: "NeaVision", role: "Art Director", dates: "June 2025 — June 2026", clients: [
  ["Vertis S.G.R", "Private Equity & Venture Capital"], ["UniVertis", "Business School"], ["IPE Business School", "Business School"], ["Stoà Business School", "Business School"], ["ForMare", "Maritime Shipping & Finance"], ["Progetto Civico Italia — Ercolano", "Political Campaign"], ["Ciclope Beach", "Beach Club Events"], ["Talia", "Restaurant"], ["Sequoia", "Restaurant"]] },
{ co: "Overcome", role: "Art Director", dates: "Oct 2024 — June 2025", clients: [
  ["Museo Maradona", "Sport Museum"], ["FulFilledOne", "Logistics & Transport"]] },
{ co: "Fatelardo", role: "Visual / Motion Designer", dates: "Oct 2023 — Oct 2024", clients: [
  ["Capatoast", "Food Chain"], ["Puok", "Food Chain"], ["Supermercati Piccolo", "Supermarket Chain"], ["Grrrinta", "Fast Food"], ["Bricklane", "Restaurant"], ["Panamar", "Restaurant"], ["Santeribs", "Restaurant"], ["Saomaki Sushi", "Restaurant"]] },
{ co: "ZuZuWork", role: "Visual Designer", dates: "June 2023 — Oct 2023", clients: [
  ["Molini Ambrosio", "Flour Milling Industry"], ["Cesto Bakery", "Award-winning Bakery"], ["TuttoPizza", "Pizza Festival"], ["Pomodoreria", "Italian Tomato Brand"]] }];

const EDUCATION = [
{ title: "Design della Comunicazione Multimediale", school: "Accademia di Belle Arti di Napoli", years: "2023 — 2025", kind: "Master" },
{ title: "Design della Comunicazione Multimediale", school: "Accademia di Belle Arti di Napoli", years: "2020 — 2023", kind: "Bachelor" }];


// ─── Helpers ───────────────────────────────────────────────────────────────
function Pill({ kind, size = "sm", children }) {
  const t = TAGS[kind] || TAGS.visual;
  const s = size === "lg" ?
  { fontSize: 12, padding: "4px 11px", letterSpacing: ".01em" } :
  { fontSize: 10.5, padding: "3px 9px", letterSpacing: ".01em" };

  // "art" tag is outlined/transparent — use the current text color so it
  // stays readable on either dark (BLU) or light (ROSSO) portfolio bg.
  const themeAware = kind === "art";
  return (
    <span style={{
      display: "inline-flex",
      alignItems: "center",
      borderRadius: 999,
      fontWeight: 500,
      lineHeight: 1.15,
      background: t.bg,
      color: themeAware ? "currentColor" : t.color,
      border: themeAware ? "1px solid currentColor" : t.border || "none",
      whiteSpace: "nowrap",
      ...s
    }}>{children ?? t.label}</span>);

}

// Permanently-baked media for slots the user filled by drag-drop. Extracted
// from localStorage into real project files so they survive export/publish
// and appear identically on every machine. Keyed by slotId.
const BAKED_MEDIA = {
  "hero-anim-blu-top":   { type: "image", url: "assets/dropped/hero-anim-blu-top.svg" },
  "hero-anim-rosso-top": { type: "image", url: "assets/dropped/hero-anim-rosso-top.png" },
  "hand-footer-blu":     { type: "image", url: "assets/dropped/hand-footer-blu.png" },
  "hand-footer-rosso":   { type: "image", url: "assets/dropped/hand-footer-blu.png", whiten: true, force: true },
  "cta-animation-blu":   { type: "video", url: "assets/dropped/cta-animation-blu.webm" },
  "cta-animation-rosso": { type: "video", url: "assets/dropped/cta-animation-rosso.mp4" },
};

// True once the intro video has finished — project media wait for it so the
// first visit isn't fighting the intro for bandwidth.
function useIntroDone() {
  const [ok, setOk] = React.useState(!!window.__introDone);
  React.useEffect(() => {
    if (ok) return;
    const on = () => setOk(true);
    window.addEventListener("intro-done", on);
    return () => window.removeEventListener("intro-done", on);
  }, [ok]);
  return ok;
}

// ─── Network-aware media ──────────────────────────────────────────────────
// Light JPG posters (≈10–70 KB) show instantly; the video streams in behind
// them. On slow / data-saver connections videos don't download until tapped.
const POSTERS = new Set(["menu-v2","logo-loop","logo-intro","v06","v07b","limited-v2","v08","v09","v10","v11","v13","v14","v15","v16","grazie"]);
function posterFor(url) {
  const m = /assets\/capatoast\/([\w-]+)\.mp4$/.exec(url || "");
  return m && POSTERS.has(m[1]) ? `assets/capatoast/posters/${m[1]}.jpg` : undefined;
}
const SLOW_NET = (() => {
  const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
  if (!c) return false;
  return !!c.saveData || /(^|-)2g|3g/.test(c.effectiveType || "") || (c.downlink > 0 && c.downlink < 1.5);
})();
window.__SLOW_NET = SLOW_NET;

// ─── VideoGovernor — only videos actually on screen decode and play; the rest
// pause. Ten 1080p loops playing at once starve the decoder and make single
// videos stall, especially on phones.
function VideoGovernor() {
  React.useEffect(() => {
    const io = new IntersectionObserver((es) => es.forEach((e) => {
      const v = e.target;
      if (v.controls) return; // user-driven player
      if (e.isIntersecting) { if (window.__SLOW_NET && !v.dataset.user) return; const p = v.play(); if (p && p.catch) p.catch(() => {}); }
      else v.pause();
    }), { threshold: 0.15 });
    const seen = new WeakSet();
    const scan = () => document.querySelectorAll("#root video").forEach((v) => {
      if (seen.has(v)) return; seen.add(v);
      v.setAttribute("playsinline", ""); v.muted = v.muted || !v.controls;
      io.observe(v);
    });
    const mo = new MutationObserver(scan);
    mo.observe(document.getElementById("root"), { childList: true, subtree: true });
    scan();
    return () => { mo.disconnect(); io.disconnect(); };
  }, []);
  return null;
}

// ─── Museo Maradona case study — self-contained web component (shadow DOM),
// loaded once on first open. Assets live next to the script in museo-maradona/.
function MaradonaEmbed() {
  React.useEffect(() => {
    if (customElements.get("maradona-case-study") || document.querySelector("script[data-mm-cs]")) return;
    const s = document.createElement("script");
    s.src = "museo-maradona/maradona-case-study.js";
    s.setAttribute("data-mm-cs", "");
    document.body.appendChild(s);
  }, []);
  return (
    <div className="cs-embed" style={{ padding: "12px 0 0" }}>
      <maradona-case-study transparent="" style={{ display: "block", "--mm-edge": "22px", "--mm-sticky-top": "16px" }}></maradona-case-study>
    </div>);
}

// ─── PlayDie — floating die button on the red home: double-tap starts the Quest.
function PlayDie({ show, invite = false, onSwitch }) {
  const [hop, setHop] = React.useState(0);
  const last = React.useRef(0);
  // Hidden while a Quest is running and the red header is on screen.
  const [quest, setQuest] = React.useState(false);
  const [heroVis, setHeroVis] = React.useState(true);
  React.useEffect(() => {
    const on = (e) => { if (e.data && typeof e.data.quest === "boolean") setQuest(e.data.quest); };
    window.addEventListener("message", on);
    return () => window.removeEventListener("message", on);
  }, []);
  React.useEffect(() => {
    if (!show) return;
    const el = document.querySelector(".hero-screen");
    if (!el || !("IntersectionObserver" in window)) return;
    const io = new IntersectionObserver(([e]) => setHeroVis(e.intersectionRatio > 0.25), { threshold: [0, .25, .5, 1] });
    io.observe(el);
    return () => io.disconnect();
  }, [show]);
  // Invite mode (blue page): show only while "Let's Keep in Touch" is on screen.
  const [ctaVis, setCtaVis] = React.useState(false);
  React.useEffect(() => {
    if (!show || !invite) return;
    // Visible from Work History onward (down to the CTA and footer).
    let raf = 0;
    const check = () => {
      raf = 0;
      const el = document.querySelector(".ow-title") || document.querySelector(".pf-archive") || document.querySelector(".pf-narrow") || document.querySelector(".cta-wrap");
      setCtaVis(!!el && el.getBoundingClientRect().top < window.innerHeight * 0.85);
    };
    const on = () => { if (!raf) raf = requestAnimationFrame(check); };
    window.addEventListener("scroll", on, { passive: true, capture: true });
    window.addEventListener("resize", on);
    const t = setTimeout(check, 300);
    return () => { clearTimeout(t); cancelAnimationFrame(raf); window.removeEventListener("scroll", on, { capture: true }); window.removeEventListener("resize", on); };
  }, [show, invite]);
  const hidden = invite ? !ctaVis : (quest && heroVis);
  // Re-run the roll-in each time the die (re)appears.
  const [entry, setEntry] = React.useState(0);
  const wasHidden = React.useRef(true);
  React.useEffect(() => {
    if (!show) { wasHidden.current = true; return; }
    if (wasHidden.current && !hidden) setEntry((n) => n + 1);
    wasHidden.current = hidden;
  }, [show, hidden]);
  if (!show) return null;
  const start = () => {
    if (invite && onSwitch) { onSwitch(); return; }
    const root = document.getElementById("root");
    if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: "smooth" });
    window.scrollTo({ top: 0, behavior: "smooth" });
    setTimeout(() => {
      const fr = document.querySelector('iframe[src^="hero-rosso.html"]');
      if (fr && fr.contentWindow) fr.contentWindow.postMessage({ cmd: "startQuest" }, "*");
    }, 450);
  };
  const tap = () => {
    const t = performance.now();
    if (t - last.current < 340) { last.current = 0; start(); }
    else { last.current = t; setHop((h) => h + 1); }
  };
  return ReactDOM.createPortal(
    <button type="button" className={"play-die" + (hidden ? " is-hidden" : "")} tabIndex={hidden ? -1 : 0} onClick={tap} onDoubleClick={(e) => e.preventDefault()} aria-label="Double-tap to play">
      <span className="play-die__balloon" key={"b" + hop + "-" + entry}>Double-tap to play</span>
      <span className="play-die__roll" key={"r" + entry}>
      <span className="play-die__cube">
        <svg viewBox="0 0 100 100" width="46" height="46" aria-hidden="true">
          <polygon points="50,4 90,27 90,73 50,96 10,73 10,27" fill="#fff3ea" stroke="#c4241b" strokeWidth="3.5" strokeLinejoin="round" />
          <polygon points="50,22 76,66 24,66" fill="#ffe8de" stroke="rgba(196,36,27,.28)" strokeWidth="1.6" strokeLinejoin="round" />
          <path d="M50 4 L50 22 M90 27 L76 66 M10 27 L24 66 M50 96 L76 66 M50 96 L24 66 M10 27 L50 22 L90 27 M10 73 L24 66 M90 73 L76 66" fill="none" stroke="rgba(196,36,27,.24)" strokeWidth="1.4" strokeLinejoin="round" strokeLinecap="round" />
          <text x="50" y="57" textAnchor="middle" fontFamily="Inter, system-ui, sans-serif" fontWeight="800" fontStyle="italic" fontSize="21" fill="#c4241b">20</text>
        </svg>
      </span>
      </span>
    </button>, document.body);
}

// ─── Very Cool Italian Gestures — self-contained slide deck in an iframe that
// reports its own height, so it reads as part of the page (no inner scroll).
function GesturesEmbed() {
  const [h, setH] = React.useState(0);
  React.useEffect(() => {
    const on = (e) => { if (e.data && e.data.vcigHeight) setH(e.data.vcigHeight); };
    window.addEventListener("message", on);
    return () => window.removeEventListener("message", on);
  }, []);
  return (
    <div className="cs-embed" style={{ padding: "16px 0 0" }}>
      <iframe src="gestures/case/index.html" title="Very Cool Italian Gestures" loading="lazy"
      style={{ display: "block", width: "100%", height: h || "56.25vw", minHeight: 200, border: 0, background: "#111" }} />
    </div>);
}

// ─── Archive — every other client, grouped. Masonry of images and loops at
// their natural proportions; media load lazily as they near the screen.
const ARCHIVE = [{"c":"Merendina","s":"","i":[["v","assets/archive/merendina/logo-animation.mp4",1,1.778],["i","assets/archive/merendina/carta-pizza.jpg",null,0.75],["i","assets/archive/merendina/ingredienti.jpg",null,1],["i","assets/archive/merendina/maglie.jpg",null,0.75],["i","assets/archive/merendina/tatoo.jpg",null,0.75]]},{"c":"Grrrinta","s":"Fast Food","i":[["v","assets/archive/grrrinta/grrinta-monitor-esterno-1.mp4",1,3.241],["v","assets/archive/grrrinta/grrinta-monitor-esterno-2.mp4",1,3.241],["v","assets/archive/grrrinta/grrinta-monitor-interno-1.mp4",1,1.778],["v","assets/archive/grrrinta/grrinta-ruggito-logo.mp4",null,0.8]]},{"c":"Gate 20","s":"","i":[["v","assets/archive/gate-20/gate20-menu-monitor-best-of.mp4",1,1.778],["v","assets/archive/gate-20/comparsa-logo.mp4",null,0.8],["v","assets/archive/gate-20/gate-20-menu-inspo.mp4",null,0.8],["v","assets/archive/gate-20/gate-20.mp4",null,0.8]]},{"c":"Bricklane","s":"Restaurant","i":[["v","assets/archive/bricklane/bricklane-new-logo.mp4",null,0.8],["v","assets/archive/bricklane/bricklane-logo-animation.mp4",2,0.563],["s",["assets/archive/bricklane/bri-deliverytavola-disegno-1-copia-15.png","assets/archive/bricklane/bri-glovotavola-disegno-1-copia-11.png","assets/archive/bricklane/bri-glovotavola-disegno-1-copia-12.png","assets/archive/bricklane/bri-nuove-storietavola-disegno-1-copia-16-2x.png","assets/archive/bricklane/bri-nuove-storietavola-disegno-1-copia-17-2x.png","assets/archive/bricklane/bri-storietavola-disegno-1-copia-21.png"],2,0.563],["s",["assets/archive/bricklane/tavola-da-disegno-3.png","assets/archive/bricklane/tavola-disegno-1-copia-33.png","assets/archive/bricklane/britavola-disegno-1-copia-4.png","assets/archive/bricklane/bri-yellow-submarinetavola-disegno-1-copia-5.png"],null,0.8],["i","assets/archive/bricklane/panini-bricktavola-disegno-1-copia.png",2,0.563],["i","assets/archive/bricklane/onion.png",2,0.563]]},{"c":"Comunik Hat","s":"","i":[["v","assets/archive/comunik-hat/4-2.mp4",null,0.8],["v","assets/archive/comunik-hat/5-2.mp4",null,0.8],["v","assets/archive/comunik-hat/monogram.mp4",null,0.804],["i","assets/archive/comunik-hat/3.jpg",null,0.8],["i","assets/archive/comunik-hat/img-7756.jpg",null,0.8],["i","assets/archive/comunik-hat/color-of-hat.png",null,0.8],["i","assets/archive/comunik-hat/hat-stadio.png",null,0.714]]},{"c":"Puok","s":"Food Chain","i":[["v","assets/archive/puok/gift-card.mp4",null,0.8],["v","assets/archive/puok/monitor-puok.mp4",2,0.563],["v","assets/archive/puok/puo-campagna-baby-glovo-back-to-school-animazion.mp4",null,0.8],["v","assets/archive/puok/puo-campagna-baby-glovo-back-to-school-animazion-b.mp4",null,0.8],["v","assets/archive/puok/puo-napoli-in-un-panino.mp4",2,0.563],["v","assets/archive/puok/puo-storia-compleanno.mp4",2,0.563],["v","assets/archive/puok/puo-video-imbustiamo-la-felicit.mp4",2,0.563],["v","assets/archive/puok/puok-glovo.mp4",2,0.563],["i","assets/archive/puok/battlestar-galacticatavola-disegno-1-copia-19.png",2,0.563],["i","assets/archive/puok/battlestar-galacticatavola-disegno-1-copia-20.png",2,0.563],["i","assets/archive/puok/battlestar-galacticatavola-disegno-1-copia-21.png",2,0.563],["i","assets/archive/puok/battlestar-galacticatavola-disegno-1-copia-23.png",2,0.563],["i","assets/archive/puok/battlestar-galacticatavola-disegno-1-copia-73.png",null,1],["i","assets/archive/puok/puo-glovo-babypuoktavola-disegno-1-copia-14.png",null,0.8],["i","assets/archive/puok/puo-glovo-babypuoktavola-disegno-1-copia-15.png",2,0.563],["i","assets/archive/puok/puo-nennella-moptavola-disegno-1-copia-22.png",2,0.563],["i","assets/archive/puok/puo-nennella-moptavola-disegno-127-copia-9.png",2,0.563]],"big":1},{"c":"Del Gallo","s":"","i":[["v","assets/archive/del-gallo/del-gallo-logo-animation.mp4",null,0.8]]},{"c":"Santeribs","s":"Restaurant","i":[["v","assets/archive/santeribs/santeribs-logo.mp4",null,0.8],["v","assets/archive/santeribs/santeribs-teriyaki-ribs-video.mp4",2,0.563],["v","assets/archive/santeribs/santeribs-video-menu.mp4",null,0.8]]}];
// Fast alternating stills, like a GIF.
function ArchFlip({ srcs, ms = 420 }) {
  const [i, setI] = React.useState(0);
  React.useEffect(() => { const t = setInterval(() => setI((n) => (n + 1) % srcs.length), ms); return () => clearInterval(t); }, [srcs.length, ms]);
  return (
    <div style={{ position: "relative", width: "100%", height: "100%" }}>
      {srcs.map((s, k) => <img key={s} src={s} alt="" loading="lazy" decoding="async" style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", position: "absolute", inset: 0, opacity: k === i ? 1 : 0 }} />)}
    </div>);
}
// Justified rows computed in JS: tiles keep their real aspect ratio, every
// row is exactly the container width, and a short last row is merged into
// the previous one so nothing is left empty or oversized.
function ArchJustified({ items, big = false }) {
  const ref = React.useRef(null);
  const [W, setW] = React.useState(0);
  React.useEffect(() => {
    const el = ref.current; if (!el) return;
    const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width)));
    ro.observe(el); return () => ro.disconnect();
  }, []);
  const desk = W >= 640, gap = desk ? 14 : 8, target = (desk ? 380 : 230) * (big ? 1.45 : 1);
  let rows = [];
  if (W) {
    let cur = [], sum = 0;
    items.forEach((it) => {
      // Horizontal pieces always get a full row of their own.
      if (it[3] > 1.15) { if (cur.length) { rows.push(cur); cur = []; sum = 0; } rows.push([it]); return; }
      cur.push(it); sum += it[3];
      if (sum * target + gap * (cur.length - 1) >= W) { rows.push(cur); cur = []; sum = 0; }
    });
    if (cur.length) {
      const fill = cur.reduce((n, x) => n + x[3], 0) * target / W;
      if (fill < 0.35 && rows.length) rows[rows.length - 1] = rows[rows.length - 1].concat(cur); else rows.push(cur);
    }
  }
  const maxH = typeof window !== "undefined" ? window.innerHeight * 0.7 : 600;
  return (
    <div ref={ref} className="arch-just">
      {rows.map((row, ri) => {
        const sr = row.reduce((n, x) => n + x[3], 0);
        let h = (W - gap * (row.length - 1)) / sr;
        const capped = h > maxH; if (capped) h = maxH;
        return (
          <div key={ri} className="arch-row" style={{ gap, marginBottom: gap, justifyContent: capped ? "center" : "flex-start" }}>
            {row.map(([t, u, k, ar]) =>
            <div key={Array.isArray(u) ? u[0] : u} className="arch-item" style={{ width: h * ar, height: h, flex: "none" }}>
              {t === "s" ? <ArchFlip srcs={u} /> : t === "v" ?
              <video src={u} muted loop playsInline preload="metadata" /> :
              <img src={u} alt="" loading="lazy" decoding="async" />}
            </div>
            )}
          </div>);
      })}
    </div>);
}
function ArchiveEmbed({ theme }) {
  const [archCols, setArchCols] = React.useState(() => window.matchMedia("(min-width:900px)").matches ? 3 : 2);
  React.useEffect(() => {
    const mq = window.matchMedia("(min-width:900px)");
    const on = () => setArchCols(mq.matches ? 3 : 2);
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => { mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on); };
  }, []);
  return (
    <div className="cs-embed archive" style={{ padding: "8px 18px 0", color: theme.portfolioFg }}>
      {ARCHIVE.map((g) =>
      <section key={g.c} style={{ margin: "0 0 44px" }}>
          <header style={{ display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap", margin: "0 4px 14px", paddingTop: 14, borderTop: `1px solid ${theme.chipBorder}` }}>
            <h3 style={{ margin: 0, fontSize: 22, fontWeight: 700, letterSpacing: "-.02em" }}>{g.c}</h3>
            {g.s && <span style={{ fontSize: 12, color: theme.softFg }}>{g.s}</span>}
            <span style={{ marginLeft: "auto", fontSize: 11, color: theme.softerFg }}>{g.i.length} {g.i.length === 1 ? "piece" : "pieces"}</span>
          </header>
          {/* Justified rows: every tile keeps its original proportions; tiles in a
              row share one height and the row fills the width exactly. */}
          <ArchJustified items={g.i} big={!!g.big} />
        </section>
      )}
    </div>);
}

// ─── SiteLoader — bottom-right pulsing ring while visible media is loading.
// Colorful on black/white grounds, cream on the red grounds of the ROSSO theme.
function SiteLoader() {
  const [on, setOn] = React.useState(false);
  const [cream, setCream] = React.useState(false);
  React.useEffect(() => {
    let showT = 0;
    const vh = () => window.innerHeight, vw = () => window.innerWidth;
    const visible = (el) => { const b = el.getBoundingClientRect(); return b.width > 0 && b.bottom > 0 && b.top < vh() && b.right > 0 && b.left < vw(); };
    const tick = () => {
      if (!window.__introDone) { setOn(false); return; }
      let busy = false;
      document.querySelectorAll("#root video").forEach((v) => { if (!busy && v.readyState < 3 && visible(v)) busy = true; });
      if (!busy) document.querySelectorAll("#root img").forEach((im) => { if (!busy && !im.complete && visible(im)) busy = true; });
      if (busy) { if (!showT) showT = setTimeout(() => setOn(true), 250); }
      else { clearTimeout(showT); showT = 0; setOn(false); }
      // Background under the loader → pick the right variant.
      let el = document.elementFromPoint(vw() - 40, vh() - 40), red = false;
      while (el && el !== document.documentElement) {
        const c = getComputedStyle(el).backgroundColor, m = c.match(/\d+(\.\d+)?/g);
        if (m && (m.length < 4 || +m[3] > 0.5)) { red = +m[0] > 190 && +m[1] < 130 && +m[2] < 130; break; }
        el = el.parentElement;
      }
      setCream(red);
    };
    const iv = setInterval(tick, 300);
    tick();
    return () => { clearInterval(iv); clearTimeout(showT); };
  }, []);
  return ReactDOM.createPortal(
    <div className={"site-loader" + (on ? " on" : "")} aria-hidden={!on}>
      <div className={"loader" + (cream ? " is-cream" : "")} role="status" aria-label="Caricamento">
        {[0,1,2,3,4,5,6,7,8,9].map((n) => <i key={n} style={{ "--n": n }} />)}
      </div>
    </div>, document.body);
}

// ─── MediaSlot — drop zone that accepts images, GIF, MP4 / WebM ────────────
function MediaSlot({ slotId, theme, placeholder = "Drop animation, GIF or MP4", shape = "rounded", radius = 22, frame = "default", wave = false, fit = "cover", defaultMedia = null, style }) {
  const [media, setMedia] = React.useState(null); // { type, url, name }
  const [over, setOver] = React.useState(false);
  const inputRef = React.useRef(null);
  const boxRef = React.useRef(null);
  const [near0, setNear] = React.useState(false);
  const introOk = useIntroDone();
  const near = near0 && introOk;
  React.useEffect(() => {
    const el = boxRef.current;
    if (!el || !("IntersectionObserver" in window)) { setNear(true); return; }
    const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setNear(true); io.disconnect(); } }, { rootMargin: "900px 0px" });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  const depthRef = React.useRef(0);

  // Restore from localStorage. Also check legacy keys from before slotIds
  // included the theme suffix, so older drops still come back. CRITICAL:
  // always reset state when slotId changes — otherwise switching themes
  // leaves the previous theme's media visible if the new theme is empty.
  React.useEffect(() => {
    try {
      // current key first
      let raw = localStorage.getItem(`media-slot-${slotId}`);
      // then strip "-blu" / "-rosso" from the id and try again
      if (!raw) {
        const legacyId = slotId.replace(/-(blu|rosso)(-|$)/, "$2").replace(/-$/, "");
        if (legacyId !== slotId) {
          raw = localStorage.getItem(`media-slot-${legacyId}`);
          // migrate it forward so the new theme key picks it up next time
          if (raw) {
            try {localStorage.setItem(`media-slot-${slotId}`, raw);} catch (e) {}
          }
        }
      }
      setMedia(raw ? JSON.parse(raw) : null);
    } catch (e) {setMedia(null);}
  }, [slotId]);

  const ingest = (file) => {
    if (!file) return;
    const isVideo = /^video\//.test(file.type);
    const isImage = /^image\//.test(file.type);
    if (!isVideo && !isImage) return;
    const reader = new FileReader();
    reader.onload = () => {
      const m = { type: isVideo ? "video" : "image", url: reader.result, name: file.name };
      setMedia(m);
      try {localStorage.setItem(`media-slot-${slotId}`, JSON.stringify(m));}
      catch (e) {/* file too big for localStorage — session only */}
    };
    reader.readAsDataURL(file);
  };

  const clear = (e) => {
    e?.stopPropagation();
    setMedia(null);
    try {localStorage.removeItem(`media-slot-${slotId}`);} catch (e) {}
  };

  const borderRadius =
  shape === "circle" ? "50%" :
  shape === "pill" ? 9999 :
  radius;

  const fg = theme?.heroFg || theme?.portfolioFg || "#0E0E0E";
  const isDark = theme?.key === "blu" || /[a-f]/i.test(""); // crude check
  const dashedBorder = theme?.key === "blu" ?
  "rgba(255,255,255,.3)" :
  "rgba(0,0,0,.22)";
  const bg = theme?.key === "blu" ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.04)";

  // display = user-dropped media (priority) or defaultMedia fallback.
  // hasUser = the dropped state, used to gate the "remove" button.
  // hasContent = anything to render (drops the dashed placeholder).
  const baked = BAKED_MEDIA[slotId];
  const display = (baked && baked.force ? baked : null) || media || defaultMedia || baked || null;
  const hasUser = !!media;
  const hasContent = !!display;

  // frame="none": when filled, drop bg/border/overflow so the media floats
  // free (no circular crop, no background). Empty state keeps the dashed
  // placeholder so the drop target is still discoverable.
  const isFloating = frame === "none" && hasContent;
  const mediaStyle = {
    width: "100%",
    height: "100%",
    display: "block",
    objectFit: fit,
    animation: wave && hasContent ? "handwave 2.2s ease-in-out infinite" : "none",
    transformOrigin: "70% 80%"
  };

  return (
    <div
      ref={boxRef}
      role="button"
      tabIndex={0}
      onClick={() => inputRef.current && inputRef.current.click()}
      onDragEnter={(e) => {e.preventDefault();depthRef.current++;setOver(true);}}
      onDragOver={(e) => {e.preventDefault();if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";}}
      onDragLeave={(e) => {e.preventDefault();if (--depthRef.current <= 0) {depthRef.current = 0;setOver(false);}}}
      onDrop={(e) => {
        e.preventDefault();
        depthRef.current = 0;
        setOver(false);
        const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
        ingest(f);
      }}
      style={{
        position: "relative",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        textAlign: "center",
        background: isFloating ? "transparent" : bg,
        border: hasContent || isFloating ?
        "none" :
        `1.5px dashed ${over ? "rgba(255,71,71,.7)" : dashedBorder}`,
        borderRadius: isFloating ? 0 : borderRadius,
        color: fg,
        overflow: isFloating ? "visible" : "hidden",
        cursor: "pointer",
        transition: "border-color 160ms, transform 160ms",
        transform: over ? "scale(1.02)" : "scale(1)",
        ...style
      }}>
      {near && display?.type === "video" &&
      <video
        src={display.url}
        poster={display.poster || posterFor(display.url)}
        autoPlay={!SLOW_NET} loop muted playsInline preload={SLOW_NET ? "none" : "metadata"} draggable={false}
        onClick={(e) => { e.stopPropagation(); const v = e.currentTarget; v.dataset.user = "1"; if (v.paused) { const p = v.play(); if (p && p.catch) p.catch(() => {}); } }}
        style={mediaStyle} />

      }
      {near && display?.type === "image" &&
      <img
        src={display.url}
        decoding="async" draggable={false}
        alt={display.name || "animation"}
        style={display.whiten ? { ...mediaStyle, filter: "brightness(0) invert(1)" } : mediaStyle} />

      }
      {!hasContent &&
      <div style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 6,
        padding: 10,
        opacity: .8
      }}>
          <span className="material-symbols-rounded" style={{ fontSize: 22, opacity: .6 }}>
            movie
          </span>
          <span style={{
          fontSize: 10.5,
          fontWeight: 500,
          letterSpacing: ".02em",
          lineHeight: 1.25,
          maxWidth: 180
        }}>{placeholder}</span>
        </div>
      }
      {hasUser &&
      <button
        onClick={clear}
        aria-label="remove"
        className="media-slot-x"
        style={{
          position: "absolute",
          top: 6, right: 6,
          width: 24, height: 24,
          border: "none",
          borderRadius: "50%",

          color: "#fff",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          backdropFilter: "blur(6px)",
          opacity: 0,
          pointerEvents: "none",
          transition: "opacity 160ms", background: "rgb(14, 14, 14)"
        }}>
          <span className="material-symbols-rounded" style={{ fontSize: 16 }}></span>
        </button>
      }
      <input
        ref={inputRef}
        type="file"
        accept="image/*,video/mp4,video/webm,video/quicktime"
        hidden
        onChange={(e) => {ingest(e.target.files && e.target.files[0]);e.target.value = "";}} />
      
    </div>);

}

function Hand({ accent, tint, size = 64 }) {
  // Material front_hand symbol, tinted
  return (
    <div style={{
      width: size, height: size,
      display: "flex", alignItems: "center", justifyContent: "center",
      background: accent,
      borderRadius: "50%",
      color: tint,
      flex: "0 0 auto"
    }}>
      <span className="material-symbols-rounded" style={{
        fontSize: size * 0.6,
        fontVariationSettings: "'FILL' 1, 'wght' 500"
      }}></span>
    </div>);

}

const FOLDER_MASK = 'url("data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129 91" preserveAspectRatio="none"><path d="' + FOLDER_PATH + '"/></svg>') + '")';
// Folder-shape image card. width determines proportional size (129:91 + 7 top extra)
function FolderCard({ img, video, gallery, tone, name, tags, fit = "cover", solid = false, accent, theme, width = 150, onClick, focus }) {
  const introOk = useIntroDone();
  // The folder has a tab from y=0 to y=7 (proportional). With width W, h = W * 91/129.
  const h = width * (91 / 129);
  const clipId = `fc-${name.replace(/\W+/g, '')}`;
  // Slideshow source list: every media of the project (hero video, gallery
  // items, image-group images), falling back to the cover image.
  const slides = React.useMemo(() => {
    const out = [];
    const isVid = (u) => /\.(mp4|webm|mov|ogg)(\?.*)?$/i.test(u || "");
    if (video) out.push({ type: "video", url: video });
    if (Array.isArray(gallery)) {
      gallery.forEach((it) => {
        if (it.url && (it.type === "video" || isVid(it.url))) out.push({ type: "video", url: it.url });
      });
    }
    // No animations at all → fall back to the cover so the folder isn't empty.
    if (!out.length && img) out.push({ type: isVid(img) ? "video" : "image", url: img });
    return out.slice(0, 1);
  }, [img, video, gallery]);
  const [slide, setSlide] = React.useState(0);
  React.useEffect(() => {
    if (slides.length < 2) return;
    const t = setInterval(() => setSlide((s) => (s + 1) % slides.length), 1400);
    return () => clearInterval(t);
  }, [slides.length]);
  const cur = slides[slide % Math.max(slides.length, 1)];
  return (
    <button
      onClick={onClick}
      style={{
        display: "block",
        background: "transparent",
        border: "none",
        padding: 0,
        margin: 0,
        cursor: "pointer",
        width,
        textAlign: "center",
        color: "inherit"
      }}>
      <div style={{
        position: "relative",
        width, height: h,
        filter: "drop-shadow(0 6px 18px rgba(0,0,0,.25))",
        ...(focus === undefined ? {} : {
          transform: focus ? "scale(1.04)" : "scale(.95)",
          transformOrigin: "50% 60%",
          transition: "transform 600ms cubic-bezier(.25,.8,.25,1)"
        })
      }}>
        <div style={{
          position: "absolute", inset: 0,
          background: tone,
          WebkitMaskImage: FOLDER_MASK, maskImage: FOLDER_MASK,
          WebkitMaskSize: "100% 100%", maskSize: "100% 100%",
          WebkitMaskRepeat: "no-repeat", maskRepeat: "no-repeat",
          overflow: "hidden"
        }}>
          {!solid && cur && introOk &&
          <div style={{ position: "absolute", left: 0, right: 0, bottom: 0, top: (7 / 91 * 100) + "%", background: tone, overflow: "hidden" }}>
            {cur.type === "video" ?
            <video key={slide} src={cur.url} poster={posterFor(cur.url)} autoPlay={!SLOW_NET} preload={SLOW_NET ? "none" : "metadata"} muted loop playsInline
            style={{ width: "100%", height: "100%", objectFit: fit, display: "block", animation: "slidefade 260ms ease" }} /> :
            <img key={slide} src={cur.url} alt=""
            style={{ width: "100%", height: "100%", objectFit: fit, display: "block", animation: "slidefade 260ms ease" }} />
            }
          </div>
          }
        </div>
        <svg viewBox="0 0 129 91" width={width} height={h} preserveAspectRatio="none" style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
          <path d={FOLDER_PATH} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth=".5" vectorEffect="non-scaling-stroke" />
        </svg>
      </div>
      <div style={{
        marginTop: 12,
        fontSize: 13,
        fontWeight: 500,
        color: theme.portfolioFg,
        lineHeight: 1.25,
        padding: "0 4px",
        textWrap: "balance"
      }}>{name}</div>
      <div style={{
        marginTop: 8,
        display: "flex",
        flexWrap: "wrap",
        gap: 4,
        justifyContent: "center"
      }}>
        {tags.map((t, i) => <Pill key={i} kind={t} />)}
      </div>
    </button>);

}

// Checker pattern decoration (8×2 grid of small squares)
function Checkers({ accent, rows = 2, cols = 8, sz = 14, gap = 0, opacity = 1 }) {
  const cells = [];
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const on = (r + c) % 2 === 0;
      cells.push(
        <div key={`${r}-${c}`} style={{
          width: sz, height: sz,
          background: on ? accent : "transparent"
        }} />
      );
    }
  }
  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: `repeat(${cols}, ${sz}px)`,
      gridAutoRows: `${sz}px`,
      gap,
      opacity
    }}>{cells}</div>);

}

// ─── Top swipe bar ─────────────────────────────────────────────────────────
function SwipeBar({ theme, accent, idx, setIdx }) {
  return (
    <div style={{
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      justifyContent: "center",
      gap: 6,
      padding: "10px 16px 8px",
      fontSize: 11,
      fontWeight: 700,
      color: theme.barColor,
      letterSpacing: ".02em"
    }}>
      <span style={{ opacity: .9, whiteSpace: "nowrap" }}>← Swipe →</span>
      <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
        {[0, 1].map((i) =>
        <button key={i} onClick={() => setIdx(i)} aria-label={`go ${i}`} style={{
          width: 7, height: 7, borderRadius: "50%",
          background: i === idx ? accent : theme.dotInactive,
          border: "none", padding: 0, cursor: "pointer",
          transition: "background 200ms"
        }} />
        )}
      </div>
    </div>);

}

// ─── Cycling greetings (hero + footer) ─────────────────────────────────────
// Flat lists so the wheel scrolls through ONE shared menu of greetings.
// Order is fixed; each row is one entry ahead of the next.
const HELLOS = ["Ciao", "Hello", "Hola", "Bonjour", "你好"];
const BYES = ["Ciao", "Bye", "Adios", "Au revoir", "再见"];
// On ROSSO the footer is narrower (hand on the right), so "Au revoir"
// crops. Use a shorter list there.
const BYES_ROSSO = BYES.filter((w) => w !== "Au revoir");

// Legacy lists left here in case anything still references them by name
const HELLO_LISTS = [HELLOS, HELLOS, HELLOS];
const BYE_LISTS = [BYES, BYES, BYES];


function useCyclingWord(list, interval) {
  const [i, setI] = React.useState(0);
  React.useEffect(() => {
    const t = setInterval(() => setI((v) => (v + 1) % list.length), interval);
    return () => clearInterval(t);
  }, [list, interval]);
  return [list[i], i];
}

// ─── Greeting wheel — iOS-picker-style scrolling list of greetings ────────
// One shared list. Each tick, the current word moves up one slot. The
// middle slot is largest and gets the colored bar. Above/below are smaller
// and faded (top and bottom rows).
function GreetingWheel({ words, theme, accent, baseSize, barColor, interval = 2400, align = "left", weight = "auto" }) {
  // step is monotonic (never wraps) so each rendered item has a unique
  // absolute index. React keys by absIdx → the same DOM node moves smoothly
  // from row to row as step advances, instead of jumping when the modulo
  // wraps. Items at rel=±2 are invisible buffers so entry/exit happen
  // off-screen (no pop).
  const [step, setStep] = React.useState(0);
  const N = words.length;
  React.useEffect(() => {
    const t = setInterval(() => setStep((s) => s + 1), interval);
    return () => clearInterval(t);
  }, [interval]);

  // weight: "auto" uses 800/600 (bold). "light" uses 300/200 (Inter Light).
  const midW = weight === "light" ? 300 : 800;
  const sideW = weight === "light" ? 200 : 600;
  const lineH = Math.round(baseSize * 0.86);
  const viewportH = lineH * 3 + 4;
  const RANGE = 2;
  const items = [];
  for (let i = -RANGE; i <= RANGE; i++) {
    const absIdx = step + i;
    const realIdx = (absIdx % N + N) % N;
    items.push({ rel: i, absIdx, word: words[realIdx] });
  }

  // Visible only at rel = -1, 0, 1. ±2 are buffers (opacity 0).
  const opAt = (rel) => {
    const d = Math.abs(rel);
    if (d === 0) return 1;
    if (d === 1) return 0.45;
    return 0;
  };
  const sizeAt = (rel) => {
    const d = Math.abs(rel);
    if (d === 0) return baseSize;
    if (d === 1) return Math.round(baseSize * 0.62);
    return Math.round(baseSize * 0.5);
  };

  return (
    <div style={{
      position: "relative",
      height: viewportH,
      overflow: "hidden"
    }}>
      {items.map(({ rel, absIdx, word }) => {
        const isMid = rel === 0;
        return (
          <div key={absIdx} style={{
            position: "absolute",
            left: 0,
            right: 0,
            top: `calc(50% + ${rel * lineH}px)`,
            transform: "translateY(-50%)",
            transition:
            "top 520ms cubic-bezier(.4,0,.2,1), " +
            "opacity 520ms ease, " +
            "font-size 520ms ease, " +
            "font-weight 520ms ease",
            display: "flex",
            alignItems: "center",
            justifyContent: align === "center" ? "center" : "flex-start",
            fontSize: sizeAt(rel),
            fontWeight: isMid ? midW : sideW,
            opacity: opAt(rel),
            color: "inherit",
            lineHeight: 1,
            letterSpacing: "-.02em",
            whiteSpace: "nowrap"
          }}>
            {/* bar fades in only at the middle row */}
            <span style={{
              display: "inline-block",
              width: 6,
              height: ".82em",
              background: barColor || accent,
              marginRight: 6,
              borderRadius: 1,
              opacity: isMid ? 1 : 0,
              transition: "opacity 520ms ease",
              flex: "0 0 auto"
            }} />
            <span>{word}</span>
          </div>);

      })}
    </div>);

}

// ─── WaveHero — faithful port of wave_greeting_ver5_intro.html
// (timing, colors, proportions identical). Fills its positioned parent.
const WAVE_WORDS = ["Hello","Hola","Bonjour","你好","Hallo","こんにちは","Olá","Ciao","Привет","안녕하세요","Merhaba","नमस्ते"];
const WAVE_INTRO = ["I'm Giovanni Iacomino","Soy Giovanni Iacomino","Je suis Giovanni Iacomino","我是 Giovanni Iacomino","Ich bin Giovanni Iacomino","Giovanni Iacomino です","Sou o Giovanni Iacomino","Sono Giovanni Iacomino","Я Giovanni Iacomino","저는 Giovanni Iacomino입니다","Ben Giovanni Iacomino","मैं Giovanni Iacomino हूँ"];
// Original palette from the prototype.
const WAVE_PAL = [["#1f2bff","#d9dcff"],["#8a3dff","#eadcff"],["#00a3ff","#d6f1ff"],["#ff4d6d","#ffdbe2"],["#2bd47d","#d6ffe9"],["#ff6a3d","#ffe2d6"],["#c23dff","#f4dcff"],["#3d7bff","#dce6ff"],["#00c2c7","#d4fbfc"],["#ffb020","#fff0d1"],["#ff3dbf","#ffd9f2"],["#6a5cff","#e1ddff"]];
// Refined palette: same lightness + chroma for every language, only the hue
// changes (hues follow the originals, de-duplicated so no two languages clash).
const WAVE_HUES = [268, 298, 238, 12, 152, 42, 322, 208, 188, 78, 348, 282];
const WAVE_PAL_REFINED = WAVE_HUES.map((h) => [`oklch(0.66 0.19 ${h})`, `oklch(0.93 0.045 ${h})`]);

function WaveHero({ palette = "refined" }) {
  const rootRef = React.useRef(null), listRef = React.useRef(null), barRef = React.useRef(null);
  const ambRef = React.useRef(null), baseRef = React.useRef(null), nameRef = React.useRef(null), roleRef = React.useRef(null), textRef = React.useRef(null), spillRef = React.useRef(null);
  const palRef = React.useRef(palette);
  palRef.current = palette;
  React.useEffect(() => {
    const ph = rootRef.current, list = listRef.current, bar = barRef.current, amb = ambRef.current, base = baseRef.current, nameEl = nameRef.current, roleEl = roleRef.current;
    if (!ph) return;
    const words = WAVE_WORDS, intro = WAVE_INTRO;
    const pal = () => (palRef.current === "original" ? WAVE_PAL : WAVE_PAL_REFINED);
    let i = window.__greetI || 1, swapAnim = null; const timers = [];
    list.innerHTML = "";
    [...words, ...words].forEach((t) => { const d = document.createElement("div"); d.className = "wv-w"; d.textContent = t; list.appendChild(d); });
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const render = () => {
      [...list.children].forEach((c, k) => c.classList.toggle("on", k === i));
      list.style.transform = "translateY(calc(" + (-(i - 1)) + " * var(--row)))";
    };
    const updateBar = () => {
      const p = pal()[i % pal().length];
      bar.style.setProperty("--bar", p[0]);
      bar.classList.remove("tick"); void bar.offsetWidth; bar.classList.add("tick");
    };
    const updateIntro = (animate) => {
      const idx = i % words.length, p = pal()[idx];
      nameEl.style.setProperty("--c", p[0]);
      if (!animate || reduced) { textRef.current.textContent = intro[idx]; return; }
      const txt = textRef.current;
      if (swapAnim) swapAnim.cancel();
      swapAnim = txt.animate([
        { opacity: 1, transform: "translateY(0)", offset: 0, easing: "cubic-bezier(.25,.8,.25,1)" },
        { opacity: 0, transform: "translateY(-6px)", offset: 0.30 },
        { opacity: 0, transform: "translateY(8px)", offset: 0.31, easing: "cubic-bezier(.25,.8,.25,1)" },
        { opacity: 1, transform: "translateY(0)", offset: 1 }
      ], { duration: 900 });
      timers.push(setTimeout(() => { txt.textContent = intro[idx]; }, 270));
      [nameEl, roleEl].forEach((el) => { el.classList.remove("glow"); void el.offsetWidth; el.classList.add("glow"); });
    };
    const wave = () => {
      if (reduced) return;
      const p = pal()[i % pal().length];
      amb.style.setProperty("--c", p[0]);
      amb.classList.remove("go"); void amb.offsetWidth; amb.classList.add("go");
      [base, spillRef.current].forEach((el) => { el.style.setProperty("--c", p[0]); el.classList.remove("go"); void el.offsetWidth; el.classList.add("go"); });
      const K = window.innerWidth >= 900 ? 2 : 1;
      const n = 7, center = ph.clientHeight / 2, gap = Math.min(66 * K, (ph.clientHeight / 2 - 39 * K - 24) / 3);
      for (let k = 0; k < n; k++) {
        const t = k / (n - 1);
        const s = Math.sin(t * Math.PI);
        const w = (125 + s * 220) * K, h = (39 + s * 64) * K;
        const y = center + (3 - k) * gap;
        const e = document.createElement("div"); e.className = "wv-el";
        e.style.width = w + "px"; e.style.height = h + "px"; e.style.marginLeft = (-w / 2) + "px"; e.style.top = (y - h / 2) + "px";
        e.style.setProperty("--c", p[0]); e.style.setProperty("--l", p[1]);
        e.style.setProperty("--o", (0.35 + s * 0.65).toFixed(2));
        e.style.animationDelay = (k * 0.09) + "s";
        ph.insertBefore(e, base.nextSibling);
        requestAnimationFrame(() => e.classList.add("go"));
        timers.push(setTimeout(() => e.remove(), 2300));
      }
    };
    const step = () => {
      i++;
      if (i >= words.length + 1) { list.style.transition = "none"; i = 1; render(); list.offsetHeight; list.style.transition = ""; }
      else render();
      window.__greetI = i;
      updateBar();
      updateIntro(true);
      wave();
    };
    render(); updateBar(); updateIntro(false);
    { const p0 = pal()[i % pal().length]; [base, spillRef.current].forEach((el) => el.style.setProperty("--c", p0[0])); }
    let iv = 0;
    const start = () => { wave(); iv = setInterval(step, 2200); };
    let onIntro = null;
    if (window.__introDone) timers.push(setTimeout(start, 40));
    else { onIntro = () => start(); window.addEventListener("intro-done", onIntro, { once: true }); }
    return () => { clearInterval(iv); if (onIntro) window.removeEventListener("intro-done", onIntro); timers.forEach(clearTimeout); ph.querySelectorAll(".wv-el").forEach((x) => x.remove()); };
  }, []);
  return (
    <>
    <div ref={spillRef} className="wv-spill"></div>
    <div ref={rootRef} className="wave-layer">
      <div ref={ambRef} className="wv-amb"></div>
      <div ref={baseRef} className="wv-base"></div>
      <div ref={barRef} className="wv-bar"></div>
      <div className="wv-stack"><div ref={listRef} className="wv-list"></div></div>
      <div className="wv-bio">
        <div ref={nameRef} className="wv-name"><span ref={textRef} className="wv-name-t"></span></div>
        <div ref={roleRef} className="wv-role">Human-centered multimedia designer<br />through motion, visual &amp; UI/UX</div>
      </div>
    </div>
    </>);
}

// ─── RossoHero — port of 02_hero_tema_rosso.html: tone-on-tone ovals that
// always touch, two dice rolled by an invisible force, mirrored greetings.
// Vertical positions are scaled from the 540px-tall prototype.
const ROSSO_PIPS = ['#b0204a','#ff5fa2','#7a3cff','#ff7a2f','#3d5bff','#5a0612'];
function RossoHero() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const ph = ref.current;
    if (!ph) return;
    ph.innerHTML = '<div class="rh-bar"></div><div class="rh-stack"><div class="rh-list"></div></div><div class="rh-bio"><div class="rh-name"></div><div class="rh-role">Playful by nature, bold by choice.<br>Always chasing something new</div></div>';
    const words = WAVE_WORDS, intro = WAVE_INTRO, pips = ROSSO_PIPS;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const R = (a, b) => a + Math.random() * (b - a);
    const host = ph.parentElement;
    const H = ((host && host.clientHeight) || ph.clientHeight || 540), W = ph.clientWidth || (host && host.clientWidth) || 300;
    const sy = H / 540, sx = W / 300;
    const DS = window.innerWidth >= 900 ? 96 : 60;
    const timers = [];

    // OVALS
    const BIG_TOP = 292 * sy, BIG_H = [146 * sy, 158 * sy];
    const layout = () => {
      const slots = [];
      const fill = (y0, y1, n) => { const w = [...Array(n)].map(() => R(.6, 1.4)), s = w.reduce((a, b) => a + b, 0); let y = y0;
        return w.map((v) => { const h = (y1 - y0) * v / s, rr = { top: y, h }; y = rr.top + h; return rr; }); };
      slots.push({ top: -190 * sy, h: 110 * sy });
      slots.push(...fill(-80 * sy, BIG_TOP, 5));
      const bigH = R(BIG_H[0], BIG_H[1]);
      slots.push({ top: BIG_TOP, h: bigH });
      slots.push(...fill(BIG_TOP + bigH, 640 * sy, 2));
      return slots;
    };
    const NS = 9;
    let ovals = [...Array(NS)].map(() => { const o = document.createElement('div'); o.className = 'rh-ov'; ph.insertBefore(o, ph.firstChild); return o; });
    const place = (slots, animate) => ovals.forEach((o, k) => {
      if (!animate) o.style.transition = 'none';
      o.style.top = slots[k].top + 'px'; o.style.height = slots[k].h + 'px';
      if (!animate) { o.offsetHeight; o.style.transition = ''; }
    });
    const shiftOvals = () => {
      const top = ovals.shift(); ovals.push(top);
      const last = ovals[NS - 2];
      top.style.transition = 'none'; top.style.top = (parseFloat(last.style.top) + parseFloat(last.style.height)) + 'px'; top.style.height = (90 * sy) + 'px'; top.offsetHeight; top.style.transition = '';
      place(layout(), !reduced);
    };
    place(layout(), false);

    // DICE
    const layoutPips = { 1: [5], 2: [1, 9], 3: [1, 5, 9], 4: [1, 3, 7, 9], 5: [1, 3, 5, 7, 9], 6: [1, 3, 4, 6, 7, 9] };
    const rot = { 1: [0, 0], 6: [0, 180], 3: [0, -90], 4: [0, 90], 5: [-90, 0], 2: [90, 0] };
    const ZONE = { x0: 12 * sx, x1: 112 * sx, y0: 340 * sy, y1: 388 * sy };
    const buildDie = () => {
      const sh = document.createElement('div'); sh.className = 'rh-shadow';
      const slot = document.createElement('div'); slot.className = 'rh-slot';
      const cube = document.createElement('div'); cube.className = 'rh-cube';
      for (let v = 1; v <= 6; v++) { const fc = document.createElement('div'); fc.className = 'rh-face rh-f' + v;
        for (let c = 1; c <= 9; c++) fc.appendChild(document.createElement(layoutPips[v].includes(c) ? 'i' : 'span')); cube.appendChild(fc); }
      slot.appendChild(cube); ph.appendChild(sh); ph.appendChild(slot);
      return { slot, sh, cube, x: 0, y: 0, rz: 0, rx: 0, ry: 0 };
    };
    const dice = [buildDie(), buildDie()];
    dice[0].x = 24 * sx; dice[0].y = 350 * sy; dice[1].x = 92 * sx; dice[1].y = 372 * sy;
    dice.forEach((d) => { d.rz = R(-20, 20); d.slot.style.transform = `translate(${d.x}px,${d.y}px) rotate(${d.rz}deg)`; d.sh.style.transform = `translate(${d.x}px,${d.y + DS}px)`; });
    const pickSpots = () => { let a, b, t = 0; do { a = [R(ZONE.x0, ZONE.x1), R(ZONE.y0, ZONE.y1)]; b = [R(ZONE.x0, ZONE.x1), R(ZONE.y0, ZONE.y1)]; t++; } while (Math.hypot(a[0] - b[0], a[1] - b[1]) < DS * 1.4 && t < 60); return [a, b]; };
    const landOn = (cur, target) => { const tt = ((target % 360) + 360) % 360, c = ((cur % 360) + 360) % 360; return cur + ((tt - c + 360) % 360); };
    const nudge = (color) => {
      const spots = pickSpots();
      dice.forEach((d, k) => {
        d.cube.querySelectorAll('i').forEach((p) => p.style.setProperty('--pip', color));
        if (reduced) return;
        const [x2, y2] = spots[k], dx = x2 - d.x, dy = y2 - d.y, v = 1 + Math.floor(Math.random() * 6), [bx, by] = rot[v];
        const turns = Math.max(1, Math.round(Math.hypot(dx, dy) / 45));
        const rx2 = landOn(d.rx + 360 * turns, bx), ry2 = landOn(d.ry + 360 * Math.max(1, turns - 1), by), rz2 = d.rz + dx * .35 + R(-15, 15);
        const dur = R(1000, 1250), delay = k * R(40, 140) + 120, ease = 'cubic-bezier(.22,.7,.25,1)', h = R(26, 40);
        const P = (t) => [d.x + dx * t, d.y + dy * t];
        d.slot.animate([
          { transform: `translate(${d.x}px,${d.y}px) rotate(${d.rz}deg)`, offset: 0 },
          { transform: `translate(${P(.35)[0]}px,${P(.35)[1] - h}px) rotate(${d.rz + (rz2 - d.rz) * .45}deg)`, offset: .3 },
          { transform: `translate(${P(.62)[0]}px,${P(.62)[1]}px) rotate(${d.rz + (rz2 - d.rz) * .75}deg)`, offset: .55 },
          { transform: `translate(${P(.82)[0]}px,${P(.82)[1] - h * .3}px) rotate(${d.rz + (rz2 - d.rz) * .92}deg)`, offset: .72 },
          { transform: `translate(${x2}px,${y2}px) rotate(${rz2}deg)`, offset: 1 }], { duration: dur, delay, easing: ease, fill: 'both' });
        d.cube.animate([{ transform: `rotateX(${d.rx}deg) rotateY(${d.ry}deg)` }, { transform: `rotateX(${rx2}deg) rotateY(${ry2}deg)` }], { duration: dur, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'both' });
        d.sh.animate([
          { transform: `translate(${d.x}px,${d.y + DS}px) scale(1)`, offset: 0 },
          { transform: `translate(${P(.35)[0]}px,${P(.35)[1] + DS}px) scale(.62)`, opacity: .6, offset: .3 },
          { transform: `translate(${P(.62)[0]}px,${P(.62)[1] + DS}px) scale(1)`, opacity: 1, offset: .55 },
          { transform: `translate(${x2}px,${y2 + DS}px) scale(1)`, opacity: 1, offset: 1 }], { duration: dur, delay, easing: ease, fill: 'both' });
        Object.assign(d, { x: x2, y: y2, rz: rz2, rx: rx2, ry: ry2 });
      });
    };

    // GREETINGS
    const list = ph.querySelector('.rh-list'), bar = ph.querySelector('.rh-bar'), nameEl = ph.querySelector('.rh-name');
    [...words, ...words].forEach((t) => { const d = document.createElement('div'); d.className = 'rh-w'; d.textContent = t; list.appendChild(d); });
    let i = window.__greetI || 1;
    const render = () => { [...list.children].forEach((c, k) => c.classList.toggle('on', k === i)); list.style.transform = 'translateY(calc(var(--row) * ' + (-(i - 1)) + '))'; };
    const step = () => {
      i++;
      if (i >= words.length + 1) { list.style.transition = 'none'; i = 1; render(); list.offsetHeight; list.style.transition = ''; } else render();
      const idx = i % words.length;
      window.__greetI = i;
      bar.classList.remove('tick'); void bar.offsetWidth; bar.classList.add('tick');
      nameEl.classList.remove('swap'); void nameEl.offsetWidth; nameEl.classList.add('swap');
      timers.push(setTimeout(() => { nameEl.textContent = intro[idx]; }, 270));
      shiftOvals();
      nudge(pips[idx % pips.length]);
    };
    const i0 = i % words.length;
    dice.forEach((d) => d.cube.querySelectorAll('i').forEach((p) => p.style.setProperty('--pip', pips[i0 % pips.length])));
    nameEl.textContent = intro[i0]; render();
    timers.push(setTimeout(() => nudge(pips[i0 % pips.length]), 40));
    const iv = setInterval(step, 2400);
    return () => { clearInterval(iv); timers.forEach((t) => t && t.cancel ? t.cancel() : clearTimeout(t)); ph.innerHTML = ''; };
  }, []);
  return <div ref={ref} className="rosso-hero" />;
}

// ─── Hero ──────────────────────────────────────────────────────────────────
function Hero({ theme, accent, size, mirror }) {
  return (
    <div style={{
      padding: "8px 22px 28px",
      display: "grid",
      gridTemplateColumns: "1fr auto",
      alignItems: "center",
      gap: 12,
      overflow: "hidden"
    }}>
      <div style={{ minWidth: 0 }}>
        <GreetingWheel
          words={mirror ? BYES : HELLOS}
          theme={theme}
          accent={accent}
          baseSize={size}
          weight={theme.key === "blu" ? "light" : "auto"}
          interval={2600} />
        
      </div>
      {/* Animation slot — drop a Lottie/GIF/PNG/MP4. Per theme so each side keeps its own. */}
      <div style={{
        width: 76, height: 76,
        flex: "0 0 auto"
      }}>
        <MediaSlot
          slotId={`hero-anim-${theme.key}-${mirror ? "footer" : "top"}`}
          theme={theme}
          shape="circle"
          frame="none"
          fit="contain"
          wave={true}
          placeholder="Drop animation"
          style={{ width: "100%", height: "100%" }} />
        
      </div>
    </div>);

}

// ─── Tab bar (About / Experience / Contact) ───────────────────────────────
function Tabs({ tab, setTab, theme, accent }) {
  const items = [
  { k: "experience", label: "Work History" },
  { k: "contact", label: "Contact" }];

  return (
    <div style={{
      display: "flex",
      justifyContent: "center",
      gap: 6,
      padding: "4px 22px 22px"
    }}>
      {items.map((it) => {
        const on = tab === it.k;
        return (
          <button key={it.k} onClick={() => setTab(it.k)} style={{
            appearance: "none",
            border: `1px solid ${on ? accent : theme.chipBorder}`,
            background: on ? accent : "transparent",
            color: on ? "#fff" : theme.heroFg,
            padding: "6px 14px",
            borderRadius: 999,
            fontSize: 12,
            fontWeight: 500,
            letterSpacing: ".01em",
            cursor: "pointer",
            transition: "all 180ms"
          }}>{it.label}</button>);

      })}
    </div>);

}

// ─── Tab content ───────────────────────────────────────────────────────────
function AboutPanel({ theme }) {
  // Each line is its own block so text-wrap: balance applies per group
  // and we avoid orphan words on the third (longest) line.
  const lineCss = {
    display: "block",
    textWrap: "balance",
    WebkitHyphens: "none",
    hyphens: "none"
  };
  return (
    <div style={{ padding: "0 18px 28px", color: theme.heroFg, textAlign: "center" }}>
      <p style={{
        margin: 0,
        fontFamily: "Inter",
        fontSize: 15,
        lineHeight: 1.5,
        color: theme.heroFg
      }}>
        <span style={{ ...lineCss, fontWeight: 700 }}>
          Multimedia Designer and Art Director
        </span>
        <span style={{ ...lineCss, fontWeight: 400, opacity: .8, marginTop: 2 }}>
          Focused on Visual Identity and Motion Design
        </span>
        <span style={{ ...lineCss, fontWeight: 300, fontStyle: "italic", opacity: .7, marginTop: 2 }}>Clear, expressive
and human-centered visual systems.
        </span>
      </p>
    </div>);

}

function ExperiencePanel({ theme, accent }) {
  return (
    <div className="pf-narrow" style={{ padding: "0 24px 28px", color: theme.heroFg }}>
      <SectionTitle theme={theme}>Work History</SectionTitle>
      <ol style={{ listStyle: "none", margin: "12px 0 22px", padding: 0, display: "grid", gap: 14 }}>
        {WORK_HISTORY.map((w, i) =>
        <li key={i} style={{
          display: "grid",
          gridTemplateColumns: "8px 1fr",
          gap: 12
        }}>
            <div style={{
            width: 7, height: 7, borderRadius: "50%",
            background: accent, marginTop: 6
          }} />
            <div>
              <div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "baseline" }}>
                <span style={{ fontWeight: 600, fontSize: 14 }}>{w.co}</span>
                <span style={{ fontSize: 10.5, color: theme.softFg, whiteSpace: "nowrap" }}>{w.dates}</span>
              </div>
              <div style={{ fontSize: 12, color: theme.softFg, marginTop: 2 }}>{w.role}</div>
              <ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexWrap: "wrap", gap: "6px 14px" }}>
                {w.clients.map(([n, sec]) =>
                <li key={n} style={{ fontSize: 11.5, lineHeight: 1.35, color: theme.softFg }}>
                  {n}<span style={{ color: theme.softerFg }}> · {sec}</span>
                </li>
                )}
              </ul>
            </div>
          </li>
        )}
      </ol>
      <SectionTitle theme={theme}>Education</SectionTitle>
      <ol style={{ listStyle: "none", margin: "12px 0 0", padding: 0, display: "grid", gap: 12 }}>
        {EDUCATION.map((e, i) =>
        <li key={i} style={{
          border: `1px solid ${theme.chipBorder}`,
          borderRadius: 12,
          padding: "10px 12px",
          background: theme.chipBg
        }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8 }}>
              <span style={{ fontSize: 13, fontWeight: 600 }}>{e.title}</span>
              <span style={{ fontSize: 10, color: theme.softFg, whiteSpace: "nowrap" }}>{e.years}</span>
            </div>
            <div style={{ fontSize: 11, color: theme.softFg, marginTop: 4 }}>{e.kind} · {e.school}</div>
          </li>
        )}
      </ol>
    </div>);

}

function ContactPanel({ theme, accent, exclude = [] }) {
  const links = [
  { icon: "mail", label: "Email", v: "jacominos.design@gmail.com", href: "mailto:jacominos.design@gmail.com" },
  { icon: "photo_camera", label: "Instagram", v: "@jacominos", short: "@jacominos", href: "https://instagram.com/jacominos" },
  { icon: "language", label: "Behance", v: "behance.net/giovanniacomin", short: "behance.net/giovanniacomin", href: "https://www.behance.net/giovanniacomin" },
  { icon: "alternate_email", label: "LinkedIn", v: "linkedin.com/in/giovanni-iacomino", short: "linkedin.com/in/giovanni-iacomino", href: "https://www.linkedin.com/in/giovanni-iacomino-a4706b176/" }];

  return (
    <div style={{ padding: "0 22px 28px", color: theme.heroFg, display: "grid", gap: 10, maxWidth: 304, margin: "0 auto" }}>
      {links.filter((l) => !exclude.includes(l.label)).map((l, i) =>
      <a key={i} href={l.href} target="_blank" rel="noreferrer" style={{
        display: "grid",
        gridTemplateColumns: "auto 1fr auto",
        alignItems: "center",
        columnGap: 12,
        padding: "12px 16px",
        background: theme.chipBg,
        border: `1px solid ${theme.chipBorder}`,
        borderRadius: 14,
        textDecoration: "none",
        color: theme.heroFg,
        minWidth: 0
      }}>
          <span className="material-symbols-rounded" style={{
          fontSize: 22,
          color: theme.key === "rosso" ? theme.heroFg : accent,
          opacity: theme.key === "rosso" ? .8 : 1
        }}>{l.icon}</span>
          <div style={{ minWidth: 0, textAlign: "left" }}>
            <div style={{
            fontSize: 10.5,
            color: theme.softFg,
            letterSpacing: ".08em",
            textTransform: "uppercase",
            fontWeight: 600
          }}>{l.label}</div>
            <div style={{
            fontSize: 14,
            fontWeight: 500,
            marginTop: 2,
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap"
          }}>{l.v}</div>
          </div>
          <span className="material-symbols-rounded" style={{ fontSize: 18, color: theme.softFg }}>arrow_outward</span>
        </a>
      )}
    </div>);

}

function SectionTitle({ theme, children }) {
  return (
    <div style={{
      fontSize: 10.5,
      letterSpacing: ".18em",
      textTransform: "uppercase",
      color: theme.softFg,
      fontWeight: 600
    }}>{children}</div>);

}

// ─── Portfolio header (label + work/personal toggle) ──────────────────────
function PortfolioHeader({ theme, idx, setIdx, blu, rosso }) {
  return (
    <div style={{
      position: "relative",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      padding: "26px 18px 18px",
      color: theme.portfolioFg,
      gap: 8
    }}>
      {/* Folder tab — protrudes above the section, like the project cards */}
      <svg width="130" height="40" viewBox="0 0 130 40" aria-hidden="true"
      style={{
        position: "absolute",
        left: 0,
        top: -19,
        display: "block",
        pointerEvents: "none"
      }}>
        <path
          d="M 115 13.5 C 115 17.642 118.358 21 122.5 21 C 126.642 21 130 24.358 130 28.5 L 130 30.5 C 130 35.747 125.747 40 120.5 40 L 10 40 C 4.477 40 0 35.523 0 30 L 0 10 C 0 4.477 4.477 0 10 0 L 105 0 C 110.523 0 115 4.477 115 10 L 115 13.5 Z"
          fill={theme.portfolioBg} />
      </svg>
      <div style={{
        position: "absolute",
        left: 18,
        top: -8,
        fontSize: 10.5,
        letterSpacing: ".22em",
        textTransform: "uppercase",
        fontWeight: 600,
        opacity: .6,
        pointerEvents: "none"
      }}>Portfolio</div>
      <div style={{
        display: "flex",
        padding: 3,
        background: theme.key === "blu" ? "rgba(255,255,255,.06)" : "rgba(0,0,0,.05)",
        borderRadius: 999,
        border: `1px solid ${theme.chipBorder}`,
        gap: 2, opacity: "1"
      }}>
        <button onClick={() => setIdx(0)} style={{
          appearance: "none", border: "none",
          padding: "5px 12px",
          borderRadius: 999,
          fontSize: 11,
          fontWeight: 500,
          background: idx === 0 ? blu : "transparent",
          color: idx === 0 ? "#fff" : theme.portfolioFg,
          cursor: "pointer"
        }}>Selected Work</button>
        <button onClick={() => setIdx(1)} style={{
          appearance: "none", border: "none",
          padding: "5px 12px",
          borderRadius: 999,
          fontSize: 11,
          fontWeight: 500,
          background: idx === 1 ? rosso : "transparent",
          color: idx === 1 ? "#fff" : theme.portfolioFg,
          cursor: "pointer"
        }}>Side Quests</button>
      </div>
    </div>);

}

// ─── Portfolio grid ────────────────────────────────────────────────────────
// Hue (0–360) and saturation (0–1) of a #rrggbb color.
function hexHS(hex) {
  const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
  if (!m) return null;
  const n = parseInt(m[1], 16), rr = (n >> 16 & 255) / 255, gg = (n >> 8 & 255) / 255, bb = (n & 255) / 255;
  const mx = Math.max(rr, gg, bb), mn = Math.min(rr, gg, bb), d = mx - mn;
  let h = 0;
  if (d) h = mx === rr ? ((gg - bb) / d) % 6 : mx === gg ? (bb - rr) / d + 2 : (rr - gg) / d + 4;
  return { h: (h * 60 + 360) % 360, s: mx ? d / mx : 0 };
}
// Index of the header-palette color whose hue is closest to the project's
// tone. Neutral (gray) tones fall back to the first color.
function nearestPalIdx(tone) {
  const t = hexHS(tone);
  if (!t || t.s < .15) return 0;
  let best = 0, bestD = Infinity;
  WAVE_PAL.forEach(([c], i) => {
    const p = hexHS(c);
    const d = Math.min(Math.abs(p.h - t.h), 360 - Math.abs(p.h - t.h));
    if (d < bestD) { bestD = d; best = i; }
  });
  return best;
}

function PortfolioGrid({ projects, theme, accent, onOpen, palette = "refined" }) {
  const glowPal = palette === "original" ? WAVE_PAL : WAVE_PAL_REFINED;
  // Single column — one project per row. Only the folder whose center is
  // nearest the middle of the visible scroll area is "focused".
  const wrapRef = React.useRef(null);
  const [focusIdx, setFocusIdx] = React.useState(-1);
  const mouseRef = React.useRef(null);
  const [edge, setEdge] = React.useState(null);
  const [glows, setGlows] = React.useState([]);
  // One edge glow at a time: each focus change starts a new layer (bright,
  // then decaying while idle); the previous layer fades out.
  const [layers, setLayers] = React.useState([]);
  const layerSeq = React.useRef(0);
  React.useEffect(() => {
    if (focusIdx < 0) return;
    const c = (palette === "original" ? WAVE_PAL : WAVE_PAL_REFINED)[nearestPalIdx(projects[focusIdx].tone)][0];
    const id = ++layerSeq.current;
    setLayers((ls) => [...ls.slice(-1).map((l) => ({ ...l, out: true })), { id, c, out: false }]);
  }, [focusIdx, palette]);
  React.useEffect(() => {
    let raf = 0;
    const measure = () => {
      raf = 0;
      const wrap = wrapRef.current;
      if (!wrap) return;
      const root = document.getElementById("root");
      const scrollsInside = root && root.scrollHeight > root.clientHeight + 1 && getComputedStyle(root).overflowY !== "visible";
      const box = scrollsInside ? root.getBoundingClientRect() : { top: 0, height: window.innerHeight };
      const mid = box.top + box.height / 2;
      const rr = scrollsInside ? root.getBoundingClientRect() : { left: 0, width: window.innerWidth, top: 0, height: window.innerHeight };
      setEdge({ left: rr.left, width: rr.width, top: rr.top, height: rr.height });
      let best = -1, bestD = Infinity;
      const gl = [];
      const useMouse = window.matchMedia("(min-width: 900px) and (hover: hover)").matches && mouseRef.current;
      [...wrap.children].forEach((el, i) => {
        const b = el.getBoundingClientRect();
        if (b.bottom < box.top || b.top > box.top + box.height) return;
        const cy = b.top + b.height * 0.4;
        const d = Math.abs(cy - mid);
        const w = Math.max(0, 1 - d / (box.height * 0.35));
        if (w > 0) gl.push({ i, y: cy - box.top, w });
        const dd = useMouse ? Math.hypot(b.left + b.width / 2 - mouseRef.current.x, cy - mouseRef.current.y) : d;
        if (dd < bestD) { bestD = dd; best = i; }
      });
      setFocusIdx(best);
      setGlows(gl);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(measure); };
    const onMouse = (e) => { mouseRef.current = { x: e.clientX, y: e.clientY }; onScroll(); };
    measure();
    window.addEventListener("scroll", onScroll, { passive: true, capture: true });
    window.addEventListener("resize", onScroll);
    window.addEventListener("mousemove", onMouse, { passive: true });
    return () => { window.removeEventListener("scroll", onScroll, { capture: true }); window.removeEventListener("resize", onScroll); window.removeEventListener("mousemove", onMouse); cancelAnimationFrame(raf); };
  }, [projects.length]);
  const main = projects.filter((p) => !p.archive);
  const archive = projects.filter((p) => p.archive);
  return (
    <>
    <div style={{
      padding: "4px 22px 28px",
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      gap: 28
    }} ref={wrapRef} className="pf-grid">

      {main.map((p, i) =>
      <FocusFolder key={i} glow={glowPal[nearestPalIdx(p.tone)]} on={i === focusIdx}>
        <FolderCard {...p} theme={theme} accent={accent} width={330}
        onClick={() => onOpen(p)} />
      </FocusFolder>
      )}
    </div>
    {archive.length > 0 &&
    <div className="pf-archive" style={{
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      padding: "36px 22px 40px"
    }}>
      {archive.map((p, i) =>
      <FolderCard key={i} {...p} theme={theme} accent={accent} width={330}
      onClick={() => onOpen(p)} />
      )}
      <p style={{
        margin: "4px 0 0",
        maxWidth: 300,
        fontSize: 12.5,
        lineHeight: 1.5,
        textAlign: "center",
        color: theme.softFg
      }}>Logos, motion and social work for other clients.</p>
    </div>
    }
    </>);

}

// Grows slightly and glows in its own color while it sits in the middle
// band of the scroll viewport.
function FocusFolder({ glow, on, children }) {
  const [tone, light] = glow;
  // The ring plays to completion even if focus moves on mid-animation.
  const [ring, setRing] = React.useState(0);
  const prevOn = React.useRef(false);
  React.useEffect(() => {
    if (on && !prevOn.current) setRing((n) => n + 1);
    prevOn.current = on;
  }, [on]);
  const [ringLive, setRingLive] = React.useState(false);
  React.useEffect(() => { if (ring) setRingLive(true); }, [ring]);
  return (
    <div style={{ position: "relative", isolation: "isolate" }}>
      {/* Faint, tight, constant glow around the focused folder */}
      <div aria-hidden="true" style={{
        position: "absolute",
        left: "7%", right: "7%", top: "6%", height: "58%",
        borderRadius: "18%",
        background: tone,
        filter: "blur(16px) saturate(1.4)",
        opacity: on ? .18 : 0,
        transition: "opacity 900ms ease 700ms",
        zIndex: -1,
        pointerEvents: "none"
      }} />
      {/* One-shot ring on entering focus: background-colored core, light
          only at the edge, expanding outward and fading away. */}

      {React.cloneElement(children, { focus: !!on })}
    </div>);

}

// ─── Case Study (full page) ─────────────────────────────────────────────
function CaseStudy({ project, allProjects, theme, accent, themeKey, onOpen, onClose }) {
  if (!project) return null;
  const idx = allProjects.findIndex(p => p.name === project.name);
  const otherWorks = allProjects.filter((_, i) => i !== idx);
  // The big hero folder — bigger than the grid card. ~346 wide fits 390 viewport.
  const HERO_W = 346;
  // Museo Maradona: pure black page down to Other Works, then a soft fade
  // back to the theme background.
  const isMM = project.embed === "maradona";
  const pageBg = isMM ? "#000" : theme.portfolioBg;
  return (
    <div style={{
      position: "relative",
      background: pageBg,
      color: theme.portfolioFg,
      minHeight: "100vh",
      animation: "slideup 320ms cubic-bezier(.2,.8,.2,1)",
    }} className="cs-page">
      {/* Top bar */}
      <div style={{
        position: "sticky",
        top: 0,
        zIndex: 5,
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        padding: "14px 18px 12px",
        background: pageBg,
        borderBottom: `1px solid ${theme.chipBorder}`,
        backdropFilter: "blur(8px)",
      }}>
        <button
          onClick={onClose}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 4,
            background: "transparent",
            border: "none",
            color: theme.portfolioFg,
            cursor: "pointer",
            fontSize: 13,
            fontWeight: 500,
            padding: "6px 8px",
            borderRadius: 8,
          }}>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_back</span>
          <span>Home</span>
        </button>
        <div style={{
          fontSize: 10.5,
          letterSpacing: ".22em",
          textTransform: "uppercase",
          fontWeight: 600,
          opacity: .6,
        }}>Case Study</div>
      </div>

      {project.embed !== "archive" && <>
      {/* Hero — uploadable image/video slot (defaults to the project's
          own media). No folder shape, no name caption, no tags — those
          live just below. */}
      <div style={{
        padding: "20px 22px 22px",
      }}>
        <div style={{
          width: "100%",
          aspectRatio: "16 / 9",
        }}>
          <MediaSlot
            slotId={`cs-${themeKey}-${slugify(project.name)}-hero${isMM ? "-sun" : ""}`}
            theme={theme}
            shape="rounded"
            radius={18}
            placeholder="Drop hero image or video"
            defaultMedia={
              project.video
                ? { type: "video", url: project.video }
                : project.img
                  ? { type: "image", url: project.img }
                  : null
            }
            style={{ width: "100%", height: "100%" }}
          />
        </div>
      </div>

      {/* Title */}
      <h1 style={{
        margin: "8px 22px 14px",
        fontFamily: "Inter",
        fontWeight: 700,
        fontSize: 44,
        lineHeight: .96,
        letterSpacing: "-.025em",
        color: theme.portfolioFg,
        textAlign: "center",
        textWrap: "balance",
      }}>{project.name}</h1>

      {/* Subtitle pill */}
      <div style={{ display: "flex", justifyContent: "center", padding: "0 22px" }}>
        <div style={{
          background: theme.key === "blu" ? "rgba(255,255,255,.10)" : "rgba(0,0,0,.06)",
          borderRadius: 999,
          padding: "6px 14px",
          fontSize: 11,
          fontWeight: 500,
          letterSpacing: ".01em",
          textAlign: "center",
          maxWidth: "100%",
          textWrap: "balance",
        }}>{project.subtitle}</div>
      </div>

      {/* Year + tags */}
      <div style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        flexWrap: "wrap",
        gap: 8,
        padding: "12px 22px 0",
      }}>
        <span style={{ fontSize: 12, fontWeight: 500, opacity: .85 }}>{project.year}</span>
        <span style={{
          width: 4, height: 4, borderRadius: "50%",
          background: theme.softFg,
          margin: "0 2px",
        }} />
        <div style={{ display: "flex", flexWrap: "wrap", gap: 4, justifyContent: "center" }}>
          {project.tags.map((tag, i) => <Pill key={i} kind={tag} />)}
        </div>
      </div>

      </>}

      {project.embed === "maradona" ? <MaradonaEmbed /> : project.embed === "gestures" ? <GesturesEmbed /> : project.embed === "archive" ? <ArchiveEmbed theme={theme} /> : <>
      {/* Description (supports paragraph breaks via \n\n) */}
      {String(project.description || "").split(/\n\n+/).map((para, i) => (
        <p key={i} style={{
          margin: i === 0 ? "22px 26px 8px" : "12px 26px 8px",
          fontSize: 13.5,
          lineHeight: 1.6,
          color: theme.portfolioFg,
          opacity: .85,
          textAlign: "center",
          textWrap: "pretty",
        }}>{para}</p>
      ))}

      {/* Gallery — predefined media items, or empty slots when gallery is a
          number. Items with a `url` render the asset directly; items without
          render a drop slot. Each item can declare its own aspect ratio. */}
      <div style={{
        display: "grid",
        gap: 14,
        padding: "22px 22px 0"
      }} className="cs-gallery">
        {(() => {
          // Normalize to array of items.
          const g = project.gallery;
          const items = Array.isArray(g)
            ? g
            : Array.from({ length: g || 2 }, () => ({}));
          return items.map((it, i) => {
            const ratio = it.ratio || "16 / 9";
            // Text block — a standalone paragraph rendered inside the gallery
            // grid so the author can split the case-study narrative around
            // media items.
            if (it.type === "text") {
              return (
                <p key={i} style={{
                  margin: "8px 4px 14px",
                  fontSize: 13.5,
                  lineHeight: 1.6,
                  color: theme.portfolioFg,
                  opacity: .85,
                  textAlign: "center",
                  textWrap: "pretty",
                  whiteSpace: "pre-line",
                }}>{it.content}</p>
              );
            }
            // image-group: a shared horizontal carousel for multiple images
            // that share a theme. Renders as one gallery slot.
            if (it.type === "image-group" && Array.isArray(it.images)) {
              return (
                <figure key={i} style={{ margin: 0 }}>
                  <ImageGroupCarousel
                    images={it.images}
                    ratio={ratio}
                    theme={theme}
                    accent={accent}
                  />
                  {it.caption && (
                    <figcaption style={{
                      marginTop: 8,
                      fontSize: 11,
                      color: theme.softFg,
                      textAlign: "center",
                      letterSpacing: ".01em",
                    }}>{it.caption}</figcaption>
                  )}
                </figure>
              );
            }
            if (it.type === "quote") {
              return (
                <p key={i} className="cs-wide" style={{
                  margin: "26px auto 22px",
                  maxWidth: 620,
                  fontSize: it.size || "clamp(22px, 5.4vw, 34px)",
                  fontStyle: it.italic ? "italic" : "normal",
                  whiteSpace: "pre-line",
                  lineHeight: 1.18,
                  fontWeight: 600,
                  letterSpacing: "-.02em",
                  color: theme.portfolioFg,
                  textAlign: "center",
                  textWrap: "balance"
                }}>{it.content}</p>);
            }
            if (it.type === "player") {
              return (
                <figure key={i} className="cs-wide" style={{ margin: "10px 0 6px" }}>
                  <video src={it.url} controls playsInline preload="metadata"
                  style={{ width: "100%", aspectRatio: "16 / 9", display: "block", borderRadius: 18, background: "#000", objectFit: "cover" }} />
                  {it.caption && <figcaption style={{ marginTop: 12, fontSize: 15, fontWeight: 600, color: theme.portfolioFg, textAlign: "center", letterSpacing: "-.01em" }}>{it.caption}</figcaption>}
                </figure>);
            }
            const hasUrl = !!it.url;
            return (
              <figure key={i} style={{ margin: 0 }}>
                <div style={{ width: "100%", aspectRatio: ratio }}>
                  <MediaSlot
                    slotId={`cs-${themeKey}-${slugify(project.name)}-gallery-${i}`}
                    theme={theme}
                    shape="rounded"
                    radius={16}
                    placeholder={`Drop image #${i + 1}`}
                    defaultMedia={
                      hasUrl ? { type: it.type || "image", url: it.url } : null
                    }
                    style={{ width: "100%", height: "100%" }}
                  />
                </div>
                {it.caption && (
                  <figcaption style={{
                    marginTop: 8,
                    fontSize: 11,
                    color: theme.softFg,
                    textAlign: "center",
                    letterSpacing: ".01em",
                  }}>{it.caption}</figcaption>
                )}
              </figure>
            );
          });
        })()}
      </div>

      {/* Description 2 */}
      {project.description2 && (
        <p style={{
          margin: "26px 26px 8px",
          fontSize: 13.5,
          lineHeight: 1.6,
          color: theme.portfolioFg,
          opacity: .85,
          textAlign: "center",
          textWrap: "pretty",
        }}>{project.description2}</p>
      )}

      </>}
      <div className={isMM ? "cs-fade" : undefined} style={isMM ? { background: `linear-gradient(180deg, #000 0%, ${theme.portfolioBg} 260px)` } : undefined}>
      {/* Other Works carousel */}
      <div style={{ padding: "36px 0 24px", overflow: "hidden" }}>
        <h2 style={{
          margin: "0 22px 22px",
          fontFamily: "Inter",
          fontWeight: 300,
          fontSize: 28,
          lineHeight: 1,
          letterSpacing: "-.015em",
          color: theme.portfolioFg,
          textAlign: "center",
        }} className="ow-title">Other Works</h2>
        <OtherWorksCarousel
          works={otherWorks}
          theme={theme}
          accent={accent}
          onOpen={onOpen}
        />
      </div>

      {/* Contact rimando — Instagram + Email matching the home CTA. */}
      <CTA theme={theme} accent={accent} />
      </div>

      {/* Footer */}
      <Footer theme={theme} accent={accent} />
    </div>);
}

// Center one folder card, with the previous/next ones peeking out at half
// opacity. Tap a side card to focus it. Dots beneath.
// Pointer/touch swipeable with smooth follow-the-finger drag + spring snap.
function OtherWorksCarousel({ works, theme, accent, onOpen }) {
  const [focus, setFocus] = React.useState(0);
  const [drag, setDrag] = React.useState(0); // px, >0 = swiping right
  const startRef = React.useRef(null);
  const N = works.length;
  if (N === 0) return null;
  const SPACING = 200;

  const commit = (delta) => {
    if (Math.abs(delta) > SPACING * 0.28) {
      setFocus(f => ((f + (delta < 0 ? 1 : -1)) % N + N) % N);
    }
    setDrag(0);
  };

  const handlers = {
    onPointerDown: (e) => {
      if (e.button !== undefined && e.button !== 0) return;
      startRef.current = { x: e.clientX, y: e.clientY, captured: false, id: e.pointerId };
    },
    onPointerMove: (e) => {
      const s = startRef.current;
      if (!s) return;
      const dx = e.clientX - s.x;
      const dy = e.clientY - s.y;
      if (!s.captured) {
        if (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy)) {
          s.captured = true;
          try { e.currentTarget.setPointerCapture?.(s.id); } catch (_) {}
        } else if (Math.abs(dy) > 8) {
          startRef.current = null;
          return;
        } else {
          return;
        }
      }
      const clamped = Math.max(-SPACING * 1.2, Math.min(SPACING * 1.2, dx));
      setDrag(clamped);
    },
    onPointerUp: (e) => {
      const s = startRef.current;
      if (!s) return;
      const dx = e.clientX - s.x;
      startRef.current = null;
      if (s.captured) commit(dx);
      else setDrag(0);
    },
    onPointerCancel: () => { startRef.current = null; setDrag(0); },
    onLostPointerCapture: () => { startRef.current = null; setDrag(0); },
  };

  // Render a ±2 window so the incoming card slides in smoothly.
  const RANGE = 2;
  const cards = [];
  for (let rel = -RANGE; rel <= RANGE; rel++) {
    if (N === 1 && rel !== 0) continue;
    if (N === 2 && (rel === 2 || rel === -2 || rel === 1)) continue;
    const i = ((focus + rel) % N + N) % N;
    cards.push({ rel, i });
  }

  return (
    <div>
      <div
        {...handlers}
        onDragStart={(e) => e.preventDefault()}
        className="ow-track"
        style={{
          position: "relative",
          height: 260,
          touchAction: "pan-y",
          cursor: drag ? "grabbing" : "grab",
          userSelect: "none",
        }}>
        {cards.map(({ rel, i }) => {
          // Drag right (positive dx) = pull previous card toward center → effRel goes negative
          const dragRel = drag / SPACING;
          const effRel = rel + dragRel;
          const absEff = Math.abs(effRel);
          const isCenter = absEff < 0.5;
          // size: 188 at center, 152 at neighbors, smoothly interpolated
          const w = Math.max(152, 188 - Math.min(absEff, 1) * 36);
          // opacity: full at center, .22 at neighbor, 0 at rel=2
          const op = absEff <= 1
            ? 1 - absEff * 0.78
            : Math.max(0, 0.22 - (absEff - 1) * 0.22);
          return (
            <div key={i + "-" + rel} style={{
              position: "absolute",
              left: "50%",
              top: "50%",
              transform: `translate(calc(-50% + ${effRel * SPACING}px), -50%)`,
              opacity: op,
              transition: drag === 0 ? "all 360ms cubic-bezier(.4,0,.2,1)" : "none",
              filter: isCenter ? "none" : `grayscale(${Math.min(absEff, 1) * 0.5})`,
              willChange: "transform, opacity",
            }}>
              <FolderCard
                {...works[i]}
                theme={theme}
                accent={accent}
                width={w}
                onClick={() => {
                  if (Math.abs(drag) > 4) return;
                  if (isCenter) onOpen(works[i]);
                  else setFocus(i);
                }}
              />
            </div>
          );
        })}
      </div>
      {/* dots */}
      <div style={{ display: "flex", justifyContent: "center", gap: 6, marginTop: 12 }}>
        {works.map((_, i) => (
          <button key={i} onClick={() => setFocus(i)} aria-label={`go to ${i}`} style={{
            width: 7, height: 7, borderRadius: "50%",
            background: i === focus ? accent : theme.dotInactive,
            border: "none", padding: 0, cursor: "pointer",
            transition: "background 200ms",
          }} />
        ))}
      </div>
    </div>
  );
}

function slugify(s) {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}

// ─── ImageGroupCarousel ────────────────────────────────────────────────
// Snap-scroll horizontal gallery for several images sharing one slot.
// Includes prev/next arrow buttons so swiping with mouse is one-tap easy.
function ImageGroupCarousel({ images, ratio = "16 / 9", theme, accent }) {
  const scrollRef = React.useRef(null);
  const [active, setActive] = React.useState(0);
  const N = images.length;

  const onScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const i = Math.round(el.scrollLeft / el.clientWidth);
    if (i !== active && i >= 0 && i < N) setActive(i);
  };

  const goTo = (i) => {
    const el = scrollRef.current;
    if (!el) return;
    const clamped = Math.max(0, Math.min(N - 1, i));
    el.scrollTo({ left: clamped * el.clientWidth, behavior: "smooth" });
  };

  const arrowStyle = (side) => ({
    position: "absolute",
    top: "50%",
    [side]: 8,
    transform: "translateY(-50%)",
    width: 36, height: 36,
    borderRadius: "50%",
    background: "rgba(0,0,0,.55)",
    color: "#fff",
    border: "none",
    cursor: "pointer",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    backdropFilter: "blur(8px)",
    transition: "background 160ms, opacity 160ms",
    opacity: .9,
    zIndex: 2,
  });

  return (
    <div>
      <div style={{ position: "relative" }}>
        <div
          ref={scrollRef}
          onScroll={onScroll}
          className="no-bar"
          style={{
            position: "relative",
            width: "100%",
            aspectRatio: ratio,
            overflowX: "auto",
            overflowY: "hidden",
            scrollSnapType: "x mandatory",
            display: "flex",
            gap: 0,
            borderRadius: 16,
            background: theme.key === "blu" ? "rgba(255,255,255,.04)" : "rgba(0,0,0,.04)",
            WebkitOverflowScrolling: "touch",
          }}
        >
          {images.map((src, i) => (
            <div key={i} style={{
              flex: "0 0 100%",
              scrollSnapAlign: "start",
              position: "relative",
            }}>
              <img src={src}
                   alt={`image ${i + 1}`}
                   style={{
                     width: "100%",
                     height: "100%",
                     objectFit: "cover",
                     display: "block",
                   }} />
            </div>
          ))}
        </div>
        {/* Prev / next arrows — hidden at the edges */}
        {active > 0 && (
          <button onClick={() => goTo(active - 1)} aria-label="previous" style={arrowStyle("left")}>
            <span className="material-symbols-rounded" style={{ fontSize: 22 }}>chevron_left</span>
          </button>
        )}
        {active < N - 1 && (
          <button onClick={() => goTo(active + 1)} aria-label="next" style={arrowStyle("right")}>
            <span className="material-symbols-rounded" style={{ fontSize: 22 }}>chevron_right</span>
          </button>
        )}
      </div>
      <div style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        gap: 5,
        marginTop: 10,
      }}>
        {images.map((_, i) => (
          <button key={i} onClick={() => goTo(i)} aria-label={`image ${i + 1}`} style={{
            width: i === active ? 16 : 5,
            height: 5,
            borderRadius: 999,
            background: i === active ? accent : theme.dotInactive,
            border: "none", padding: 0, cursor: "pointer",
            transition: "width 240ms ease, background 200ms",
          }} />
        ))}
      </div>
    </div>
  );
}

// ─── Contact CTA (used on every case study, after Other Works) ────────────
// Smaller variant of the home CTA: no animation slot, just the two pill
// buttons. Title is light italic to keep the page airy and consistent with
// the home's "Let's Keep in Touch".
function ContactCTA({ theme, accent }) {
  return (
    <div style={{
      padding: "8px 22px 36px",
      color: theme.portfolioFg,
      textAlign: "center",
    }}>
      <h3 style={{
        margin: "0 0 18px",
        fontFamily: "Inter",
        fontStyle: "italic",
        fontWeight: 300,
        fontSize: 32,
        lineHeight: 1.05,
        letterSpacing: "-.02em",
        color: theme.portfolioFg,
        textWrap: "balance",
      }}>Like this work?<br />Let's get in&nbsp;touch.</h3>
      <div style={{ display: "grid", gap: 10, maxWidth: 260, margin: "0 auto", width: "100%" }}>
        <a
          href="https://instagram.com/jacominos"
          target="_blank"
          rel="noreferrer"
          className="cta-btn cta-btn--fill"
          style={{
            "--cta-accent": accent,
            "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
            display: "flex", alignItems: "center", justifyContent: "space-between",
            padding: "13px 22px",
            background: accent,
            color: theme.btnTextOnAccent,
            border: `1.5px solid ${accent}`,
            borderRadius: 999,
            fontSize: 17,
            fontWeight: 500,
            textDecoration: "none",
            letterSpacing: "-.005em",
            transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease",
          }}>
          <span>Instagram</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
        <a
          href="mailto:jacominos.design@gmail.com"
          className="cta-btn cta-btn--outline"
          style={{
            "--cta-accent": accent,
            "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
            display: "flex", alignItems: "center", justifyContent: "space-between",
            padding: "13px 22px",
            background: "transparent",
            color: theme.btnTextOnOutline,
            border: `1.5px solid ${theme.btnOutlineBorder}`,
            borderRadius: 999,
            fontSize: 17,
            fontWeight: 500,
            textDecoration: "none",
            letterSpacing: "-.005em",
            transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease",
          }}>
          <span>Email</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
      </div>
    </div>
  );
}

// ─── CTA "Let's Keep in Touch" + Instagram / Email buttons ────────────────
function CTA({ theme, accent, ctaWeight }) {
  return (
    <div className="cta-wrap" style={{
      padding: "10px 22px 64px",
      color: theme.portfolioFg,
      textAlign: "center",
      position: "relative"
    }}>
      {/* Animation slot — drop a Lottie/GIF/PNG; replaces the “Let's Keep in Touch” title */}
      <div className="cta-media" style={{
        margin: "4px -22px 16px",
        width: "calc(100% + 44px)",
        maxWidth: "none",
        aspectRatio: "16 / 9",
        position: "relative"
      }}>
        <MediaSlot
          slotId={`cta-animation-${theme.key}`}
          theme={theme}
          shape="rounded"
          radius={22}
          placeholder="Drop animation, GIF or MP4"
          style={{ width: "100%", height: "100%" }} />
        {/* ROSSO: the checkerboard is baked into the video — mask it with
            the page white (positions in % of the 1920×1080 frame). */}
        {theme.key === "rosso" && [
          { left: "8%", top: "10%", width: "28%", height: "14%" },
          { left: "62.4%", top: "75.8%", width: "28%", height: "13.8%" }
        ].map((m, k) =>
        <div key={k} aria-hidden="true" style={{ position: "absolute", background: "#fff", pointerEvents: "none", ...m }} />
        )}
      </div>
      <div className="cta-buttons" style={{ display: "grid", gap: 10, maxWidth: 260, margin: "0 auto", width: "100%" }}>
        <a href="https://instagram.com/jacominos" target="_blank" rel="noreferrer"
        className="cta-btn cta-btn--fill"
        style={{
          "--cta-accent": accent,
          "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
          display: "flex", alignItems: "center", justifyContent: "space-between",
          padding: "13px 22px",
          background: accent,
          color: theme.btnTextOnAccent,
          border: `1.5px solid ${accent}`,
          borderRadius: 999,
          fontSize: 17,
          fontWeight: 500,
          textDecoration: "none",
          letterSpacing: "-.005em",
          transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease"
        }}>
          <span>Instagram</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
        <a href="mailto:jacominos.design@gmail.com"
        className="cta-btn cta-btn--outline"
        style={{
          "--cta-accent": accent,
          "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
          "--cta-outline-fg": theme.btnTextOnOutline,
          "--cta-outline-border": theme.btnOutlineBorder,
          display: "flex", alignItems: "center", justifyContent: "space-between",
          padding: "13px 22px",
          background: "transparent",
          color: theme.btnTextOnOutline,
          border: `1.5px solid ${theme.btnOutlineBorder}`,
          borderRadius: 999,
          fontSize: 17,
          fontWeight: 500,
          textDecoration: "none",
          letterSpacing: "-.005em",
          transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease"
        }}>
          <span>Email</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
        <a href="https://www.behance.net/giovanniacomin" target="_blank" rel="noreferrer"
        className="cta-btn cta-btn--outline"
        style={{
          "--cta-accent": accent,
          "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
          "--cta-outline-fg": theme.btnTextOnOutline,
          "--cta-outline-border": theme.btnOutlineBorder,
          display: "flex", alignItems: "center", justifyContent: "space-between",
          padding: "13px 22px",
          background: "transparent",
          color: theme.btnTextOnOutline,
          border: `1.5px solid ${theme.btnOutlineBorder}`,
          borderRadius: 999,
          fontSize: 17,
          fontWeight: 500,
          textDecoration: "none",
          letterSpacing: "-.005em",
          transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease"
        }}>
          <span>Behance</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
        <a href="https://www.linkedin.com/in/giovanni-iacomino-a4706b176/" target="_blank" rel="noreferrer"
        className="cta-btn cta-btn--outline"
        style={{
          "--cta-accent": accent,
          "--cta-grad-end": theme.key === "blu" ? "#CBDDF2" : "#FFD4D4",
          "--cta-outline-fg": theme.btnTextOnOutline,
          "--cta-outline-border": theme.btnOutlineBorder,
          display: "flex", alignItems: "center", justifyContent: "space-between",
          padding: "13px 22px",
          background: "transparent",
          color: theme.btnTextOnOutline,
          border: `1.5px solid ${theme.btnOutlineBorder}`,
          borderRadius: 999,
          fontSize: 17,
          fontWeight: 500,
          textDecoration: "none",
          letterSpacing: "-.005em",
          transition: "background 280ms ease, color 200ms ease, border-color 200ms ease, transform 160ms ease"
        }}>
          <span>LinkedIn</span>
          <span className="material-symbols-rounded" style={{ fontSize: 18 }}>arrow_outward</span>
        </a>
      </div>
    </div>);

}

// ─── Footer (mirror hero with cycling byes in many languages) ─────────────
function Footer({ theme, accent }) {
  const barColor = theme.key === "blu" ? accent : "#fff";
  return (
    <div className="site-footer" style={{
      background: theme.key === "blu" ? "#fff" : accent,
      color: theme.key === "blu" ? "#0E0E0E" : "#fff",
      padding: "40px 22px 24px",
      position: "relative",
      zIndex: 3
    }}>
      {/* Folder tab — protrudes above the footer like the portfolio section.
                         Fill matches the footer bg so the seam disappears. */}
      <svg width="130" height="40" viewBox="0 0 130 40" aria-hidden="true"
      style={{
        position: "absolute",
        left: 0,
        top: -19,
        display: "block",
        pointerEvents: "none"
      }}>
        <path
          d="M 115 13.5 C 115 17.642 118.358 21 122.5 21 C 126.642 21 130 24.358 130 28.5 L 130 30.5 C 130 35.747 125.747 40 120.5 40 L 10 40 C 4.477 40 0 35.523 0 30 L 0 10 C 0 4.477 4.477 0 10 0 L 105 0 C 110.523 0 115 4.477 115 10 L 115 13.5 Z"
          fill={theme.key === "blu" ? "#fff" : accent} />
      </svg>
      <div style={{
        position: "absolute",
        left: 18,
        top: -8,
        fontSize: 10.5,
        letterSpacing: ".22em",
        textTransform: "uppercase",
        fontWeight: 600,
        opacity: .6,
        pointerEvents: "none",
        color: theme.key === "blu" ? "#0E0E0E" : "#fff"
      }}>Footer</div>
      <div style={{
        display: "grid",
        gridTemplateColumns: "1fr auto",
        alignItems: "center",
        gap: 12
      }}>
        <div style={{ minWidth: 0 }}>
          <GreetingWheel
            words={theme.key === "rosso" ? BYES_ROSSO : BYES}
            theme={theme}
            accent={accent}
            barColor={barColor}
            baseSize={60}
            weight={theme.key === "blu" ? "light" : "auto"}
            interval={2600}
            align="left" />
        </div>
        {/* Waving hand — same slot mechanic as the hero. Drop a light-blue
                           (azzurra) hand animation here. Per-theme. */}
        <div className="footer-hand" style={{ width: 76, height: 76, flex: "0 0 auto" }}>
          <MediaSlot
            slotId={`hand-footer-${theme.key}`}
            theme={theme}
            shape="circle"
            frame="none"
            fit="contain"
            wave={true}
            placeholder="Drop azzurra hand"
            style={{ width: "100%", height: "100%" }} />
        </div>
      </div>
      

      <div style={{
        marginTop: 24,
        fontSize: 10.5,
        textAlign: "center",
        opacity: .65,
        letterSpacing: ".06em"
      }}>Designed by me, vibecoded with love
      </div>
    </div>);

}

// ─── Root ──────────────────────────────────────────────────────────────────
function App() {
  const [t, setTweak] = useTweaks(TWEAKS_DEFAULTS);
  const [idx, setIdx] = useState(0); // 0 = blu, 1 = rosso
  const [tab, setTab] = useState(null);
  const [openProject, setOpenProject] = useState(null);
  // Live background color of the red hero (spells recolor it) — keeps the
  // page behind the Portfolio tab seamless with the game.
  const [rossoBg, setRossoBg] = useState("#ff4438");
  useEffect(() => {
    const on = (e) => { if (e.data && typeof e.data.heroBg === "string") setRossoBg(e.data.heroBg); };
    window.addEventListener("message", on);
    return () => window.removeEventListener("message", on);
  }, []);
  React.useLayoutEffect(() => {
    const root = document.getElementById("root");
    if (root) root.scrollTop = 0;
    document.documentElement.scrollTop = 0; document.body.scrollTop = 0;
    window.scrollTo(0, 0);
  }, [openProject]);

  const themeKey = idx === 0 ? "blu" : "rosso";
  const theme = THEMES[themeKey];
  const accent = themeKey === "blu" ? t.bluAccent : t.rossoAccent;
  const heroTheme = themeKey === "blu" ? theme : { ...theme, heroFg: "#fff", barColor: "rgba(255,255,255,.9)", dotInactive: "rgba(255,255,255,.4)", handTint: "#fff" };
  const projects = PROJECTS[themeKey];

  // ─ Swipe gesture: horizontal pan toggles theme. Live finger-follow with
  //   a transform so the swipe feels responsive on mobile, then snaps. ─────
  const startRef = useRef(null);
  const [swipeDX, setSwipeDX] = useState(0);
  const [swiping, setSwiping] = useState(false);
  const onPointerDown = (e) => {
    startRef.current = { x: e.clientX, y: e.clientY, captured: false, id: e.pointerId, t: Date.now() };
  };
  const onPointerMove = (e) => {
    const s = startRef.current;
    if (!s) return;
    const dx = e.clientX - s.x;
    const dy = e.clientY - s.y;
    if (!s.captured) {
      // Capture as soon as horizontal intent is clear (lower threshold,
      // looser ratio → triggers earlier and more reliably on touch).
      if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) {
        s.captured = true;
        setSwiping(true);
        try { e.currentTarget.setPointerCapture?.(s.id); } catch (_) {}
      } else if (Math.abs(dy) > 12) {
        // Vertical scroll wins — abandon the gesture.
        startRef.current = null;
        return;
      } else {
        return;
      }
    }
    // Resist swiping past the available direction (rubber-band at the edges).
    let eff = dx;
    if ((dx < 0 && idx === 1) || (dx > 0 && idx === 0)) eff = dx * 0.25;
    setSwipeDX(eff);
  };
  const finish = (e) => {
    const s = startRef.current;
    startRef.current = null;
    if (!s) { setSwipeDX(0); setSwiping(false); return; }
    const dx = (e && e.clientX != null) ? e.clientX - s.x : swipeDX;
    const dt = Date.now() - s.t;
    const fast = Math.abs(dx) / Math.max(dt, 1) > 0.45; // px/ms flick
    const past = Math.abs(dx) > 55;
    if (s.captured && (fast || past) && Math.abs(dx) > 24) {
      if (dx < 0 && idx === 0) setIdx(1);
      else if (dx > 0 && idx === 1) setIdx(0);
    }
    setSwipeDX(0);
    setSwiping(false);
  };
  const onPointerUp = finish;

  // Blue-page die → staged hop to the Quest so the user stays oriented:
  // 1) the page swipes out to the left like a real swipe, 2) the red page
  // slides in, 3) a smooth scroll back up to the header, 4) the Quest starts.
  const jumpToQuest = () => {
    const W = window.innerWidth;
    setSwiping(false);
    setSwipeDX(-W * 0.9);
    setTimeout(() => {
      setSwiping(true);
      setIdx(1);
      setSwipeDX(W * 0.9);
      requestAnimationFrame(() => requestAnimationFrame(() => { setSwiping(false); setSwipeDX(0); }));
      setTimeout(() => {
        const root = document.getElementById("root");
        const sc = root && root.scrollHeight > root.clientHeight + 1 && getComputedStyle(root).overflowY !== "visible" ? root : null;
        const from = sc ? sc.scrollTop : window.scrollY;
        const dur = Math.min(1400, 500 + from * 0.25);
        const t0 = performance.now();
        const ease = (t) => 1 - Math.pow(1 - t, 3);
        const step = (now) => {
          const k = Math.min(1, (now - t0) / dur), y = from * (1 - ease(k));
          if (sc) sc.scrollTop = y; else window.scrollTo(0, y);
          if (k < 1) requestAnimationFrame(step);
          else {
            let n = 0;
            const ping = () => {
              const fr = document.querySelector('iframe[src^="hero-rosso.html"]');
              if (fr && fr.contentWindow) fr.contentWindow.postMessage({ cmd: "startQuest" }, "*");
              if (++n < 5) setTimeout(ping, 300);
            };
            setTimeout(ping, 250);
          }
        };
        requestAnimationFrame(step);
      }, 520);
    }, 340);
  };

  // ─ Reset tab to about when switching themes
  useEffect(() => {setTab(null);}, [idx]);

  // Animation key: when theme changes, re-mount content tree
  return (
    <div style={{
      background: openProject ? theme.portfolioBg : themeKey === "rosso" ? rossoBg : theme.heroBg,
      color: openProject ? theme.portfolioFg : theme.heroFg,
      minHeight: "100vh",
      position: "relative",
      transition: "background-color 360ms ease",
      overflow: "hidden"
    }}>
      {/* Conditional: home OR case study (mutually exclusive — keeps the
          wrapper height bounded so there's no leftover black space below). */}
      {openProject ? (
        <CaseStudy
          project={openProject}
          allProjects={projects}
          theme={theme}
          accent={accent}
          themeKey={themeKey}
          onOpen={(p) => { setOpenProject(p); }}
          onClose={() => setOpenProject(null)}
        />
      ) : (
      <div style={{ position: "relative", zIndex: 1, touchAction: "pan-y" }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={finish}>

        <div style={{
          transform: `translateX(${swipeDX}px)`,
          transition: swiping ? "none" : "transform 320ms cubic-bezier(.2,.8,.2,1)",
          willChange: "transform",
        }}>
        <div className="hero-screen" style={{ display: "flex", flexDirection: "column", position: "relative", overflow: "visible", background: themeKey === "blu" ? "#000" : rossoBg, color: themeKey === "blu" ? undefined : "#fff3ea" }}>
        {themeKey === "blu" ? <WaveHero palette={t.wavePalette} /> : <div className="rosso-hero" key="rosso-hero" style={{ pointerEvents: "auto" }}><iframe src="hero-rosso.html?extra=72" title="Hero" loading="eager" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", border: 0, display: "block", background: "#ff4438", pointerEvents: "auto" }} /></div>}
        <SwipeBar theme={THEMES.blu} accent={themeKey === "blu" ? accent : "#fff3ea"} idx={idx} setIdx={setIdx} />

        <div style={{ position: "relative", marginTop: "auto", marginBottom: "auto", pointerEvents: themeKey === "blu" ? undefined : "none" }}>
          {false &&
          <>
            <div style={{ position: "absolute", top: 6, right: 14, zIndex: 0 }}>
              <Checkers accent="#fff" rows={2} cols={5} sz={12} opacity={.9} />
            </div>
          </>
          }
          {true ?
          <div style={{ height: 150 }} /> :
          <Hero theme={THEMES.blu} accent="#fff" size={t.heroSize}
          mirror={false} />}
        </div>
        {false &&
        <p lang="en" style={{
          margin: "0 26px 18px",
          fontFamily: "Inter",
          fontSize: 13.5,
          fontWeight: 400,
          lineHeight: 1.6,
          color: THEMES.blu.heroFg,
          opacity: .85,
          textAlign: "center",
          textWrap: "balance",
          hyphens: "auto",
          WebkitHyphens: "auto"
        }}>I'm Giovanni Iacomino, a multidisciplinary designer turning ideas into distinctive visuals, identities, and digital experiences.</p>
        }
        {false &&
        <div style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          gap: 2,
          padding: "0 0 22px",
          color: THEMES.blu.heroFg,
          opacity: .6,
          fontSize: 11,
          fontWeight: 500,
          letterSpacing: ".08em",
          textTransform: "uppercase"
        }}>
          <span>Scroll</span>
          <span className="material-symbols-rounded" style={{ fontSize: 20, animation: "scrollhint 1.6s ease-in-out infinite" }}>keyboard_arrow_down</span>
        </div>
        }
        </div>


        {/* Portfolio section — darker on blu. Top-left flat: the folder tab
                                              inside PortfolioHeader fills that corner & pokes up like a Mac folder. */}
        <div style={{
          background: theme.portfolioBg,
          color: theme.portfolioFg,
          borderRadius: "0 14px 0 0",
          paddingBottom: 4,
          marginTop: 32,
          position: "relative",
          zIndex: 2,
          boxShadow: themeKey === "blu" ? "0 -10px 40px rgba(0,122,255,.18)" : "0 -10px 30px rgba(0,0,0,.06)"
        }}>
          <PortfolioHeader theme={theme} idx={idx} setIdx={setIdx}
          blu={t.bluAccent} rosso={t.rossoAccent} />
          <PortfolioGrid projects={projects} theme={theme} accent={accent}
          onOpen={setOpenProject} palette={t.wavePalette} />
          <div style={{ paddingTop: 8 }}>
            <ExperiencePanel theme={theme} accent={accent} />
          </div>
          <CTA theme={theme} accent={accent} ctaWeight={t.ctaWeight} />
        </div>

        <Footer theme={theme} accent={accent} />
        </div>
      </div>
      )}

      <PlayDie show={themeKey === "rosso" && !openProject} />
      <PlayDie show={themeKey === "blu" && !openProject} invite onSwitch={jumpToQuest} />
      {openProject && <PlayDie key={"cs-" + openProject.name} show invite onSwitch={() => { setOpenProject(null); setTimeout(jumpToQuest, 60); }} />}
      <SiteLoader />
      <VideoGovernor />
      <TweaksUI t={t} setTweak={setTweak} />

      <style>{`
        @keyframes fadein {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes slideup {
          from { opacity: 0; transform: translateY(20px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes slidefade { from { opacity: 0; } to { opacity: 1; } }
        .folder-ring { opacity: 0; animation: folderring 1.4s ease-in-out both; }
        @keyframes folderring { 0% { opacity: 0; transform: scale(.85); } 40% { opacity: .6; } 100% { opacity: 0; transform: scale(1.15); } }
        @media (prefers-reduced-motion: reduce) { .folder-ring { display: none; } }
        .edge-glow { animation: edgein 3.2s cubic-bezier(.25,.8,.25,1) forwards; }
        .edge-glow.is-out { animation: edgeout .5s ease forwards; }
        @keyframes edgein { 0% { opacity: 0; } 18% { opacity: .8; } 100% { opacity: .18; } }
        @keyframes edgeout { from { opacity: var(--from, .3); } to { opacity: 0; } }
        @media (prefers-reduced-motion: reduce) { .edge-glow { animation: none; opacity: .25; } }
        @keyframes scrollhint { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
        .hero-screen { min-height: 100vh; min-height: 100svh; }
        .play-die{position:fixed;right:18px;bottom:18px;z-index:8000;display:flex;flex-direction:column;align-items:flex-end;gap:8px;background:none;border:0;padding:0;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;font-family:Inter,system-ui,sans-serif}
        .play-die__roll{display:block;animation:dieRollIn 1.05s cubic-bezier(.22,.9,.3,1) both}
        @keyframes dieRollIn{0%{opacity:0;transform:translateX(120px) rotate(540deg)}12%{opacity:1}62%{transform:translateX(-6px) rotate(-14deg)}78%{transform:translateX(3px) translateY(-6px) rotate(6deg)}100%{transform:none}}
        .play-die__balloon{position:relative;background:#fff3ea;color:#c4241b;font-size:11.5px;font-weight:800;font-style:italic;letter-spacing:-.01em;padding:7px 12px;border-radius:999px;white-space:nowrap;box-shadow:0 6px 18px rgba(90,6,18,.25);animation:balloonIn .5s cubic-bezier(.25,.8,.25,1) .85s both}
        .play-die__balloon::after{content:"";position:absolute;right:16px;bottom:-5px;width:10px;height:10px;background:#fff3ea;transform:rotate(45deg);border-radius:2px}
        .play-die__cube{position:relative;display:block;width:46px;height:46px;filter:drop-shadow(0 8px 12px rgba(90,6,18,.35));animation:dieBob 2.4s cubic-bezier(.25,.8,.25,1) infinite;margin-right:4px}
        .play-die{transition:opacity .35s ease,transform .45s cubic-bezier(.25,.8,.25,1)}
        .play-die.is-hidden{opacity:0;transform:translateY(16px) scale(.85);pointer-events:none}
        .play-die:active .play-die__cube{transform:scale(.9) rotate(-8deg)}
        @keyframes dieBob{0%,100%{transform:translateY(0) rotate(-6deg)}50%{transform:translateY(-5px) rotate(4deg)}}
        @keyframes balloonIn{0%{opacity:0;transform:translateY(6px) scale(.9)}100%{opacity:1;transform:none}}
        @media (min-width:900px){.play-die{right:32px;bottom:32px}}
        @media (prefers-reduced-motion: reduce){.play-die__cube,.play-die__balloon,.play-die__roll{animation:none}}
        .arch-just{width:100%}
        .arch-row{display:flex}
        .arch-just .arch-item img,.arch-just .arch-item video{display:block;width:100%;height:100%;object-fit:cover}
        .arch-wide{display:grid;gap:10px;margin-bottom:10px}
        .arch-wide .arch-item{margin:0}
        @media (min-width:900px){.arch-wide{gap:16px;margin-bottom:16px}}
        .arch-gwrap{container-type:inline-size;--cols:2;--gap:10px}
        .arch-grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));grid-auto-rows:calc((100cqi - (var(--cols) - 1) * var(--gap)) / var(--cols) * 1.25);grid-auto-flow:row dense;gap:var(--gap)}
        .arch-item{border-radius:12px;overflow:hidden;background:rgba(127,127,127,.12);min-height:0}
        .arch-item.tall{grid-row:span 2}
        .arch-grid .arch-item img,.arch-grid .arch-item video{display:block;width:100%;height:100%;object-fit:cover}
        .arch-grid .arch-item > div{height:100%}
        .arch-wide .arch-item img,.arch-wide .arch-item video{display:block;width:100%;height:auto}
        @media (min-width:900px){.arch-gwrap{--cols:3;--gap:16px}.arch-item{border-radius:16px}}
        .ow-track img, .ow-track video { -webkit-user-drag: none; user-select: none; pointer-events: none; }
        .hero-screen{container-type:inline-size}
        .rosso-hero{--fs:clamp(48px,13cqw,104px);--row:calc(var(--fs) * 1.2);position:absolute;inset:0;overflow:hidden;transform:translateZ(0);isolation:isolate;pointer-events:none;z-index:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
        .hero-screen > .rosso-hero{position:absolute;z-index:0;bottom:-72px}
        .hero-screen > .rosso-hero .rh-bio{bottom:114px}
        .hero-screen > .rosso-hero .rh-stack,.hero-screen > .rosso-hero .rh-bar{top:calc(50% - 36px)}
        .rh-ov{position:absolute;left:-3%;width:106%;border-radius:50%;background:#e5352a;transition:top 1.15s cubic-bezier(.25,.8,.25,1),height 1.15s cubic-bezier(.25,.8,.25,1)}
        .rh-slot{position:absolute;left:0;top:0;width:60px;height:60px;perspective:400px;z-index:2}
        .rh-cube{position:relative;width:60px;height:60px;transform-style:preserve-3d}
        .rh-face{position:absolute;inset:0;border-radius:13px;background:#fff3ea;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr);padding:9px;box-shadow:inset 0 0 0 1px rgba(120,20,30,.08)}
        .rh-face i{width:10px;height:10px;border-radius:50%;background:var(--pip,#b0204a);align-self:center;justify-self:center;transition:background-color .9s}
                .rh-f1{transform:translateZ(30px)}
        .rh-f6{transform:rotateY(180deg) translateZ(30px);background:#f3ddd0}
        .rh-f3{transform:rotateY(90deg) translateZ(30px);background:#f6e4d9}
        .rh-f4{transform:rotateY(-90deg) translateZ(30px);background:#f6e4d9}
        .rh-f5{transform:rotateX(90deg) translateZ(30px);background:#fbeee5}
        .rh-f2{transform:rotateX(-90deg) translateZ(30px);background:#ecd2c4}
        .rh-shadow{position:absolute;left:0;top:0;width:68px;height:15px;border-radius:50%;background:rgba(90,6,18,.3);filter:blur(4px);z-index:1}
        .rh-stack{position:absolute;right:28px;left:16px;top:50%;transform:translateY(-50%);height:calc(var(--row) * 3);overflow:hidden;z-index:3;text-align:right}
        .rh-list{transition:transform .9s cubic-bezier(.25,.8,.25,1)}
        .rh-w{height:var(--row);line-height:var(--row);font-size:calc(var(--fs) * .62);font-weight:800;font-style:italic;color:rgba(255,243,234,.45);white-space:nowrap;transition:font-size .9s,color .9s;letter-spacing:-.02em;padding-right:4px}
        .rh-w.on{font-size:var(--fs);color:#fff3ea}
        .rh-bar{position:absolute;right:14px;top:50%;width:5px;height:calc(var(--fs) * .95);margin-top:calc(var(--fs) * -.475);border-radius:3px;z-index:3;background:#fff3ea}
        .rh-bar.tick{animation:wvtick .9s cubic-bezier(.25,.8,.25,1)}
        .rh-bio{position:absolute;bottom:42px;left:20px;right:20px;text-align:center;z-index:3}
        .rh-name{font-size:17px;font-weight:800;font-style:italic;color:#fff3ea;min-height:24px;white-space:nowrap}
        .rh-name.swap{animation:rhswap .9s cubic-bezier(.25,.8,.25,1)}
        @keyframes rhswap{0%{opacity:1;transform:translateY(0)}30%{opacity:0;transform:translateY(-6px)}31%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}
        .rh-role{margin-top:6px;font-size:11.5px;line-height:1.5;color:rgba(255,243,234,.9)}
        @media (prefers-reduced-motion: reduce){.rh-ov,.rh-list,.rh-w{transition:none}.rh-bar.tick,.rh-name.swap{animation:none}}
        .wave-layer{--fs:clamp(48px,13cqw,104px);--row:calc(var(--fs) * 1.2);position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
        .hero-screen > *:not(.wave-layer):not(.wv-spill):not(.rosso-hero){position:relative;z-index:1}
        .wv-spill{position:absolute;left:0;right:0;bottom:-32px;height:200px;pointer-events:none;z-index:1;opacity:.22;background:radial-gradient(ellipse 80% 100% at 50% 100%,var(--c,#1f2bff) 0%,transparent 72%);transform-origin:50% 100%;transform:scaleY(.85)}
        .wv-spill.go{animation:wvspill 1.6s ease-out forwards}
        @keyframes wvspill{0%{opacity:.22;transform:scaleY(.85)}25%{opacity:.85;transform:scaleY(1)}100%{opacity:.22;transform:scaleY(.85)}}
        .wv-amb{position:absolute;inset:0;pointer-events:none;opacity:0;background:radial-gradient(ellipse 95% 50% at 50% 55%,var(--c,#1f2bff) 0%,transparent 70%)}
        .wv-amb.go{animation:wvamb 1.9s ease-in-out forwards}
        @keyframes wvamb{0%{opacity:0;transform:translateY(40px)}40%{opacity:.16}100%{opacity:0;transform:translateY(-40px)}}
        .wv-base{display:none;position:absolute;left:0;right:0;bottom:0;height:160px;pointer-events:none;z-index:0;opacity:.12;background:radial-gradient(ellipse 75% 100% at 50% 100%,var(--c,#1f2bff) 0%,transparent 70%);transform-origin:50% 100%;transform:scaleY(.85)}
        .wv-base.go{animation:wvbase 1.6s ease-out forwards}
        @keyframes wvbase{0%{opacity:.12;transform:scaleY(.85)}25%{opacity:.55;transform:scaleY(1)}100%{opacity:.12;transform:scaleY(.85)}}
        .wv-el{position:absolute;left:50%;border-radius:50%;background:radial-gradient(ellipse 60% 70% at 50% 35%,#000 25%,var(--c) 55%,var(--l) 78%,var(--c) 90%,transparent 100%);filter:blur(5px);opacity:0;pointer-events:none}
        .wv-el.go{animation:wvflash 1.4s ease-in-out forwards}
        @keyframes wvflash{0%{opacity:0;transform:translateY(20px) scaleX(.85)}40%{opacity:var(--o)}100%{opacity:0;transform:translateY(-30px) scaleX(1.05)}}
        .wv-stack{position:absolute;left:28px;right:16px;top:50%;transform:translateY(-50%);height:calc(var(--row) * 3);overflow:hidden;z-index:1}
        .wv-list{transition:transform .9s cubic-bezier(.25,.8,.25,1)}
        .wv-w{height:var(--row);line-height:var(--row);font-size:calc(var(--fs) * .62);color:rgba(255,255,255,.35);white-space:nowrap;transition:font-size .9s,color .9s}
        .wv-w.on{font-size:var(--fs);color:#fff;text-shadow:0 0 16px rgba(0,0,0,.7)}
        .wv-bar{position:absolute;left:16px;top:50%;width:4px;height:calc(var(--fs) * .95);margin-top:calc(var(--fs) * -.475);border-radius:2px;z-index:2;background:var(--bar,#1f2bff);transition:background-color .9s ease}
        .wv-bar.tick{animation:wvtick .9s cubic-bezier(.25,.8,.25,1)}
        @keyframes wvtick{0%{transform:scaleY(1)}35%{transform:scaleY(.35)}100%{transform:scaleY(1)}}
        .wv-bio{position:absolute;bottom:42px;left:20px;right:20px;text-align:center;z-index:1}
        .wv-name{font-size:17px;font-weight:500;color:#fff;white-space:nowrap;min-height:24px}
        .wv-name.swap{animation:wvswap .9s cubic-bezier(.25,.8,.25,1)}
        @keyframes wvswap{0%{opacity:1;transform:translateY(0)}30%{opacity:0;transform:translateY(-6px)}31%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}
        .wv-name.glow{animation:wvglow 1.6s ease-out forwards}
        .wv-name-t{display:inline-block}
        @keyframes wvglow{0%{text-shadow:0 0 0 transparent}30%{text-shadow:0 0 14px var(--c),0 0 2px var(--c)}100%{text-shadow:0 0 0 transparent}}
        .wv-role{margin-top:6px;font-size:11.5px;line-height:1.5;color:rgba(255,255,255,.55)}
        .wv-role.glow{animation:wvroleglow 1.6s ease-out forwards}
        @keyframes wvroleglow{0%{color:rgba(255,255,255,.55)}30%{color:rgba(255,255,255,.9)}100%{color:rgba(255,255,255,.55)}}
        @media (prefers-reduced-motion: reduce){.wv-list,.wv-w,.wv-bar{transition:none}.wv-bar.tick{animation:none}.wv-spill{display:none}}

        @keyframes wordin {
          0%   { opacity: 0; transform: translateY(.4em) scale(.92); filter: blur(2px); }
          60%  { opacity: 1; filter: blur(0); }
          100% { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes handwave {
          0%, 100% { transform: rotate(0deg); }
          10%      { transform: rotate(-14deg); }
          20%      { transform: rotate(14deg); }
          30%      { transform: rotate(-12deg); }
          40%      { transform: rotate(12deg); }
          50%      { transform: rotate(-8deg); }
          60%      { transform: rotate(8deg); }
          70%      { transform: rotate(0deg); }
        }
        .portfolio-toggle:not(.is-on):hover,
        .portfolio-toggle:not(.is-on):focus-visible {
          background: color-mix(in srgb, var(--pill-accent) 12%, transparent) !important;
          transform: translateY(-1px);
        }
        .portfolio-toggle.is-on:hover,
        .portfolio-toggle.is-on:focus-visible {
          transform: translateY(-1px);
        }
        .portfolio-toggle:active { transform: translateY(0) !important; }
        .cta-btn:hover,
        .cta-btn:focus-visible,
        .cta-btn:active {
          background: linear-gradient(135deg, var(--cta-accent) 0%, var(--cta-grad-end) 100%) !important;
          color: #fff !important;
          border-color: transparent !important;
          transform: translateY(-1px);
        }
        .cta-btn:active { transform: translateY(0); }
        [role="button"]:hover > .media-slot-x,
        [role="button"]:focus-within > .media-slot-x {
          opacity: .85;
          pointer-events: auto;
        }
      `}</style>
    </div>);

}

// ─── Tweaks panel ──────────────────────────────────────────────────────────
function TweaksUI({ t, setTweak }) {
  return (
    <TweaksPanel title="Tweaks">
      <TweakSection title="Intro">
        <TweakButton label="Riguarda l'intro" onClick={() => { try { sessionStorage.removeItem("introSeen"); } catch (e) {} location.reload(); }} />
      </TweakSection>
      <TweakSection title="Accents">
        <TweakColor label="Blu accent" value={t.bluAccent}
        options={["#007AFF", "#3B82F6", "#2563EB", "#06B6D4", "#10B981"]}
        onChange={(v) => setTweak("bluAccent", v)} />
        <TweakColor label="Rosso accent" value={t.rossoAccent}
        options={["#FF4040", "#E53E3E", "#F35528", "#D9480F", "#F472B6"]}
        onChange={(v) => setTweak("rossoAccent", v)} />
      </TweakSection>
      <TweakSection title="Hero">
        <TweakSlider label="Size" min={48} max={84} step={2}
        value={t.heroSize} onChange={(v) => setTweak("heroSize", v)} />
        <TweakToggle label="Checker pattern" value={t.checkers}
        onChange={(v) => setTweak("checkers", v)} />
      </TweakSection>
      <TweakSection title="Hero">
        <TweakRadio label="Palette saluti" value={t.wavePalette}
        options={[
        { label: "Rifinita", value: "refined" },
        { label: "Originale", value: "original" }]
        }
        onChange={(v) => setTweak("wavePalette", v)} />
      </TweakSection>
      <TweakSection title="CTA style">
        <TweakRadio label="“Let's Keep in Touch”" value={t.ctaWeight}
        options={[
        { label: "Bold italic", value: "italic-bold" },
        { label: "Light", value: "light" }]
        }
        onChange={(v) => setTweak("ctaWeight", v)} />
      </TweakSection>
    </TweaksPanel>);

}

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