/* Reusable layout primitives — pure formatting, no resume-specific fields.
   Each accepts `left`, `right`, `children`, etc. so you can drop anything in. */

/* =====================================================================
   AssetRail — single global panel that shows assets for the currently-
   hovered <Highlight>. Lives in the sticky <aside id="asset-rail">.

   Protocol:
     <Highlight assets={[{src, type, caption}, ...]}> ... </Highlight>
       - type: "video" | "image" (auto-detected from extension if omitted)
       - on mouseenter, after a short debounce, the Highlight tells the rail
         to show its assets and positions the panel near the hovered element
         while keeping it on-screen (right-click-menu-style flipping).
       - on mouseleave, the rail schedules a hide after HIDE_DELAY_MS.
       - moving to another Highlight cancels the pending hide; SHOW_DEBOUNCE_MS
         absorbs fast mouse sweeps so we don't thrash the panel.

   Videos are loaded as blob: URLs so the hosting proxy's lack of HTTP
   range support doesn't break HTML5 <video>. Custom hover-only controls
   keep the initial frame clean (no OS video-player overlays).
   ===================================================================== */

/* Global mouse-speed tracker — rolling average over last 100ms. Highlights
   only switch videos when the cursor is slow enough (reading speed), so
   fast transits across the page don't thrash through every video.

   Crucial: when the mouse STOPS moving, no more mousemove events fire, so
   we can't rely on the last computed speed. Instead, prune stale samples
   on each getter call; if there's nothing in the last 100ms, speed = 0. */
const mouseSamples = []; // {x, y, t}
const SPEED_WINDOW_MS = 100;
const SPEED_THRESHOLD = 0.2; // px/ms — below this = slow enough to switch
function getMouseSpeed() {
  const now = performance.now();
  while (mouseSamples.length > 0 && now - mouseSamples[0].t > SPEED_WINDOW_MS) {
    mouseSamples.shift();
  }
  if (mouseSamples.length < 2) return 0;
  let dist = 0;
  for (let i = 1; i < mouseSamples.length; i++) {
    dist += Math.hypot(
      mouseSamples[i].x - mouseSamples[i - 1].x,
      mouseSamples[i].y - mouseSamples[i - 1].y
    );
  }
  const dt = mouseSamples[mouseSamples.length - 1].t - mouseSamples[0].t;
  return dt > 0 ? dist / dt : 0;
}
if (typeof window !== "undefined" && !window.__mouseSpeedTrackerInstalled) {
  window.__mouseSpeedTrackerInstalled = true;
  window.addEventListener("mousemove", (e) => {
    mouseSamples.push({ x: e.clientX, y: e.clientY, t: performance.now() });
    // Samples are pruned lazily inside getMouseSpeed().
  }, { passive: true });
}

const AssetRailContext = React.createContext(null);
const HIDE_DELAY_MS = 900;
const SHOW_DEBOUNCE_MS = 150;
const SWAP_MS = 340;

/* Mobile detection — we use a CSS-backed media query so it stays in sync
   with the responsive breakpoint (820px in the stylesheet). Updates reactively
   on resize / orientation change. */
const MOBILE_MQ = "(max-width: 820px)";
function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(MOBILE_MQ).matches;
  });
  React.useEffect(() => {
    const mql = window.matchMedia(MOBILE_MQ);
    const onChange = (e) => setIsMobile(e.matches);
    // Safari < 14 only supports the legacy API.
    if (mql.addEventListener) mql.addEventListener("change", onChange);
    else mql.addListener(onChange);
    return () => {
      if (mql.removeEventListener) mql.removeEventListener("change", onChange);
      else mql.removeListener(onChange);
    };
  }, []);
  return isMobile;
}

function detectType(src) {
  const ext = (src.split(/[?#]/)[0].split('.').pop() || '').toLowerCase();
  if (["mp4", "webm", "mov", "m4v", "ogv"].includes(ext)) return "video";
  return "image";
}

/* Video loader: fetch as blob, create object URL, and (importantly) also
   pre-decode metadata in a hidden <video> so that when the real element
   mounts with the cached blob URL, it has dimensions + first frame ready
   instantly — no "black rectangle" flash during the swap animation. */
const BLOB_CACHE = new Map();   // src -> Promise<objectURL>
const DIM_CACHE = new Map();    // src -> {width, height}
const POSITION_CACHE = new Map(); // src -> last currentTime (seconds)
// Module-level flag: has the user ever unmuted during this page session?
// Used to restart the active video from the top the first time audio comes on.
let EVER_UNMUTED = false;
const ASSET_VERSION = 'v=2026-04-20b';
function bust(src) {
  return src + (src.includes('?') ? '&' : '?') + ASSET_VERSION;
}
function loadBlobUrl(src) {
  if (BLOB_CACHE.has(src)) return BLOB_CACHE.get(src);
  const p = fetch(bust(src))
    .then(r => r.blob())
    .then(b => {
      const url = URL.createObjectURL(b);
      // Kick off metadata decode so dimensions are cached + first frame is
      // decoded by the time the visible video renders.
      return new Promise(resolve => {
        const v = document.createElement('video');
        v.muted = true;
        v.playsInline = true;
        v.preload = 'auto';
        v.src = url;
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          if (v.videoWidth && v.videoHeight) {
            DIM_CACHE.set(src, { width: v.videoWidth, height: v.videoHeight });
          }
          resolve(url);
        };
        v.addEventListener('loadeddata', finish, { once: true });
        v.addEventListener('canplay', finish, { once: true });
        // Safety timeout in case metadata never fires
        setTimeout(finish, 3500);
      });
    });
  BLOB_CACHE.set(src, p);
  return p;
}
function getCachedDim(src) { return DIM_CACHE.get(src) || null; }

/* SF Symbols–inspired glyphs (thin strokes). These are hand-drawn paths
   that feel native on iOS/macOS rather than Material boxes. */
const Icon = {
  SpeakerOn: () => (
    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4 9.5h3.2L11.5 6v12L7.2 14.5H4z"/>
      <path d="M15.5 9c1 .9 1.6 2 1.6 3s-.6 2.1-1.6 3"/>
      <path d="M18 6.4c2 1.5 3.2 3.4 3.2 5.6s-1.2 4.1-3.2 5.6"/>
    </svg>
  ),
  SpeakerOff: () => (
    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4 9.5h3.2L11.5 6v12L7.2 14.5H4z"/>
      <path d="M15.5 9.5l5 5M20.5 9.5l-5 5"/>
    </svg>
  ),
  Restart: () => (
    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M20 12a8 8 0 1 1-2.3-5.6"/>
      <path d="M20 4v4h-4"/>
    </svg>
  ),
  Fullscreen: () => (
    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>
    </svg>
  ),
  FullscreenExit: () => (
    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M9 4v4H5M15 4v4h4M9 20v-4H5M15 20v-4h4"/>
    </svg>
  ),
};

/* Global mute state — unmuting one video unmutes them all. Also: the first
   user gesture anywhere on the page auto-unmutes (once), since browsers
   won't permit unmuted autoplay until a gesture has occurred. */
const MuteCtx = React.createContext(null);
function MuteProvider({ children }) {
  const [muted, setMuted] = React.useState(true);
  const unlockedRef = React.useRef(false);
  React.useEffect(() => {
    if (unlockedRef.current) return;
    const unlock = (e) => {
      if (unlockedRef.current) return;
      unlockedRef.current = true;
      // If the gesture was the mute chip itself, let its own handler decide
      // the next state — don't fight it by also flipping to unmuted here.
      const onMuteChip = e && e.target && e.target.closest && e.target.closest('[data-mute-chip]');
      if (!onMuteChip) setMuted(false);
      window.removeEventListener('click', unlock, true);
      window.removeEventListener('keydown', unlock, true);
    };
    window.addEventListener('click', unlock, true);
    window.addEventListener('keydown', unlock, true);
    return () => {
      window.removeEventListener('click', unlock, true);
      window.removeEventListener('keydown', unlock, true);
    };
  }, []);
  return (
    <MuteCtx.Provider value={{ muted, setMuted }}>{children}</MuteCtx.Provider>
  );
}

function VideoAsset({ src, active, noAudio, onOrientation, expanded, onToggleExpand, hideRestart }) {
  const [url, setUrl] = React.useState(null);
  const cachedDim = getCachedDim(src); // {width, height} or null
  const muteCtx = React.useContext(MuteCtx);
  const globallyMuted = muteCtx ? muteCtx.muted : true;
  const effectiveMuted = noAudio ? true : globallyMuted;
  const videoRef = React.useRef(null);
  const isMobile = useIsMobile();
  const showRestart = !(hideRestart && isMobile);

  React.useEffect(() => {
    let cancelled = false;
    loadBlobUrl(src).then(u => { if (!cancelled) setUrl(u); });
    return () => { cancelled = true; };
  }, [src]);

  // Apply mute changes (global toggle + active state).
  // Videos don't use the `autoPlay` attribute — that would start EVERY video
  // at page load, so by the time you scrolled to one it'd be mid-playback.
  // Instead we drive play/pause imperatively here, and remember the last
  // position per src so coming back resumes ~1s before where you left off.
  React.useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    if (!active) {
      // Save position before pausing (only if we have a valid time and the
      // video has actually been played — currentTime > 0 is a good proxy).
      if (isFinite(v.currentTime) && v.currentTime > 0) {
        POSITION_CACHE.set(src, v.currentTime);
      }
      v.muted = true;
      try { v.pause(); } catch (e) {}
    } else {
      v.muted = effectiveMuted;
      // First-ever unmute of the session: restart the active video from 0 so
      // the user hears it from the beginning, not mid-stream.
      const firstUnmute = !effectiveMuted && !EVER_UNMUTED;
      if (firstUnmute) {
        EVER_UNMUTED = true;
        POSITION_CACHE.set(src, 0);
      }
      // Seek to remembered position (minus a 1s lead-in) or 0 for first view.
      const saved = POSITION_CACHE.get(src);
      const target = firstUnmute ? 0 : (saved != null ? Math.max(0, saved - 1) : 0);
      const seekAndPlay = () => {
        try { v.currentTime = target; } catch (e) {}
        v.play().catch(() => {});
      };
      // If metadata isn't ready yet, currentTime assignments get ignored —
      // wait for it, then seek. readyState >= 1 means HAVE_METADATA.
      if (v.readyState >= 1) {
        seekAndPlay();
      } else {
        const onMeta = () => {
          v.removeEventListener('loadedmetadata', onMeta);
          seekAndPlay();
        };
        v.addEventListener('loadedmetadata', onMeta);
      }
    }
  }, [active, effectiveMuted, src, url]);

  const onFs = (e) => {
    e.stopPropagation();
    if (onToggleExpand) onToggleExpand();
  };

  const toggleMute = (e) => {
    e.stopPropagation();
    if (!muteCtx) return;
    muteCtx.setMuted(!muteCtx.muted);
  };

  const restart = (e) => {
    e.stopPropagation();
    const v = videoRef.current;
    if (!v) return;
    try {
      v.currentTime = 0;
      POSITION_CACHE.set(src, 0);
      v.play().catch(() => {});
    } catch (err) {}
  };

  return (
    <>
      <div className="video-chips">
        {!noAudio && (
          <button type="button" data-mute-chip className="chip" onClick={toggleMute} aria-label={globallyMuted ? "Unmute" : "Mute"}>
            {globallyMuted ? <Icon.SpeakerOn /> : <Icon.SpeakerOff />}
            <span>{globallyMuted ? "Unmute" : "Mute"}</span>
          </button>
        )}
        {showRestart && (
          <button type="button" className="chip" onClick={restart} aria-label="Restart">
            <Icon.Restart /><span>Restart</span>
          </button>
        )}
        <button type="button" className="chip chip--fullscreen" onClick={onFs} aria-label={expanded ? "Exit fullscreen" : "Fullscreen"}>
          {expanded ? <Icon.FullscreenExit /> : <Icon.Fullscreen />}
          <span>{expanded ? "Exit" : "Fullscreen"}</span>
        </button>
      </div>
      <div
        className="video-wrap"
        onClick={(e) => {
          e.stopPropagation();
          if (!expanded && onToggleExpand) onToggleExpand();
        }}
        style={!expanded && onToggleExpand ? { cursor: "zoom-in" } : undefined}
      >
        <video
          ref={videoRef}
          src={url || undefined}
          width={cachedDim ? cachedDim.width : undefined}
          height={cachedDim ? cachedDim.height : undefined}
          style={cachedDim ? { aspectRatio: `${cachedDim.width} / ${cachedDim.height}` } : undefined}
          loop
          muted
          playsInline
          preload="auto"
          onLoadedMetadata={(e) => {
            const el = e.currentTarget;
            if (onOrientation && el.videoWidth && el.videoHeight) {
              onOrientation(el.videoWidth < el.videoHeight);
            }
          }}
        />
      </div>
    </>
  );
}

function AssetItem({ asset, active }) {
  const type = asset.type || detectType(asset.src);

  let initialPortrait = false;
  if (typeof asset.portrait === "boolean") {
    initialPortrait = asset.portrait;
  } else if (type === "video") {
    const cached = getCachedDim(asset.src);
    if (cached) initialPortrait = cached.width < cached.height;
  }
  const [portrait, setPortrait] = React.useState(initialPortrait);

  // Expansion: "idle" | "open" | "closing". Single <video> element for both
  // states — when expanded we just add a class that promotes the container
  // to position:fixed fullscreen. No duplicate video, no remount, no dual
  // audio, no lost playback position.
  const [phase, setPhase] = React.useState("idle");
  const expanded = phase === "open";
  const showingOverlay = phase !== "idle";

  const onImgLoad = (e) => {
    const img = e.currentTarget;
    if (img.naturalWidth && img.naturalHeight) {
      setPortrait(img.naturalWidth < img.naturalHeight);
    }
  };

  const open = () => { if (phase === "idle") setPhase("open"); };
  const close = () => { if (phase === "open") setPhase("closing"); };
  const toggle = () => (expanded ? close() : open());

  // ESC closes.
  React.useEffect(() => {
    if (!showingOverlay) return;
    const onKey = (e) => { if (e.key === "Escape") close(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [showingOverlay]);

  // Browser back button → close fullscreen. Push a history state when opening
  // so the first "back" closes the fullscreen view instead of navigating away.
  React.useEffect(() => {
    if (phase !== "open") return;
    const token = { __videoExpanded: true };
    history.pushState(token, "");
    const onPop = () => close();
    window.addEventListener("popstate", onPop);
    return () => {
      window.removeEventListener("popstate", onPop);
      if (history.state && history.state.__videoExpanded) {
        history.back();
      }
    };
  }, [phase]);

  // Lock body scroll while expanded so the backdrop doesn't show the page
  // scrolling behind it.
  React.useEffect(() => {
    if (!showingOverlay) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, [showingOverlay]);

  const itemClass =
    "asset-item" +
    (portrait ? " portrait" : "") +
    (expanded ? " asset-item--expanded" : "") +
    (phase === "closing" ? " asset-item--closing" : "");

  return (
    <>
      {/* Backdrop rendered inline (sibling of .asset-item) so both live in
          .asset-slide's stacking context. If we portal the backdrop to <body>,
          its z-index:1000 is compared at body level — above the whole .layout
          subtree (z-index:2), which hides the expanded item (whose z-index:1001
          is trapped inside .asset-slide). Keeping them as siblings lets the
          natural 1000/1001 ordering put the item above the backdrop. This
          also keeps the fullscreen element DOM-inside .asset-panel, so the
          panel's mouseleave doesn't fire while the user is hovering the
          fullscreen view. */}
      {showingOverlay && (
        <div
          className={"asset-expand-backdrop" + (phase === "closing" ? " asset-expand-backdrop--closing" : "")}
          onClick={close}
          onAnimationEnd={(e) => {
            if (e.target !== e.currentTarget) return;
            if (phase === "closing") setPhase("idle");
          }}
        />
      )}
      <div
        className={itemClass}
        onClick={expanded ? (e) => { if (e.target === e.currentTarget) close(); } : undefined}
      >
        {type === "video" ? (
          <VideoAsset
            src={asset.src}
            active={active}
            noAudio={asset.noAudio}
            onOrientation={setPortrait}
            expanded={expanded || phase === "closing"}
            onToggleExpand={toggle}
            hideRestart={asset.hideRestart}
          />
        ) : (
          <div className="video-wrap" onClick={(e) => e.stopPropagation()}>
            <img src={asset.src} alt={asset.caption || ""} onLoad={onImgLoad} />
          </div>
        )}
        {asset.caption && <div className="asset-caption">{asset.caption}</div>}
      </div>
    </>
  );
}

function RailSlide({ assets, top, visible, enterOffset }) {
  // Persistent slide: always mounted, visibility toggled via opacity + top.
  // We animate `top` (not transform) on purpose — a CSS transform on this
  // element would create a containing block and trap position:fixed for any
  // descendant (e.g. the expanded asset item overlay), causing the fullscreen
  // view to render inside the rail instead of over the whole viewport.
  //
  // `top` only transitions while visible. A never-shown slide starts at
  // top=0; the first show sets a real top (e.g. 400px). If `top` transitioned
  // while hidden, that assignment would animate the slide "in from the top of
  // the screen" when opacity fades in. AssetRail.doShow pre-positions the
  // slide (still hidden) one frame before revealing it, and omitting the top
  // transition here lets that pre-position snap silently into place.
  const effectiveTop = top + (visible ? 0 : enterOffset);
  return (
    <div
      className={"asset-slide" + (visible ? " asset-slide--visible" : "")}
      style={{
        top: effectiveTop + "px",
        opacity: visible ? 1 : 0,
        pointerEvents: visible ? "auto" : "none",
        transition: visible
          ? `top ${SWAP_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity ${SWAP_MS}ms ease`
          : `opacity ${SWAP_MS}ms ease`,
      }}
    >
      {assets.map((a, i) => <AssetItem key={i} asset={a} active={visible} />)}
    </div>
  );
}

function AssetRail({ children }) {
  const isMobile = useIsMobile();
  // Registered highlights — each Highlight registers its asset set on mount,
  // and AssetRail renders one (hidden) RailSlide per registration. Showing a
  // highlight just toggles which slide is visible + positions the panel; the
  // underlying <video> elements are mounted once and never destroyed.
  const [registry, setRegistry] = React.useState(new Map()); // id -> assets
  const [activeId, setActiveId] = React.useState(null);
  const [topById, setTopById] = React.useState({}); // id -> top px
  const [visible, setVisible] = React.useState(false);
  const [enterOffset, setEnterOffset] = React.useState(12);

  const hideTimer = React.useRef(null);
  const showTimer = React.useRef(null);
  const activeHlRef = React.useRef(null);
  const lastTopRef = React.useRef(null);
  const nextId = React.useRef(1);
  const positionedIds = React.useRef(new Set());

  const registerHighlight = React.useCallback((assets) => {
    const id = nextId.current++;
    setRegistry(prev => {
      const next = new Map(prev);
      next.set(id, assets);
      return next;
    });
    return id;
  }, []);

  const unregisterHighlight = React.useCallback((id) => {
    setRegistry(prev => {
      const next = new Map(prev);
      next.delete(id);
      return next;
    });
  }, []);

  // Smart positioning.
  const computeTop = (elRect, railRect, estimatedHeight = 360) => {
    const railTopAbs = railRect.top;
    const railHeight = railRect.height;
    let top = elRect.top - railTopAbs;
    const maxTop = railHeight - estimatedHeight;
    if (top > maxTop) top = maxTop;
    if (top < 0) top = 0;
    return top;
  };

  const doShow = React.useCallback((id, assets, elRect, sourceTop, el) => {
    const rail = document.getElementById("asset-rail");
    if (!rail) return;
    const railRect = rail.getBoundingClientRect();
    const estH = Math.min(railRect.height, assets.length * 280 + 40);
    const top = computeTop(elRect, railRect, estH);

    if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }

    const prev = lastTopRef.current;
    lastTopRef.current = sourceTop;
    let off = 10;
    if (prev != null) {
      const delta = sourceTop - prev;
      off = delta >= 0 ? 12 : -12;
    }
    setEnterOffset(off);

    // Move active class.
    if (activeHlRef.current && activeHlRef.current !== el) {
      activeHlRef.current.classList.remove("highlight--active");
    }
    if (el) {
      el.classList.add("highlight--active");
      activeHlRef.current = el;
    }

    const firstTime = !positionedIds.current.has(id);
    positionedIds.current.add(id);
    setTopById(prevTops => ({ ...prevTops, [id]: top }));
    if (firstTime) {
      // Slide has never been positioned — it's sitting at top=0. Commit the
      // new top first (still hidden, so it snaps in place), then reveal on
      // the next frame so the opacity fade-in starts near the target instead
      // of triggering a long top animation from the top of the viewport.
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          setActiveId(id);
          setVisible(true);
        });
      });
    } else {
      setActiveId(id);
      setVisible(true);
    }
  }, []);

  const pendingRef = React.useRef(null); // { id, assets, el, args }
  const pollRef = React.useRef(null);

  const show = React.useCallback((id, assets, elRect, sourceTop, el) => {
    if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
    if (el && el === activeHlRef.current) return;

    const panelOpen = activeHlRef.current != null;
    const runNow = (rect) => doShow(id, assets, rect || elRect, sourceTop, el);

    // No panel open yet — show instantly on first hover.
    if (!panelOpen) { runNow(); return; }

    // Panel open: gate on cursor speed. Slow → switch now. Fast → queue
    // the target and wait until speed drops below threshold, as long as
    // the cursor is still over this element.
    if (getMouseSpeed() <= SPEED_THRESHOLD) {
      runNow();
      pendingRef.current = null;
      if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
      return;
    }

    pendingRef.current = { id, assets, sourceTop, el };
    if (pollRef.current) clearInterval(pollRef.current);
    pollRef.current = setInterval(() => {
      const p = pendingRef.current;
      if (!p) { clearInterval(pollRef.current); pollRef.current = null; return; }
      if (getMouseSpeed() <= SPEED_THRESHOLD) {
        // Only switch if the cursor is still over the pending element.
        const hovering = p.el.matches(":hover");
        if (hovering) {
          const rect = p.el.getBoundingClientRect();
          doShow(p.id, p.assets, rect, p.sourceTop, p.el);
        }
        pendingRef.current = null;
        clearInterval(pollRef.current);
        pollRef.current = null;
      }
    }, 40);
  }, [doShow]);

  const clearPending = React.useCallback((el) => {
    const p = pendingRef.current;
    if (p && (!el || p.el === el)) {
      pendingRef.current = null;
      if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
    }
  }, []);

  const scheduleHide = React.useCallback(() => {
    if (showTimer.current) { clearTimeout(showTimer.current); showTimer.current = null; }
    if (hideTimer.current) clearTimeout(hideTimer.current);
    hideTimer.current = setTimeout(() => {
      setVisible(false);
      setActiveId(null);
      lastTopRef.current = null;
      if (activeHlRef.current) {
        activeHlRef.current.classList.remove("highlight--active");
        activeHlRef.current = null;
      }
      hideTimer.current = null;
    }, HIDE_DELAY_MS);
  }, []);

  const cancelHide = React.useCallback(() => {
    if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
  }, []);

  const ctx = React.useMemo(() => ({
    show, scheduleHide, cancelHide, clearPending, registerHighlight, unregisterHighlight,
  }), [show, scheduleHide, cancelHide, clearPending, registerHighlight, unregisterHighlight]);
  React.useEffect(() => { window.__assetRailCtx = ctx; }, [ctx]);

  // Mobile: toggle body class so CSS can animate the drawer + resize the
  // document. Also wire the close button.
  React.useEffect(() => {
    if (!isMobile) {
      document.body.classList.remove("asset-drawer-open");
      return;
    }
    document.body.classList.toggle("asset-drawer-open", visible);
  }, [isMobile, visible]);

  // Shared close routine (used by back button, rail backdrop tap, ESC,
  // and browser back button).
  const closeOverlay = React.useCallback(() => {
    setVisible(false);
    setActiveId(null);
    lastTopRef.current = null;
    if (activeHlRef.current) {
      activeHlRef.current.classList.remove("highlight--active", "highlight--tap-active");
      activeHlRef.current = null;
    }
  }, []);

  // Wire the Back button.
  React.useEffect(() => {
    const btn = document.getElementById("asset-drawer-close");
    if (!btn) return;
    const onClick = (e) => { e.stopPropagation(); closeOverlay(); };
    btn.addEventListener("click", onClick);
    return () => btn.removeEventListener("click", onClick);
  }, [closeOverlay]);

  // Tap the rail backdrop (anywhere outside a slide/chip/video) to close
  // on mobile. We attach to the <aside id="asset-rail"> element itself so
  // clicks land there only when they hit empty backdrop space.
  React.useEffect(() => {
    if (!isMobile) return;
    const rail = document.getElementById("asset-rail");
    if (!rail) return;
    const onClick = (e) => {
      // Only close if the target is the rail itself or the panel wrapper —
      // clicks inside a slide, chip, or video bubble here too, but we
      // only want to dismiss on "empty" taps.
      const t = e.target;
      if (!t) return;
      if (
        t === rail ||
        t.classList?.contains("asset-panel") ||
        t.classList?.contains("asset-slide")
      ) {
        closeOverlay();
      }
    };
    rail.addEventListener("click", onClick);
    return () => rail.removeEventListener("click", onClick);
  }, [isMobile, closeOverlay]);

  // ESC closes on any platform, and the browser back button closes on mobile
  // (we push a history state when opening so back doesn't leave the page).
  React.useEffect(() => {
    if (!visible) return;
    const onKey = (e) => { if (e.key === "Escape") closeOverlay(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [visible, closeOverlay]);

  // Browser back button on mobile: push a history entry when opening,
  // pop it on close; a user-triggered popstate closes the overlay.
  React.useEffect(() => {
    if (!isMobile) return;
    if (!visible) return;
    const token = { __assetOverlay: true };
    history.pushState(token, "");
    const onPop = () => closeOverlay();
    window.addEventListener("popstate", onPop);
    return () => {
      window.removeEventListener("popstate", onPop);
      // If we're still at our pushed state (closed via other means), go back
      // to clean it up.
      if (history.state && history.state.__assetOverlay) {
        history.back();
      }
    };
  }, [isMobile, visible, closeOverlay]);

  return (
    <AssetRailContext.Provider value={ctx}>
      {children}
      {ReactDOM.createPortal(
        <div
          className={"asset-panel" + (visible ? " visible" : "")}
          onMouseEnter={isMobile ? undefined : cancelHide}
          onMouseLeave={isMobile ? undefined : scheduleHide}
        >
          {[...registry.entries()].map(([id, assets]) => (
            <RailSlide
              key={id}
              assets={assets}
              top={topById[id] || 0}
              visible={id === activeId}
              enterOffset={enterOffset}
            />
          ))}
        </div>,
        document.getElementById("asset-rail")
      )}
    </AssetRailContext.Provider>
  );
}

function Highlight({ as = "div", className = "", assets, children, ...rest }) {
  const Tag = as;
  const ref = React.useRef(null);
  const ctx = React.useContext(AssetRailContext) || (typeof window !== "undefined" ? window.__assetRailCtx : null);
  const idRef = React.useRef(null);
  const isMobile = useIsMobile();

  // Register this highlight with the rail once on mount so its RailSlide
  // is rendered persistently (never unmounted → videos never re-load).
  React.useEffect(() => {
    if (!ctx || !assets || assets.length === 0) return;
    if (idRef.current != null) return;
    idRef.current = ctx.registerHighlight(assets);
    return () => {
      if (idRef.current != null && ctx.unregisterHighlight) {
        ctx.unregisterHighlight(idRef.current);
        idRef.current = null;
      }
    };
  }, [ctx, assets]);

  const handleEnter = (e) => {
    if (rest.onMouseEnter) rest.onMouseEnter(e);
    if (isMobile) return; // mobile uses click, not hover
    if (!ctx) return;
    // Highlight with no assets — tell the rail to hide whatever's active
    // so the user isn't left with a mismatched video + highlighted row.
    if (!assets || assets.length === 0) {
      ctx.scheduleHide();
      return;
    }
    if (idRef.current == null) return;
    const el = ref.current;
    const rail = document.getElementById("asset-rail");
    if (!el || !rail) return;
    const elRect = el.getBoundingClientRect();
    ctx.show(idRef.current, assets, elRect, elRect.top + window.scrollY, el);
  };

  const handleLeave = (e) => {
    if (rest.onMouseLeave) rest.onMouseLeave(e);
    if (isMobile) return;
    if (!ctx) return;
    // Clear any pending show tied to this element (cursor left before
    // slowing down enough to trigger the switch).
    if (ctx.clearPending) ctx.clearPending(ref.current);
    ctx.scheduleHide();
  };

  // Tap on mobile → show the assets for this highlight. Tapping the same
  // highlight again closes the drawer.
  const handleClick = (e) => {
    if (rest.onClick) rest.onClick(e);
    if (!isMobile) return;
    if (!ctx) return;
    if (!assets || assets.length === 0) return;
    // Don't fire when user tapped a link or button inside the highlight.
    if (e.target && e.target.closest && e.target.closest("a, button")) return;
    if (idRef.current == null) return;
    const el = ref.current;
    if (!el) return;
    const elRect = el.getBoundingClientRect();
    ctx.cancelHide && ctx.cancelHide();
    ctx.show(idRef.current, assets, elRect, elRect.top + window.scrollY, el);
    // Mark this row as the tapped one (visual emphasis).
    document.querySelectorAll(".highlight--tap-active").forEach(n => {
      if (n !== el) n.classList.remove("highlight--tap-active");
    });
    el.classList.add("highlight--tap-active");
  };

  const { onMouseEnter, onMouseLeave, onMouseMove, onClick, ...passRest } = rest;

  return (
    <Tag
      ref={ref}
      className={("highlight " + className).trim()}
      onMouseEnter={handleEnter}
      onMouseLeave={handleLeave}
      onClick={handleClick}
      {...passRest}
    >
      {children}
    </Tag>
  );
}

function ResumeHeader({ left, middle, right }) {
  return (
    <header className="header row">
      <div>{left}</div>
      {middle != null && <div className="header-middle">{middle}</div>}
      <div className="contact">{right}</div>
    </header>
  );
}

/* Inline SVG QR code via qrcode-generator (loaded as a global from
   Resume.html). Re-encodes whenever `value` changes (e.g. on language
   switch). Cell size auto-derived from `size`.

   Encoding defaults are tuned for the resume:
     - errorLevel "L" (~7% recovery, fewest filler modules)
     - mode "Alphanumeric" (≈5.5 bits/char vs 8 in byte mode)
     - Alphanumeric requires the payload to be uppercase A-Z, digits,
       space, or one of $ % * + - . / :
     - The component uppercases the value internally — visible URL stays
       lowercase. DNS is case-insensitive; the path part needs server-
       side case-insensitive routing (see CLAUDE.md). For our short URLs
       the result is a version-1 QR (21x21) instead of version 2 (25x25).
*/
function QRCode({ value, size = 44, errorLevel = "L", mode = "Alphanumeric" }) {
  const html = React.useMemo(() => {
    if (typeof window === "undefined" || !window.qrcode || !value) return "";
    const qr = window.qrcode(0, errorLevel);
    const data = mode === "Alphanumeric" ? String(value).toUpperCase() : value;
    qr.addData(data, mode);
    qr.make();
    const count = qr.getModuleCount();
    const cellSize = Math.max(1, Math.floor(size / count));
    return qr.createSvgTag({ cellSize, margin: 0 });
  }, [value, size, errorLevel, mode]);
  return (
    <span
      className="qr-code"
      role="img"
      aria-label={`QR code linking to ${value}`}
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

function Section({ title, children }) {
  return (
    <section>
      {title && <h2 className="section-title">{title}</h2>}
      {children}
    </section>
  );
}

function Card({ left, meta, right, children }) {
  return (
    <div className="card">
      {(left || right || meta) && (
        <div className="card-head">
          <div className="card-head-left">
            {left}
            {meta && (
              <>
                <span className="leader" aria-hidden="true" />
                <span className="meta">{meta}</span>
              </>
            )}
          </div>
          {right && <div className="card-head-right">{right}</div>}
        </div>
      )}
      {children && <div className="card-body">{children}</div>}
    </div>
  );
}

function Bullets({ children }) {
  const items = React.Children.toArray(children);
  return (
    <ul className="bullets">
      {items.map((child, i) => <li key={i}>{child}</li>)}
    </ul>
  );
}

function Grid2({ children }) {
  return <div className="grid-2">{children}</div>;
}

function DividerBlock({ children }) {
  return <div className="divider-block">{children}</div>;
}

function Row({ left, right }) {
  return (
    <div className="row">
      <div>{left}</div>
      <div>{right}</div>
    </div>
  );
}

/* Preload videos as blobs so first hover is instant. Call with an array of
   asset arrays (or a flat list of {src, type?} / strings). */
function preloadAssets(input) {
  const srcs = [];
  const walk = (v) => {
    if (!v) return;
    if (typeof v === "string") srcs.push(v);
    else if (Array.isArray(v)) v.forEach(walk);
    else if (v.src) srcs.push(v.src);
    else if (typeof v === "object") Object.values(v).forEach(walk);
  };
  walk(input);
  srcs.forEach(s => {
    const t = detectType(s);
    if (t === "video") loadBlobUrl(s).catch(() => {});
    else { const img = new Image(); img.src = bust(s); }
  });
}

Object.assign(window, {
  AssetRail, AssetRailContext, preloadAssets, MuteProvider,
  Highlight, ResumeHeader, QRCode, Section, Card, Bullets, Grid2, DividerBlock, Row,
});
