// delafolla — main app
// React via global, JSX via Babel.

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

// ---------- Tweak defaults (host can rewrite on disk) ----------
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "theme": "cream",
  "density": "comfortable",
  "asteriskSpeed": 12,
  "showCursorPreview": true
} /*EDITMODE-END*/;

// ---------- Asterisk (literal * glyph in Akira Expanded, blue) ----------
const Asterisk = ({ size = 40, color = "var(--blue)", spin = false, speed = 8, style = {} }) =>
<span
  aria-hidden="true"
  style={{
    fontFamily: '"Akira Expanded", sans-serif',
    fontWeight: 700,
    color,
    fontSize: typeof size === "number" ? `${size}px` : size,
    lineHeight: 0.6,
    display: "inline-block",
    verticalAlign: "middle",
    animation: spin ? `da-spin ${speed}s linear infinite` : "none",
    transformOrigin: "50% 42%",
    ...style
  }}>
*
</span>;

// Alias used by some call sites.
const Star = Asterisk;

// ---------- Live clock (Madrid time) ----------
const useClock = () => {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const t = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(t);
  }, []);
  return now;
};

const formatClock = (d) => {
  const tz = "Europe/Madrid";
  const time = new Intl.DateTimeFormat("en-GB", {
    timeZone: tz, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
  }).format(d);
  return time;
};

// ---------- Custom blue * cursor (follows mouse with smooth lerp) ----------
const CursorFollower = ({ mouse }) => {
  const ref = useRef(null);
  const posRef = useRef({ x: -1000, y: -1000 });
  const [variant, setVariant] = useState("idle"); // idle | big | hidden
  const [onBlue, setOnBlue] = useState(false);

  useEffect(() => {
    let raf = 0;
    const tick = () => {
      const p = posRef.current;
      const dx = (mouse.x - p.x) * 0.32;
      const dy = (mouse.y - p.y) * 0.32;
      p.x += dx;p.y += dy;
      if (ref.current) ref.current.style.transform = `translate(${p.x}px, ${p.y}px) translate(-50%, -50%)`;
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [mouse.x, mouse.y]);

  useEffect(() => {
    const onOver = (e) => {
      const t = e.target;
      if (!t || !t.closest) return;
      setOnBlue(!!t.closest(".about"));
      // Over the list rows the project preview already follows the cursor —
      // hide the * cursor there so they don't overlap.
      if (t.closest(".row")) setVariant("hidden");else
      if (t.closest("button, a, .chip, [data-cursor='big']")) setVariant("big");else
      setVariant("idle");
    };
    document.addEventListener("pointerover", onOver);
    return () => document.removeEventListener("pointerover", onOver);
  }, []);

  return (
    <div ref={ref} className={`cursor-dot cursor-dot--${variant}${onBlue ? " cursor-dot--white" : ""}`} aria-hidden="true" />);

};

// ---------- Animated number (lerps between values) ----------
const AnimatedNumber = ({ value }) => {
  const [display, setDisplay] = useState(value);
  const fromRef = useRef(value);
  const startRef = useRef(0);
  useEffect(() => {
    const from = display;
    const to = value;
    if (from === to) return;
    fromRef.current = from;
    startRef.current = performance.now();
    let raf = 0;
    const dur = 360;
    const easeOut = (t) => 1 - Math.pow(1 - t, 3);
    const tick = (now) => {
      const t = Math.min(1, (now - startRef.current) / dur);
      const v = Math.round(from + (to - from) * easeOut(t));
      setDisplay(v);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value]);
  return <span className="anim-num">{String(display).padStart(2, "0")}</span>;
};
const useMouse = () => {
  const [pos, setPos] = useState({ x: -1000, y: -1000 });
  useEffect(() => {
    const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener("pointermove", onMove);
    return () => window.removeEventListener("pointermove", onMove);
  }, []);
  return pos;
};

// ---------- Themes ----------
const THEMES = {
  cream: { "--bg": "#FFFFFF", "--bg-soft": "#F2F2F2", "--fg": "#0A0A0A", "--fg-soft": "#8A8A8A", "--blue": "#364BE7", "--rule": "#0A0A0A" },
  dark: { "--bg": "#0A0A0A", "--bg-soft": "#161513", "--fg": "#F1EDE6", "--fg-soft": "#857F70", "--blue": "#364BE7", "--rule": "#F1EDE6" },
  paper: { "--bg": "#FFFFFF", "--bg-soft": "#F2F2F2", "--fg": "#0A0A0A", "--fg-soft": "#8A8A8A", "--blue": "#364BE7", "--rule": "#0A0A0A" }
};

// ---------- Project row (List view) ----------
const ProjectRow = ({ p, idx, onHover, onLeave, onClick, density, lang = "es" }) => {
  const ref = useRef(null);
  const cats = (p.categories || []).map((c) => t(CAT_I18N[c] || c, lang)).join(" · ");
  return (
    <button
      ref={ref}
      className={`row row--${density}`}
      style={{ "--i": idx }}
      onPointerEnter={(e) => onHover(p, ref.current)}
      onPointerLeave={onLeave}
      onFocus={() => onHover(p, ref.current)}
      onBlur={onLeave}
      onClick={() => onClick(p)}
      aria-label={`Abrir ${p.title}`}>
      
      <span className="row__index">{String(idx + 1).padStart(2, "0")}</span>
      <span className="row__title">
        {p.title}
        {p.models && <sup className="row__models">{p.models}</sup>}
      </span>
      <span className="row__client">{p.client}</span>
      <span className="row__year">{p.year}</span>
      <span className="row__cat">{cats}</span>
      <span className="row__arrow">
        <Asterisk size={18} color="currentColor" spin speed={10} />
      </span>
    </button>);

};

// ---------- Cursor preview (image follows cursor) ----------
const CursorPreview = ({ project, mouse, enabled }) => {
  // Read the hero media from localStorage so the preview matches what the
  // user dropped in the detail view.
  const [media, setMedia] = useState(null);
  useEffect(() => {
    if (!project) {setMedia(null);return;}
    try {
      // Try thumb first (dedicated thumbnail), fall back to hero.
      const t = localStorage.getItem(`delafolla:media:p-${project.id}-thumb`);
      if (t) { setMedia(JSON.parse(t)); return; }
      const raw = localStorage.getItem(`delafolla:media:p-${project.id}-hero`);
      if (raw) { setMedia(JSON.parse(raw)); return; }
      // Fall back to baked-in defaults from PROJECT_DEFAULTS
      const d = PROJECT_DEFAULTS[project.id];
      if (d && d.thumb) setMedia({ type: "image", url: d.thumb });
      else if (d && d.hero) setMedia({ type: "image", url: d.hero });
      else if (d && d.images && d.images[0]) setMedia({ type: "image", url: d.images[0] });
      else setMedia(null);
    } catch {setMedia(null);}
  }, [project && project.id]);

  if (!enabled || !project) return null;
  const w = 320,h = 380;
  const x = Math.max(16, Math.min(window.innerWidth - w - 16, mouse.x + 28));
  const y = Math.max(16, Math.min(window.innerHeight - h - 16, mouse.y - 60));
  return (
    <div
      className="cursor-preview"
      style={{ transform: `translate(${x}px, ${y}px)`, width: w, height: h }}>
      
      <div className="cursor-preview__art" style={{ background: project.color }}>
        {media && media.type === "image" &&
        <img decoding="async" src={media.url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
        }
        {media && media.type === "video" &&
        <video src={media.url} autoPlay loop muted playsInline style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
        }
        {!media && <PlaceholderArt project={project} />}
      </div>
      <div className="cursor-preview__meta">
        <span>{project.client}</span>
        <span>{project.year}</span>
      </div>
    </div>);

};

// ---------- Standalone-mode detection ----------
// Always render media slots as fixed (no drop zones, no edit tools).
const IS_STANDALONE = true;
// Resolve via window.__resources for the standalone bundle; fall back to
// project-relative paths when running unbundled.
const RES = (id, fallback) =>
  (typeof window !== "undefined" && window.__resources && window.__resources[id]) || fallback;
const PROJECT_DEFAULTS = {
  "odyssey": {
    thumb: RES("odysseyThumb", "assets/odyssey-thumb.gif"),
    caseStudy: [
      {
        kind: "boards",
        n: "01",
        label: { es: "PROPUESTA VISUAL 01", en: "VISUAL PROPOSAL 01" },
        title: { es: "Línea épica moderna", en: "Modern epic line" },
        caption: {
          es: "Una dirección luminosa y monumental: tipografía blanca de gran peso sobre azul eléctrico, héroes a contraluz y un sistema de pósters por elemento —cielo, tierra y océano.",
          en: "A luminous, monumental direction: heavy white type over electric blue, backlit heroes and a per-element poster system —sky, land and ocean.",
        },
        items: [
          "assets/odyssey-l1-logo.png",
          "assets/odyssey-l1-warrior.png",
          { triptych: [
            { src: "assets/odyssey-l1-sky.gif", caption: { es: "El Cielo", en: "The Sky" } },
            { src: "assets/odyssey-l1-land.gif", caption: { es: "La Tierra", en: "The Land" } },
            { src: "assets/odyssey-l1-ocean.gif", caption: { es: "El Agua", en: "The Water" } },
          ] },
        ],
      },
      {
        kind: "boards",
        n: "02",
        label: { es: "PROPUESTA VISUAL 02", en: "VISUAL PROPOSAL 02" },
        title: { es: "Línea mito clásico", en: "Classical myth line" },
        caption: {
          es: "Una dirección oscura y atemporal: logotipo serif grabado en piedra, bustos de mármol enfrentados y elementos de piedra, metal e hilo como medio narrativo de toda la odisea.",
          en: "A dark, timeless direction: a serif logotype engraved in stone, facing marble busts and stone as the narrative medium of the whole odyssey.",
        },
        items: [
          "assets/odyssey-l2-type.png",
          "assets/odyssey-l2-busts.png",
          { stonePanels: [
            { src: "assets/odyssey-l2-stone-gif.gif", caption: { es: "La piedra como medio narrativo.", en: "Stone as a narrative medium." } },
            { src: "assets/odyssey-l2-coin.gif", caption: { es: "Los distintos objetos como símbolos del significado de la historia.", en: "The different objects as symbols of the story's meaning." } },
            { src: "assets/odyssey-l2-triton.gif", caption: { es: "Bestias y personajes como representación de los distintos capítulos del viaje.", en: "Beasts and characters as representations of the different chapters of the journey." } },
          ] },
        ],
      },
    ],
  },
  "olfleck-plan": {
    thumb: RES("olfleckThumb", "assets/olfleck-thumb.gif"),
    posters: [
      RES("olfleckDooh1", "assets/olfleck-dooh-01.png"),
      RES("olfleckDooh2", "assets/olfleck-dooh-02.png"),
      RES("olfleckDooh3", "assets/olfleck-dooh-03.png"),
      RES("olfleckDooh4", "assets/olfleck-dooh-04.png"),
    ],
    doohMupis: {
      n: "02",
      label: { es: "DOOH · MUPIS MUNICH", en: "DOOH · MUPIS MUNICH" },
      title: { es: "Mupis en calle", en: "Street mupis" },
      caption: {
        es: "Las piezas llevadas a mupis digitales en el mobiliario urbano de Munich.",
        en: "The pieces taken to digital mupis across Munich's street furniture.",
      },
      items: [
        RES("olfleckMupi1", "assets/olfleck-mupi-01.jpg"),
        RES("olfleckMupi2", "assets/olfleck-mupi-02.jpg"),
        RES("olfleckMupi3", "assets/olfleck-mupi-03.jpg"),
        RES("olfleckMupi4", "assets/olfleck-mupi-04.jpg"),
      ],
    },
    tramWrap: {
      n: "03",
      label: { es: "VINILADO · TRANVÍA MUNICH", en: "TRAM WRAP · MUNICH" },
      title: { es: "Tranvía vinilado", en: "Wrapped tram" },
      caption: {
        es: "La campaña envolviendo un tranvía completo de la red MVG de Munich, del diseño técnico a su circulación por la ciudad.",
        en: "The campaign wrapping a full tram on Munich's MVG network, from the technical artwork to it running through the city.",
      },
      items: [
        RES("olfleckTram1", "assets/olfleck-tram-01.png"),
        RES("olfleckTram2", "assets/olfleck-tram-02.jpg"),
        RES("olfleckTram3", "assets/olfleck-tram-03.jpg"),
        RES("olfleckTram4", "assets/olfleck-tram-04.jpg"),
        RES("olfleckTram5", "assets/olfleck-tram-05.jpg"),
      ],
    },
  },
  "love-button": {
    thumb: RES("loveButtonHero", "assets/love-button-hero.png"),
    hero:  RES("loveButtonHero", "assets/love-button-hero.png"),
    caseStudy: [
      {
        kind: "piece",
        n: "01",
        label: { es: "LA PIEZA", en: "THE PIECE" },
        title: { es: "Feliz San Valentín", en: "Happy Valentine's" },
        caption: {
          es: "La pieza gráfica de la campaña: el botón para reclinar el asiento del coche convertido en una declaración de amor por San Valentín.",
          en: "The campaign's print piece: Toyota's ISOFIX button turned into a Valentine's declaration of love.",
        },
        item: "assets/love-button-hero.png",
      },
      {
        kind: "video",
        n: "02",
        label: { es: "CASE STUDY", en: "CASE STUDY" },
        title: { es: "El caso", en: "The case" },
        caption: {
          es: "El vídeo de caso que cuenta la idea y los resultados de una campaña viral a coste cero.",
          en: "The case-study video telling the idea and results of a zero-cost viral campaign.",
        },
        embed: "https://player.vimeo.com/video/1193990607",
      },
    ],
  },
  "dia-del-padre": {
    thumb: RES("diaPadreThumb", "assets/dia-padre-thumb.png"),
    images: [
      RES("diaPadre1", "assets/dia-padre-01.png"),
      RES("diaPadre2", "assets/dia-padre-02.png"),
      RES("diaPadre3", "assets/dia-padre-03.png"),
    ],
  },
  "charms": {
    thumb: RES("breakfastThumb", "assets/breakfast-thumb.gif"),
    caseStudy: [
      {
        kind: "video",
        n: "01",
        label: { es: "EL SPOT", en: "THE SPOT" },
        title: { es: "Concepto", en: "Umbrella concept" },
        caption: {
          es: "Un spot para redes que presenta el concepto paraguas de la campaña.",
          en: "A social spot that lands the campaign's umbrella concept.",
        },
        embed: "https://player.vimeo.com/video/1196767738?h=41bc578109",
        poster: "assets/american-breakfast-cover.png",
      },
      {
        kind: "posters",
        n: "02",
        label: { es: "GRÁFICA", en: "THE POSTERS" },
        title: { es: "Posters", en: "AI-generated" },
        caption: {
          es: "Una serie de pósters generados con IA para mostrar a la familia y al producto.",
          en: "A series of AI-generated posters showing the family and the product.",
        },
        items: [
          RES("breakfastPoster1", "assets/breakfast-poster-01.png"),
          RES("breakfastPoster2", "assets/breakfast-poster-02.png"),
          RES("breakfastPoster3", "assets/breakfast-poster-03.png"),
          RES("breakfastPoster4", "assets/breakfast-poster-04.png"),
        ],
      },
      {
        kind: "guerrilla",
        n: "03",
        label: { es: "ACCIÓN DE GUERRILLA", en: "GUERRILLA ACTION" },
        title: { es: "Acción Gimnasios", en: "Vulnerable moment" },
        caption: {
          es: "Una acción de guerrilla que tentaba a quienes más se saltan el desayuno —los que van al gym por las mañanas.",
          en: "A guerrilla action tempting the people who skip breakfast the most —those who hit the gym in the mornings.",
        },
        items: [
          RES("breakfastGuerrilla1", "assets/breakfast-guerrilla-01.gif"),
          RES("breakfastGuerrilla2", "assets/breakfast-guerrilla-02.gif"),
          RES("breakfastGuerrilla3", "assets/breakfast-guerrilla-03.gif"),
          RES("breakfastGuerrilla4", "assets/breakfast-guerrilla-04.gif"),
          RES("breakfastGuerrilla5", "assets/breakfast-guerrilla-05.gif"),
          RES("breakfastGuerrilla6", "assets/breakfast-guerrilla-06.gif"),
        ],
      },
    ],
  },
  "batalla-sabor": {
    thumb: RES("batallaSaborThumb", "assets/batalla-sabor-thumb.gif"),
    caseStudy: [
      {
        kind: "video",
        n: "01",
        label: { es: "SPOT TEASER", en: "TEASER SPOT" },
        title: { es: "La batalla del sabor", en: "The flavor battle" },
        caption: {
          es: "Teaser de lanzamiento de la campaña: un duelo de chefs con estética de cartel de cine.",
          en: "Campaign launch teaser: a chefs' duel with a movie-poster aesthetic.",
        },
        embed: "https://player.vimeo.com/video/1196442840",
        poster: "assets/batalla-teaser-poster.png",
        posterHasPlay: true,
      },
      {
        kind: "digital",
        n: "02",
        label: { es: "BAJADA DIGITAL", en: "DIGITAL ROLLOUT" },
        title: { es: "Piezas RRSS", en: "Character posters" },
        caption: {
          es: "Adaptación a redes: carteles de los chefs y jueces con tratamiento de cartel rasgado.",
          en: "Social adaptation: torn-poster character pieces for the chefs and judges.",
        },
        items: [
          "assets/batalla-lineup.png",
          "assets/batalla-miriam.png",
          "assets/batalla-peter.png",
          "assets/batalla-kenza-isa.jpg",
          "assets/batalla-kenza-chia.jpg",
        ],
      },
      {
        kind: "social",
        n: "03",
        label: { es: "SPOTS SOCIAL", en: "SOCIAL SPOTS" },
        title: { es: "Batallas Chef vs Influencers", en: "Pure flavor that grips" },
        caption: {
          es: "Píldoras verticales para redes",
          en: "Vertical social cutdowns: 9:16 product shots carrying the campaign claim.",
        },
        items: [
          "assets/batalla-sabor-social-01.gif",
          "assets/batalla-sabor-social-02.gif",
          "assets/batalla-social-04.gif",
          "assets/batalla-social-05.gif",
          "assets/batalla-social-06.gif",
          "uploads/%C2%A1La%20Batalla%20Del%20Sabor%20ha%20terminado!%20%F0%9F%94%A5En%20un%20duelo%20e%CC%81pico%20%F0%9F%92%AA%20por%20el%20puro%20sabor%20que%20atrapa%2C%20%40cocina%20(1)-78268062.gif",
        ],
      },
    ],
  },
  "rumbo-zero": {
    thumb: RES("rumboZeroThumb", "assets/rumbo-zero-thumb.png"),
    storyboard: [
      { src: "assets/rumbo-zero-sb-01.png", n: "01", title: "Apertura — Cuidar el planeta",
        caption: "Tipografía cinética con máscaras de vídeo dentro de las palabras: «El mundo cada vez se preocupa más por cuidar del planeta»." },
      { src: "assets/rumbo-zero-sb-02.png", n: "02", title: "Toyota Woven City",
        caption: "Vista aérea de la ciudad prototipo con el Monte Fuji; el logo aparece sobre un fondo de curvas de nivel." },
      { src: "assets/rumbo-zero-sb-03.png", n: "03", title: "El recorrido",
        caption: "Un vehículo atraviesa el valle; el plano se fragmenta en seis franjas verticales que recomponen el paisaje." },
      { src: "assets/rumbo-zero-sb-04.png", n: "04", title: "Mario Picazo",
        caption: "Presentación del invitado en el set del podcast, con el lockup Rumbo Zero · Toyota." },
      { src: "assets/rumbo-zero-sb-05.png", n: "05", title: "Cabecera del podcast",
        caption: "Logo Rumbo Zero y el Toyota Mirai sobre césped. «Un podcast de Toyota»." },
      { src: "assets/rumbo-zero-sb-06.png", n: "06", title: "Actores del cambio",
        caption: "Split entre un planeta vivo y un aerogenerador: «Somos muchos los actores del cambio que compartimos este propósito»." },
      { src: "assets/rumbo-zero-sb-07.png", n: "07", title: "Let's Go Beyond",
        caption: "Transición de impacto sobre el cristal. «Bajo nuestro lema: Let's Go Beyond»." },
      { src: "assets/rumbo-zero-sb-08.png", n: "08", title: "Misión cero emisiones",
        caption: "El Toyota Mirai sobre césped y el dato 0%: «En Toyota también. Viajamos hacia un futuro de zero emisiones»." },
    ],
  },
  "masters-iese": {
    thumb: RES("mastersIeseThumb", "assets/masters-iese-thumb.gif"),
    paper: true,
    hero: "assets/masters-hero.png",
    sections: [
      {
        label: { es: "DOOH", en: "DOOH" },
        title: { es: "Pantallas en calle", en: "Out-of-home screens" },
        caption: {
          es: "La campaña adaptada a pantallas digitales de gran formato en Milán, Berlín, Bolonia, Hamburgo, París y Londres.",
          en: "The campaign adapted to large-format digital screens across Milan, Berlin, Bologna and Hamburg.",
        },
        columns: [
          [
            { src: "assets/masters-dooh-worldwide.png", label: "Milano · Worldwide views" },
            { src: "assets/masters-dooh-degree.png", label: "Our degree" },
            { src: "assets/masters-dooh-wallstreet.gif", label: "Wall Street" },
          ],
          [
            { src: "assets/masters-dooh-suit.png", label: "Milano · New suit" },
            { src: "assets/masters-dooh-excel.png", label: "Berlin · Excel is a verb" },
            { src: "assets/masters-dooh-mindbusiness.png", label: "Mind your own business" },
          ],
          [
            { src: "assets/masters-dooh-network.png", label: "Bologna · Network" },
            { src: "assets/masters-dooh-values.jpg", label: "Out of values" },
          ],
        ],
      },
      {
        label: { es: "Banners", en: "Banners" },
        title: { es: "Display digital", en: "Digital display" },
        caption: {
          es: "Kit completo de banners animados para medios pagados, declinando cada concepto a los distintos formatos.",
          en: "A full kit of animated banners for paid media, declining each concept across the different formats.",
        },
        columns: [
          [
            { src: "assets/masters-banner-v1.png", label: "Starships" },
            { src: "assets/masters-banner-01.png", label: "Worldwide views" },
            { src: "assets/masters-banner-04.png", label: "Ta-Da!" },
            { src: "assets/masters-banner-07.jpg", label: "Happiness" },
          ],
          [
            { src: "assets/masters-banner-02.png", label: "New suit" },
            { src: "assets/masters-banner-05.png", label: "Our degree" },
            { src: "assets/masters-banner-08.jpg", label: "Heartquarters" },
            { src: "assets/masters-banner-10.png", label: "Boss's boss" },
            { src: "assets/masters-banner-11.png", label: "Risk-free" },
          ],
          [
            { src: "assets/masters-banner-v2.png", label: "Unicorn" },
            { src: "assets/masters-banner-03.png", label: "Find you" },
            { src: "assets/masters-banner-06.png", label: "Network" },
            { src: "assets/masters-banner-09.jpg", label: "Out of values" },
          ],
        ],
      },
    ],
  },
  "diccionario-moderno": {
    thumb: RES("diccionarioModernoThumb", "assets/diccionario-moderno-thumb.png"),
    embed2: "https://player.vimeo.com/video/1196616452",
    embed2Poster: "assets/diccionario-poster.png",
    caseStudy: [
      {
        kind: "video",
        n: "01",
        label: { es: "CASE STUDY", en: "CASE STUDY" },
        title: { es: "El caso", en: "The case" },
        caption: {
          es: "El vídeo de caso: el reto de hablarle a la gen Z sin dejar de gustar a todos los públicos.",
          en: "The case-study video: the challenge of speaking to Gen Z without losing the wider audience.",
        },
        embed: "https://player.vimeo.com/video/938127851",
      },
      {
        kind: "video",
        n: "02",
        label: { es: "EL SPOT", en: "THE SPOT" },
        title: { es: "Diccionario Moderno Español", en: "Modern Spanish Dictionary" },
        caption: {
          es: "El spot con Pablo Lluch: un diccionario de jerga moderna que traduce el menú al idioma de los jóvenes.",
          en: "The spot with Pablo Lluch: a modern-slang dictionary translating the menu into young people's language.",
        },
        embed: "https://player.vimeo.com/video/1196616452",
        poster: "assets/diccionario-poster.png",
      },
    ],
  },
  "revolucion-lujo": {
    thumb: RES("revolucionLujoThumb", "assets/revolucion-lujo-thumb.gif"),
    titleImg: RES("yamaydoTitleGif", "uploads/Grabacio%CC%81n%20de%20pantalla%202026-05-25%20a%20las%2018.56.15-3fb008f9.gif"),
    finalImg: RES("yamaydoFinal", "uploads/Grabacio%CC%81n%20de%20pantalla%202026-05-25%20a%20las%2018.50.18_1%20(1).gif"),
    moodboard: [
      { src: RES("yamaydoMood1", "assets/yamaydo-mood-01.png"), x: 8,  y: 4,  w: 18, label: "01" },
      { src: RES("yamaydoMood2", "assets/yamaydo-mood-02.png"), x: 64, y: 2,  w: 16, label: "02" },
      { src: RES("yamaydoMood3", "assets/yamaydo-mood-03.png"), x: 38, y: 22, w: 24, label: "03" },
      { src: RES("yamaydoMood4", "assets/yamaydo-mood-04.png"), x: 2,  y: 36, w: 14, label: "04" },
      { src: RES("yamaydoMood5", "assets/yamaydo-mood-05.png"), x: 74, y: 28, w: 22, label: "05" },
      { src: RES("yamaydoMood6", "assets/yamaydo-mood-06.png"), x: 22, y: 52, w: 20, label: "06" },
      { src: RES("yamaydoMood7", "assets/yamaydo-mood-07.png"), x: 56, y: 58, w: 18, label: "07" },
      { src: RES("yamaydoMood8", "assets/yamaydo-mood-08.png"), x: 8,  y: 74, w: 22, label: "08" },
      { src: RES("yamaydoMood9", "assets/yamaydo-mood-09.png"), x: 70, y: 80, w: 20, label: "09" },
    ],
  },
};
const projectDefault = (id, slot) => {
  const d = PROJECT_DEFAULTS[id];
  return d && d[slot] ? d[slot] : null;
};
// `defaultSrc` is used when localStorage is empty — lets authors ship a baked-in
// thumbnail that the user can still override by dropping their own file.
const MediaSlot = ({ id, placeholder, className, defaultSrc, defaultType = "image" }) => {
  const storeKey = `delafolla:media:${id}`;
  const [media, setMedia] = useState(null); // { type: 'image'|'video', url }
  const [over, setOver] = useState(false);
  const [err, setErr] = useState(null);
  const inputRef = useRef(null);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(storeKey);
      if (raw) setMedia(JSON.parse(raw));
      else if (defaultSrc) setMedia({ type: defaultType, url: defaultSrc, fromDefault: true });
    } catch {}
  }, [storeKey, defaultSrc]);

  const ingest = (file) => {
    if (!file) return;
    const isImg = file.type.startsWith("image/");
    const isVid = file.type.startsWith("video/");
    if (!isImg && !isVid) {setErr("Solo imagen o vídeo");return;}
    setErr(null);
    const reader = new FileReader();
    reader.onload = (e) => {
      const next = { type: isImg ? "image" : "video", url: e.target.result };
      setMedia(next);
      try {localStorage.setItem(storeKey, JSON.stringify(next));}
      catch {setErr("Archivo grande — visible solo esta sesión");}
    };
    reader.readAsDataURL(file);
  };

  const onDrop = (e) => {
    e.preventDefault();e.stopPropagation();
    setOver(false);
    const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    if (f) ingest(f);
  };
  const openPicker = (e) => {
    e && e.stopPropagation();
    inputRef.current && inputRef.current.click();
  };
  const clear = (e) => {
    e.stopPropagation();
    setMedia(defaultSrc ? { type: defaultType, url: defaultSrc, fromDefault: true } : null);
    setErr(null);
    try {localStorage.removeItem(storeKey);} catch {}
  };

  // Standalone (exported) mode → render-only, no drop zone, no tools.
  if (IS_STANDALONE) {
    const initial = defaultSrc ? { type: defaultType, url: defaultSrc } : null;
    const fixed = media || initial;
    if (!fixed) return null;
    return (
      <div className={`media-slot media-slot--filled media-slot--readonly ${className || ""}`}>
        {fixed.type === "image" && <img decoding="async" src={fixed.url} alt="" className="media-slot__media" />}
        {fixed.type === "video" && (
          <video src={fixed.url} className="media-slot__media" autoPlay loop muted playsInline />
        )}
      </div>
    );
  }

  return (
    <div
      className={`media-slot ${over ? "media-slot--over" : ""} ${media ? "media-slot--filled" : ""} ${className || ""}`}
      onDragEnter={(e) => {e.preventDefault();setOver(true);}}
      onDragOver={(e) => {e.preventDefault();setOver(true);}}
      onDragLeave={() => setOver(false)}
      onDrop={onDrop}
      onClick={!media ? openPicker : undefined}
      role="button"
      tabIndex={0}>
      
      {media && media.type === "image" &&
      <img decoding="async" src={media.url} alt="" className="media-slot__media" />
      }
      {media && media.type === "video" &&
      <video
        src={media.url}
        className="media-slot__media"
        autoPlay loop muted playsInline />

      }
      {!media &&
      <div className="media-slot__empty">
          <Star size={28} style={{ marginBottom: 4 }} />
          <span className="mono small">{placeholder || "Arrastra imagen o vídeo"}</span>
          <span className="mono small muted">o click para subir · img · mp4 · webm</span>
        </div>
      }
      {media &&
      <div className="media-slot__tools">
          <button className="media-slot__btn" onClick={openPicker}>Reemplazar</button>
          <button className="media-slot__btn media-slot__btn--ghost" onClick={clear}>Quitar</button>
        </div>
      }
      {err && <div className="media-slot__err mono small">{err}</div>}
      <input
        ref={inputRef}
        type="file"
        accept="image/*,video/*"
        style={{ display: "none" }}
        onChange={(e) => ingest(e.target.files && e.target.files[0])} />
      
    </div>);

};
// Uses project id to vary; intentionally abstract so the user can drop real imagery later.
const PlaceholderArt = ({ project, big = false }) => {
  const seed = useMemo(() => {
    let h = 0;
    for (let i = 0; i < project.id.length; i++) h = h * 31 + project.id.charCodeAt(i) >>> 0;
    return h;
  }, [project.id]);
  const variant = seed % 6;

  // Each variant is a distinct compositional treatment.
  if (variant === 0) {
    return (
      <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
        <rect width="320" height="380" fill={project.color} />
        <g fill="#F1EDE6" opacity="0.94">
          <rect x="32" y="40" width="180" height="6" />
          <rect x="32" y="60" width="120" height="6" />
          <text x="32" y="200" fontFamily="'Newake', sans-serif" fontWeight="800" fontSize="120" fill="#F1EDE6" letterSpacing="-6">
            {project.title.slice(0, 1).toUpperCase()}
          </text>
          <rect x="32" y="320" width="256" height="2" />
          <text x="32" y="350" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">
            {project.client.toUpperCase()} — {project.year}
          </text>
        </g>
      </svg>);

  }
  if (variant === 1) {
    return (
      <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
        <rect width="320" height="380" fill={project.color} />
        <g>
          {Array.from({ length: 7 }).map((_, i) =>
          <rect key={i} x={32 + i * 38} y={60 + i % 3 * 18} width="28" height={260 - i % 3 * 18} fill="#F1EDE6" opacity={0.18 + i % 4 * 0.18} />
          )}
          <text x="32" y="352" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">{project.title.toUpperCase()}</text>
        </g>
      </svg>);

  }
  if (variant === 2) {
    return (
      <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
        <rect width="320" height="380" fill={project.color} />
        <circle cx="160" cy="190" r="120" fill="none" stroke="#F1EDE6" strokeWidth="2" opacity="0.6" />
        <circle cx="160" cy="190" r="80" fill="none" stroke="#F1EDE6" strokeWidth="2" opacity="0.8" />
        <circle cx="160" cy="190" r="40" fill="#F1EDE6" />
        <text x="32" y="352" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">◯ {(project.categories && project.categories[0] || "").toUpperCase()}</text>
      </svg>);

  }
  if (variant === 3) {
    return (
      <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
        <rect width="320" height="380" fill={project.color} />
        <g fill="#F1EDE6">
          <text x="160" y="220" textAnchor="middle" fontFamily="'Newake', sans-serif" fontWeight="800" fontSize="60" letterSpacing="-2">
            {project.title}
          </text>
        </g>
        <line x1="32" y1="240" x2="288" y2="240" stroke="#F1EDE6" strokeWidth="1" />
        <text x="32" y="352" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">{project.role.toUpperCase()}</text>
      </svg>);

  }
  if (variant === 4) {
    return (
      <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
        <rect width="320" height="380" fill={project.color} />
        <g transform="translate(160 190)">
          {Array.from({ length: 6 }).map((_, i) =>
          <ellipse key={i} cx="0" cy="-70" rx="34" ry="80" fill="#F1EDE6" opacity="0.95" transform={`rotate(${i * 60})`} />
          )}
        </g>
        <text x="32" y="352" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">{project.client.toUpperCase()}</text>
      </svg>);

  }
  // variant 5
  return (
    <svg viewBox="0 0 320 380" preserveAspectRatio="xMidYMid slice" className="art">
      <rect width="320" height="380" fill={project.color} />
      <g stroke="#F1EDE6" strokeWidth="1" opacity="0.7">
        {Array.from({ length: 12 }).map((_, i) =>
        <line key={i} x1="0" y1={32 * i} x2="320" y2={32 * i - 80} />
        )}
      </g>
      <text x="32" y="200" fontFamily="'Newake', sans-serif" fontWeight="800" fontSize="44" fill="#F1EDE6" letterSpacing="-1">
        {project.title}
      </text>
      <text x="32" y="230" fontFamily="system-ui, -apple-system, 'Helvetica Neue', sans-serif" fontSize="11" fill="#F1EDE6">{project.year}</text>
    </svg>);

};

// ---------- Grid (Display view) ----------
const GridCard = ({ p, onClick }) =>
<button className="card" onClick={() => onClick(p)}>
    <div className="card__art">
      <PlaceholderArt project={p} />
    </div>
    <div className="card__meta">
      <span className="card__title">{p.title}</span>
      <span className="card__cat">{p.categories.join(" · ")}</span>
    </div>
    <div className="card__sub">
      <span>{p.client}</span>
      <span>{p.year}</span>
    </div>
  </button>;


// ---------- Moodboard with scroll parallax ----------
// Renders an irregular grid of images on a (paper-coloured) canvas. Each tile
// drifts at its own speed as the user scrolls past the section, hovers scale
// the tile up, and clicking opens a full-size lightbox.
const Moodboard = ({ items, columns, sections, hero, row, video, titleImg, finalImg, paper, canvasH, embed, embedPoster, lang = "es" }) => {
  const L = (o) => (o && typeof o === "object" && !Array.isArray(o)) ? (o[lang] || o.es) : o;
  const wrapRef = useRef(null);
  const tileRefs = useRef([]);
  const colRefs = useRef([]);
  const [lightbox, setLightbox] = useState(null); // { src, label }
  const [embedPlaying, setEmbedPlaying] = useState(false);
  // Parallax speed assigned by horizontal band (column), not by index. Tiles
  // that share a column drift together so a tightly-packed column never
  // self-overlaps, while the columns slide past each other for a clear
  // moodboard parallax. Items in different bands are horizontally separated,
  // so differing speeds can't cause collisions.
  const speeds = useMemo(() => (items || []).map((it) => {
    const x = it.x;
    if (x < 30) return 0.20;   // left column — fastest
    if (x < 55) return 0.07;   // middle column — slowest
    return 0.14;               // right column — medium
  }), [items]);
  // Per-column parallax speeds for the clean grid layout. Whole columns drift
  // together (never self-overlap) and slide past each other gently.
  const colSpeeds = [0.10, 0.038, 0.072];

  useEffect(() => {
    let raf = 0;
    const tick = () => {
      const wrap = wrapRef.current;
      if (wrap) {
        const rect = wrap.getBoundingClientRect();
        const center = rect.top + rect.height / 2 - window.innerHeight / 2;
        if (columns) {
          colRefs.current.forEach((el, i) => {
            if (!el) return;
            const raw = -center * (colSpeeds[i] ?? 0.06);
            const offset = Math.max(-60, Math.min(60, raw));
            el.style.setProperty("--py", `${offset}px`);
          });
        } else {
          tileRefs.current.forEach((el, i) => {
            if (!el) return;
            const raw = -center * speeds[i];
            const max = 70;
            const offset = Math.max(-max, Math.min(max, raw));
            el.style.setProperty("--py", `${offset}px`);
          });
        }
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [speeds, columns]);

  // Close lightbox on Esc
  useEffect(() => {
    if (!lightbox) return;
    const onKey = (e) => { if (e.key === "Escape") setLightbox(null); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [lightbox]);

  const openLb = (it) => setLightbox({ src: it.src, label: it.label });

  return (
    <div className={`moodboard ${paper ? "moodboard--paper" : ""}`} ref={wrapRef}>
      {embed && embedPoster && (
        <div className="moodboard__embed moodboard__embed--top">
          {embedPlaying ? (
            <iframe
              title="Vídeo"
              src={embed + (embed.includes("?") ? "&" : "?") + "autoplay=1"}
              frameBorder="0"
              allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
              referrerPolicy="strict-origin-when-cross-origin"
              allowFullScreen />
          ) : (
            <button
              type="button"
              className="moodboard__poster"
              onClick={() => setEmbedPlaying(true)}
              aria-label="Reproducir vídeo">
              <img decoding="async" src={embedPoster} alt="" />
              <span className="moodboard__play" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="34" height="34"><path d="M8 5v14l11-7z" fill="currentColor" /></svg>
              </span>
            </button>
          )}
        </div>
      )}
      {titleImg && (
        <div className="moodboard__title">
          <img decoding="async" src={titleImg} alt="" />
        </div>
      )}
      {video && (
        <div className="moodboard__video">
          <video src={video} autoPlay loop muted playsInline />
        </div>
      )}
      {sections ? (
        <div className="moodboard__sections">
          {hero && (
            <figure className="moodboard__hero" onClick={() => openLb({ src: hero, label: "Wall Street" })}>
              <img decoding="async" src={hero} alt="" />
            </figure>
          )}
          {sections.map((sec, si) => (
            <section className="moodboard__section case__sec" key={si}>
              <header className="case__head">
                <span className="case__n">{`0${si + 1}`.slice(-2)}</span>
                <div className="case__headtext">
                  <span className="mono small case__eyebrow">{L(sec.label)}</span>
                  {sec.title && <h3 className="case__title" style={{ fontFamily: "system-ui" }}>{L(sec.title)}</h3>}
                  {sec.caption && <p className="case__caption" style={{ font: "500 13px system-ui" }}>{L(sec.caption)}</p>}
                </div>
              </header>
              {sec.wide ? (
                <div className="moodboard__feature">
                  {sec.columns.flat().map((it, i) => (
                    <figure
                      key={i}
                      className="moodboard__featfig"
                      onClick={() => openLb(it)}
                      role="button"
                      tabIndex={0}
                      onKeyDown={(e) => { if (e.key === "Enter") openLb(it); }}
                      aria-label={`Open image ${it.label}`}
                    >
                      <div className="moodboard__tile-img">
                        <img decoding="async" src={it.src} alt="" />
                      </div>
                    </figure>
                  ))}
                </div>
              ) : (
              <div className="moodboard__grid">
                {sec.columns.map((col, ci) => (
                  <div className="moodboard__col" key={ci}>
                    {col.map((it, i) => (
                      <figure
                        key={i}
                        className="moodboard__gtile"
                        onClick={() => openLb(it)}
                        role="button"
                        tabIndex={0}
                        onKeyDown={(e) => { if (e.key === "Enter") openLb(it); }}
                        aria-label={`Open image ${it.label}`}
                      >
                        <div className="moodboard__tile-img">
                          <img decoding="async" src={it.src} alt="" />
                        </div>
                      </figure>
                    ))}
                  </div>
                ))}
              </div>
              )}
            </section>
          ))}
        </div>
      ) : columns ? (
        <div className="moodboard__grid">
          {columns.map((col, ci) => (
            <div className="moodboard__col" key={ci} ref={(el) => (colRefs.current[ci] = el)}>
              {col.map((it, i) => (
                <figure
                  key={i}
                  className="moodboard__gtile"
                  onClick={() => openLb(it)}
                  role="button"
                  tabIndex={0}
                  onKeyDown={(e) => { if (e.key === "Enter") openLb(it); }}
                  aria-label={`Open image ${it.label}`}
                >
                  <div className="moodboard__tile-img">
                    <img decoding="async" src={it.src} alt="" />
                  </div>
                  <figcaption className="moodboard__cap">({it.label})</figcaption>
                </figure>
              ))}
            </div>
          ))}
        </div>
      ) : (
        <div className="moodboard__canvas" style={canvasH ? { height: `${canvasH}px` } : undefined}>
          {items.map((it, i) => (
            <figure
              key={i}
              ref={(el) => (tileRefs.current[i] = el)}
              className="moodboard__tile"
              style={{
                left: `${it.x}%`,
                top: `${it.y}%`,
                width: `${it.w}%`,
              }}
              onClick={() => openLb(it)}
              role="button"
              tabIndex={0}
              onKeyDown={(e) => { if (e.key === "Enter") openLb(it); }}
              aria-label={`Open image ${it.label}`}
            >
              <div className="moodboard__tile-img">
                <img decoding="async" src={it.src} alt="" />
              </div>
              <figcaption className="moodboard__cap">({it.label})</figcaption>
            </figure>
          ))}
        </div>
      )}
      {finalImg && (
        <div className="moodboard__final">
          <img decoding="async" src={finalImg} alt="" />
        </div>
      )}
      {embed && !embedPoster && (
        <div className="moodboard__embed">
          <iframe
            title="Vídeo"
            src={embed}
            frameBorder="0"
            allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
            referrerPolicy="strict-origin-when-cross-origin"
            allowFullScreen />
        </div>
      )}
      {lightbox && (
        <div className="lightbox" onClick={() => setLightbox(null)} role="dialog" aria-modal="true">
          <button className="lightbox__close" onClick={(e) => { e.stopPropagation(); setLightbox(null); }} aria-label="Close">×</button>
          <span className="lightbox__label mono small">({lightbox.label})</span>
          <img decoding="async" className="lightbox__img" src={lightbox.src} alt="" onClick={(e) => e.stopPropagation()} />
        </div>
      )}
    </div>
  );
};

// ---------- Project detail overlay ----------
const CaseVideo = ({ embed, poster, title, hidePlay }) => {
  const [playing, setPlaying] = useState(false);
  return (
    <div className="case__video">
      {playing ? (
        <iframe
          title={title}
          src={embed + (embed.includes("?") ? "&" : "?") + "autoplay=1"}
          frameBorder="0"
          allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
          referrerPolicy="strict-origin-when-cross-origin"
          allowFullScreen />
      ) : (
        <button className="case__videoposter" onClick={() => setPlaying(true)} aria-label={"Play " + title}>
          <img decoding="async" src={poster} alt={title} />
          {!hidePlay && (
            <span className="case__playbtn" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="34" height="34"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
            </span>
          )}
        </button>
      )}
    </div>
  );
};

const ProjectDetail = ({ project, onClose, lang = "es" }) => {
  const [posterLb, setPosterLb] = useState(null);
  const rootRef = useRef(null);
  const L = (o) => (o && typeof o === "object" && !Array.isArray(o)) ? (o[lang] || o.es) : o;
  useEffect(() => {
    const onKey = (e) => {if (e.key === "Escape") onClose();};
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {window.removeEventListener("keydown", onKey);document.body.style.overflow = "";};
  }, [onClose]);

  // Reveal/hide images with a soft fade as they enter and leave the viewport.
  // Bidirectional: fades in on the way in, fades back out once scrolled past
  // (in either direction). Scroll/rAF based so it works inside the detail's
  // own scroll container, where IntersectionObserver proved unreliable.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const reduce = window.matchMedia &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const imgs = Array.from(root.querySelectorAll("img:not(.detail__home-img)"));
    imgs.forEach((im) => im.classList.add("reveal-img"));
    if (reduce) { imgs.forEach((im) => im.classList.add("is-in")); return; }

    let ticking = false;
    const update = () => {
      ticking = false;
      const vh = window.innerHeight || root.clientHeight;
      const m = vh * 0.06; // stay fully visible until just past the edge
      for (let i = 0; i < imgs.length; i++) {
        const im = imgs[i];
        const r = im.getBoundingClientRect();
        const inView = r.bottom > -m && r.top < vh + m;
        // Only reveal once the pixels are actually decoded — with lazy loading a
        // visible-but-still-downloading image would otherwise fade an empty box
        // and then pop in. Gating on `complete` makes the fade coincide with the
        // image arriving, so lazy loads ease in instead of snapping.
        if (inView && im.complete && im.naturalWidth > 0) im.classList.add("is-in");
        else if (!inView) im.classList.remove("is-in");
      }
    };
    const onScroll = () => {
      if (!ticking) { ticking = true; requestAnimationFrame(update); }
    };
    root.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    // Lazy images collapse to 0px until they load, which shifts layout — re-run
    // the visibility check whenever one loads (or errors) so it reveals correctly.
    imgs.forEach((im) => {
      im.addEventListener("load", onScroll);
      im.addEventListener("error", onScroll);
    });
    // Double rAF so the opacity:0 base state paints once before is-in is added —
    // otherwise above-the-fold images snap in with no transition.
    const raf1 = requestAnimationFrame(() => requestAnimationFrame(update));
    const t = setTimeout(update, 400);
    return () => {
      cancelAnimationFrame(raf1);
      clearTimeout(t);
      root.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      imgs.forEach((im) => {
        im.removeEventListener("load", onScroll);
        im.removeEventListener("error", onScroll);
      });
    };
  }, [project && project.id]);

  if (!project) return null;
  return (
    <div ref={rootRef} className={`detail detail--${project.id}`} role="dialog" aria-label={project.title}>
      <button className="detail__home" onClick={onClose} aria-label={t("detail_close", lang)}>
        <img decoding="async" src={RES("mark", "assets/delafolla-mark.png")} alt="delafolla" className="detail__home-img detail__home-img--light" />
        <img decoding="async" src={RES("markInvert", "assets/delafolla-mark-invert.png")} alt="delafolla" className="detail__home-img detail__home-img--dark" />
      </button>

      <div className="detail__inner" style={{ fontFamily: "Degular" }}>
        <div className="detail__head">
          {(() => {
            const pi = PROJECT_I18N[project.id] || {};
            const idx = String(PROJECTS.findIndex((x) => x.id === project.id) + 1).padStart(2, "0");
            const cats = project.categories.map((c) => t(CAT_I18N[c] || c, lang)).join(" · ");
            const role = pi.role ? t(pi.role, lang) : project.role;
            const loc  = pi.loc  ? t(pi.loc,  lang) : project.location;
            const summary = pi.summary ? t(pi.summary, lang) : project.summary;
            const award = project.award ? L(project.award) : null;
            return (
              <>
                <span className="mono small">{idx} / {cats}</span>
                <h1 className="detail__title" style={{ fontFamily: "system-ui" }}>
                  {project.title}
                  {project.models && <sup>{project.models}</sup>}
                </h1>
                <div className="detail__meta-row">
                  <div><span className="mono small">{t("detail_client", lang)}</span><br />{project.client}</div>
                  <div><span className="mono small">{t("detail_year", lang)}</span><br />{project.year}</div>
                  <div style={{ fontFamily: "system-ui" }}><span className="mono small">{t("detail_role", lang)}</span><br />{role}</div>
                  <div><span className="mono small">{t("detail_location", lang)}</span><br />{loc}</div>
                  {award && (
                    <div><span className="mono small">{t("detail_award", lang)}</span><br /><span className="detail__award">{award}</span></div>
                  )}
                </div>
                <p className="detail__summary" style={{ font: "500 13px system-ui" }}>{summary}</p>
              </>
            );
          })()}
        </div>

        <div className="detail__gallery">
          {(PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].caseStudy) ? (
            <div className="case">
              {PROJECT_DEFAULTS[project.id].caseStudy.map((sec, si) => (
                <section className="case__sec" key={si}>
                  <header className="case__head">
                    <span className="case__n">{sec.n}</span>
                    <div className="case__headtext">
                      <span className="mono small case__eyebrow">{L(sec.label)}</span>
                      <h2 className="case__title" style={{ fontFamily: "system-ui" }}>{L(sec.title)}</h2>
                      <p className="case__caption" style={{ font: "500 13px system-ui" }}>{L(sec.caption)}</p>
                    </div>
                  </header>
                  {sec.kind === "video" && (
                    sec.poster
                      ? <CaseVideo embed={sec.embed} poster={sec.poster} title={L(sec.title)} hidePlay={sec.posterHasPlay} />
                      : (
                        <div className="case__video">
                          <iframe
                            title={L(sec.title)}
                            src={sec.embed}
                            frameBorder="0"
                            allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
                            referrerPolicy="strict-origin-when-cross-origin"
                            allowFullScreen />
                        </div>
                      )
                  )}
                  {sec.kind === "posters" && (
                    <div className="case__posters">
                      {sec.items.map((src, i) => (
                        <figure key={i} className="case__poster" onClick={() => setPosterLb(src)}>
                          <img decoding="async" src={src} alt={`${project.title} — póster ${i + 1}`} />
                        </figure>
                      ))}
                    </div>
                  )}
                  {sec.kind === "guerrilla" && (
                    <div className="case__guerrilla">
                      {sec.items.map((src, i) => (
                        <figure key={i} className="case__gcell" onClick={() => setPosterLb(src)}>
                          <img decoding="async" src={src} alt={`${project.title} — fase ${i + 1}`} />
                        </figure>
                      ))}
                    </div>
                  )}
                  {sec.kind === "digital" && (
                    <div className="case__digital">
                      {sec.items.map((src, i) => (
                        <figure key={i} className="case__dcell" onClick={() => setPosterLb(src)}>
                          <img decoding="async" src={src} alt={`${project.title} — digital ${i + 1}`} />
                        </figure>
                      ))}
                    </div>
                  )}
                  {sec.kind === "social" && (
                    <div className="case__social">
                      {sec.items.map((src, i) => (
                        <figure key={i} className="case__scell" onClick={() => setPosterLb(src)}>
                          <img decoding="async" src={src} alt={`${project.title} — social ${i + 1}`} />
                        </figure>
                      ))}
                    </div>
                  )}
                  {sec.kind === "piece" && (
                    <div className="case__piece">
                      <figure className="case__piece-fig">
                        <img decoding="async" src={sec.item} alt={`${project.title} — ${L(sec.title)}`} />
                      </figure>
                    </div>
                  )}
                  {sec.kind === "boards" && (
                    <div className="case__boards">
                      {sec.items.map((item, i) => (
                        typeof item === "object" && item.triptych ? (
                          <div key={i} className="case__triptych-gifs">
                            {item.triptych.map((g, j) => (
                              <figure key={j} className="case__triptych-gif">
                                <div className="case__triptych-media">
                                  <img decoding="async" src={typeof g === "string" ? g : g.src} alt={`${project.title} — ${L(sec.title)} ${j + 1}`} />
                                </div>
                                {typeof g === "object" && g.caption && (
                                  <figcaption className="case__triptych-cap">
                                    <span className="case__triptych-arrow" aria-hidden="true">↘</span>
                                    <span>{L(g.caption)}</span>
                                  </figcaption>
                                )}
                              </figure>
                            ))}
                          </div>
                        ) : typeof item === "object" && item.stonePanels ? (
                          <div key={i} className="case__stonewall">
                            <div className="case__stonewall-inner">
                              {item.stonePanels.map((panel, j) => (
                                <figure key={j} className="case__stonepanel">
                                  <div className="case__stonepanel-media">
                                    <img decoding="async" src={panel.src} alt="" />
                                  </div>
                                  <figcaption className="case__stonepanel-cap">
                                    <span className="case__stonepanel-arrow" aria-hidden="true">↘</span>
                                    <span>{L(panel.caption)}</span>
                                  </figcaption>
                                </figure>
                              ))}
                            </div>
                          </div>
                        ) : (
                          <figure key={i} className="case__board">
                            <img decoding="async" src={item} alt={`${project.title} — ${L(sec.title)} ${i + 1}`} />
                          </figure>
                        )
                      ))}
                    </div>
                  )}
                </section>
              ))}
            </div>
          ) : (PROJECT_DEFAULTS[project.id] && (PROJECT_DEFAULTS[project.id].moodboard || PROJECT_DEFAULTS[project.id].columns || PROJECT_DEFAULTS[project.id].sections)) ? (
            <Moodboard
              items={PROJECT_DEFAULTS[project.id].moodboard}
              columns={PROJECT_DEFAULTS[project.id].columns}
              sections={PROJECT_DEFAULTS[project.id].sections}
              lang={lang}
              hero={PROJECT_DEFAULTS[project.id].hero}
              video={project.video}
              titleImg={PROJECT_DEFAULTS[project.id].titleImg}
              finalImg={PROJECT_DEFAULTS[project.id].finalImg}
              paper={PROJECT_DEFAULTS[project.id].paper}
              canvasH={PROJECT_DEFAULTS[project.id].canvasH}
              embed={PROJECT_DEFAULTS[project.id].embed}
              embedPoster={PROJECT_DEFAULTS[project.id].embedPoster}
            />
          ) : project.video ? (
            <div className="detail__embed">
              <video src={project.video} className="detail__videofile" autoPlay loop muted playsInline controls />
            </div>
          ) : project.embed ? (
            <>
              <div className="detail__embed">
                <iframe
                  title={project.title}
                  src={project.embed}
                  frameBorder="0"
                  allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
                  referrerPolicy="strict-origin-when-cross-origin"
                  allowFullScreen />
              </div>
              {PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].embed2 && (
                <div className="detail__embed">
                  <CaseVideo
                    embed={PROJECT_DEFAULTS[project.id].embed2}
                    poster={PROJECT_DEFAULTS[project.id].embed2Poster}
                    title={`${project.title} — 2`} />
                </div>
              )}
              {PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].storyboard && (
                <div className="storyboard">
                  <header className="case__head">
                    <span className="case__n">04</span>
                    <div className="case__headtext">
                      <span className="mono small case__eyebrow">STYLEFRAMES</span>
                      <h2 className="case__title" style={{ fontFamily: "system-ui" }}>Secuencia del spot</h2>
                      <p className="case__caption" style={{ font: "500 13px system-ui" }}>Selección de styleframes clave de la pieza de tres minutos, del concepto a la animación final.</p>
                    </div>
                  </header>
                  <div className="storyboard__grid">
                    {PROJECT_DEFAULTS[project.id].storyboard.map((f, i) => (
                      <figure key={i} className="storyboard__frame" onClick={() => setPosterLb(f.src)}>
                        <div className="storyboard__img">
                          <img decoding="async" src={f.src} alt={`${project.title} — styleframe ${i + 1}`} />
                        </div>
                      </figure>
                    ))}
                  </div>
                </div>
              )}
              {PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].posters && (
                <div className="detail__posters">
                  {PROJECT_DEFAULTS[project.id].posters.map((src, i) => (
                    <figure
                      key={i}
                      className="detail__poster"
                      onClick={() => setPosterLb(src)}>
                      <img decoding="async" src={src} alt={`${project.title} — ${i + 1}`} />
                    </figure>
                  ))}
                </div>
              )}
              {PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].guerrilla && (
                <div className="detail__guerrilla">
                  {PROJECT_DEFAULTS[project.id].guerrilla.map((src, i) => (
                    <figure
                      key={i}
                      className="detail__guerrilla-cell"
                      onClick={() => setPosterLb(src)}>
                      <img decoding="async" src={src} alt={`${project.title} — guerrilla ${i + 1}`} />
                    </figure>
                  ))}
                </div>
              )}
            </>
          ) : (PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].images) ? (
            <div className="detail__triptych">
              {PROJECT_DEFAULTS[project.id].images.map((src, i) => (
                <div key={i} className="detail__triptych-cell">
                  <img decoding="async" src={src} alt={`${project.title} — ${i + 1}`} />
                </div>
              ))}
            </div>
          ) : (PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].cleanBoard) ? (
            <div className="detail__cleanboard">
              {PROJECT_DEFAULTS[project.id].cleanBoard.map((row, i) => {
                if (row.kind === "caption") {
                  return (
                    <div key={i} className="cleanboard__caption">
                      <span className="mono small">({row.label})</span>
                      <span className="cleanboard__caption-title">{row.title}</span>
                    </div>
                  );
                }
                if (row.kind === "wide") {
                  return (
                    <figure key={i} className="cleanboard__wide">
                      <img decoding="async" src={row.src} alt="" />
                    </figure>
                  );
                }
                if (row.kind === "pair") {
                  return (
                    <div key={i} className="cleanboard__pair">
                      {row.items.map((src, j) => (
                        <figure key={j} className="cleanboard__cell cleanboard__cell--banner">
                          <img decoding="async" src={src} alt="" />
                        </figure>
                      ))}
                    </div>
                  );
                }
                if (row.kind === "verticals") {
                  return (
                    <div key={i} className="cleanboard__verticals">
                      {row.items.map((src, j) => (
                        <figure key={j} className="cleanboard__cell cleanboard__cell--vertical">
                          <img decoding="async" src={src} alt="" />
                        </figure>
                      ))}
                    </div>
                  );
                }
                return null;
              })}
            </div>
          ) : (PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].posters) ? (
            <>
            <div className="detail__posters">
              {PROJECT_DEFAULTS[project.id].posters.map((src, i) => (
                <figure
                  key={i}
                  className="detail__poster"
                  onClick={() => setPosterLb(src)}>
                  <img decoding="async" src={src} alt={`${project.title} — ${i + 1}`} />
                </figure>
              ))}
            </div>
            {PROJECT_DEFAULTS[project.id].doohMupis && (() => {
              const sec = PROJECT_DEFAULTS[project.id].doohMupis;
              return (
                <section className="case__sec detail__mupis-sec">
                  <header className="case__head">
                    <span className="case__n">{sec.n}</span>
                    <div className="case__headtext">
                      <span className="mono small case__eyebrow">{L(sec.label)}</span>
                      <h2 className="case__title" style={{ fontFamily: "system-ui" }}>{L(sec.title)}</h2>
                      <p className="case__caption" style={{ font: "500 13px system-ui" }}>{L(sec.caption)}</p>
                    </div>
                  </header>
                  <div className="detail__mupis">
                    {sec.items.map((src, i) => (
                      <figure key={i} className="detail__mupi" onClick={() => setPosterLb(src)}>
                        <img decoding="async" src={src} alt={`${project.title} — mupi ${i + 1}`} />
                      </figure>
                    ))}
                  </div>
                </section>
              );
            })()}
            {PROJECT_DEFAULTS[project.id].tramWrap && (() => {
              const sec = PROJECT_DEFAULTS[project.id].tramWrap;
              return (
                <section className="case__sec detail__mupis-sec">
                  <header className="case__head">
                    <span className="case__n">{sec.n}</span>
                    <div className="case__headtext">
                      <span className="mono small case__eyebrow">{L(sec.label)}</span>
                      <h2 className="case__title" style={{ fontFamily: "system-ui" }}>{L(sec.title)}</h2>
                      <p className="case__caption" style={{ font: "500 13px system-ui" }}>{L(sec.caption)}</p>
                    </div>
                  </header>
                  <div className="detail__mupis">
                    {sec.items.map((src, i) => (
                      <figure key={i} className="detail__mupi" onClick={() => setPosterLb(src)}>
                        <img decoding="async" src={src} alt={`${project.title} — tranvía ${i + 1}`} />
                      </figure>
                    ))}
                  </div>
                </section>
              );
            })()}
            </>
          ) : (PROJECT_DEFAULTS[project.id] && PROJECT_DEFAULTS[project.id].boards) ? (
            <div className="detail__boards">
              {PROJECT_DEFAULTS[project.id].boards.map((src, i) => (
                <div key={i} className="detail__board">
                  <img decoding="async" src={src} alt={`${project.title} — ${i + 1}`} />
                </div>
              ))}
            </div>
          ) : (
            <>
              <div className="detail__hero" style={{ background: project.color }}>
                <MediaSlot
                  id={`p-${project.id}-hero`}
                  placeholder="Imagen o vídeo principal"
                  defaultSrc={projectDefault(project.id, "hero")} />
              </div>
              {project.embedBelow && (
                <div className="detail__embed">
                  <iframe
                    title={`${project.title} — vídeo`}
                    src={project.embedBelow}
                    frameBorder="0"
                    allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
                    referrerPolicy="strict-origin-when-cross-origin"
                    allowFullScreen />
                </div>
              )}
            </>
          )}
        </div>

        <div className="detail__tags">
          {project.tags.map((t) => <span key={t} className="tag">{t}</span>)}
        </div>

        <div className="detail__nav">
          <ProjectNav project={project} dir={-1} lang={lang} />
          <ProjectNav project={project} dir={+1} lang={lang} />
        </div>
      </div>
      {posterLb && (
        <div className="lightbox" onClick={() => setPosterLb(null)} role="dialog" aria-modal="true">
          <button className="lightbox__close" onClick={(e) => { e.stopPropagation(); setPosterLb(null); }} aria-label="Close">×</button>
          <img decoding="async" className="lightbox__img" src={posterLb} alt="" onClick={(e) => e.stopPropagation()} />
        </div>
      )}
    </div>);

};

const ProjectNav = ({ project, dir, lang = "es" }) => {
  const idx = PROJECTS.findIndex((x) => x.id === project.id);
  const next = PROJECTS[(idx + dir + PROJECTS.length) % PROJECTS.length];
  return (
    <button className="detail__navlink" onClick={() => window.dispatchEvent(new CustomEvent("delafolla:open", { detail: next.id }))}>
      <span className="mono small">{dir < 0 ? t("detail_prev", lang) : t("detail_next", lang)}</span>
      <span className="detail__navtitle">{next.title}</span>
      <span className="mono small muted">{next.client} · {next.year}</span>
    </button>);

};

// ---------- Asterisk physics field (interactive, collides + reacts to touch) ----------
const AsteriskField = () => {
  const canvasRef = useRef(null);
  const wrapRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = wrapRef.current;
    if (!canvas || !wrap) return;
    const ctx = canvas.getContext("2d");

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

    let W = 0, H = 0, DPR = Math.min(window.devicePixelRatio || 1, 2);
    const parts = [];
    const obstacles = [];   // text boxes the asterisks bounce off
    const pointer = { x: -9999, y: -9999, active: false };
    const TWO_PI = Math.PI * 2;
    const rand = (a, b) => a + Math.random() * (b - a);

    // Build the particle set sized to the area.
    const overlapsObstacle = (x, y, r) => {
      for (let k = 0; k < obstacles.length; k++) {
        const o = obstacles[k];
        const cx = Math.max(o.x, Math.min(x, o.x + o.w));
        const cy = Math.max(o.y, Math.min(y, o.y + o.h));
        const dx = x - cx, dy = y - cy;
        if (dx * dx + dy * dy < r * r) return true;
      }
      return false;
    };

    // Find an open spawn point clear of the text boxes (falls back after N tries).
    const openSpot = (r) => {
      for (let i = 0; i < 80; i++) {
        const x = rand(r + 4, W - r - 4);
        const y = rand(r + 4, H - r - 4);
        if (!overlapsObstacle(x, y, r)) return { x, y };
      }
      return { x: rand(r + 4, W - r - 4), y: rand(r + 4, H - r - 4) };
    };

    const seed = () => {
      parts.length = 0;
      // exactly 3 very large asterisks, all fully opaque, uniform size
      const n = 3;
      const r = Math.min(92, Math.max(60, W * 0.07));
      for (let i = 0; i < n; i++) {
        const s = openSpot(r);
        parts.push({
          x: s.x,
          y: s.y,
          vx: rand(-0.2, 0.2),
          vy: rand(-0.2, 0.2),
          r,
          angle: rand(0, TWO_PI),
          spin: rand(-0.01, 0.01),
          alpha: 1,
          stuck: 0
        });
      }
    };

    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      W = rect.width; H = rect.height;
      DPR = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(W * DPR);
      canvas.height = Math.round(H * DPR);
      canvas.style.width = W + "px";
      canvas.style.height = H + "px";
      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
      if (!parts.length) seed();
      else parts.forEach((p) => {
        p.x = Math.max(p.r, Math.min(W - p.r, p.x));
        p.y = Math.max(p.r, Math.min(H - p.r, p.y));
      });
      measureObstacles();
    };

    // Cache the text boxes (heading, bio, contact block) as solid rects in canvas space.
    const measureObstacles = () => {
      obstacles.length = 0;
      const host = wrap.parentElement;
      if (!host) return;
      const base = canvas.getBoundingClientRect();
      const pad = 7; // a little breathing room around the glyphs
      host.querySelectorAll(".about__h, .about__p, .about__block").forEach((el) => {
        const r = el.getBoundingClientRect();
        if (!r.width || !r.height) return;
        obstacles.push({
          x: r.left - base.left - pad,
          y: r.top - base.top - pad,
          w: r.width + pad * 2,
          h: r.height + pad * 2
        });
      });
    };

    // Bounce a circular asterisk off an axis-aligned text box.
    const resolveRect = (p, o) => {
      const cx = Math.max(o.x, Math.min(p.x, o.x + o.w));
      const cy = Math.max(o.y, Math.min(p.y, o.y + o.h));
      let dx = p.x - cx, dy = p.y - cy;
      let d2 = dx * dx + dy * dy;
      if (d2 > p.r * p.r) return;
      if (d2 > 0.0001) {
        const d = Math.sqrt(d2);
        const nx = dx / d, ny = dy / d;
        p.x = cx + nx * p.r;
        p.y = cy + ny * p.r;
        const vn = p.vx * nx + p.vy * ny;
        if (vn < 0) { p.vx -= 1.7 * vn * nx; p.vy -= 1.7 * vn * ny; }
      } else {
        // center is inside the box — eject along the nearest edge
        const left = p.x - o.x, right = o.x + o.w - p.x;
        const top = p.y - o.y, bottom = o.y + o.h - p.y;
        const m = Math.min(left, right, top, bottom);
        if (m === left)        { p.x = o.x - p.r;       p.vx = -Math.abs(p.vx) * 0.7; }
        else if (m === right)  { p.x = o.x + o.w + p.r;  p.vx =  Math.abs(p.vx) * 0.7; }
        else if (m === top)    { p.y = o.y - p.r;        p.vy = -Math.abs(p.vy) * 0.7; }
        else                   { p.y = o.y + o.h + p.r;  p.vy =  Math.abs(p.vy) * 0.7; }
      }
      p.spin += (Math.random() - 0.5) * 0.02;
    };

    // Draw the brand's literal "*" glyph in Akira Expanded, optically centered.
    const drawAsterisk = (p) => {
      const fs = p.r * 2.5;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.angle);
      ctx.globalAlpha = p.alpha;
      ctx.fillStyle = "#FFFFFF";
      ctx.font = `700 ${fs}px "Akira Expanded", system-ui, sans-serif`;
      ctx.textAlign = "center";
      ctx.textBaseline = "alphabetic";
      const m = ctx.measureText("*");
      const asc = m.actualBoundingBoxAscent || fs * 0.7;
      const desc = m.actualBoundingBoxDescent || 0;
      ctx.fillText("*", 0, (asc - desc) / 2);
      ctx.restore();
    };

    // Elastic resolution between two circular asterisks.
    const collide = (a, b) => {
      const dx = b.x - a.x, dy = b.y - a.y;
      const min = a.r + b.r;
      let d2 = dx * dx + dy * dy;
      if (d2 >= min * min || d2 === 0) return;
      const d = Math.sqrt(d2) || 0.0001;
      const nx = dx / d, ny = dy / d;
      const overlap = (min - d) * 0.5;
      // separate
      a.x -= nx * overlap; a.y -= ny * overlap;
      b.x += nx * overlap; b.y += ny * overlap;
      // relative velocity along normal
      const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
      const vn = rvx * nx + rvy * ny;
      if (vn > 0) return;
      const imp = -1.7 * vn / 2; // equal mass, restitution ~0.7
      const ix = imp * nx, iy = imp * ny;
      a.vx -= ix; a.vy -= iy;
      b.vx += ix; b.vy += iy;
      // spin kick for flair
      a.spin += (Math.random() - 0.5) * 0.02;
      b.spin += (Math.random() - 0.5) * 0.02;
    };

    let raf = 0;
    const step = () => {
      ctx.clearRect(0, 0, W, H);
      const FR = 0.992;       // friction
      const INF = 132;        // pointer influence radius
      for (let i = 0; i < parts.length; i++) {
        const p = parts[i];
        // pointer repulsion — "react when touched"
        if (pointer.active) {
          const dx = p.x - pointer.x, dy = p.y - pointer.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < INF * INF) {
            const d = Math.sqrt(d2) || 0.0001;
            const f = (1 - d / INF) * 1.5;
            p.vx += (dx / d) * f;
            p.vy += (dy / d) * f;
            p.spin += (Math.random() - 0.5) * 0.01;
          }
        }
        // integrate
        p.vx *= FR; p.vy *= FR;
        // gentle ambient drift so they never fully die
        if (Math.abs(p.vx) < 0.04 && Math.abs(p.vy) < 0.04) {
          p.vx += rand(-0.03, 0.03);
          p.vy += rand(-0.03, 0.03);
        }
        // clamp top speed
        const sp = Math.hypot(p.vx, p.vy);
        const MAX = 7;
        if (sp > MAX) { p.vx = p.vx / sp * MAX; p.vy = p.vy / sp * MAX; }
        p.x += p.vx; p.y += p.vy;
        p.angle += p.spin + sp * 0.004;
        p.spin *= 0.98;
        // walls
        if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx) * 0.7; }
        if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx) * 0.7; }
        if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy) * 0.7; }
        if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy) * 0.7; }
        // text boxes
        for (let k = 0; k < obstacles.length; k++) resolveRect(p, obstacles[k]);
        // anti-trap: if a big asterisk gets pinned (squeezed + barely moving), relocate it
        if (Math.hypot(p.vx, p.vy) < 0.25 && overlapsObstacle(p.x, p.y, p.r)) {
          p.stuck = (p.stuck || 0) + 1;
          if (p.stuck > 70) {
            const s = openSpot(p.r);
            p.x = s.x; p.y = s.y;
            p.vx = rand(-0.4, 0.4); p.vy = rand(-0.4, 0.4);
            p.stuck = 0;
          }
        } else {
          p.stuck = 0;
        }
      }
      // collisions
      for (let i = 0; i < parts.length; i++)
        for (let j = i + 1; j < parts.length; j++)
          collide(parts[i], parts[j]);
      // draw
      for (let i = 0; i < parts.length; i++) drawAsterisk(parts[i]);
      raf = requestAnimationFrame(step);
    };

    // pointer handling on the whole about panel (canvas itself is click-through)
    const toLocal = (e) => {
      const rect = canvas.getBoundingClientRect();
      pointer.x = e.clientX - rect.left;
      pointer.y = e.clientY - rect.top;
    };
    const onMove = (e) => { toLocal(e); pointer.active = true; };
    const onLeave = () => { pointer.active = false; pointer.x = pointer.y = -9999; };
    const onDown = (e) => {
      toLocal(e);
      pointer.active = true;
      // burst: shove everything near the touch point
      for (const p of parts) {
        const dx = p.x - pointer.x, dy = p.y - pointer.y;
        const d = Math.hypot(dx, dy) || 0.0001;
        if (d < 220) {
          const f = (1 - d / 220) * 9;
          p.vx += (dx / d) * f;
          p.vy += (dy / d) * f;
          p.spin += (Math.random() - 0.5) * 0.08;
        }
      }
    };

    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    const host = wrap.parentElement || wrap;
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerdown", onDown);
    host.addEventListener("pointerleave", onLeave);
    window.addEventListener("scroll", measureObstacles, true);

    // Re-measure once the Akira font has loaded (it reflows the heading/bio).
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => { resize(); });
    }
    const settle = setTimeout(measureObstacles, 400);

    if (reduce) {
      // static scatter, still reacts to clicks but no ambient loop
      for (let i = 0; i < parts.length; i++) { parts[i].vx = 0; parts[i].vy = 0; }
      ctx.clearRect(0, 0, W, H);
      for (let i = 0; i < parts.length; i++) drawAsterisk(parts[i]);
    } else {
      raf = requestAnimationFrame(step);
    }

    return () => {
      cancelAnimationFrame(raf);
      clearTimeout(settle);
      ro.disconnect();
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerdown", onDown);
      host.removeEventListener("pointerleave", onLeave);
      window.removeEventListener("scroll", measureObstacles, true);
    };
  }, []);

  return (
    <div className="about__field" ref={wrapRef} aria-hidden="true">
      <canvas ref={canvasRef} className="about__field-canvas"></canvas>
    </div>
  );
};

// ---------- About panel ----------
const AboutPanel = ({ lang = "es" }) => (
  <div className="about">
    <AsteriskField />
    <div className="about__col">
      <h2 className="about__h">{t("about_h", lang)}</h2>
      <p className="about__p">{t("bio1", lang)}</p>
    </div>
    <div className="about__col about__col--side">
      <div className="about__block">
        <span className="mono small">{t("about_contact", lang)}</span>
        <ul className="about__list about__list--link">
          <li><a href={`mailto:${ABOUT.contact.email}`}>{ABOUT.contact.email}</a></li>
          <li><a href={ABOUT.contact.linkedin} target="_blank" rel="noopener noreferrer">{t("linkedin_label", lang)}</a></li>
        </ul>
      </div>
    </div>
  </div>
);
// ---------- Press panel ----------
const PressPanel = () =>
<div className="press">
    {PRESS.map((p, i) =>
  <a key={i} href={p.url} className="press__row">
        <span className="press__year">{p.year}</span>
        <span className="press__outlet">{p.outlet}</span>
        <span className="press__title">{p.title}</span>
        <span className="press__arrow">↗</span>
      </a>
  )}
  </div>;


// ---------- Marquee (used in footer) ----------
const Marquee = ({ items, speed = 60 }) => {
  const content =
  <span className="marq__inner">
      {items.map((t, i) =>
    <span key={i} className="marq__item">
          <Asterisk size={14} /> {t}
        </span>
    )}
    </span>;

  return (
    <div className="marq">
      <div className="marq__track" style={{ animationDuration: `${speed}s` }}>
        {content}{content}
      </div>
    </div>);

};

// ---------- Loading screen ----------
const LoadingScreen = ({ onDone, lang = "es" }) => {
  const [pct, setPct] = useState(0);
  const onDoneRef = useRef(onDone);
  useEffect(() => {onDoneRef.current = onDone;}, [onDone]);
  useEffect(() => {
    let v = 0;
    const t = setInterval(() => {
      v += Math.random() * 14 + 6;
      if (v >= 100) {
        v = 100;
        clearInterval(t);
        setTimeout(() => onDoneRef.current && onDoneRef.current(), 480);
      }
      setPct(v);
    }, 90);
    return () => clearInterval(t);
  }, []);
  return (
    <div className="loader">
      <div className="loader__center">
        <Asterisk size={88} spin speed={1.4} color="var(--blue)" />
        <div className="loader__pct mono">{String(Math.floor(pct)).padStart(3, "0")}</div>
      </div>
      <div className="loader__edge mono small">delafolla<span>*</span> studio — {t("loader_text", lang)}</div>
    </div>);

};

// ---------- Main app ----------
const App = () => {
  const [tweaks, setTweaks] = window.useTweaks(TWEAK_DEFAULTS);
  const [view, setView] = useState("list"); // list | display | about | press
  const [filter, setFilter] = useState("Todos");
  const [hovered, setHovered] = useState(null);
  const [openId, setOpenId] = useState(null);
  const [loading, setLoading] = useState(true);
  const [lang, setLang] = useState(() => {
    try { return localStorage.getItem("delafolla:lang") || "es"; } catch { return "es"; }
  });
  const [theme, setTheme] = useState(() => {
    try { return localStorage.getItem("delafolla:theme") || "paper"; } catch { return "paper"; }
  });
  const mouse = useMouse();
  const now = useClock();

  // Persist lang
  useEffect(() => {
    try { localStorage.setItem("delafolla:lang", lang); } catch {}
    document.documentElement.lang = lang;
  }, [lang]);

  // Apply theme — local state wins, tweaks panel still works by reading from setter
  useEffect(() => {
    try { localStorage.setItem("delafolla:theme", theme); } catch {}
    const themeVars = THEMES[theme] || THEMES.cream;
    Object.entries(themeVars).forEach(([k, v]) => document.documentElement.style.setProperty(k, v));
    document.documentElement.setAttribute("data-theme", theme);
  }, [theme]);

  // Mirror tweak panel changes into local theme state
  useEffect(() => { if (tweaks.theme && tweaks.theme !== theme) setTheme(tweaks.theme); }, [tweaks.theme]);

  // Cross-component open
  useEffect(() => {
    const handler = (e) => setOpenId(e.detail);
    window.addEventListener("delafolla:open", handler);
    return () => window.removeEventListener("delafolla:open", handler);
  }, []);

  // Preload every project's hover thumbnail up front so the cursor preview
  // appears instantly on hover instead of fetching (and popping in) on demand.
  // Mirrors CursorPreview's source resolution: user-dropped media wins, then
  // the baked-in PROJECT_DEFAULTS thumb / hero / first image.
  useEffect(() => {
    const resolve = (id) => {
      try {
        const t = localStorage.getItem(`delafolla:media:p-${id}-thumb`);
        if (t) return JSON.parse(t);
        const h = localStorage.getItem(`delafolla:media:p-${id}-hero`);
        if (h) return JSON.parse(h);
      } catch {}
      const d = PROJECT_DEFAULTS[id];
      if (d && d.thumb) return { type: "image", url: d.thumb };
      if (d && d.hero) return { type: "image", url: d.hero };
      if (d && d.images && d.images[0]) return { type: "image", url: d.images[0] };
      return null;
    };
    PROJECTS.forEach((p) => {
      const m = resolve(p.id);
      if (m && m.type === "image" && m.url) {
        const im = new Image();
        im.decoding = "async";
        im.src = m.url;
      }
    });
  }, []);

  const filtered = useMemo(
    () => filter === "Todos" ? PROJECTS : PROJECTS.filter((p) => p.categories.includes(filter)),
    [filter]
  );

  const open = useCallback((p) => setOpenId(p.id), []);
  const openProject = openId ? PROJECTS.find((p) => p.id === openId) : null;

  return (
    <div className="app">
      {loading && <LoadingScreen onDone={() => setLoading(false)} lang={lang} />}

      {/* Corner monograms */}
      <CornerMonograms mouse={mouse} speed={tweaks.asteriskSpeed} />

      {/* Top bar */}
      <header className="topbar">
        <div className="topbar__left">
          <span className="brand">
            <img fetchpriority="high" decoding="async" src={RES("wordmark", "assets/delafolla-wordmark.png")} alt="delafolla" className="brand__img brand__img--light" />
            <img fetchpriority="high" decoding="async" src={RES("wordmarkWhite", "assets/delafolla-wordmark-white.png")} alt="delafolla" className="brand__img brand__img--dark" />
          </span>
          <span className="mono small muted topbar__tagline" style={{ width: "270px" }}>{t("topbar_role", lang)}
          </span>
        </div>
        <nav className="topbar__nav">
          <NavItem id="list" cur={view} set={setView} dot label={t("nav_projects", lang)} />
          <NavItem id="about" cur={view} set={setView} label={t("nav_about", lang)} />
        </nav>
      </header>

      {/* Hero strip (always visible above main) */}
      {view === "list" && <section className="hero">
          <div className="hero__filters">
            <Asterisk size={20} spin speed={8} style={{ marginRight: 8 }} />
            <span className="mono small muted">{t("filter_label", lang)}</span>
            {CATEGORIES.map((c) =>
          <button
            key={c}
            className={`chip ${filter === c ? "chip--on" : ""}`}
            onClick={() => setFilter(c)}>
            
                {c === "Todos" ? t("filter_all", lang) : t(CAT_I18N[c] || c, lang)}
                {filter === c && <Asterisk size={12} color="currentColor" />}
              </button>
          )}
            <span className="mono small muted"><AnimatedNumber value={filtered.length} /> {filtered.length === 1 ? t("filter_count_one", lang) : t("filter_count_many", lang)}</span>
          </div>
        </section>
      }

      {/* Main */}
      <main className={`main main--${view}`}>
        {view === "list" &&
        <>
          <ListView
            key={filter}
            items={filtered}
            density={tweaks.density}
            lang={lang}
            onHover={(p) => setHovered(p)}
            onLeave={() => setHovered(null)}
            onClick={open} />

          <ClientsSection />
        </>
        }
        {view === "display" &&
        <DisplayView items={filtered} onClick={open} />
        }
        {view === "about" && <AboutPanel lang={lang} />}
        {view === "press" && <PressPanel />}
      </main>

      {/* Footer */}
      <footer className="footer">
        <div className="footer__bottom mono small">
          <span>delafolla<Asterisk size={10} color="var(--blue)" /></span>
          <span className="muted">© 2026 — Fernando delafolla</span>
        </div>
      </footer>

      {/* Cursor follower (only on list view, when not loading) */}
      <CursorPreview
        project={view === "list" ? hovered : null}
        mouse={mouse}
        enabled={tweaks.showCursorPreview && !loading && !openProject} />

      {/* Custom blue * cursor — follows mouse everywhere */}
      {!loading && <CursorFollower mouse={mouse} />}
      

      {/* Detail overlay */}
      {openProject && <ProjectDetail project={openProject} lang={lang} onClose={() => setOpenId(null)} />}

      {/* Floating controls — palette + language */}
      <ControlsBar theme={theme} setTheme={setTheme} lang={lang} setLang={setLang} />

      {/* Tweaks */}
      <window.TweaksPanel title="Tweaks">
        <window.TweakSection label="Tema" />
        <window.TweakRadio
          label="Paleta"
          value={tweaks.theme}
          onChange={(v) => setTweaks("theme", v)}
          options={[
          { value: "cream", label: "Crema" },
          { value: "dark", label: "Oscuro" },
          { value: "paper", label: "Papel" }]
          } />
        
        <window.TweakRadio
          label="Densidad"
          value={tweaks.density}
          onChange={(v) => setTweaks("density", v)}
          options={[
          { value: "compact", label: "Compacto" },
          { value: "comfortable", label: "Amplio" }]
          } />
        
        <window.TweakSection label="Movimiento" />
        <window.TweakSlider
          label="Giro asterisco"
          min={1} max={40} step={1} unit="s"
          value={tweaks.asteriskSpeed}
          onChange={(v) => setTweaks("asteriskSpeed", v)} />
        
        <window.TweakToggle
          label="Preview cursor"
          value={tweaks.showCursorPreview}
          onChange={(v) => setTweaks("showCursorPreview", v)} />
        
      </window.TweaksPanel>
    </div>);

};

// ---------- Subviews ----------
const NavItem = ({ id, cur, set, label, dot, ring }) => {
  const ref = useRef(null);
  const onMove = (e) => {
    const r = ref.current.getBoundingClientRect();
    const x = e.clientX - r.left - r.width / 2;
    const y = e.clientY - r.top - r.height / 2;
    ref.current.style.transform = `translate(${x * 0.18}px, ${y * 0.18}px)`;
  };
  const reset = () => {if (ref.current) ref.current.style.transform = "translate(0,0)";};
  return (
    <button
      ref={ref}
      className={`nav ${cur === id ? "nav--on" : ""}`}
      onMouseMove={onMove}
      onMouseLeave={reset}
      onClick={() => set(id)}>
      
      {dot && <span className="nav__dot" />}
      {ring && <span className="nav__ring" />}
      <span>{label}</span>
    </button>);

};

const ListView = ({ items, density, onHover, onLeave, onClick, lang }) =>
<div className="list" data-screen-label="Índice">
    <div className="list__head mono small">
      <span>{t("col_n", lang)}</span>
      <span>{t("col_project", lang)}</span>
      <span>{t("col_client", lang)}</span>
      <span>{t("col_year", lang)}</span>
      <span>{t("col_category", lang)}</span>
      <span></span>
    </div>
    {items.map((p, i) =>
  <ProjectRow
    key={p.id}
    p={p}
    idx={i}
    density={density}
    lang={lang}
    onHover={onHover}
    onLeave={onLeave}
    onClick={onClick} />

  )}
  </div>;


const DisplayView = ({ items, onClick }) =>
<div className="grid" data-screen-label="Galería">
    {items.map((p) => <GridCard key={p.id} p={p} onClick={onClick} />)}
  </div>;


// ---------- Clients section (animated logo marquee, drag-to-fill) ----------
// Renders the 16 client slots twice for a seamless loop; pauses on hover so
// you can actually drop a logo onto a slot. Each slot persists by its id —
// the duplicate copy in the second half of the track shares the same id and
// therefore shows the same dropped image automatically.
// ---------- Clients marquee (static logos — not editable) ----------
const CLIENT_SLOT_COUNT = 11;
const ClientsSection = () => {
  const half =
  <div className="clients__strip">
      {Array.from({ length: CLIENT_SLOT_COUNT }).map((_, i) => {
        const id = `client-${String(i + 1).padStart(2, "0")}`;
        return (
          <div key={i} className="clients__logo">
            <img
              src={`assets/logo-${id}.webp`}
              alt={`Logo cliente ${i + 1}`}
              draggable="false"
              decoding="async"
              onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
              ref={(el) => { if (el && el.complete && el.naturalWidth > 0) el.classList.add("is-loaded"); }}
              className="clients__logo-img lazy-fade" />
          </div>);

      })}
    </div>;

  return (
    <section className="clients" data-screen-label="Clientes">
      <div className="clients__marq">
        <div className="clients__marq-track">
          {half}
          {half}
        </div>
      </div>
    </section>);

};



// ---------- Corner monograms with parallax ----------
const CornerMonograms = ({ mouse, speed }) => {
  const left = useRef(null);
  const right = useRef(null);
  useEffect(() => {
    const w = window.innerWidth,h = window.innerHeight;
    const dx = (mouse.x / w - 0.5) * 20;
    const dy = (mouse.y / h - 0.5) * 20;
    if (left.current) left.current.style.transform = `translate(${dx}px, ${dy}px)`;
    if (right.current) right.current.style.transform = `translate(${-dx}px, ${-dy}px)`;
  }, [mouse]);
  return (
    <>
      <div ref={left} className="corner corner--left" aria-hidden="true">
        <Star size={"clamp(280px, 38vw, 600px)"} spin speed={24} style={{ lineHeight: 0.78 }} />
      </div>
      <div ref={right} className="corner corner--right" aria-hidden="true">
        <Star size={"clamp(280px, 38vw, 600px)"} spin speed={18} style={{ lineHeight: 0.78 }} />
      </div>
    </>);

};

// ---------- Floating controls — light/dark toggle + language ----------
const ControlsBar = ({ theme, setTheme, lang, setLang }) => {
  const isDark = theme === "dark";
  const toggleTheme = () => setTheme(isDark ? "paper" : "dark");
  return (
    <div className="controls" role="toolbar" aria-label="Display controls">
      <button
        type="button"
        className={`controls__moon ${isDark ? "controls__moon--on" : ""}`}
        onClick={toggleTheme}
        aria-label={isDark ? "Modo claro / Light mode" : "Modo oscuro / Dark mode"}
        title={isDark ? "Modo claro" : "Modo oscuro"}
      >
        {/* Crescent moon — rotates a bit when in dark mode */}
        <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
          <path d="M21 13.5A9 9 0 1 1 10.5 3a7 7 0 0 0 10.5 10.5z" fill="currentColor" />
        </svg>
      </button>
      <span className="controls__sep" />
      <div className="controls__group" aria-label="Language">
        <button
          type="button"
          className={`controls__lang ${lang === "es" ? "controls__lang--on" : ""}`}
          onClick={() => setLang("es")}>ES</button>
        <button
          type="button"
          className={`controls__lang ${lang === "en" ? "controls__lang--on" : ""}`}
          onClick={() => setLang("en")}>EN</button>
      </div>
    </div>
  );
};

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