// euggn v2

const PROJECTS = [
  {
    id: '01-jecza-gallery',
    name: 'Jecza Gallery',
    images: [
      "projectImg/01-jecza-gallery/gardenOfHooks1g FB event@2x-100.jpg",
      "projectImg/01-jecza-gallery/gardenOfHooks1g IG post@2x-100.jpg",
      "projectImg/01-jecza-gallery/gardenOfHooks1g patrat@2x-100.jpg",
      "projectImg/01-jecza-gallery/gardenOfHooks1g SITE 16p9@2x-100.jpg",
      "projectImg/01-jecza-gallery/gardenOfHooks1g WAPP + IG STORY@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza Laab IG story@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza Lab D IG post.jpg",
      "projectImg/01-jecza-gallery/jecza Lab IG post copy 2.jpg",
      "projectImg/01-jecza-gallery/jecza Lab IG post.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal 3b IG story@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal 3b Poster 77x97cm@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal 3b Site 1920x1080.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal 4FB page cover@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal b  IG post copy.jpg",
      "projectImg/01-jecza-gallery/jecza moreReal b  IG post.jpg",
      "projectImg/01-jecza-gallery/jecza thereWere 4 black ig post copy 4-100.jpg",
      "projectImg/01-jecza-gallery/jecza ZoCo 6 FB 1920x1005@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza ZoCo 6 IG post 1080x1350@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza ZoCo 6 Patrat 1080x1080@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza ZoCo 6 Site 1920x1080@2x-100.jpg",
      "projectImg/01-jecza-gallery/jecza27 Artboard 20 copy 4-100.jpg",
      "projectImg/01-jecza-gallery/jecza27 Artboard 20 copy 5-100.jpg",
      "projectImg/01-jecza-gallery/jecza27 Artboard 20 copy 6-100 b.jpg",
      "projectImg/01-jecza-gallery/jecza27 Artboard 20 copy 6-100.jpg",
      "projectImg/01-jecza-gallery/triadePr2 i  Afis A2 -- 5mm bleed@2x-100.jpg",
      "projectImg/01-jecza-gallery/triadePr2 i  FB 820x360@2x-100.jpg",
      "projectImg/01-jecza-gallery/triadePr2 i  IG post 1080x1350@2x-100.jpg",
      "projectImg/01-jecza-gallery/triadePrNanca C Afis A1@2x-100.jpg",
      "projectImg/01-jecza-gallery/triadePrNanca C IG story 1080x1920@2x-100.jpg",
      "projectImg/01-jecza-gallery/zfj2 Artboard 1 copy@2x-100.jpg",
    ],
  },
  {
    id: '02-suprainfinit',
    name: 'Suprainfinit',
    images: [
      "projectImg/02-suprainfinit/00_--_marathon_call_2_poster_wide-100.jpg",
      "projectImg/02-suprainfinit/01_--_marathon-call-2.png",
      "projectImg/02-suprainfinit/01_--_seq_blue_landscapeposter_FULL_copy-100.jpg",
      "projectImg/02-suprainfinit/01b_--_seq4_Alina_Usurelu_-_poster_FULL@2x-100.jpg",
      "projectImg/02-suprainfinit/01c_--_seq4_Alina_Usurelu_-_ig_post@2x-100.jpg",
      "projectImg/02-suprainfinit/01d_--_seq4_Alina_Usurelu_-_poster@2x-100.jpg",
      "projectImg/02-suprainfinit/01e_--_seq_5_mono_ig_post-100.jpg",
      "projectImg/02-suprainfinit/01f_--_seq_5_mono_poster-100.jpg",
      "projectImg/02-suprainfinit/01g_--_seq7_specula2_ig_post-100.jpg",
      "projectImg/02-suprainfinit/01h_--_seq7_specula2_poster-100.jpg",
      "projectImg/02-suprainfinit/01i_--_seq13_specula_mov_ig_post.png",
      "projectImg/02-suprainfinit/01j_--_seq13_specula_mov_poster.png",
      "projectImg/02-suprainfinit/11_--_skin6.jpg",
      "projectImg/02-suprainfinit/13_--_skin_II_c_ig_post-100.jpg",
      "projectImg/02-suprainfinit/14_--_skinII_tururi_posterNoLogos_copy-100.jpg",
      "projectImg/02-suprainfinit/19_--_cutter_skin_preview_Artboard_1-100.jpg",
      "projectImg/02-suprainfinit/21_--_marathon_expo3d_noLogosig_post-100.jpg",
      "projectImg/02-suprainfinit/21b_--_marathon_expo3d_noLogosposter-100.jpg",
      "projectImg/02-suprainfinit/23_--_affair2c_ig_post-100.jpg",
      "projectImg/02-suprainfinit/23b_--_affair2c_posterNoLogos-100.jpg",
      "projectImg/02-suprainfinit/32_--_pj3_ig_post-100.jpg",
      "projectImg/02-suprainfinit/32_--_pj3_posterNoLogos-100.jpg",
      "projectImg/02-suprainfinit/41_--_slowburn_c_ig_post-100.jpg",
      "projectImg/02-suprainfinit/42_--_slowburn_c_posterNoLogos-100.jpg",
      "projectImg/02-suprainfinit/51_--_seq_iawna_dd_ig_post@2x-100.jpg",
      "projectImg/02-suprainfinit/51b_--_seq_iawna_dd_posterNoLogos@2x-100.jpg",
      "projectImg/02-suprainfinit/52_--_unburied_ig_post-100.jpg",
      "projectImg/02-suprainfinit/52_--_unburied_ig_story-100.jpg",
      "projectImg/02-suprainfinit/61_--_seq18_ig_post-100.jpg",
      "projectImg/02-suprainfinit/61_apparatus_18ian_ig_post-100.jpg",
      "projectImg/02-suprainfinit/71_--_seq19_ig_post-100.jpg",
      "projectImg/02-suprainfinit/72 kristin dinamo e ig post.png",
      "projectImg/02-suprainfinit/bc_10_--_becoming_d_ig_Post-100.jpg",
      "projectImg/02-suprainfinit/bc_11_--_becoming_d_Poster-100.jpg",
      "projectImg/02-suprainfinit/bc_21_--becoming_Vitaly_d_ig-100.jpg",
      "projectImg/02-suprainfinit/bc_22_--_becoming_Vitaly_d_poster-100.jpg",
      "projectImg/02-suprainfinit/bc_22b_--_becoming_walks_c_ig_slide1-100.jpg",
      "projectImg/02-suprainfinit/bc_31_--_becoming_Daria_w3_ig-100.jpg",
      "projectImg/02-suprainfinit/bc_31b_--becoming_Daria_w3_Artboard_20-100.jpg",
      "projectImg/02-suprainfinit/bc_31c_--_becoming_Daria_6_ig-100.jpg",
      "projectImg/02-suprainfinit/bc_31d_--_becoming_Daria_7_Artboard_20-100.jpg",
      "projectImg/02-suprainfinit/bc_51_--_becoming_dariaTalk_ig-100.jpg",
      "projectImg/02-suprainfinit/bc_52_--becoming_dariaTalk_poster-100.jpg",
      "projectImg/02-suprainfinit/bc_61_--_becoming_Screening_ig-100.jpg",
      "projectImg/02-suprainfinit/bc_62_--_becoming_Screening_poster-100.jpg",
      "projectImg/02-suprainfinit/bc_63_--_becoming_UAmuseums_b_ig_Post-100.jpg",
      "projectImg/02-suprainfinit/bc_64_--becoming_UAmuseums_b_Poster-100.jpg",
      "projectImg/02-suprainfinit/call CriticalVisionsII ig post-100.jpg",
      "projectImg/02-suprainfinit/call mentors 1ccc ig post-100.jpg",
      "projectImg/02-suprainfinit/call mentors 3 ig post-100.jpg",
      "projectImg/02-suprainfinit/marathon call 2 ig post-100.jpg",
      "projectImg/02-suprainfinit/marathon call 2b ig post-100.jpg",
      "projectImg/02-suprainfinit/mentors2 ig post copy-100.jpg",
      "projectImg/02-suprainfinit/mentors2 ig post-100.jpg",
      "projectImg/02-suprainfinit/oleg kaska b ig post-100.jpg",
      "projectImg/02-suprainfinit/promo supra b fb event@2x.png",
      "projectImg/02-suprainfinit/promo supra b ig post slide2@2x.png",
      "projectImg/02-suprainfinit/supradrinks bbb ig post-100.jpg",
      "projectImg/02-suprainfinit/supradrinks bbb ig story-100.jpg",
    ],
  },
  {
    id: '03-quote-unquote',
    name: 'Quote-Unquote',
    images: [
      "projectImg/03-quote-unquote/m00_--_melodiy_general_c_ig-100.jpg",
      "projectImg/03-quote-unquote/m10_--_melodiy_timisoara_Event2_Concert_HEI_ig-100.jpg",
      "projectImg/03-quote-unquote/m11_--_timisoara_Event2_Concert_HEI_poster-100.jpg",
      "projectImg/03-quote-unquote/m21_--_brasov_Event1_Toti_ig_Post-100.jpg",
      "projectImg/03-quote-unquote/m35_--_melodiy_afcn_Iasi_c_ig_Post-100.jpg",
      "projectImg/03-quote-unquote/m42_--_afcn_Cluj_ig_Slide_2-100.jpg",
      "projectImg/03-quote-unquote/quPP_tim_ig_post_SLIDE_1.png",
      "projectImg/03-quote-unquote/quPP_tim_poster.png",
      "projectImg/03-quote-unquote/qupp_walk2_ig_post_SLIDE_1.png",
      "projectImg/03-quote-unquote/qupp_walk2_poster.png",
      "projectImg/03-quote-unquote/quPP7_shortdec_ig_post.png",
      "projectImg/03-quote-unquote/quPP7_shortdec_poster.png",
      "projectImg/03-quote-unquote/z00_--_destiuent7_ig_post_SLIDE1-100.jpg",
      "projectImg/03-quote-unquote/z01_--_destiuent7_poster-100.jpg",
      "projectImg/03-quote-unquote/z02_--_destiuent7_ig_post_SLIDE2-100.jpg",
      "projectImg/03-quote-unquote/z10_--_desti8_spinelli_ig_post_SLIDE1-z100.jpg",
      "projectImg/03-quote-unquote/z11_--_desti8_spinelli_fb_event-100.jpg",
      "projectImg/03-quote-unquote/zdestiRP fb event-100.jpg",
      "projectImg/03-quote-unquote/zdestiRP ig post SLIDE1-100.jpg",
    ],
  },
  {
    id: '04-goethe-institut',
    name: 'Goethe Institut',
    images: [
      "projectImg/04-goethe-institut/01_--_open-up-4-e.jpg",
      "projectImg/04-goethe-institut/01b_--_open_up_cover_PAGE.png",
      "projectImg/04-goethe-institut/02_--_open-up-4a.jpg",
      "projectImg/04-goethe-institut/03_--_open_up_4_Artboard_1_copy_2-100.jpg",
      "projectImg/04-goethe-institut/04_--_open_up_4_Artboard_1_copy-100.jpg",
      "projectImg/04-goethe-institut/05_--_open_up_4v2c.jpg",
      "projectImg/04-goethe-institut/carton-open-up-wednesday.png",
      "projectImg/04-goethe-institut/invitatie-hcz-v2.jpg",
      "projectImg/04-goethe-institut/rooted-discussion.jpg",
      "projectImg/04-goethe-institut/xfokustalk2 Artboard 1 copy 3@2x.png",
    ],
  },
  {
    id: '05-noua',
    name: 'NOUA',
    images: [
      "projectImg/05-noua/noua_13-14_patrat.png",
      "projectImg/05-noua/noua_23jul_Artboard_1-100.jpg",
      "projectImg/05-noua/noua_30iul_d_FINAL_Artboard_1_copy_4-100.jpg",
      "projectImg/05-noua/noua_6-7_aug_D_Artboard_1_copy_4.png",
    ],
  },
  {
    id: '06-rawomania',
    name: 'RAWOMANIA',
    images: [
      "projectImg/06-rawomania/1 rawomania Socials e ig post copy 3-100.jpg",
      "projectImg/06-rawomania/carusel2_exh_COVER -- Artboard 1 copy 8@2x-100.jpg",
      "projectImg/06-rawomania/carusel3 Artboard 1 copy 4@2x-100.jpg",
      "projectImg/06-rawomania/carusel3 Artboard 1@2x-100.jpg",
      "projectImg/06-rawomania/postExh Artboard 1 copy 8@2x-100.jpg",
      "projectImg/06-rawomania/rawHNY Artboard 1@2x-100.jpg",
      "projectImg/06-rawomania/rawXmas Artboard 1@2x-100.jpg",
      "projectImg/06-rawomania/xpost4 Artboard 1@2x-100.jpg",
    ],
  },
  {
    id: '07-unibuc',
    name: 'UNIBUC',
    images: [
      "projectImg/07-unibuc/unibuc-01-home-10-1024.jpg",
      "projectImg/07-unibuc/unibuc-01-home-10-768.jpg",
      "projectImg/07-unibuc/unibuc-06-2-facultati.jpg",
      "projectImg/07-unibuc/unibuc-18-event.jpg",
    ],
  },
  {
    id: '08-event-flyers',
    name: 'Event flyers',
    images: [
      "projectImg/08-event-flyers/aethREVig SLIDE 1@2x-100.jpg",
      "projectImg/08-event-flyers/baristro 2ig post-100.jpg",
      "projectImg/08-event-flyers/dmmf25nov v2 patrat-100.jpg",
      "projectImg/08-event-flyers/doi txt 6 Artboard 1-100.jpg",
      "projectImg/08-event-flyers/emrm a insta post Artboard 1@2x-100.jpg",
      "projectImg/08-event-flyers/emrm insta post Artboard 1 copy@2x-100.jpg",
      "projectImg/08-event-flyers/maimuca1dec e ig post-100.jpg",
      "projectImg/08-event-flyers/matteo-pe-l1-2.png",
      "projectImg/08-event-flyers/mbdjs walk the night cc ig Slide 1-100.jpg",
      "projectImg/08-event-flyers/moss farai the owl b ig square.png",
      "projectImg/08-event-flyers/owl dmmf 4final SAT Artboard 2-100.jpg",
      "projectImg/08-event-flyers/owl fri11 patrat.png",
      "projectImg/08-event-flyers/pop is dead joi 21 square @2x-100.jpg",
      "projectImg/08-event-flyers/pranzo domenicale b ig square.png",
      "projectImg/08-event-flyers/sad-girls-manasia-square.jpg",
      "projectImg/08-event-flyers/sick-gems-22-jun.jpg",
      "projectImg/08-event-flyers/wsici 9 ig post-100.jpg",
      "projectImg/08-event-flyers/wsici 9 poster-100.jpg",
    ],
  },
  {
    id: '09-logofolio',
    name: 'Logofolio',
    images: [
      "projectImg/09-logofolio/345937842_214232638004900_8265869387307217909_n.jpg",
      "projectImg/09-logofolio/fashionhunt plansa Artboard 1@2x-100.jpg",
      "projectImg/09-logofolio/lc1-v8-FINAL-3.jpg",
      "projectImg/09-logofolio/lc7 fff Artboard 1 copy 2-100.jpg",
      "projectImg/09-logofolio/lc7 fff Artboard 1 copy 3-100.jpg",
      "projectImg/09-logofolio/logo-linia1.jpg",
      "projectImg/09-logofolio/noua beachbar plansa Artboard 1@2x-100.jpg",
      "projectImg/09-logofolio/story-logo-bdp.jpg",
      "projectImg/09-logofolio/unum plansa Artboard 1@2x-100.jpg",
      "projectImg/09-logofolio/xpodata_4.gif",
      "projectImg/09-logofolio/z logo_manadelucru_1_2.gif",
    ],
  },
];

const MENU_ITEMS = [
  { id: 'home',      label: 'Home' },
  { id: 'portfolio', label: 'Portfolio' },
  { id: 'about',     label: 'About' },
  { id: 'contact',   label: 'Contact' },
];

const PROJECT_DESCRIPTIONS = {
  '01-jecza-gallery':
    "Ongoing graphic design for Jecza Gallery, covering exhibition identities, posters, and social media visuals. Each project demands quick turnaround and close reading of the exhibiting artist's work — translating concept and atmosphere into something original and minimal. The constraint is the same every time: serve the art without overshadowing it.",
  '02-suprainfinit':
    "Graphic design and production for Suprainfinit Gallery, covering exhibition identities, print, social media, and on-site materials — from curatorial texts and exhibition maps to cutter/plotter work on the glass facade. A close, fast-paced collaboration with a genuinely avant-garde program that shaped how I approach art-world work.",
  '03-quote-unquote':
    "Posters, social media, event materials, and artist welcome packages for Quote-Unquote — an NGO at the crossroads of public speaking and art. AFCN-funded projects with a visual language that stays deliberately soft and abstract, resisting the pull toward the generic.",
  '04-goethe-institut':
    "Materials for Goethe Institut Bucharest across a broad range of event types and formats — social media, print, internal documents, and more. Each project brought different demands, rewarding structured thinking and precision. A partial view of a larger body of work.",
  '05-noua':
    "Ongoing graphic design for NOUA Restaurant & Bar, covering menus, event materials, social media, and brand extensions including NOUA Bucătărie Montană and NOUA Beach Bar. The print identity is my creation — a visual language that blends minimalism with updated Romanian references, modern in sensibility but rooted in local visual culture.",
  '06-rawomania':
    "Rawomania is Romania's first natural wine fair — and I built its entire visual world, from the website to every print and digital format, including original illustration. The tone is warm and celebratory, reflecting the spirit of the project. A new edition arrives in 2026.",
  '07-unibuc':
    "Website design for the University of Bucharest — a large-scale project spanning around 50 distinct layouts, including dedicated sub-sites for the university's faculties. As sole graphic designer within a broader development team, the work demanded a modular system capable of housing many different types of content and information architecture.",
  '08-event-flyers':
    "Flyers for clubs and cultural events, collected here as a single category. Every brief brings its own logic — its own music, crowd, and energy to distill into a single image. Quick, self-contained, and consistently enjoyable work.",
  '09-logofolio':
    "Logos made for various clients and contexts, selected here on personal merit. Some are live, some remained proposals — all of them stuck around for a reason.",
};
const ABOUT_TEXT = "I'm Eugen, a graphic designer working at the intersection of brand, editorial and the moving image. I care about making things make sense while having them look beautiful — systems, type, the small rules that hold an identity together. Currently based in Bucharest, available for projects worldwide. Working with galleries, corporations, horeca actors, and the occasional friend's napkin idea.";

// ──────────────────────────────────────────────────────────
// Stage — full width/height, no letterbox. Inner canvas scales to viewport.
// ──────────────────────────────────────────────────────────
function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState(() => {
    try { return window.innerWidth <= 768; } catch { return false; }
  });
  React.useEffect(() => {
    const onResize = () => setIsMobile(window.innerWidth <= 768);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  return isMobile;
}

function Stage({ children, invert = false }) {
  const bg = invert ? '#000' : '#f2f2f0';
  const fg = invert ? '#f2f2f0' : '#000';
  const [scale, setScale] = React.useState(() => {
    try { return window.innerWidth <= 768 ? 0.5 : 1; } catch { return 1; }
  });

  React.useEffect(() => {
    const onResize = () => setScale(window.innerWidth <= 768 ? 0.5 : 1);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return (
    <div style={{
      width:'100vw', height:'100vh', background: bg, color: fg,
      overflow:'hidden', position:'relative',
      fontFamily:'Inter, sans-serif',
      '--bg': bg,
      '--fg': fg,
      '--s': scale,
      '--shapeFill': invert
        ? 'color-mix(in srgb, #000 97%, #fff 3%)'
        : 'color-mix(in srgb, #f2f2f0 96%, #000 4%)',
      // Default stroke (will be overwritten by the RGB cycler effect).
      '--shapeStroke': invert
        ? 'color-mix(in srgb, #000 82%, #fff 18%)'
        : 'color-mix(in srgb, #f2f2f0 86%, #000 14%)',
    }}>
      {children}
    </div>
  );
}

function LightbulbToggle({ on, setOn, hidden, dock = 'bottom-center' }) {
  if (hidden) return null;
  const onPath =
    "M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7m2.85 11.1-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1";
  const offPath =
    "M9 21c0 .5.4 1 1 1h4c.6 0 1-.5 1-1v-1H9zm3-19C8.1 2 5 5.1 5 9c0 2.4 1.2 4.5 3 5.7V17c0 .5.4 1 1 1h6c.6 0 1-.5 1-1v-2.3c1.8-1.3 3-3.4 3-5.7 0-3.9-3.1-7-7-7";
  const baseStyle =
    dock === 'top-right'
      ? { top: 28, right: 32, left: 'auto', bottom: 'auto', transform: 'none' }
      : { left:'50%', bottom: 28, top: 'auto', right: 'auto', transform:'translateX(-50%)' };

  return (
    <button
      type="button"
      aria-label={on ? 'Turn off dark mode' : 'Turn on dark mode'}
      onClick={() => setOn(!on)}
      style={{
        position:'fixed',
        ...baseStyle,
        zIndex: 60,
        background:'transparent',
        border:'none',
        padding: 8,
        width: 36,
        height: 36,
        cursor:'pointer',
        opacity: 1,
        transition:'transform .2s',
        pointerEvents: 'auto',
        display:'flex',
        alignItems:'center',
        justifyContent:'center',
      }}
      onMouseEnter={(e) => {
        e.currentTarget.style.transform =
          dock === 'top-right' ? 'scale(1.05)' : 'translateX(-50%) scale(1.05)';
      }}
      onMouseLeave={(e) => {
        e.currentTarget.style.transform = dock === 'top-right' ? 'none' : 'translateX(-50%)';
      }}
    >
      <svg
        viewBox="0 0 24 24"
        width="22"
        height="22"
        style={{
          display:'block',
          color: 'var(--fg)',
          filter: on ? 'none' : 'none',
        }}
        aria-hidden="true"
      >
        <path
          d={on ? onPath : offPath}
          fill="currentColor"
        />
      </svg>
    </button>
  );
}

// ──────────────────────────────────────────────────────────
// Hamburger menu overlay
// ──────────────────────────────────────────────────────────
function Hamburger({ onNavigate }) {
  const [open, setOpen] = React.useState(false);
  const LINE = 3;
  const SHIFT = 8;
  return (
    <>
      <button
        onClick={() => setOpen(o => !o)}
        aria-label="menu"
        style={{
          position:'fixed', top: 28, left: 32, zIndex: 50,
          background:'transparent', border:'none', cursor:'pointer',
          padding: 8, width: 36, height: 36,
          display:'flex', flexDirection:'column', justifyContent:'center', gap: 5,
        }}
      >
        <span style={{
          display:'block', width: 22, height: LINE, background:'var(--fg)',
          transform: open ? `translateY(${SHIFT}px) rotate(45deg)` : 'none',
          transition:'transform .25s',
        }}/>
        <span style={{
          display:'block', width: 22, height: LINE, background:'var(--fg)',
          opacity: open ? 0 : 1, transition:'opacity .2s',
        }}/>
        <span style={{
          display:'block', width: 22, height: LINE, background:'var(--fg)',
          transform: open ? `translateY(-${SHIFT}px) rotate(-45deg)` : 'none',
          transition:'transform .25s',
        }}/>
      </button>

      <div style={{
        position:'fixed', inset: 0, zIndex: 40,
        background:'var(--bg)',
        opacity: open ? 1 : 0,
        pointerEvents: open ? 'auto' : 'none',
        transition:'opacity .3s',
        display:'flex', alignItems:'center', justifyContent:'center',
        flexDirection:'column', gap: 18,
        fontFamily:'Inter, sans-serif',
      }}>
        {MENU_ITEMS.map(m => (
          <div
            key={m.id}
            onClick={() => { setOpen(false); onNavigate(m.id); }}
            style={{
              fontSize: 48, lineHeight: '72px',
              textDecoration:'underline', cursor:'pointer',
              transition:'opacity .2s',
            }}
            onMouseEnter={e => e.currentTarget.style.opacity = 0.55}
            onMouseLeave={e => e.currentTarget.style.opacity = 1}
          >
            {m.label}
          </div>
        ))}
      </div>
    </>
  );
}

// ──────────────────────────────────────────────────────────
// Hero
// ──────────────────────────────────────────────────────────
function PongHero({ isMobile, obstacleRef, onPaddleHit }) {
  const hostRef = React.useRef(null);
  const rafRef = React.useRef(0);
  const ptrRef = React.useRef({ x: 0, has: false, drag: false, id: null });
  const topPaddleRef = React.useRef(null);
  const bottomPaddleRef = React.useRef(null);
  const ballRef = React.useRef(null);

  const [score, setScore] = React.useState({ top: 0, bottom: 0 });

  const gameRef = React.useRef({
    w: 1,
    h: 1,
    paddleTopX: 0,
    paddleBottomX: 0,
    ballX: 0,
    ballY: 0,
    ballVX: 0,
    ballVY: 0,
    lastT: 0,
    started: false,
    waiting: true,
    waitUntil: 0,
    serveFrom: 'bottom', // 'top' | 'bottom'
    passedAboveObstacle: false,
    inObstacle: false,
    slowedByObstacle: false,
  });

  const parkBall = React.useCallback((serveFrom = 'bottom', nowMs = performance.now()) => {
    const g = gameRef.current;
    const w = g.w || 1;
    const h = g.h || 1;
    const paddleW = isMobile ? 150 : 180;
    // Keep these in sync with the main tick() constants.
    const paddleH = isMobile ? 9 : 10;
    const ballR = isMobile ? 7 : 7;
    const topInset = isMobile ? 64 : 82; // below hamburger + top UI
    const bottomInset = isMobile ? 40 : 52;
    const topY = topInset;
    const bottomY = h - bottomInset;

    // Ensure paddle is sane (and centered by default).
    if (!Number.isFinite(g.paddleTopX) || g.paddleTopX < 0) g.paddleTopX = w / 2;
    if (!Number.isFinite(g.paddleBottomX) || g.paddleBottomX < 0) g.paddleBottomX = w / 2;

    // Park ball in a symmetric "serve" spot near the serving paddle.
    g.ballX = w / 2;
    // Park it so it slightly overlaps the serving paddle hitbox,
    // allowing the "paddle passes by it" check to succeed.
    g.ballY =
      serveFrom === 'bottom'
        ? (bottomY - paddleH / 2 - ballR)
        : (topY + paddleH / 2 + ballR);
    g.ballVX = 0;
    g.ballVY = 0;
    g.started = true;
    g.waiting = true;
    g.waitUntil = nowMs + 350; // short pause before it can be "picked up"
    g.serveFrom = serveFrom;
    g.passedAboveObstacle = false;
    g.inObstacle = false;
    g.slowedByObstacle = false;
    const ballEl = ballRef.current;
    if (ballEl) ballEl.style.background = 'var(--fg)';
  }, [isMobile]);

  const startServe = React.useCallback((serveFrom = 'bottom') => {
    const g = gameRef.current;
    const speed = isMobile ? 520 : 600;
    // Serve toward the opponent.
    const dirY = serveFrom === 'bottom' ? -1 : 1;
    const angle = (Math.random() * 0.9 - 0.45); // -0.45..0.45
    g.ballVX = speed * angle;
    g.ballVY = speed * dirY;
    g.waiting = false;
  }, [isMobile]);

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const readSize = () => {
      const r = host.getBoundingClientRect();
      const g = gameRef.current;
      g.w = Math.max(1, r.width);
      g.h = Math.max(1, r.height);
      if (!ptrRef.current.has) {
        g.paddleTopX = g.w / 2;
        g.paddleBottomX = g.w / 2;
      }
      if (!g.started) parkBall('top');
    };
    readSize();

    const onResize = () => readSize();
    window.addEventListener('resize', onResize);
    window.visualViewport?.addEventListener('resize', onResize);
    return () => {
      window.removeEventListener('resize', onResize);
      window.visualViewport?.removeEventListener('resize', onResize);
    };
  }, [parkBall]);

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const setFromClient = (clientX) => {
      const r = host.getBoundingClientRect();
      const x = clientX - r.left;
      const g = gameRef.current;
      const clamped = Math.max(0, Math.min(g.w, x));
      // Mobile and desktop both move paddles together (single control).
      g.paddleTopX = clamped;
      g.paddleBottomX = clamped;
    };

    const onPointerMove = (e) => {
      if (isMobile && !ptrRef.current.drag) return;
      if (isMobile && ptrRef.current.drag) e.preventDefault();
      setFromClient(e.clientX);
      ptrRef.current.has = true;
      ptrRef.current.x = e.clientX;
    };

    const onPointerDown = (e) => {
      if (!isMobile) return;
      const r = host.getBoundingClientRect();
      const y = e.clientY - r.top;
      const frac = y / Math.max(1, r.height);
      // Only react in top/bottom 25% zones.
      const inZone = (frac <= 0.25) || (frac >= 0.75);
      if (!inZone) return;
      e.preventDefault(); // don't start scrolling when dragging a paddle
      ptrRef.current.drag = true;
      ptrRef.current.id = e.pointerId;
      try { host.setPointerCapture(e.pointerId); } catch {}
      setFromClient(e.clientX);
      ptrRef.current.has = true;
      ptrRef.current.x = e.clientX;
    };

    const endDrag = (e) => {
      if (!isMobile) return;
      if (ptrRef.current.id !== e.pointerId) return;
      e.preventDefault();
      ptrRef.current.drag = false;
      ptrRef.current.id = null;
      try { host.releasePointerCapture(e.pointerId); } catch {}
    };

    // Use non-passive so we can prevent scrolling during paddle drags.
    host.addEventListener('pointermove', onPointerMove, { passive: false });
    host.addEventListener('pointerdown', onPointerDown, { passive: false });
    host.addEventListener('pointerup', endDrag, { passive: false });
    host.addEventListener('pointercancel', endDrag, { passive: false });
    // On desktop, keep the last paddle position when the pointer leaves.
    return () => {
      host.removeEventListener('pointermove', onPointerMove);
      host.removeEventListener('pointerdown', onPointerDown);
      host.removeEventListener('pointerup', endDrag);
      host.removeEventListener('pointercancel', endDrag);
    };
  }, [isMobile]);

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const paddleW = isMobile ? 150 : 180;
    const paddleH = isMobile ? 9 : 10;
    const ballR = isMobile ? 7 : 7;
    const topInset = isMobile ? 64 : 82; // below hamburger + top UI (tighter on mobile)
    const bottomInset = isMobile ? 40 : 52; // tighter on mobile
    const maxSpeed = isMobile ? 1050 : 1250;

    const collidePaddle = (ballX, ballY, paddleX, paddleY) => {
      const px = paddleX;
      const half = paddleW / 2;
      const left = px - half;
      const right = px + half;
      const top = paddleY - paddleH / 2;
      const bottom = paddleY + paddleH / 2;
      if (ballX + ballR < left || ballX - ballR > right) return false;
      if (ballY + ballR < top || ballY - ballR > bottom) return false;
      return true;
    };

    const tick = (t) => {
      const g = gameRef.current;
      if (!g.lastT) g.lastT = t;
      const dt = Math.min(0.03, Math.max(0.001, (t - g.lastT) / 1000));
      g.lastT = t;

      const topY = topInset;
      const bottomY = g.h - bottomInset;
      // Keep paddles in a valid, usable position even if only one
      // of them has been dragged so far (mobile uses split controls).
      if (!Number.isFinite(g.paddleTopX) || g.paddleTopX < 0 || g.paddleTopX > g.w) g.paddleTopX = (g.w || 1) / 2;
      if (!Number.isFinite(g.paddleBottomX) || g.paddleBottomX < 0 || g.paddleBottomX > g.w) g.paddleBottomX = (g.w || 1) / 2;

      // Waiting state: ball is parked until the serving paddle passes under it.
      if (g.waiting) {
        if (t >= g.waitUntil) {
          const serveY = g.serveFrom === 'bottom' ? bottomY : topY;
          const serveX = g.serveFrom === 'bottom' ? g.paddleBottomX : g.paddleTopX;
          // Safety: if we're already overlapping the serving paddle, start immediately.
          if (collidePaddle(g.ballX, g.ballY, serveX, serveY)) {
            startServe(g.serveFrom);
          }
        }

        // Render parked state.
        {
          const pxTop = g.paddleTopX || 0;
          const pxBottom = g.paddleBottomX || 0;
          const topEl = topPaddleRef.current;
          const botEl = bottomPaddleRef.current;
          const ballEl = ballRef.current;
          if (topEl) topEl.style.transform = `translate3d(${Math.round(pxTop - paddleW / 2)}px, ${Math.round(topY - paddleH / 2)}px, 0)`;
          if (botEl) botEl.style.transform = `translate3d(${Math.round(pxBottom - paddleW / 2)}px, ${Math.round(bottomY - paddleH / 2)}px, 0)`;
          if (ballEl) ballEl.style.transform = `translate3d(${Math.round((g.ballX || 0) - ballR)}px, ${Math.round((g.ballY || 0) - ballR)}px, 0)`;
        }

        rafRef.current = requestAnimationFrame(tick);
        return;
      }

      // Move ball
      const prevX = g.ballX;
      const prevY = g.ballY;
      g.ballX += g.ballVX * dt;
      g.ballY += g.ballVY * dt;

      // Side walls
      if (g.ballX - ballR < 0) { g.ballX = ballR; g.ballVX = Math.abs(g.ballVX); }
      if (g.ballX + ballR > g.w) { g.ballX = g.w - ballR; g.ballVX = -Math.abs(g.ballVX); }

      // Paddle collisions
      if (g.ballVY > 0 && collidePaddle(g.ballX, g.ballY, g.paddleBottomX, bottomY)) {
        g.ballY = bottomY - paddleH / 2 - ballR;
        g.ballVY = -Math.abs(g.ballVY);
        const dx = (g.ballX - g.paddleBottomX) / (paddleW / 2);
        const kick = (isMobile ? 220 : 260) * (g.passedAboveObstacle ? 0.45 : 1);
        g.ballVX += dx * kick;
        if (g.slowedByObstacle) {
          g.ballVX *= 2;
          g.ballVY *= 2;
          g.slowedByObstacle = false;
        }
        if (g.inObstacle || g.passedAboveObstacle) {
          g.inObstacle = false;
          g.passedAboveObstacle = false;
        }
        const ballEl = ballRef.current;
        if (ballEl) ballEl.style.background = 'var(--fg)';
        if (typeof onPaddleHit === 'function') onPaddleHit();
      } else if (g.ballVY < 0 && collidePaddle(g.ballX, g.ballY, g.paddleTopX, topY)) {
        g.ballY = topY + paddleH / 2 + ballR;
        g.ballVY = Math.abs(g.ballVY);
        const dx = (g.ballX - g.paddleTopX) / (paddleW / 2);
        const kick = (isMobile ? 220 : 260) * (g.passedAboveObstacle ? 0.45 : 1);
        g.ballVX += dx * kick;
        if (g.slowedByObstacle) {
          g.ballVX *= 2;
          g.ballVY *= 2;
          g.slowedByObstacle = false;
        }
        if (g.inObstacle || g.passedAboveObstacle) {
          g.inObstacle = false;
          g.passedAboveObstacle = false;
        }
        const ballEl = ballRef.current;
        if (ballEl) ballEl.style.background = 'var(--fg)';
        if (typeof onPaddleHit === 'function') onPaddleHit();
      }

      // Clamp speed so it doesn't explode.
      {
        const v = Math.hypot(g.ballVX, g.ballVY);
        if (v > maxSpeed) {
          const k = maxSpeed / v;
          g.ballVX *= k;
          g.ballVY *= k;
        }
      }

      // Text obstacle interaction:
      // - When entering the text region, the ball passes "under" it (no bounce),
      //   slows to 50% speed and turns red.
      // - Once it emerges below the text, it returns to black.
      // - Full speed is restored after the next paddle hit (see above).
      const obsEl = obstacleRef && obstacleRef.current;
      if (obsEl) {
        const hostRect = host.getBoundingClientRect();
        const r = obsEl.getBoundingClientRect();
        const ox = r.left - hostRect.left;
        const oy = r.top - hostRect.top;
        const ow = r.width;
        const oh = r.height;
        const left = ox;
        const right = ox + ow;
        const top = oy;
        const bottom = oy + oh;

        const hit =
          (g.ballX + ballR > left) &&
          (g.ballX - ballR < right) &&
          (g.ballY + ballR > top) &&
          (g.ballY - ballR < bottom);

        if (hit && !g.inObstacle) {
          g.inObstacle = true;
          if (!g.slowedByObstacle) {
            g.ballVX *= 0.5;
            g.ballVY *= 0.5;
            g.slowedByObstacle = true;
          }
          const ballEl = ballRef.current;
          if (ballEl) ballEl.style.background = '#ff2d2d';
        } else if (!hit && g.inObstacle) {
          // We've exited the obstacle region.
          g.inObstacle = false;
          // Only revert color once it has emerged below the text.
          if (g.ballY - ballR >= bottom && g.ballVY > 0) {
            const ballEl = ballRef.current;
            if (ballEl) ballEl.style.background = 'var(--fg)';
          }
        }
      }

      // Scoring (missed paddles)
      if (g.ballY - ballR > g.h) {
        // bottom missed -> top scores
        setScore((s) => ({ ...s, top: s.top + 1 }));
        g.lastT = 0;
        parkBall('top', t);
      } else if (g.ballY + ballR < 0) {
        // top missed -> bottom scores
        setScore((s) => ({ ...s, bottom: s.bottom + 1 }));
        g.lastT = 0;
        parkBall('bottom', t);
      }

      // Render (imperatively) so the ball/paddles actually move on screen.
      {
        const pxTop = g.paddleTopX || 0;
        const pxBottom = g.paddleBottomX || 0;
        const topEl = topPaddleRef.current;
        const botEl = bottomPaddleRef.current;
        const ballEl = ballRef.current;
        if (topEl) {
          topEl.style.transform = `translate3d(${Math.round(pxTop - paddleW / 2)}px, ${Math.round(topY - paddleH / 2)}px, 0)`;
        }
        if (botEl) {
          botEl.style.transform = `translate3d(${Math.round(pxBottom - paddleW / 2)}px, ${Math.round(bottomY - paddleH / 2)}px, 0)`;
        }
        if (ballEl) {
          ballEl.style.transform = `translate3d(${Math.round((g.ballX || 0) - ballR)}px, ${Math.round((g.ballY || 0) - ballR)}px, 0)`;
        }
      }

      rafRef.current = requestAnimationFrame(tick);
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
    };
  }, [isMobile, obstacleRef, parkBall, startServe]);

  const paddleW = isMobile ? 150 : 180;
  const paddleH = isMobile ? 9 : 10;
  const ballR = isMobile ? 7 : 7;

  return (
    <div
      ref={hostRef}
      aria-hidden
      style={{
        position: 'absolute',
        inset: 0,
        zIndex: 0,
        pointerEvents: 'auto',
        // Allow normal scroll gestures; we only block scrolling while dragging a paddle.
        touchAction: isMobile ? 'pan-y' : 'auto',
      }}
    >
      {/* Top paddle */}
      <div
        ref={topPaddleRef}
        style={{
          position: 'absolute',
          left: 0,
          top: 0,
          width: paddleW,
          height: paddleH,
          background: 'var(--fg)',
          opacity: 0.9,
          willChange: 'transform',
          transform: 'translate3d(0,0,0)',
        }}
      />

      {/* Bottom paddle */}
      <div
        ref={bottomPaddleRef}
        style={{
          position: 'absolute',
          left: 0,
          top: 0,
          width: paddleW,
          height: paddleH,
          background: 'var(--fg)',
          opacity: 0.9,
          willChange: 'transform',
          transform: 'translate3d(0,0,0)',
        }}
      />

      {/* Ball */}
      <div
        ref={ballRef}
        style={{
          position: 'absolute',
          left: 0,
          top: 0,
          width: ballR * 2,
          height: ballR * 2,
          borderRadius: 999,
          background: 'var(--fg)',
          opacity: 0.95,
          willChange: 'transform',
          transform: 'translate3d(0,0,0)',
        }}
      />

      {/* Score (bottom only) */}
      <div style={{
        position: 'absolute',
        left: 0,
        right: 0,
        bottom: isMobile ? 10 : 18,
        textAlign: 'center',
        fontSize: isMobile ? 14 : 16,
        lineHeight: isMobile ? '18px' : '22px',
        letterSpacing: '.08em',
        textTransform: 'uppercase',
        opacity: 0.75,
        pointerEvents: 'none',
      }}>
        {score.top} — {score.bottom}
      </div>
    </div>
  );
}

function Hero({ onContinue }) {
  const isMobile = useIsMobile();
  const obstacleRef = React.useRef(null);
  const ENDINGS = React.useMemo(() => ([
    'look beautiful',
    'done swiftly',
    'be perfect creative solutions',
    'look playful and enticing',
    'behave properly',
    'be harmoniously composed',
  ]), []);
  const [endingIdx, setEndingIdx] = React.useState(0);

  return (
    <div style={{
      position: isMobile ? 'sticky' : 'absolute',
      top: isMobile ? 0 : undefined,
      // Use dynamic viewport height on mobile so the game fits with browser chrome.
      height: isMobile ? '100dvh' : undefined,
      inset: isMobile ? undefined : 0,
      overflow: 'hidden',
      display:'flex', alignItems:'center', justifyContent:'center',
      fontSize: `calc(48px * var(--s))`, lineHeight: `calc(72px * var(--s))`, textAlign:'center',
      padding: '0 40px',
    }}>
      <PongHero
        isMobile={isMobile}
        obstacleRef={obstacleRef}
        onPaddleHit={() => setEndingIdx((i) => (i + 1) % ENDINGS.length)}
      />
      <div ref={obstacleRef} style={{ maxWidth: 900, position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
        Hello, it&rsquo;s Eugen
        <br/><br/>
        <span
          onClick={onContinue}
          style={{textDecoration:'underline', cursor:'pointer', transition:'opacity .2s'}}
          onMouseEnter={e => e.currentTarget.style.opacity = 0.55}
          onMouseLeave={e => e.currentTarget.style.opacity = 1}
        >
          I make things make sense while having them {ENDINGS[endingIdx] || 'look beautiful'}.
        </span>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────
// Portfolio menu
// ──────────────────────────────────────────────────────────
function Portfolio({ onOpen }) {
  const isMobile = useIsMobile();
  return (
    <div style={{
      position: isMobile ? 'sticky' : 'absolute',
      top: isMobile ? 0 : undefined,
      height: isMobile ? '100vh' : undefined,
      inset: isMobile ? undefined : 0,
      display:'flex', alignItems:'center', justifyContent:'center',
    }}>
      <div style={{
        display:'flex', flexDirection:'column', alignItems:'center',
        fontSize: `calc(48px * var(--s))`, lineHeight: `calc(72px * var(--s))`,
      }}>
        {PROJECTS.map(p => (
          <div
            key={p.id}
            onClick={() => onOpen(p.id)}
            style={{
              textDecoration:'underline', cursor:'pointer',
              transition:'opacity .2s', whiteSpace:'nowrap',
            }}
            onMouseEnter={e => e.currentTarget.style.opacity = 0.55}
            onMouseLeave={e => e.currentTarget.style.opacity = 1}
          >
            {p.name}
          </div>
        ))}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────
// Project detail — full-viewport slide interaction
// ──────────────────────────────────────────────────────────
function Detail({ projectId, onPrev, onNext, onBack }) {
  const isMobile = useIsMobile();
  const p = PROJECTS.find(x => x.id === projectId) || PROJECTS[0];
  const images = p.images || [];
  const rects = p.rects || [];
  const description = PROJECT_DESCRIPTIONS[p.id] || '';
  const BODY_FONT = isMobile ? 20 : 24;
  const BODY_LINE = isMobile ? 34 : 38;

  const wrapRef = React.useRef(null);
  const stripRef = React.useRef(null);
  const mobileScrollerRef = React.useRef(null);
  const [showBack, setShowBack] = React.useState(false);
  const dragRef = React.useRef({
    active: false,
    startX: 0,
    startScrollLeft: 0,
  });
  const DRAG_Y_ZONE = { top: 0.2, bottom: 0.8 }; // middle 60% height

  React.useEffect(() => {
    const el = stripRef.current;
    if (el) el.scrollLeft = 0;
    setShowBack(false);
  }, [projectId]);

  React.useEffect(() => {
    if (!isMobile) {
      setShowBack(false);
      return;
    }
    const el = mobileScrollerRef.current;
    if (!el) return;
    const onScroll = () => {
      const vh = window.innerHeight || 1;
      setShowBack(el.scrollTop > vh * 0.55);
    };
    onScroll();
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, [isMobile, projectId]);

  // Desktop: map vertical wheel to horizontal strip scroll (no native vertical scroll in detail).
  React.useEffect(() => {
    if (isMobile) return;
    const host = wrapRef.current;
    if (!host) return;
    const onWheel = (e) => {
      const strip = stripRef.current;
      if (!strip) return;
      const max = strip.scrollWidth - strip.clientWidth;
      if (max <= 0) return;
      const dy = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : 0;
      const dx = dy === 0 ? e.deltaX : 0;
      const delta = dy + dx;
      if (delta === 0) return;
      const prev = strip.scrollLeft;
      strip.scrollLeft = Math.max(0, Math.min(max, prev + delta));
      if (strip.scrollLeft !== prev) e.preventDefault();
    };
    host.addEventListener('wheel', onWheel, { passive: false });
    return () => host.removeEventListener('wheel', onWheel);
  }, [isMobile, projectId]);

  // Home / End / Page Up / Page Down — mobile: vertical scroll; desktop: horizontal strip.
  React.useEffect(() => {
    const onKeyDown = (e) => {
      if (!['Home', 'End', 'PageUp', 'PageDown'].includes(e.code)) return;
      const t = e.target;
      if (t) {
        const tag = t.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
        if (t.isContentEditable) return;
      }

      if (isMobile) {
        const el = mobileScrollerRef.current;
        if (!el) return;
        const max = Math.max(0, el.scrollHeight - el.clientHeight);
        const page = el.clientHeight || 1;
        e.preventDefault();
        if (e.code === 'Home') el.scrollTop = 0;
        else if (e.code === 'End') el.scrollTop = max;
        else if (e.code === 'PageDown') el.scrollTop = Math.min(max, el.scrollTop + page);
        else if (e.code === 'PageUp') el.scrollTop = Math.max(0, el.scrollTop - page);
      } else {
        const strip = stripRef.current;
        if (!strip) return;
        const max = Math.max(0, strip.scrollWidth - strip.clientWidth);
        const page = strip.clientWidth || 1;
        e.preventDefault();
        if (e.code === 'Home') strip.scrollLeft = 0;
        else if (e.code === 'End') strip.scrollLeft = max;
        else if (e.code === 'PageDown') strip.scrollLeft = Math.min(max, strip.scrollLeft + page);
        else if (e.code === 'PageUp') strip.scrollLeft = Math.max(0, strip.scrollLeft - page);
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [isMobile, projectId]);

  if (isMobile) {
    const TOP_BAR_SPACE = 96; // leave room for hamburger/title/lightbulb
    const GUTTER = 'min(827px, 86vw)';

    return (
      <div
        style={{
          position:'absolute',
          inset: 0,
          overflowY:'auto',
          WebkitOverflowScrolling:'touch',
          paddingTop: TOP_BAR_SPACE,
          paddingBottom: 72,
          background:'var(--bg)',
        }}
        ref={mobileScrollerRef}
      >
        {/* Description section (fills the viewport) */}
        <div
          style={{
            minHeight:'100vh',
            display:'flex',
            alignItems:'center',
            justifyContent:'center',
            padding: '0 24px',
          }}
        >
          <div style={{ width: GUTTER }}>
            <div
              style={{
                fontSize: `${BODY_FONT}px`,
                lineHeight: `${BODY_LINE}px`,
              }}
            >
              {description}
            </div>

            <div style={{
              marginTop: 0,
              height: '50vh',
              display:'flex',
              flexDirection:'column',
              alignItems:'center',
              justifyContent:'center',
              fontSize: `calc(24px * var(--s))`,
              width: '100%',
            }}>
              <svg
                width="24"
                height="82"
                viewBox="0 0 24 82"
                fill="none"
                style={{ display:'block' }}
                aria-hidden
              >
                <line x1="12" y1="0" x2="12" y2="78" stroke="var(--fg)" strokeWidth="1"/>
                <polyline points="2,68 12,80 22,68" fill="none" stroke="var(--fg)" strokeWidth="1"/>
              </svg>
            </div>
          </div>
        </div>

        {/* Images section (vertical) */}
        <div style={{ padding: '64px 24px 0' }}>
          <div style={{ width: GUTTER, margin: '0 auto' }}>
            {images.length > 0 ? (
              images.map((src, i) => (
                <img
                  key={src + i}
                  src={src}
                  alt=""
                  draggable={false}
                  onDragStart={(e) => e.preventDefault()}
                  style={{
                    display:'block',
                    width:'100%',
                    height:'auto',
                    marginBottom: 24,
                    objectFit:'contain',
                    userSelect:'none',
                    opacity: 1,
                    transition: 'none',
                    WebkitUserDrag: 'none',
                  }}
                />
              ))
            ) : (
              rects.map((r, i) => (
                <div
                  key={i}
                  style={{
                    width:'100%',
                    aspectRatio: `${r.w} / ${r.h}`,
                    background: r.c,
                    marginBottom: 24,
                  }}
                />
              ))
            )}
          </div>
        </div>

        {showBack && (
          <div
            style={{
              position:'fixed',
              left:'50%',
              bottom: 28,
              transform:'translateX(-50%)',
              zIndex: 80,
              pointerEvents:'auto',
            }}
          >
            <button
              type="button"
              onClick={() => onBack && onBack()}
              style={{
                background:'transparent',
                border:'1.5px solid var(--fg)',
                borderRadius: 999,
                padding: '10px 18px',
                fontSize: 18,
                fontFamily:'Inter, sans-serif',
                color:'var(--fg)',
                cursor:'pointer',
              }}
              onMouseEnter={(e) => { e.currentTarget.style.opacity = 0.55; }}
              onMouseLeave={(e) => { e.currentTarget.style.opacity = 1; }}
            >
              back
            </button>
          </div>
        )}
      </div>
    );
  }

  return (
    <div
      ref={wrapRef}
      onPointerDown={(e) => {
        const el = stripRef.current;
        const host = wrapRef.current;
        if (!el || !host) return;

        const r = host.getBoundingClientRect();
        const ny = (e.clientY - r.top) / r.height;
        if (ny < DRAG_Y_ZONE.top || ny > DRAG_Y_ZONE.bottom) return;

        dragRef.current.active = true;
        dragRef.current.startX = e.clientX;
        dragRef.current.startScrollLeft = el.scrollLeft;
        try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
      }}
      onPointerMove={(e) => {
        if (!dragRef.current.active) return;
        const el = stripRef.current;
        if (!el) return;
        const dx = e.clientX - dragRef.current.startX;
        el.scrollLeft = dragRef.current.startScrollLeft - dx;
      }}
      onPointerUp={(e) => {
        dragRef.current.active = false;
        try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
      }}
      onPointerCancel={() => { dragRef.current.active = false; }}
        style={{
          position:'absolute', inset: 0,
          overflow:'hidden', userSelect:'none',
          cursor: 'grab',
          touchAction: 'none',
        }}
    >
      {/* back (pill) */}
      <div
        style={{
          position:'fixed',
          left:'50%',
          bottom: 28,
          transform:'translateX(-50%)',
          zIndex: 80,
          pointerEvents:'auto',
        }}
      >
        <button
          type="button"
          onClick={(e) => { e.stopPropagation(); onBack && onBack(); }}
          style={{
            background:'transparent',
            border:'1.5px solid var(--fg)',
            borderRadius: 999,
            padding: '10px 18px',
            fontSize: 18,
            fontFamily:'Inter, sans-serif',
            color:'var(--fg)',
            cursor:'pointer',
          }}
          onMouseEnter={(e) => { e.currentTarget.style.opacity = 0.55; }}
          onMouseLeave={(e) => { e.currentTarget.style.opacity = 1; }}
        >
          back
        </button>
      </div>

      {/* images strip (includes description as first item) */}
      <div
        ref={stripRef}
        style={{
          position:'absolute',
          top: 220,
          bottom: 140,
          left: 0,
          right: 0,
          zIndex: 2,
          overflow: 'hidden',
        }}
      >
        <div
          style={{
            height: '100%',
            display:'flex',
            alignItems:'center',
            gap: 48,
            paddingLeft: 120,
            paddingRight: 420,
          }}
        >
          <div
            style={{
              flexShrink: 0,
              width: '33vw',
              minWidth: 320,
              fontSize: `${BODY_FONT}px`,
              lineHeight: `${BODY_LINE}px`,
              alignSelf: 'center',
            }}
          >
            {description}

            <div style={{
              marginTop: 22,
              display: 'flex',
              alignItems: 'center',
              gap: 16,
              fontSize: `calc(24px * var(--s))`,
              whiteSpace: 'nowrap',
            }}>
              <span>drag / scroll</span>
              <span
                style={{
                  display: 'flex',
                  width: 82,
                  height: 24,
                  alignItems: 'center',
                  justifyContent: 'center',
                  flexShrink: 0,
                }}
                aria-hidden
              >
                <svg
                  width="24"
                  height="82"
                  viewBox="0 0 24 82"
                  fill="none"
                  style={{ display: 'block', transform: 'rotate(-90deg)' }}
                >
                  <line x1="12" y1="0" x2="12" y2="78" stroke="var(--fg)" strokeWidth="1"/>
                  <polyline points="2,68 12,80 22,68" fill="none" stroke="var(--fg)" strokeWidth="1"/>
                </svg>
              </span>
            </div>
          </div>

          <div style={{ flexShrink: 0, width: 120, height: 1 }} />

          {images.length > 0 ? (
            images.map((src, i) => (
              <img
                key={src + i}
                src={src}
                alt=""
                draggable={false}
                onDragStart={(e) => e.preventDefault()}
                style={{
                  flexShrink: 0,
                  height: '100%',
                  width: 'auto',
                  maxWidth: '78vw',
                  objectFit: 'contain',
                  userSelect: 'none',
                  opacity: 1,
                  transition: 'none',
                  WebkitUserDrag: 'none',
                }}
              />
            ))
          ) : (
            rects.map((r, i) => (
              <div key={i} style={{
                flexShrink: 0, width: r.w, height: r.h, background: r.c,
              }}/>
            ))
          )}
          <div style={{ flexShrink: 0, width: 260, height: 1 }} />
        </div>
      </div>

      {/* prev button */}
      <div
        onClick={(e) => { e.stopPropagation(); onPrev(); }}
        style={{
          position:'absolute', left: 40, bottom: 40, zIndex: 10,
          cursor:'pointer', padding: 8, fontSize: `calc(20px * var(--s))`,
          display:'flex', alignItems:'center', gap: 10,
        }}
      >
        <span style={{fontSize: `calc(24px * var(--s))`, textDecoration:'none'}}>&lt;</span>
        <span style={{textDecoration:'underline'}}>prev</span>
      </div>

      {/* next button */}
      <div
        onClick={(e) => { e.stopPropagation(); onNext(); }}
        style={{
          position:'absolute', right: 40, bottom: 40, zIndex: 10,
          cursor:'pointer', padding: 8, fontSize: `calc(20px * var(--s))`,
          display:'flex', alignItems:'center', gap: 10,
        }}
      >
        <span style={{textDecoration:'underline'}}>next</span>
        <span style={{fontSize: `calc(24px * var(--s))`, textDecoration:'none'}}>&gt;</span>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────
// Simple text page — same layout as detail but no images / slide / nav
// ──────────────────────────────────────────────────────────
function TextPage({ title, body }) {
  const isMobile = useIsMobile();
  const BODY_FONT = isMobile ? 20 : 24;
  const BODY_LINE = isMobile ? 34 : 38;
  return (
    <div style={{
      position: isMobile ? 'sticky' : 'absolute',
      top: isMobile ? 0 : undefined,
      height: isMobile ? '100vh' : undefined,
      inset: isMobile ? undefined : 0,
    }}>
      <div style={{
        position:'absolute',
        left: '50%', top: '50%',
        transform:'translate(-50%,-50%)',
        width: 'min(827px, 80vw)',
        fontSize: `${BODY_FONT}px`,
        lineHeight: `${BODY_LINE}px`,
      }}>
        {body}
      </div>
    </div>
  );
}

function About()   { return <TextPage title="About"   body={ABOUT_TEXT}/>; }

function randomCaptcha() {
  const a = 2 + Math.floor(Math.random() * 8);
  const b = 2 + Math.floor(Math.random() * 8);
  return { a, b, answer: '', ok: false, tried: false };
}

function getEmailJsConfig() {
  try {
    const c = typeof window !== 'undefined' && window.__EMAILJS_CONFIG__;
    if (!c || typeof c !== 'object') return null;
    const { publicKey, serviceId, templateId } = c;
    if (!publicKey || !serviceId || !templateId) return null;
    return { publicKey: String(publicKey), serviceId: String(serviceId), templateId: String(templateId) };
  } catch {
    return null;
  }
}

function Contact() {
  const isMobile = useIsMobile();
  const [form, setForm] = React.useState({ name: '', email: '', message: '' });
  const [revealed, setRevealed] = React.useState(false);
  const [captcha, setCaptcha] = React.useState(randomCaptcha);
  const [sendState, setSendState] = React.useState({ status: 'idle', message: '' });

  const email = React.useMemo(() => {
    const user = ['euggn'].join('');
    const domain = ['proton', '.', 'me'].join('');
    return `${user}@${domain}`;
  }, []);

  const emailjsConfigured = !!getEmailJsConfig();

  const onSend = async (e) => {
    e.preventDefault();
    setSendState({ status: 'idle', message: '' });

    const name = form.name.trim();
    const fromEmail = form.email.trim();
    const message = form.message.trim();

    if (!name || !fromEmail || !message) {
      setSendState({ status: 'error', message: 'Please fill in name, email, and message.' });
      return;
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fromEmail)) {
      setSendState({ status: 'error', message: 'Please enter a valid email address.' });
      return;
    }
    if (!captcha.ok) {
      setCaptcha((c) => ({ ...c, tried: true }));
      setSendState({ status: 'error', message: 'Please solve the math check.' });
      return;
    }

    const cfg = getEmailJsConfig();
    const emailjs = typeof window !== 'undefined' ? window.emailjs : null;
    if (!cfg || !emailjs || typeof emailjs.send !== 'function') {
      setSendState({
        status: 'error',
        message:
          'Email is not configured: add publicKey, serviceId, and templateId to window.__EMAILJS_CONFIG__ in index.html, and ensure the EmailJS script loads.',
      });
      return;
    }

    setSendState({ status: 'sending', message: '' });
    try {
      await emailjs.send(
        cfg.serviceId,
        cfg.templateId,
        {
          to_name: name,
          from_email: fromEmail,
          message,
        },
        { publicKey: cfg.publicKey },
      );
      setForm({ name: '', email: '', message: '' });
      setCaptcha(randomCaptcha());
      setSendState({ status: 'success', message: 'Message sent. Thank you!' });
    } catch (err) {
      const msg =
        (err && typeof err.text === 'string' && err.text) ||
        (err && err.message) ||
        'Could not send. Try again or use the email link above.';
      setSendState({ status: 'error', message: String(msg) });
    }
  };

  return (
    <div
      style={{
        position: isMobile ? 'sticky' : 'absolute',
        top: isMobile ? 0 : undefined,
        height: isMobile ? '100vh' : undefined,
        inset: isMobile ? undefined : 0,
        display:'flex',
        flexDirection:'column',
        alignItems:'center',
        justifyContent:'center',
        gap: 42,
        padding: '96px 24px 72px',
      }}
    >
      <div style={{
        width:'min(827px, 86vw)',
        fontSize: 24,
        lineHeight: '36px',
        textAlign:'left',
      }}>
        <div style={{ marginBottom: 14 }}>
          Bucharest based, available for projects worldwide.
        </div>

        <div>
          You can write to me{' '}
          {revealed ? (
            <a
              href={`mailto:${email}`}
              style={{
                textDecoration:'underline',
                fontStyle:'italic',
                color:'inherit',
                cursor:'pointer',
                transition:'opacity .2s',
              }}
              onMouseEnter={e => e.currentTarget.style.opacity = 0.55}
              onMouseLeave={e => e.currentTarget.style.opacity = 1}
            >
              {email}
            </a>
          ) : (
            <span
              role="link"
              tabIndex={0}
              onClick={() => setRevealed(true)}
              onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && setRevealed(true)}
              style={{
                textDecoration:'underline',
                fontStyle:'italic',
                cursor:'pointer',
                transition:'opacity .2s',
                display:'inline-block',
              }}
              onMouseEnter={e => e.currentTarget.style.opacity = 0.55}
              onMouseLeave={e => e.currentTarget.style.opacity = 1}
              aria-label="Reveal email address"
            >
              here
            </span>
          )}
          {!revealed && '.'}
        </div>
      </div>

      {/* Message sender — EmailJS (template fields: to_name, from_email, message) */}
      <form
        onSubmit={onSend}
        noValidate
        style={{
          width:'min(827px, 86vw)',
          display:'grid',
          gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 1fr',
          gap: 22,
          alignItems:'start',
          fontFamily:'Inter, sans-serif',
        }}
      >
        <label style={{ display:'block' }}>
          <div style={{ fontSize: 14, lineHeight: '16px', height: 16, letterSpacing: '.08em', textTransform:'uppercase', whiteSpace: 'nowrap' }}>
            Name
          </div>
          <input
            value={form.name}
            onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
            type="text"
            name="to_name"
            autoComplete="name"
            required
            style={{
              width:'100%',
              marginTop: 10,
              fontSize: 20,
              padding: '10px 2px 10px',
              border:'none',
              borderBottom:'1.5px solid var(--fg)',
              background:'transparent',
              outline:'none',
              color:'var(--fg)',
            }}
          />
        </label>

        <label style={{ display:'block' }}>
          <div style={{ fontSize: 14, lineHeight: '16px', height: 16, letterSpacing: '.08em', textTransform:'uppercase', whiteSpace: 'nowrap' }}>
            Email
          </div>
          <input
            value={form.email}
            onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
            type="email"
            name="from_email"
            autoComplete="email"
            required
            style={{
              width:'100%',
              marginTop: 10,
              fontSize: 20,
              padding: '10px 2px 10px',
              border:'none',
              borderBottom:'1.5px solid var(--fg)',
              background:'transparent',
              outline:'none',
              color:'var(--fg)',
            }}
          />
        </label>

        <label style={{ display:'block' }}>
          <div style={{
            fontSize: 14,
            lineHeight: '16px',
            height: 16,
            letterSpacing: '.08em',
            textTransform:'uppercase',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            gap: 14,
            whiteSpace: 'nowrap',
          }}>
            <span style={{ flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis' }}>{captcha.a} + {captcha.b} = ?</span>
            <span
              role="button"
              tabIndex={0}
              onClick={() => setCaptcha(randomCaptcha)}
              onKeyDown={(ev) => (ev.key === 'Enter' || ev.key === ' ') && setCaptcha(randomCaptcha)}
              style={{
                textDecoration: 'underline',
                cursor: 'pointer',
                opacity: 0.85,
                whiteSpace: 'nowrap',
                lineHeight: '16px',
              }}
            >
              New problem
            </span>
          </div>
          <input
            value={captcha.answer}
            onChange={(e) => {
              const v = e.target.value;
              setCaptcha(c => {
                const ok = Number(v) === c.a + c.b;
                return { ...c, answer: v, ok, tried: false };
              });
            }}
            inputMode="numeric"
            placeholder="Answer"
            style={{
              width:'100%',
              marginTop: 10,
              fontSize: 20,
              padding: '10px 2px 10px',
              border:'none',
              borderBottom:'1.5px solid var(--fg)',
              background:'transparent',
              outline:'none',
              color:'var(--fg)',
            }}
          />
          <div style={{ marginTop: 10, fontSize: 14 }}>
            {captcha.tried && !captcha.ok && (
              <span>Please solve the math check to send.</span>
            )}
          </div>
        </label>

        <label style={{ display:'block', gridColumn:'1 / -1' }}>
          <div style={{ fontSize: 14, letterSpacing: '.08em', textTransform:'uppercase' }}>
            Message
          </div>
          <textarea
            value={form.message}
            onChange={(e) => setForm(f => ({ ...f, message: e.target.value }))}
            name="message"
            rows={4}
            required
            style={{
              width:'100%',
              marginTop: 10,
              fontSize: 20,
              padding: '12px 2px 12px',
              border:'none',
              borderBottom:'1.5px solid var(--fg)',
              background:'transparent',
              outline:'none',
              resize:'none',
              color:'var(--fg)',
            }}
          />
        </label>

        {sendState.message && (
          <div role="status" style={{ gridColumn: '1 / -1', fontSize: 15, lineHeight: 1.45, color: 'var(--fg)' }}>
            {sendState.message}
          </div>
        )}

        {!emailjsConfigured && (
          <div style={{ gridColumn: '1 / -1', fontSize: 13, lineHeight: 1.45, opacity: 0.75 }}>
            To enable sending: set <code style={{ fontSize: '0.95em' }}>publicKey</code>,{' '}
            <code style={{ fontSize: '0.95em' }}>serviceId</code>, and{' '}
            <code style={{ fontSize: '0.95em' }}>templateId</code> on{' '}
            <code style={{ fontSize: '0.95em' }}>window.__EMAILJS_CONFIG__</code> in{' '}
            <code style={{ fontSize: '0.95em' }}>index.html</code>. Template variables should include{' '}
            <code style={{ fontSize: '0.95em' }}>to_name</code>,{' '}
            <code style={{ fontSize: '0.95em' }}>from_email</code>, and{' '}
            <code style={{ fontSize: '0.95em' }}>message</code>.
          </div>
        )}

        <button
          type="submit"
          aria-disabled={sendState.status === 'sending'}
          style={{
            gridColumn:'1 / -1',
            justifySelf:'end',
            background:'transparent',
            border:'1.5px solid var(--fg)',
            borderRadius: 999,
            padding: '10px 18px',
            fontSize: 18,
            cursor: sendState.status === 'sending' ? 'wait' : captcha.ok ? 'pointer' : 'not-allowed',
            color: 'var(--fg)',
            WebkitTextFillColor: 'var(--fg)',
            opacity: sendState.status === 'sending' ? 0.65 : captcha.ok ? 1 : 0.45,
          }}
          onMouseEnter={(e) => {
            if (captcha.ok && sendState.status !== 'sending') e.currentTarget.style.opacity = '0.72';
          }}
          onMouseLeave={(e) => {
            e.currentTarget.style.opacity =
              sendState.status === 'sending' ? '0.65' : captcha.ok ? '1' : '0.45';
          }}
        >
          {sendState.status === 'sending' ? 'Sending…' : 'Send'}
        </button>
      </form>
    </div>
  );
}

function Visits({ onClose }) {
  const isMobile = useIsMobile();
  const [pw, setPw] = React.useState('');
  const [state, setState] = React.useState({ status: 'idle', error: '', visits: [] });

  const load = async () => {
    setState({ status: 'loading', error: '', visits: [] });
    try {
      const r = await fetch(`/api/visits?pw=${encodeURIComponent(pw)}&limit=400`, { method: 'GET' });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j || j.ok !== true) {
        throw new Error((j && j.error) || `Request failed (${r.status})`);
      }
      setState({ status: 'ready', error: '', visits: Array.isArray(j.visits) ? j.visits : [] });
    } catch (e) {
      setState({ status: 'error', error: String(e && e.message ? e.message : e), visits: [] });
    }
  };

  return (
    <div style={{
      position: 'absolute',
      inset: 0,
      overflowY: 'auto',
      WebkitOverflowScrolling: 'touch',
      padding: '96px 24px 72px',
    }}>
      <div style={{ width: 'min(980px, 92vw)', margin: '0 auto' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
          <div style={{ fontSize: 24, lineHeight: '36px' }}>Visits</div>
          <button
            type="button"
            onClick={() => onClose && onClose()}
            style={{
              background: 'transparent',
              border: '1.5px solid var(--fg)',
              borderRadius: 999,
              padding: '8px 14px',
              fontSize: 16,
              fontFamily: 'Inter, sans-serif',
              color: 'var(--fg)',
              cursor: 'pointer',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.opacity = 0.65; }}
            onMouseLeave={(e) => { e.currentTarget.style.opacity = 1; }}
          >
            close
          </button>
        </div>

        <div style={{ marginTop: 18, display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
          <input
            value={pw}
            onChange={(e) => setPw(e.target.value)}
            placeholder="Password"
            type="password"
            style={{
              width: isMobile ? '100%' : 360,
              fontSize: 18,
              padding: '10px 12px',
              border: '1px solid color-mix(in srgb, var(--fg) 30%, transparent)',
              background: 'transparent',
              outline: 'none',
              color: 'var(--fg)',
              borderRadius: 8,
              fontFamily: 'Inter, sans-serif',
            }}
            onKeyDown={(e) => e.key === 'Enter' && load()}
          />
          <button
            type="button"
            onClick={load}
            disabled={!pw || state.status === 'loading'}
            style={{
              background: 'transparent',
              border: '1.5px solid var(--fg)',
              borderRadius: 999,
              padding: '10px 16px',
              fontSize: 16,
              fontFamily: 'Inter, sans-serif',
              color: 'var(--fg)',
              cursor: (!pw || state.status === 'loading') ? 'not-allowed' : 'pointer',
              opacity: (!pw || state.status === 'loading') ? 0.5 : 1,
            }}
            onMouseEnter={(e) => { if (pw && state.status !== 'loading') e.currentTarget.style.opacity = 0.65; }}
            onMouseLeave={(e) => { e.currentTarget.style.opacity = (!pw || state.status === 'loading') ? 0.5 : 1; }}
          >
            {state.status === 'loading' ? 'Loading…' : 'Load'}
          </button>
        </div>

        {state.error && (
          <div style={{ marginTop: 14, fontSize: 14, opacity: 0.85 }}>
            {state.error}
          </div>
        )}

        {state.status === 'ready' && (
          <div style={{ marginTop: 18, fontSize: 14, opacity: 0.85 }}>
            Showing {state.visits.length} most recent visits.
          </div>
        )}

        {state.status === 'ready' && (
          <div style={{ marginTop: 18, display: 'grid', gap: 10 }}>
            {state.visits.map((v, idx) => (
              <div
                key={String(v.ts || idx) + idx}
                style={{
                  border: '1px solid color-mix(in srgb, var(--fg) 18%, transparent)',
                  borderRadius: 10,
                  padding: '12px 12px',
                  display: 'grid',
                  gap: 6,
                  fontSize: 13,
                  lineHeight: 1.35,
                }}
              >
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, opacity: 0.95 }}>
                  <span>{v.ts || ''}</span>
                  <span style={{ opacity: 0.75 }}>{v.path || ''}</span>
                </div>
                <div style={{ opacity: 0.9 }}>
                  {(v.geo && (v.geo.city || v.geo.region || v.geo.country)) ?
                    `${v.geo.city || ''}${v.geo.city ? ', ' : ''}${v.geo.region || ''}${(v.geo.region && v.geo.country) ? ', ' : ''}${v.geo.country || ''}`
                    : ''}
                </div>
                <div style={{ opacity: 0.75 }}>
                  {v.ip ? `IP: ${v.ip}` : ''}{v.lang ? ` · ${v.lang}` : ''}{(v.vw && v.vh) ? ` · ${v.vw}×${v.vh}` : ''}{v.tz ? ` · ${v.tz}` : ''}
                </div>
                {v.ref && (
                  <div style={{ opacity: 0.7, wordBreak: 'break-word' }}>
                    ref: {v.ref}
                  </div>
                )}
                {v.ua && (
                  <div style={{ opacity: 0.6, wordBreak: 'break-word' }}>
                    {v.ua}
                  </div>
                )}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Object.assign(window, {
  Stage, Hamburger, Hero, Portfolio, Detail,
  About, Contact, Visits, TextPage, PROJECTS,
});
