/* ────────────────────────────────────────────────────────────────────────
 * Zachery Eng Portfolio — App
 * ────────────────────────────────────────────────────────────────────────
 * Single-file React app (UMD via CDN). No build step. JSX transpiled in-
 * browser by Babel-standalone. CSS lives in portfolio.html.
 *
 * Architecture at a glance:
 *
 *   App
 *   ├─ TransitionContext.Provider (phase: 'in' | 'out')
 *   │  ├─ .chrome (sticky, z-index 100)
 *   │  │  ├─ Marquee
 *   │  │  └─ Header (Home / Information)
 *   │  ├─ displayedRoute === 'project'
 *   │  │    ? <ProjectPage>  (left: GalleryItem[]  right: ProjectRail)
 *   │  │    : <HomePage>     (Intro / Grid / footer)
 *   │  ├─ InfoPanel (always mounted off-screen; slides in as a drawer)
 *   │  ├─ SiteNav   (fixed bottom-right pill dock; A/W/D/R keys)
 *   │  └─ PortfolioTweaks
 *
 * Source of truth for project metadata:
 *   <script type="application/json" id="__portfolio_projects"> in portfolio.html
 *
 * Signature motion language — "mask reveal":
 *   outer { overflow: hidden }
 *   inner { transform: translateY(100%) → translateY(0) }
 *   triggered by IntersectionObserver via the useReveal() hook.
 *   Suppressed during page exit (phase='out') so masks slide closed in unison.
 *
 * Page transitions:
 *   route changes → setPhase('out') → wait EXIT_DURATION (700ms) →
 *   swap displayedRoute → scrollToTop({instant}) → next RAF → setPhase('in').
 *
 * Hard rules (do not change without explicit user request):
 *   - No build step. Keep CDN tags with integrity hashes.
 *   - No filter bar. Tags on cards are inert <span>s; clicks bubble to <a>.
 *   - No lightbox / no hover captions on gallery images.
 *   - No mix-blend-mode on header. Solid white bg + black text.
 *   - Bio font size is FIXED (no scale-with-clicks).
 *   - All reveals are mask + translateY. NO fades.
 *   - One easing curve everywhere: cubic-bezier(.2, .7, .2, 1).
 *
 * See PROJECT_CONTEXT.md for full design rationale.
 * ──────────────────────────────────────────────────────────────────────── */

/* global React, ReactDOM, TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakSlider, TweakText, TweakColor, TweakToggle, useTweaks */
const { useState, useEffect, useMemo, useCallback, useLayoutEffect } = React;

// ─── data ────────────────────────────────────────────────────────────────
// All HTML shells preload projects-data.js which sets window.__PROJECTS.
const PROJECTS = window.__PROJECTS || [];
const ALL_TAGS = Array.from(new Set(PROJECTS.flatMap((p) => p.tags))).sort();
// Per-page boot signal — set by project HTML shells before this script loads.
const START_PROJECT_ID = window.__START_PROJECT || null;

// File-based router helper: each project lives at its own URL.
//   urlFor('atbay')  -> 'atbay.html'
//   urlFor(null)     -> 'index.html'
function urlFor(projectId) {
  return projectId ? `/${projectId}` : '/';
}
function currentUrl() {
  return window.location.pathname.toLowerCase().replace(/\.html?$/, '') || '/';
}

// Project gallery. When uploaded media exists for a project (window.__PROJECT_MEDIA,
// generated from uploads/ — order + size encoded in each filename), the gallery is
// built from it: L (4:3) items go full-width, T (3:4) and S (1:1) items pair two-up
// in source order, and C groups render as a swipeable carousel. Projects with no
// uploaded media fall back to the drop-slot placeholder template.
const GALLERY_ASPECT = { L: '16/9', T: '4/5', S: '1/1' };

function buildGallery(projectId) {
  const media = (window.__PROJECT_MEDIA || {})[projectId];
  if (media && media.length) return buildGalleryFromMedia(projectId, media);
  return [
  { kind: 'full', item: { aspect: '4/3', id: `${projectId}-g1`, caption: 'Storefront / exterior' } },
  { kind: 'full', item: { aspect: '4/3', id: `${projectId}-g2`, caption: 'Primary installation view' } },
  { kind: 'pair', items: [
    { aspect: '1/1', id: `${projectId}-g3a`, caption: 'Detail A' },
    { aspect: '1/1', id: `${projectId}-g3b`, caption: 'Detail B' }]
  },
  { kind: 'full', item: { aspect: '4/3', id: `${projectId}-g4`, caption: 'Secondary view' } },
  { kind: 'full', item: { aspect: '4/3', id: `${projectId}-g5`, caption: 'Spatial context' } }];

}

function buildGalleryFromMedia(projectId, media) {
  const rows = [];
  let buffer = [];
  let n = 0;
  const flush = () => {
    while (buffer.length) rows.push({ kind: 'pair', items: buffer.splice(0, 2) });
  };
  const makeItem = (m) => ({
    id: `${projectId}-m${n++}`,
    aspect: m.aspect || GALLERY_ASPECT[m.size] || '1/1',
    type: m.type, src: m.src,
    fit: m.fit, bg: m.bg
  });
  const makeCarousel = (m) => ({
    carousel: true,
    id: `${projectId}-c${n++}`,
    slides: m.carousel,
    aspect: m.aspect || null
  });
  media.forEach((m) => {
    // `half: true` lets any block (including a carousel) share a two-up row.
    if (m.half) {
      buffer.push(m.size === 'C' ? makeCarousel(m) : makeItem(m));
      if (buffer.length === 2) flush();
    } else if (m.size === 'C') {
      flush();
      const c = makeCarousel(m);
      rows.push({ kind: 'carousel', id: c.id, slides: c.slides, aspect: c.aspect });
    } else if (m.size === 'L') {
      flush();
      rows.push({ kind: 'full', item: makeItem(m) });
    } else {
      buffer.push(makeItem(m));
      if (buffer.length === 2) flush();
    }
  });
  flush();
  return rows;
}

// ─── file router (Swup-driven) ───────────────────────────────────────────
// Each project is still its own HTML file (direct URLs + file-per-project
// model intact). Swup intercepts internal links, fetches the next file, and
// swaps ONLY the #swup container — the persistent chrome (marquee, dock,
// info drawer, tweaks) lives outside it and is never re-mounted, so it stays
// on screen with no blink. Route is derived from the URL on every visit
// (NOT from window.__START_PROJECT, which would be stale after a swap).
function routeFromUrl(url) {
  const file = (url || currentUrl()).split('?')[0].split('#')[0].split('/').pop().toLowerCase();
  const id = file.replace(/\.html?$/, '');
  if (!id || id === 'index') return { name: 'home' };
  if (PROJECTS.some((p) => p.id === id)) return { name: 'project', id };
  return { name: 'home' };
}

// ─── tiny external store (route + transition phase) ──────────────────────
// The persistent chrome and the swappable content live in separate React
// roots, so they share state through this store instead of props/context.
const appStore = {
  route: routeFromUrl(),
  phase: 'in', // 'in' | 'out'
  _subs: new Set(),
  subscribe(fn) { this._subs.add(fn); return () => this._subs.delete(fn); },
  _emit() { this._subs.forEach((fn) => fn()); },
  setRoute(r) { this.route = r; this._emit(); },
  setPhase(p) { this.phase = p; document.body.setAttribute('data-phase', p); this._emit(); }
};
function useAppStore(selector) {
  const [, force] = useState(0);
  useEffect(() => appStore.subscribe(() => force((n) => n + 1)), []);
  return selector ? selector(appStore) : appStore;
}
function useRoute() {
  const route = useAppStore((s) => s.route);
  return [route, () => {}];
}

function navTo(url) {
  if (!url) return;
  if (url === currentUrl()) { scrollToTop(); return; }
  if (window.__swup) window.__swup.navigate(url);
  else window.location.href = url;
}
// Click handler for any <a> that should route through Swup.
function onNavLinkClick(e) {
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
  const a = e.currentTarget;
  const href = a.getAttribute('href');
  if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('#')) return;
  e.preventDefault();
  navTo(href);
}

function scrollToTop({ instant = false } = {}) {
  if (window.__lenis) window.__lenis.scrollTo(0, instant ? { immediate: true } : { duration: 1.2 });else
  window.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' });
}

// ─── marquee ────────────────────────────────────────────────────────────
function Marquee({ text, paused, repeat = 12 }) {
  // Split the marquee text on its bullet separators so every phrase is its
  // own item with a dot after it — that way all dots share the same flex gap
  // and stay evenly spaced (rather than tight inline bullets + a wide
  // separator dot between repeats).
  const phrases = String(text).split('\u25cf').map((s) => s.trim()).filter(Boolean);
  const unit = phrases.length ? phrases : [String(text)];
  const reps = Array.from({ length: repeat });
  return (
    <a className="marquee" href="mailto:zachery.eng@gmail.com" aria-label="Email Zachery Eng" data-paused={paused} data-cursor-expand style={{ borderStyle: "none" }}>
      <div className="marquee-track" aria-hidden="true">
        {reps.flatMap((_, r) =>
        unit.map((phrase, i) =>
        <React.Fragment key={r + '-' + i}>
            <span className="marquee-item">{phrase}</span>
            <span className="marquee-dot">●</span>
          </React.Fragment>
        )
        )}
      </div>
    </a>);

}

// ─── header ─────────────────────────────────────────────────────────────
function Header({ onInfo, onHome, infoOpen }) {
  return (
    <header className="header" style={{ borderStyle: "none" }}>
      <button onClick={onHome} className="info-link" aria-label="Home" data-cursor-expand>Home</button>
      <div className="right">
        <button onClick={onInfo} className="info-link" aria-pressed={infoOpen} data-cursor-expand>
          {infoOpen ? "Close" : "Information"}
        </button>
      </div>
    </header>);

}

// ─── intro ──────────────────────────────────────────────────────────────
// Progressive-disclosure bio. Each click expands ONE trigger into richer
// copy. The most recently revealed text gets a light highlight; older
// revealed text fades back to plain. Once every trigger has been clicked
// the highlight clears entirely.
//
// Start state:  "Hi I'm Zachery Eng, [Designer] based in the [GTA]."
// End state:    the full paragraph the user wants on the page.
// Node types:
//  - string                         plain text
//  - {type:'trigger', id, closed, expanded}   click replaces `closed` with `expanded` in place
//  - {type:'word', id, text}        click marks id opened but the word stays put; used when the
//                                   revealed continuation lives LATER in the sentence (see 'reveal')
//  - {type:'reveal', when, content} renders `content` only once `when` has been clicked
//  - {type:'casefold', text, capWhen}  capitalises first letter once `capWhen` has been clicked
const BIO_TREE = [
"Hi, I\u2019m Zachery ",
{
  type: 'trigger', id: 'eng', closed: "Eng",
  expanded: [
  "Eng \u2014 no, the \u201cEng\u201d does not ",
  { type: 'trigger', id: 'engineer', closed: "stand for engineer",
    expanded: [
    "stand for engineer, though I understand the assumption and take it as a compliment"]

  }]

},
". ",
{
  type: 'trigger', id: 'designer', closed: "Designer",
  expanded: [
  "I\u2019m a brand designer working across brand identities, systems, and graphics, with an approach that ",
  { type: 'word', id: 'values', text: "values meaning" },
  " as much as aesthetics",
  { type: 'reveal', when: 'values', content: [
    ", and the occasional unnecessary detail that only I will notice"] },

  "."]

},
" ",
{ type: 'casefold', text: "currently", capWhen: 'designer' },
" ",
{ type: 'reveal', when: 'gta', content: ["based "] },
"in the ",
{
  type: 'trigger', id: 'gta', closed: "GTA",
  expanded: [
  "Greater Toronto Area, but ",
  { type: 'trigger', id: 'mobile', closed: "happily mobile",
    expanded: [
    "happily mobile; I\u2019ve worked across ",
    { type: 'word', id: 'cities', text: "Toronto, NYC, and San Francisco" },
    ", chasing opportunity and marginally better weather",
    { type: 'reveal', when: 'cities', content: [
      ". Available for freelance and full-time work, and open to relocating wherever things get interesting. ",
      { type: 'link', href: 'mailto:zachery.eng@gmail.com', text: 'Feel free to reach out' }] }]


  }]

},
"."];


function countTriggers(nodes) {
  let n = 0;
  for (const node of nodes) {
    if (!node || typeof node !== 'object') continue;
    if (node.type === 'trigger') n += 1 + countTriggers(node.expanded);
    else if (node.type === 'word') n += 1;
    else if (node.type === 'reveal') n += countTriggers(node.content);
  }
  return n;
}
const TOTAL_TRIGGERS = countTriggers(BIO_TREE);

function BioNode({ node, opened, latestId, anyPending, onOpen, inLatest, inOpenedBranch }) {
  if (typeof node === 'string') {
    if (!inOpenedBranch) return <>{node}</>;
    const highlighted = inLatest && anyPending;
    return (
      <span className="bio-text" data-highlighted={highlighted ? 'true' : 'false'}>
        {node}
      </span>);

  }
  if (node.type === 'casefold') {
    const cap = opened.has(node.capWhen);
    const text = cap ? node.text.charAt(0).toUpperCase() + node.text.slice(1) : node.text;
    return <>{text}</>;
  }
  if (node.type === 'link') {
    return (
      <a className="bio-link" href={node.href} data-cursor-expand>
        {node.text}
      </a>);

  }
  if (node.type === 'reveal') {
    if (!opened.has(node.when)) return null;
    const isLatest = latestId === node.when;
    return (
      <>
        {node.content.map((child, i) =>
        <BioNode
          key={i}
          node={child}
          opened={opened}
          latestId={latestId}
          anyPending={anyPending}
          onOpen={onOpen}
          inLatest={isLatest}
          inOpenedBranch={true} />
        )}
      </>);

  }
  if (node.type === 'word') {
    if (opened.has(node.id)) {
      if (!inOpenedBranch) return <>{node.text}</>;
      const highlighted = inLatest && anyPending;
      return (
        <span className="bio-text" data-highlighted={highlighted ? 'true' : 'false'}>
          {node.text}
        </span>);

    }
    return (
      <span
        className="bio-trigger"
        role="button"
        tabIndex={0}
        data-cursor-expand
        onClick={() => onOpen(node.id)}
        onKeyDown={(e) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();onOpen(node.id);}}}>
        {node.text}
      </span>);

  }
  if (!opened.has(node.id)) {
    return (
      <span
        className="bio-trigger"
        role="button"
        tabIndex={0}
        data-cursor-expand
        onClick={() => onOpen(node.id)}
        onKeyDown={(e) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();onOpen(node.id);}}}>
        {node.closed}
      </span>);

  }
  const isLatest = latestId === node.id;
  return (
    <>
      {node.expanded.map((child, i) =>
      <BioNode
        key={i}
        node={child}
        opened={opened}
        latestId={latestId}
        anyPending={anyPending}
        onOpen={onOpen}
        inLatest={isLatest}
        inOpenedBranch={true} />
      )}
    </>);

}

// Lerp helper for the bio's progressive type scaling.
const lerp = (a, b, t) => a + (b - a) * t;

function ExpandableBio() {
  const [opened, setOpened] = useState(() => new Set());
  const [latestId, setLatestId] = useState(null);
  const onOpen = useCallback((id) => {
    setOpened((prev) => {
      const next = new Set(prev);
      next.add(id);
      return next;
    });
    setLatestId(id);
  }, []);
  const anyPending = opened.size < TOTAL_TRIGGERS;

  // Bio is fixed-size display type (clamp(28-40px)) styled in CSS via .bio.
  return (
    <p className="bio">
      {BIO_TREE.map((n, i) =>
      <BioNode
        key={i}
        node={n}
        opened={opened}
        latestId={latestId}
        anyPending={anyPending}
        onOpen={onOpen}
        inLatest={false}
        inOpenedBranch={false} />
      )}
    </p>);

}

// ─── reveal-on-scroll hook ───────────────────────────────────────────
// Returns [ref, revealed]. When the element first intersects the
// viewport, revealed flips to true. While a route is exiting (phase=='out')
// every consumer reports revealed=false so the masks slide back down in
// unison. Once the new route mounts and phase flips back to 'in', the
// intersection observer drives reveals normally again.
const TransitionContext = React.createContext({ phase: 'in' });

function useReveal({ rootMargin = '0px 0px -20% 0px', threshold = 0.05 } = {}) {
  const { phase } = React.useContext(TransitionContext);
  const ref = React.useRef(null);
  const [revealed, setRevealed] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (typeof IntersectionObserver === 'undefined') {setRevealed(true);return;}
    const io = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          setRevealed(true);
          io.disconnect();
          break;
        }
      }
    }, { rootMargin, threshold });
    io.observe(el);
    return () => io.disconnect();
  }, [rootMargin, threshold]);
  return [ref, phase === 'out' ? false : revealed];
}

// Shared reveal for the project gallery: one observer on the gallery container
// flips every item at once, so the whole gallery scrolls up together in a
// single pass instead of each item triggering on its own scroll position.
const GalleryRevealContext = React.createContext(null);

// Reveal helper for gallery items. When a shared GalleryRevealContext is
// present (project pages), all items use that one boolean and animate in
// unison; otherwise each falls back to its own IntersectionObserver.
function useGalleryReveal() {
  const shared = React.useContext(GalleryRevealContext);
  const own = useReveal();
  if (shared !== null) return [null, shared];
  return own;
}

// On mobile, all home cards reveal together when the grid section enters view.
const CardRevealContext = React.createContext(null);
function useCardReveal() {
  const shared = React.useContext(CardRevealContext);
  const own = useReveal();
  if (shared !== null) return [null, shared];
  return own;
}

function Intro() {
  const [ref, revealed] = useReveal();
  return (
    <section className="intro" data-screen-label="00 Intro" data-revealed={revealed} ref={ref}>
      <div className="intro-inner">
        <ExpandableBio />
      </div>
    </section>);

}

// ─── filter bar ─────────────────────────────────────────────────────────
function FilterBar({ tags, active, onToggle, onClear, counts }) {
  const [ref, revealed] = useReveal();
  return (
    <div className="filter-bar" data-screen-label="Filter" data-revealed={revealed} ref={ref}>
      <div className="filter-bar-inner">
        <span className="label">Filter</span>
        {tags.map((t) =>
        <button
          key={t}
          className="filter-chip"
          data-active={active.has(t)}
          onClick={() => onToggle(t)}>
          
            {t}<span className="count">{counts[t]}</span>
          </button>
        )}
        {active.size > 0 &&
        <button className="filter-clear" onClick={onClear}>clear filters</button>
        }
      </div>
    </div>);

}

// ─── home card ──────────────────────────────────────────────────────────
function cardPosStyle(p, breakpoint) {
  const b = p[breakpoint] || p.desktop;
  return {
    gridColumn: `${b.col} / span ${b.span}`
  };
}
function cardFrameStyle(p, breakpoint) {
  const b = p[breakpoint] || p.desktop;
  return { aspectRatio: b.aspect };
}

const ArrowSvg = () =>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
  </svg>;


// Card tag row. Tags stay on a single line: if the full labels overflow the
// card width, the row abbreviates tags from the last one inward (e.g.
// "Environmental Graphics" → "Env. Graphics"), and as a last resort ellipsizes
// the final chip. Re-fits on resize. Chips render empty and are filled
// imperatively so a React re-render never clobbers the abbreviated text.
const TAG_ABBREV = {
  "Environmental Graphics": "Env. Graphics",
  "Spacial Graphics": "Spacial Gr.",
  "Brand Expansion": "Brand Exp.",
  "Brand System": "Brand Sys.",
  "Brand Identity": "Brand ID",
  "Miscellaneous": "Misc.",
  "Illustration": "Illus.",
  "Production": "Prod.",
  "Campaign": "Camp."
};

function TagRow({ tags }) {
  const ref = React.useRef(null);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const chips = Array.from(el.querySelectorAll('.tag'));
    const overflow = () => el.scrollWidth > el.clientWidth + 1;
    const fit = () => {
      chips.forEach((c, i) => { c.textContent = tags[i]; c.classList.remove('tag-clip'); });
      for (let i = tags.length - 1; i >= 0 && overflow(); i--) {
        const ab = TAG_ABBREV[tags[i]];
        if (ab) chips[i].textContent = ab;
      }
      if (overflow() && chips.length) chips[chips.length - 1].classList.add('tag-clip');
    };
    fit();
    let raf = 0;
    const ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(fit); });
    ro.observe(el);
    return () => { ro.disconnect(); cancelAnimationFrame(raf); };
  }, [tags]);
  return (
    <div className="tags">
      <div className="tags-inner" ref={ref}>
        {tags.map((t, i) => <span key={i} className="tag"></span>)}
      </div>
    </div>);

}

// Home-card media. When a project specifies homeSrc / homeHover, the card
// renders that file directly (img, or autoplaying muted video) instead of a
// drop-slot. Reuses the .frame-primary / .frame-secondary classes so the
// hover-wipe still applies. resolveSrc is a no-op off the bundled export.
// The primary fades in once its first frame/pixels have actually decoded, so
// the card's reveal animation never shows a half-painted or popping image.
function HomeFrameMedia({ src, className, primary }) {
  const isVideo = /\.(mp4|webm|mov)$/i.test(src);
  const ref = React.useRef(null);
  const [loaded, setLoaded] = useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (isVideo) { el.muted = true; el.volume = 0; }
    const ready = isVideo ? el.readyState >= 2 : (el.complete && el.naturalWidth > 0);
    if (ready) setLoaded(true);
  }, [isVideo]);

  // Only the always-visible primary fades; the secondary is governed by the
  // hover clip-path and must stay put.
  const style = primary ? { opacity: loaded ? 1 : 0, transition: 'opacity .5s ease' } : undefined;

  if (isVideo) {
    return (
      <video ref={ref} className={className} style={style} src={resolveSrc(src)}
        autoPlay muted loop playsInline preload="auto"
        onLoadedData={() => setLoaded(true)}></video>);

  }
  return (
    <img ref={ref} className={className} style={style} src={resolveSrc(src)}
      alt="" loading="eager" decoding="async" onLoad={() => setLoaded(true)} />);

}

function Card({ p, breakpoint }) {
  const [ref, revealed] = useCardReveal();
  // --cascade-col drives a left-to-right stagger in CSS so cards in the
  // same grid row reveal in sequence even though they share an observer.
  const cascadeCol = p.desktop && p.desktop.col || 1;
  const style = { ...cardPosStyle(p, breakpoint), '--cascade-col': cascadeCol };
  return (
    <a
      ref={ref}
      className="card"
      href={urlFor(p.id)} onClick={onNavLinkClick}
      data-empty={!!p.empty}
      data-revealed={revealed}
      data-screen-label={`${p.client} · ${p.title}`}
      data-cursor-label="View More"
      style={style}
      onClick={(e) => {
        if (e.target.closest('image-slot')) {e.preventDefault();return;}
      }}>
      
      <TagRow tags={p.tags} />
      <div className="frame" style={cardFrameStyle(p, breakpoint)}>
        {p.empty ?
        <div className="frame-fill"><div className="empty-mark">In Progress</div></div> :
        <div className="frame-fill">
          {p.homeSrc ?
          <HomeFrameMedia src={p.homeSrc} className="frame-primary" primary /> :
          <image-slot
            id={`home-${p.id}`}
            class="frame-primary"
            shape="rect"
            placeholder={`Drop work · ${p.client}`}>
          </image-slot>}
          {p.homeHover ?
          <HomeFrameMedia src={p.homeHover} className="frame-secondary" /> :
          <image-slot
            id={`home-${p.id}-hover`}
            class="frame-secondary"
            shape="rect"
            placeholder={`Hover · ${p.client}`}>
          </image-slot>}
        </div>}
      </div>
      <div className="meta">
        <div className="meta-inner">
          <p className="title">{p.title}</p>
          <p className="client">{p.client}</p>
        </div>
      </div>
    </a>);

}

// ─── responsive breakpoint hook ──────────────────────────────────────────
function useBreakpoint() {
  const [bp, setBp] = useState(() => {
    if (typeof window === 'undefined') return 'desktop';
    const w = window.innerWidth;
    if (w <= 639) return 'mobile';
    if (w <= 1023) return 'tablet';
    return 'desktop';
  });
  useEffect(() => {
    const onResize = () => {
      const w = window.innerWidth;
      setBp(w <= 639 ? 'mobile' : w <= 1023 ? 'tablet' : 'desktop');
    };
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  return bp;
}

// ─── grid (home) ────────────────────────────────────────────────────────
function Grid() {
  const bp = useBreakpoint();
  const [gridRef, gridRevealed] = useReveal({ rootMargin: '0px 0px -5% 0px', threshold: 0.01 });
  const sharedReveal = bp === 'mobile' ? gridRevealed : null;
  return (
    <CardRevealContext.Provider value={sharedReveal}>
      <section className="work" data-screen-label="Work" ref={bp === 'mobile' ? gridRef : null}>
        <div className="grid">
          {PROJECTS.map((p) =>
          <Card key={p.id} p={p} breakpoint={bp} />
          )}
        </div>
      </section>
    </CardRevealContext.Provider>);
}

// ─── home page ──────────────────────────────────────────────────────────
function HomePage() {
  return (
    <>
      <Intro />
      <Grid />
      <footer className="footer">
        <span>© 2026 Zachery Eng</span>
      </footer>
    </>);

}

// ─── project page ───────────────────────────────────────────────────────
// In a bundled standalone export, media files are inlined as blob URLs keyed
// in window.__resources by a sanitized version of their original path. This
// resolves an authored src to its blob URL when bundled, and is a no-op (just
// returns the src) when running normally with __resources undefined.
function resolveSrc(src) {
  const r = window.__resources;
  if (!r || !src) return src;
  const id = src.replace(/[^a-zA-Z0-9]/g, '_');
  return r[id] || src;
}

function MediaEl({ item, onFirstLoad }) {
  if (item.type === 'video') {
    return (
      <video
        ref={(el) => { if (el) { el.muted = true; el.volume = 0; } }}
        src={resolveSrc(item.src)}
        autoPlay muted loop playsInline preload="metadata"
        onLoadedMetadata={onFirstLoad ? (e) => onFirstLoad(e.target.videoWidth, e.target.videoHeight) : undefined}>
      </video>);

  }
  return (
    <img
      src={resolveSrc(item.src)} alt="" loading="eager" decoding="async"
      onLoad={onFirstLoad ? (e) => onFirstLoad(e.target.naturalWidth, e.target.naturalHeight) : undefined} />);

}

function GalleryItem({ item }) {
  const [ref, revealed] = useGalleryReveal();
  return (
    <div ref={ref} className="gallery-item" data-revealed={revealed} data-fit={item.fit || undefined} style={{ aspectRatio: item.aspect, background: item.bg || undefined }}>
      <div className="gi-fill">
        {item.src ?
        <MediaEl item={item} /> :
        <image-slot id={`proj-${item.id}`} shape="rect" placeholder={item.caption}></image-slot>}
      </div>
    </div>);

}

function GalleryCarousel({ row }) {
  const [ref, revealed] = useGalleryReveal();
  const trackRef = React.useRef(null);
  const [idx, setIdx] = useState(0);
  const [aspect, setAspect] = useState(row.aspect || '4/3');
  const slides = row.slides;

  const onScroll = () => {
    const t = trackRef.current;
    if (!t) return;
    const i = Math.round(t.scrollLeft / t.clientWidth);
    setIdx((prev) => (i !== prev ? i : prev));
  };
  const go = (i) => {
    const t = trackRef.current;
    if (!t) return;
    const start = t.scrollLeft;
    const end = i * t.clientWidth;
    if (Math.abs(end - start) < 1) return;
    // Animate scrollLeft by hand: native smooth scrollTo fights the mandatory
    // scroll-snap (it snaps the animation back to origin), so we disable snap
    // for the duration and ease the position ourselves, restoring snap at the end.
    t.style.scrollSnapType = 'none';
    const dur = 460;
    const t0 = performance.now();
    const ease = (p) => 1 - Math.pow(1 - p, 3);
    const stepFn = (now) => {
      const p = Math.min(1, (now - t0) / dur);
      t.scrollLeft = start + (end - start) * ease(p);
      if (p < 1) {requestAnimationFrame(stepFn);} else {t.style.scrollSnapType = '';}
    };
    requestAnimationFrame(stepFn);
  };
  const measure = (w, h) => { if (w && h) setAspect(`${w} / ${h}`); };

  // Advance to the next slide (loops). The whole carousel is clickable.
  const advance = () => go((idx + 1) % slides.length);

  // Frame the carousel to its first slide's natural ratio. The visible <img>
  // is lazy-loaded (won't report dimensions until scrolled in), so probe the
  // source directly with an off-DOM loader that resolves regardless of viewport.
  React.useEffect(() => {
    if (row.aspect) return;
    const first = slides[0];
    if (!first) return;
    if (first.type === 'video') {
      const v = document.createElement('video');
      v.preload = 'metadata';
      const onMeta = () => measure(v.videoWidth, v.videoHeight);
      v.addEventListener('loadedmetadata', onMeta, { once: true });
      v.src = resolveSrc(first.src);
      return () => v.removeEventListener('loadedmetadata', onMeta);
    }
    const probe = new Image();
    probe.onload = () => measure(probe.naturalWidth, probe.naturalHeight);
    probe.src = resolveSrc(first.src);
    return () => { probe.onload = null; };
  }, []);

  return (
    <div ref={ref} className="gallery-item gallery-carousel" data-revealed={revealed}
      data-cursor-label="View More"
      onClick={advance}
      style={{ aspectRatio: aspect }}>
      <div className="gi-fill">
        <div className="carousel-track" ref={trackRef} onScroll={onScroll}>
          {slides.map((s, i) =>
          <div className="carousel-slide" key={i}>
            <MediaEl item={s} />
          </div>
          )}
        </div>
        <div className="carousel-dots">
          {slides.map((_, i) =>
          <button
            key={i}
            type="button"
            className="carousel-dot"
            data-active={i === idx}
            onClick={(e) => { e.stopPropagation(); go(i); }}
            aria-label={`Slide ${i + 1} of ${slides.length}`}>
          </button>
          )}
        </div>
      </div>
    </div>);

}

// Each rail row is its own mask. Stagger lives in CSS via :nth-child.
function ProjectRail({ p, includeNext = true, next = null }) {
  const [ref, revealed] = useReveal();

  // The discipline/role list straight from the spreadsheet, shown as a single
  // comma-separated line beside "Role" (same set the tag pills are built from).
  const roleLine = useMemo(() => p.role || p.tags.join(', '), [p]);

  return (
    <aside className="project-rail" ref={ref} data-revealed={revealed}>
      <div className="rail-row"><div className="rail-row-inner">
        <h1>{p.title}</h1>
      </div></div>
      <div className="rail-row"><div className="rail-row-inner">
        <div className="tags">
          {p.tags.map((tg) => <span key={tg} className="tag">{tg}</span>)}
        </div>
      </div></div>
      <div className="rail-row"><div className="rail-row-inner">
        <p className="summary">{p.summary}</p>
      </div></div>
      <div className="rail-row"><div className="rail-row-inner">
        <div className="project-meta">
          <div className="meta-row">
            <span className="k">Role</span>
            <span className="v">{roleLine}</span>
          </div>
          {p.studio &&
          <div className="meta-row">
            <span className="k">Studio</span>
            <span className="v">{p.studio}</span>
          </div>
          }
        </div>
      </div></div>
      {includeNext && next &&
      <div className="rail-row rail-row--next"><div className="rail-row-inner">
        <div className="project-next">
          <a href={urlFor(next.id)} onClick={onNavLinkClick} data-cursor-expand>
            <span className="next-label">Next:</span>
            <span className="next-title">{next.client}</span>
          </a>
        </div>
      </div></div>
      }
    </aside>);

}

// Tablet / mobile hand-off: the "Next project" link relocated to the bottom
// of the page, after all gallery images. Reveals on scroll like a rail row.
function ProjectNextSignoff({ next }) {
  const [ref, revealed] = useReveal();
  if (!next) return null;
  return (
    <div ref={ref} className="project-next-signoff" data-revealed={revealed}>
      <div className="rail-row-inner">
        <div className="project-next">
          <a href={urlFor(next.id)} onClick={onNavLinkClick} data-cursor-expand>
            <span className="next-label">Next:</span>
            <span className="next-title">{next.client}</span>
          </a>
        </div>
      </div>
    </div>);

}

// Preload + readiness for a project's gallery media. Returns true once every
// image has decoded and every video has buffered enough to paint its first
// frame — so the gallery only rises after its media is ready and never
// flickers mid-reveal. A safety timeout guarantees it resolves even if a
// source stalls, and the flag also flips true immediately if there's no media.
function useMediaReady(projectId) {
  const [ready, setReady] = useState(false);
  React.useEffect(() => {
    setReady(false);
    const media = (window.__PROJECT_MEDIA || {})[projectId];
    if (!media || !media.length) { setReady(true); return; }
    const srcs = [];
    media.forEach((m) => {
      if (m.size === 'C') m.carousel.forEach((s) => srcs.push(s));
      else if (m.src) srcs.push(m);
    });
    if (!srcs.length) { setReady(true); return; }

    let done = false;
    let remaining = srcs.length;
    const nodes = [];
    const settle = () => { if (--remaining <= 0 && !done) { done = true; setReady(true); } };

    srcs.forEach((s) => {
      if (s.type === 'video') {
        const v = document.createElement('video');
        v.preload = 'auto';
        v.muted = true;
        v.addEventListener('loadeddata', settle, { once: true });
        v.addEventListener('error', settle, { once: true });
        v.src = resolveSrc(s.src);
        v.load();
        nodes.push(v);
      } else {
        const img = new Image();
        const finish = () => {
          if (img.decode) img.decode().then(settle).catch(settle);
          else settle();
        };
        img.onload = finish;
        img.onerror = settle;
        img.src = resolveSrc(s.src);
        nodes.push(img);
      }
    });

    // Safety net: never block the reveal longer than 2.5s on a stalled source.
    const timer = setTimeout(() => { if (!done) { done = true; setReady(true); } }, 2500);
    return () => {
      clearTimeout(timer);
      nodes.forEach((n) => { n.onload = null; n.onerror = null; n.src = ''; });
    };
  }, [projectId]);
  return ready;
}

// Project credits — closes out each project with a dark, rounded card styled
// after the Information drawer. Role/discipline labels sit muted on the left,
// the people (and partners) I worked with in white on the right. Data lives in
// each project's `credits` array in projects-data.js; its first entry (the
// client/company, with an empty value) becomes the card's quiet heading.
function ProjectCredits({ p }) {
  const [ref, revealed] = useReveal({ rootMargin: '0px 0px -8% 0px', threshold: 0.05 });
  const credits = (p && p.credits) || [];
  if (!credits.length) return null;
  const hasHead = credits[0] && (credits[0][1] === '' || credits[0][1] == null);
  const clientName = hasHead ? credits[0][0] : (p.client || null);
  const roleRows = (hasHead ? credits.slice(1) : credits).filter((r) => r && r[1]);
  const rows = [];
  if (clientName) rows.push(['Client', clientName]);
  if (p.studio) rows.push(['Agency', p.studio]);
  roleRows.forEach((r) => rows.push(r));
  return (
    <section className="project-credits-wrap" data-screen-label={`Credits · ${p.client}`}>
      <div className="project-credits" ref={ref} data-revealed={revealed}>
        <div className="credits-head">Credits</div>
        <div className="credits-grid">
          {rows.map(([role, name], i) =>
          <div className="credits-row" key={i}>
            <div className="credits-role">{String(role).replace(/:\s*$/, '')}</div>
            <div className="credits-name">{name}</div>
          </div>
          )}
        </div>
        {p.creditsNote &&
        <div className="credits-note">{p.creditsNote}</div>}
      </div>
    </section>);

}

function ProjectPage({ projectId }) {
  const p = PROJECTS.find((x) => x.id === projectId);
  const gallery = useMemo(() => buildGallery(projectId), [projectId]);
  const mediaReady = useMediaReady(projectId);
  const bp = useBreakpoint();
  const isDesktop = bp === 'desktop';

  const next = useMemo(() => {
    if (!p) return null;
    const idx = PROJECTS.findIndex((x) => x.id === p.id);
    return PROJECTS[(idx + 1) % PROJECTS.length];
  }, [p]);

  if (!p) return null;

  return (
    <>
      <section className="project" data-screen-label={`Project · ${p.client}`}>
        <ProjectGallery gallery={gallery} isDesktop={isDesktop} next={next} mediaReady={mediaReady} />

        <ProjectRail p={p} includeNext={isDesktop} next={next} />
      </section>
      <ProjectCredits p={p} />
      {!isDesktop &&
      <div className="project-next-signoff-wrap">
        <ProjectNextSignoff next={next} />
      </div>}
    </>);

}

// Project gallery column. One reveal observer on the column flips every item
// at once via GalleryRevealContext, so the gallery scrolls up as a single
// group rather than item-by-item on scroll.
function ProjectGallery({ gallery, isDesktop, next, mediaReady }) {
  const [ref, inView] = useReveal({ rootMargin: '0px 0px -10% 0px', threshold: 0.01 });
  // Only rise once the section is in view AND its media has finished decoding,
  // so the column never animates up while images are still painting in.
  const revealed = inView && mediaReady;
  return (
    <GalleryRevealContext.Provider value={revealed}>
      <div className="project-left" ref={ref} data-revealed={revealed}>
        {gallery.map((row, i) => {
          if (row.kind === 'carousel') {
            return <GalleryCarousel key={i} row={row} />;
          }
          if (row.kind === 'pair') {
            return (
              <div className="gallery-row pair" key={i}>
                {row.items.map((it) =>
                it.carousel ?
                <GalleryCarousel key={it.id} row={it} /> :
                <GalleryItem key={it.id} item={it} />
                )}
              </div>);

          }
          return (
            <div className="gallery-row full" key={i}>
              <GalleryItem item={row.item} />
            </div>);

        })}
      </div>
    </GalleryRevealContext.Provider>);

}

// ─── site nav (sticky bottom-right dock) ────────────────────────────────
// Same dock on home + project pages. On project pages, A/D navigate
// projects and S returns home. On home, A/D scroll up/down by viewport
// and S scrolls to top.
function SiteNav({ route }) {
  const onPrev = useCallback(() => {
    if (route.name === 'project') {
      const idx = PROJECTS.findIndex((x) => x.id === route.id);
      const prev = PROJECTS[(idx - 1 + PROJECTS.length) % PROJECTS.length];
      navTo(urlFor(prev.id));
    } else {
      navTo(urlFor(PROJECTS[PROJECTS.length - 1].id));
    }
  }, [route]);
  const onNext = useCallback(() => {
    if (route.name === 'project') {
      const idx = PROJECTS.findIndex((x) => x.id === route.id);
      const next = PROJECTS[(idx + 1) % PROJECTS.length];
      navTo(urlFor(next.id));
    } else {
      navTo(urlFor(PROJECTS[0].id));
    }
  }, [route]);
  const onTop = useCallback(() => scrollToTop(), []);
  const onHome = useCallback(() => {
    if (route.name !== 'home') navTo(urlFor(null));else
    scrollToTop();
  }, [route]);

  // Global keyboard shortcuts
  useEffect(() => {
    const onKey = (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const k = e.key.toLowerCase();
      if (k === 'a') onPrev();else
      if (k === 'd') onNext();else
      if (k === 'w') onTop();else
      if (k === 'r') onHome();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onPrev, onNext, onTop, onHome]);

  return (
    <nav className="site-nav" aria-label="Site navigation">
      <button className="nav-pill" onClick={onPrev} data-cursor-expand>Previous <span className="key">A</span></button>
      <button className="nav-pill" onClick={onTop} data-cursor-expand>Top <span className="key">W</span></button>
      <button className="nav-pill" onClick={onNext} data-cursor-expand>Next <span className="key">D</span></button>
      <button className="nav-pill" onClick={onHome} data-cursor-expand>Home <span className="key">R</span></button>
    </nav>);

}

// ─── information drawer (always mounted; open state lives in body attr) ─
// New layout (May 20 2026):
//   - "One team working together" headline, top-left of drawer
//   - "Information" label top-right (aligned with where the header button
//     sat, so it appears to remain in place as the dark drawer slides over)
//   - Social rows (Linkedin, Instagram, Are.na, Email)
//   - Experience row with a Resume pill button
//   - Colophon paragraph
// Clicking the inner "Information" label or the dim scrim closes the drawer.
function InfoPanel({ open, onClose }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  return (
    <>
      <div className="info-scrim" onClick={onClose} aria-hidden={!open} />
      <aside className="info-panel" data-screen-label="Information" aria-hidden={!open} data-cursor-invert data-lenis-prevent>
        <button className="info-close" onClick={onClose} aria-label="Close information" data-cursor-expand></button>
        <div className="info-inner">
          <h1 className="info-title">Let’s work<br/>together</h1>

          <div className="info-section">
            <div className="k">Social</div>
            <div className="v">
              <div className="info-row">
                <div className="info-row-title">LinkedIn</div>
                <a className="info-row-value" href="https://www.linkedin.com/in/zacheryeng/" target="_blank" rel="noreferrer" data-cursor-expand>zacheryeng</a>
              </div>
              <div className="info-row">
                <div className="info-row-title">Instagram</div>
                <a className="info-row-value" href="https://www.instagram.com/zchryng/" target="_blank" rel="noreferrer" data-cursor-expand>@zchryng</a>
              </div>
              <div className="info-row">
                <div className="info-row-title">Are.na</div>
                <a className="info-row-value" href="https://www.are.na/zachery-eng/" target="_blank" rel="noreferrer" data-cursor-expand>zachery-eng</a>
              </div>
              <div className="info-row">
                <div className="info-row-title">Email</div>
                <a className="info-row-value" href="mailto:zachery.eng@gmail.com" data-cursor-expand>zachery.eng@gmail.com</a>
              </div>
            </div>
          </div>

          <div className="info-section">
            <div className="k">Experience</div>
            <div className="v">
              <a className="info-pill" href="https://www.dropbox.com/scl/fi/38lmyt5l01xerment64i7/ZacheryEng_Resume.pdf?rlkey=z3ry9d1w80cp14k3nd4hcj7iv&dl=0" target="_blank" rel="noopener noreferrer" data-cursor-expand>Resume</a>
            </div>
          </div>

          <div className="info-section">
            <div className="k">Colophon</div>
            <div className="v">
              <p className="info-colophon">
                Set in Haffer. Designed and laid out in Figma. Vibe-coded with my buddy Claude, then art-directed, styled, and the HTML and CSS hand-edited by me. No templates, just vibes.
              </p>
            </div>
          </div>
        </div>
      </aside>
    </>
  );
}

// ─── tweaks ─────────────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "theme": "light",
  "marqueeText": "OPEN FOR WORK ● OPEN FOR RELOCATION ● OPEN FOR MANGOS",
  "marqueeSpeed": "slow",
  "marqueePaused": false,
  "marqueeVisible": true
} /*EDITMODE-END*/;

function PortfolioTweaks({ t, setTweak }) {
  return (
    <TweaksPanel title="Tweaks">
      <TweakSection label="Theme">
        <TweakRadio
          label="Palette"
          value={t.theme}
          options={[
          { value: "light", label: "Light" },
          { value: "dark", label: "Dark" },
          { value: "sand", label: "Sand" }]
          }
          onChange={(v) => setTweak('theme', v)} />
        
      </TweakSection>

      <TweakSection label="Marquee">
        <TweakToggle label="Show banner" value={t.marqueeVisible} onChange={(v) => setTweak('marqueeVisible', v)} />
        <TweakText label="Text" value={t.marqueeText} onChange={(v) => setTweak('marqueeText', v)} />
        <TweakRadio
          label="Speed"
          value={t.marqueeSpeed}
          options={[
          { value: "slow", label: "Slow" },
          { value: "normal", label: "Normal" },
          { value: "fast", label: "Fast" }]
          }
          onChange={(v) => setTweak('marqueeSpeed', v)} />
        
        <TweakToggle label="Pause" value={t.marqueePaused} onChange={(v) => setTweak('marqueePaused', v)} />
      </TweakSection>
    </TweaksPanel>);

}

// ─── app ────────────────────────────────────────────────────────────────
// The app is split across TWO React roots that share state via appStore:
//
//   ChromeApp   → #chrome-root  (OUTSIDE Swup's swap container, so it is
//                 never re-mounted: marquee, header, info drawer, site dock,
//                 tweaks, Lenis). This is what "stays on screen" between
//                 pages with zero blink.
//   ContentView → #page-root    (INSIDE #swup; re-rendered on every Swup
//                 visit after the container is swapped).
//
// Swup fetches the next .html file, runs the mask-close (out) animation via
// @swup/js-plugin, replaces #swup, then we render the new page and the
// entrance reveals run exactly as on a fresh load — minus the white flash.
const EXIT_DURATION = 700; // ms — matches the mask-close timing in CSS
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

// Custom cursor: a small black dot that follows the pointer and expands into a
// labelled pill over any element carrying [data-cursor-label] (project cards
// show the client name; carousels show "View more"). Desktop/fine-pointer only;
// on touch it renders nothing and the native cursor is untouched.
function CustomCursor() {
  const ref = React.useRef(null);
  const labelRef = React.useRef(null);
  useEffect(() => {
    if (!window.matchMedia || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const el = ref.current;
    if (!el) return;
    document.body.setAttribute('data-custom-cursor', 'true');
    let x = window.innerWidth / 2, y = window.innerHeight / 2, tx = x, ty = y, raf = 0, shown = false;
    const move = (e) => {
      x = e.clientX; y = e.clientY;
      if (!shown) { shown = true; tx = x; ty = y; el.style.opacity = '1'; }
      const labelHit = e.target.closest && e.target.closest('[data-cursor-label]');
      const expandHit = e.target.closest && e.target.closest('[data-cursor-expand]');
      // On dark surfaces (the Information drawer) flip the dot to light so it
      // stays visible — same expand behaviour as the header, inverted colours.
      const onDark = e.target.closest && e.target.closest('[data-cursor-invert]');
      el.setAttribute('data-invert', onDark ? 'true' : 'false');
      if (labelHit) {
        const label = labelHit.getAttribute('data-cursor-label') || '';
        if (labelRef.current.textContent !== label) labelRef.current.textContent = label;
        el.setAttribute('data-mode', 'pill');
      } else if (expandHit) {
        el.setAttribute('data-mode', 'expand');
      } else {
        el.setAttribute('data-mode', 'dot');
      }
    };
    const hide = () => { shown = false; el.style.opacity = '0'; };
    const loop = () => {
      tx += (x - tx) * 0.25; ty += (y - ty) * 0.25;
      el.style.transform = `translate(${tx}px, ${ty}px) translate(-50%, -50%)`;
      raf = requestAnimationFrame(loop);
    };
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseleave', hide);
    window.addEventListener('blur', hide);
    raf = requestAnimationFrame(loop);
    return () => {
      cancelAnimationFrame(raf);
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseleave', hide);
      window.removeEventListener('blur', hide);
      document.body.removeAttribute('data-custom-cursor');
    };
  }, []);
  return (
    <div ref={ref} className="cursor" data-mode="dot" aria-hidden="true" style={{ opacity: 0 }}>
      <span className="cursor-label" ref={labelRef}></span>
    </div>);

}

function ChromeApp() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const route = useAppStore((s) => s.route);
  const [infoOpen, setInfoOpen] = useState(false);

  // ── Lenis smooth scroll (created once; persists across Swup visits) ──
  useEffect(() => {
    if (typeof Lenis === 'undefined') return;
    const lenis = new Lenis({
      duration: 1.2,
      easing: (x) => Math.min(1, 1.001 - Math.pow(2, -10 * x)),
      smoothWheel: true,
      smoothTouch: false,
      wheelMultiplier: 1,
      touchMultiplier: 1.4
    });
    let rafId;
    const raf = (time) => { lenis.raf(time); rafId = requestAnimationFrame(raf); };
    rafId = requestAnimationFrame(raf);
    window.__lenis = lenis;
    return () => {
      cancelAnimationFrame(rafId);
      lenis.destroy();
      delete window.__lenis;
    };
  }, []);

  // When the info drawer opens, stop smooth-scroll (body is locked anyway).
  useEffect(() => {
    if (!window.__lenis) return;
    if (infoOpen) window.__lenis.stop(); else window.__lenis.start();
  }, [infoOpen]);

  useEffect(() => { document.documentElement.setAttribute('data-theme', t.theme); }, [t.theme]);

  useEffect(() => {
    const dur = t.marqueeSpeed === 'slow' ? '120s' : t.marqueeSpeed === 'fast' ? '30s' : '60s';
    document.documentElement.style.setProperty('--marquee-dur', dur);
  }, [t.marqueeSpeed]);

  useEffect(() => {
    document.documentElement.style.setProperty('--chrome-h', t.marqueeVisible ? '90px' : '54px');
  }, [t.marqueeVisible]);

  useEffect(() => { document.body.setAttribute('data-overlay', infoOpen); }, [infoOpen]);
  useEffect(() => {
    document.body.setAttribute('data-info-open', String(infoOpen));
    return () => document.body.removeAttribute('data-info-open');
  }, [infoOpen]);

  // Close the info drawer whenever a page navigation begins.
  useEffect(() => {
    const close = () => setInfoOpen(false);
    window.addEventListener('app:nav-start', close);
    return () => window.removeEventListener('app:nav-start', close);
  }, []);

  return (
    <>
      <div className="chrome">
        {t.marqueeVisible && <Marquee text={t.marqueeText} paused={t.marqueePaused} />}
        <Header
          onHome={() => { navTo(urlFor(null)); setInfoOpen(false); }}
          onInfo={() => setInfoOpen((o) => !o)}
          infoOpen={infoOpen} />
      </div>

      <InfoPanel open={infoOpen} onClose={() => setInfoOpen(false)} />
      <SiteNav route={route} />
      <PortfolioTweaks t={t} setTweak={setTweak} />
      <CustomCursor />
    </>);
}

// Swappable page content. Reads phase from the store so the OUTGOING page
// masks closed on the next navigation; mounts fresh (phase 'in') so the
// IntersectionObserver-driven reveals run on entrance.
function ContentView() {
  const phase = useAppStore((s) => s.phase);
  const route = appStore.route;
  return (
    <TransitionContext.Provider value={{ phase }}>
      {route.name === 'project' ?
        <ProjectPage projectId={route.id} /> :
        <HomePage />}
    </TransitionContext.Provider>);
}

// ─── mount + Swup wiring ─────────────────────────────────────────────────
let contentRoot = null;
function renderContent() {
  const el = document.getElementById('page-root');
  if (!el) return;
  contentRoot = ReactDOM.createRoot(el);
  contentRoot.render(<ContentView />);
}

function initSwup() {
  if (typeof Swup === 'undefined') return; // graceful fallback → plain links

  const jsPlugin = new SwupJsPlugin({
    animations: [{
      from: '(.*)', to: '(.*)',
      // Leave: drive the existing mask-close, then let Swup swap.
      out: async () => { appStore.setPhase('out'); await wait(EXIT_DURATION); },
      // Enter: content was rendered in content:replace; reveals run via IO.
      in: async () => { await wait(20); }
    }]
  });

  const swup = new Swup({ containers: ['#swup'], plugins: [jsPlugin] });
  window.__swup = swup;

  swup.hooks.on('visit:start', () => {
    window.dispatchEvent(new CustomEvent('app:nav-start'));
  });

  // Fires AFTER Swup has swapped #swup (fresh, empty #page-root present).
  swup.hooks.on('content:replace', () => {
    if (contentRoot) { try { contentRoot.unmount(); } catch (e) {} contentRoot = null; }
    appStore.setRoute(routeFromUrl());
    appStore.setPhase('in');
    if (window.__lenis) window.__lenis.scrollTo(0, { immediate: true });
    else window.scrollTo(0, 0);
    renderContent();
    if (window.__lenis) requestAnimationFrame(() => window.__lenis.resize());
  });
}

// ── boot ──
appStore.route = routeFromUrl();
document.body.setAttribute('data-phase', 'in');
ReactDOM.createRoot(document.getElementById('chrome-root')).render(<ChromeApp />);
renderContent();
initSwup();