/* global React */

// v07zz41 — Warm-thumbnail registry. The modal hero + version strip render a
// thumbnail INSTANTLY (no fade) when its URL is in this set, and only fade in
// genuinely cold (never-loaded) images. We can't rely on <img>.complete because
// /local/* is served Cache-Control:no-cache (revalidates → complete is briefly
// false even when fully cached). This set is the source of truth: a URL lands
// here once it has loaded — via prefetch below, or via any modal image's onLoad.
function _warmThumb(u) {
  if (!u || typeof window === "undefined") return;
  const set = (window.__warmedThumbs = window.__warmedThumbs || new Set());
  if (set.has(u)) return;
  const img = new Image();
  img.onload = () => { try { set.add(u); } catch (_) {} };
  img.src = u;
}

const SHIcon = {
  pin: <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 21s-7-7-7-12a7 7 0 0 1 14 0c0 5-7 12-7 12z"/><circle cx="12" cy="9" r="2.4"/></svg>,
  kebab: <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg>,
  sort: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/></svg>,
  filter: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 5h18l-7 9v6l-4-2v-4z"/></svg>,
  check: <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>,
};

// True iff the shot has at least one non-empty image path. Used to auto-
// derive the FIRST-PASS stage: as soon as a shot has any version of an
// image attached, it counts as first-pass done — the user no longer has
// to flip first_pass manually. Higher-stage flags (refinement/hero/video/
// upscale) still override this. If a shot has no image and no flags, it's
// PENDING (or PROMPT if prompt was explicitly marked).
function hasAnyImage(shot) {
  if (!shot || !shot.image_paths || typeof shot.image_paths !== "object") return false;
  for (const v of Object.values(shot.image_paths)) {
    if (v && typeof v === "string" && v.trim()) return true;
  }
  return false;
}

function getCurrentStage(shot) {
  if (!shot) return "PENDING";
  if (shot.is_archive || shot.archive_footage) return "ARCHIVE";
  const s = shot.stage_status || {};
  if (s.upscale === "done") return "UPSCALED";
  if (s.video === "done") return "VIDEO-APPROVED";
  if (s.video_prompt === "done") return "VIDEO-WIP";
  if (s.hero === "done") return "CONCEPT-APPROVED";
  if (s.refinement === "done") return "CONCEPT-WIP";
  // v07zz55 — Hugo: "unchecking first pass should go to pending. but
  // i think this is because we have an automation that if a shot has
  // an image, it's automatically First Pass status." Right. The auto-
  // derive now ONLY fires when first_pass is unset/null/undefined.
  // An explicit `"pending"` from the user wins and the shot drops to
  // PENDING even if asset_versions are attached.
  if (s.first_pass === "done") return "FIRST-PASS";
  if (s.first_pass == null && hasAnyImage(shot)) return "FIRST-PASS";
  if (s.prompt === "done") return "PROMPT";
  return "PENDING";
}

function stagePct(shot) {
  if (shot.is_archive || shot.archive_footage) return 100;
  const s = shot.stage_status || {};
  const fpAuto = hasAnyImage(shot);
  const done = (k) => s[k] === "done" || (k === "first_pass" && fpAuto);
  // v1080 — weighted like the Overall Progress circle (Frames / Video / Upscale per episode,
  // default 50 / 40 / 10): a shot with its video done reads 90%, no longer 86%. Only true
  // 100% once the 4K upscale is done. Falls back to 7 equal steps if App.jsx has not loaded.
  if (window.weightedStagePct) {
    const groups = window.stageGroupWeights ? window.stageGroupWeights(window.__appData && window.__appData.episode) : null;
    return Math.round(window.weightedStagePct(done, groups));
  }
  const order = ["prompt","first_pass","refinement","hero","video_prompt","video","upscale"];
  return Math.round((order.filter(done).length / order.length) * 100);
}

// t05e — pill palette per the reference: cream background for all pills,
// border + dot in the status colour, text in the status colour darkened
// for legibility on cream. The previous heavily-tinted backgrounds and
// the dark-glass UPSCALED treatment have been retired in favour of this
// uniform light-pill system.
// 16 Sep 2026 - every part of a pill is a token: a skin may give each status its own fill
// (--st-<s>-fill) and rim (--st-<s>-line). Without them it reads the shared pill fill and the
// status colour, exactly as before (Hugo: "The colours of all the pills are wrong").
const STAGE_TINTS = {
  // v01g — pill labels aligned with the v01g spec list:
  //   Pending / First Pass / Concept WIP / Concept Approved / Video WIP /
  //   Video Approved / Upscaled / Archival Footage. Filter UI reads these
  //   labels directly, so renaming once here updates both pill text and
  //   filter text.
  // v03a — labels renamed to match the new pipeline terminology.
  // Underlying stage_status keys (refinement/hero/video/upscale)
  // are unchanged — these are display strings only.
  UPSCALED:              { bg: "var(--st-upscaled-fill, var(--pill-bg))", border: "var(--st-upscaled-line, var(--st-upscaled))", color: "var(--st-upscaled-ink)", dot: "var(--st-upscaled)",  label: "4K UPSCALE"        },
  "VIDEO-APPROVED":      { bg: "var(--st-video-hero-fill, var(--pill-bg))", border: "var(--st-video-hero-line, var(--st-video-hero))", color: "var(--st-video-hero-ink, var(--st-hero-ink))", dot: "var(--st-video-hero)",  label: "VIDEO HERO"        },
  "VIDEO-WIP":           { bg: "var(--st-video-wip-fill, var(--pill-bg))", border: "var(--st-video-wip-line, var(--st-video-wip))", color: "var(--st-video-wip-ink)", dot: "var(--st-video-wip)",  label: "VIDEO WIP"         },
  "CONCEPT-APPROVED":    { bg: "var(--st-hero-fill, var(--pill-bg))", border: "var(--st-hero-line, var(--st-hero))", color: "var(--st-hero-ink)", dot: "var(--st-hero)",  label: "FRAME HERO"       },
  "CONCEPT-WIP":         { bg: "var(--st-wip-fill, var(--pill-bg))", border: "var(--st-wip-line, var(--st-wip))", color: "var(--st-wip-ink)", dot: "var(--st-wip)",  label: "FRAME WIP"         },
  "FIRST-PASS":          { bg: "var(--st-first-pass-fill, var(--pill-bg))", border: "var(--st-first-pass-line, var(--st-first-pass))", color: "var(--st-first-pass-ink)", dot: "var(--st-first-pass)",  label: "FIRST PASS"        },
  PROMPT:                { bg: "var(--st-prompt-fill, var(--pill-bg))", border: "var(--st-prompt-line, var(--st-prompt))", color: "var(--st-prompt-ink)", dot: "var(--st-prompt)",  label: "PROMPT"            },
  PENDING:               { bg: "var(--st-pending-fill, var(--pill-bg))", border: "var(--st-pending-line, var(--st-pending))", color: "var(--st-pending-ink)", dot: "var(--st-pending)",  label: "PENDING"           },
  ARCHIVE:               { bg: "var(--st-archive-fill, var(--pill-bg))", border: "var(--st-archive-line, var(--st-archive))", color: "var(--st-archive-ink)", dot: "var(--st-archive)",  label: "ARCHIVE"           },
  // v07zz359 — Omitted (cut from edit) is now a first-class stage: its own pill + filter row.
  OMITTED:               { bg: "var(--st-omitted-fill, var(--pill-bg))", border: "var(--st-omitted-line, var(--st-omitted))", color: "var(--st-omitted-ink)", dot: "var(--st-omitted)",  label: "OMITTED"           },
  // Legacy aliases — kept so other components that reference these keys
  // (FilterMenu's STAGE_FILTERS, modal previews) still resolve.
  HERO:        { bg: "var(--st-hero-fill, var(--pill-bg))", border: "var(--st-hero-line, var(--st-hero))", color: "var(--st-hero-ink)", dot: "var(--st-hero)", label: "FRAME HERO" },
  REFINEMENT:  { bg: "var(--st-wip-fill, var(--pill-bg))", border: "var(--st-wip-line, var(--st-wip))", color: "var(--st-wip-ink)", dot: "var(--st-wip)", label: "FRAME WIP" },
  COMPLETED:   { bg: "var(--st-video-hero-fill, var(--pill-bg))", border: "var(--st-video-hero-line, var(--st-video-hero))", color: "var(--st-video-hero-ink, var(--st-hero-ink))", dot: "var(--st-video-hero)", label: "VIDEO HERO" },
  RENDERING:   { bg: "var(--st-video-wip-fill, var(--pill-bg))", border: "var(--st-video-wip-line, var(--st-video-wip))", color: "var(--st-video-wip-ink)", dot: "var(--st-video-wip)", label: "VIDEO WIP" },
  "CONCEPT-IN-PROGRESS": { bg: "var(--st-wip-fill, var(--pill-bg))", border: "var(--st-wip-line, var(--st-wip))", color: "var(--st-wip-ink)", dot: "var(--st-wip)", label: "FRAME WIP" },
};

// 16 Sep 2026 - the sequence-hue table and the placeholder-gradient recipes live ONCE, on
// window (App.jsx): window.__seqHue / window.__seqGradient / window.__locGradient.

// Shared hook — reads the current "Auto-play thumbnail video" setting
// from localStorage (default "hover") and re-renders whenever the
// SettingsView dispatches "filmtracker:thumb-autoplay". Used by every
// shot-row thumbnail to decide whether to play continuously, on hover,
// or never.
function useThumbAutoplayMode() {
  const read = () => {
    try { return localStorage.getItem("filmtracker.thumb-autoplay") || "hover"; }
    catch (e) { return "hover"; }
  };
  const [mode, setMode] = React.useState(read);
  React.useEffect(() => {
    const handler = (e) => setMode((e && e.detail) || read());
    window.addEventListener("filmtracker:thumb-autoplay", handler);
    // Also pick up cross-tab changes via the storage event.
    const storageHandler = (e) => { if (e.key === "filmtracker.thumb-autoplay") setMode(read()); };
    window.addEventListener("storage", storageHandler);
    return () => {
      window.removeEventListener("filmtracker:thumb-autoplay", handler);
      window.removeEventListener("storage", storageHandler);
    };
  }, []);
  return mode;
}

// 17 Sep 2026 - ONE shared observer per shot list tells a video thumbnail whether it is near the
// visible part of the list: a row video loads its first frame only once it comes near. The list
// scrolls inside itself, so the observer watches the LIST (with the window as root, a row only
// counted once it was already on screen, and it showed its poster for ~1.8 s before its frame).
const _thumbNearIOs = new Map();   // the .shots-list element (null = the window) -> { io, cbs }
function _watchThumbNear(el, cb) {
  if (typeof IntersectionObserver === "undefined") { cb(true); return () => {}; }
  const root = (el.closest && el.closest(".shots-list")) || null;
  let w = _thumbNearIOs.get(root);
  if (!w) {
    const cbs = new Map();
    // look ahead one list-height (at least the rows' drawing distance): a first frame takes a moment
    // to load, so a row's video starts before the row scrolls in (a fast wheel showed no poster)
    const viewH = root ? root.clientHeight : window.innerHeight;
    const ahead = Math.round(Math.max(viewH || 0, _SHOT_ROW_NEAR_PX));
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) { const fn = cbs.get(e.target); if (fn) fn(e.isIntersecting); }
    }, { root, rootMargin: ahead + "px 0px" });
    w = { io, cbs };
    _thumbNearIOs.set(root, w);
  }
  const watch = w;
  watch.cbs.set(el, cb);
  watch.io.observe(el);
  return () => {
    if (watch.cbs.delete(el)) watch.io.unobserve(el);
    if (!watch.cbs.size && _thumbNearIOs.get(root) === watch) { watch.io.disconnect(); _thumbNearIOs.delete(root); }
  };
}
// 17 Sep 2026 - the rows' place while a project switch is on its way (the previous project's rows
// are gone, the new project's not in yet): row-sized blocks, so the list never shows another
// project's shots under the new title and never reads "No shots match". Each block is as tall as
// the last shot row shown (rows change height with the window, the layout and the thumbnail size);
// before any row was shown, the --shot-row-ph-h token.
const _SHOT_ROW_PLACEHOLDERS = 16;
// a row within this distance of the list's visible part is drawn (the rest are .is-far)
const _SHOT_ROW_NEAR_PX = 600;
// a batch of at least this many new rows (a project or episode arriving) starts hidden, see ShotsPanel
const _SHOT_ROW_BATCH = 30;
// The drawn height of each shot's row, per list (the Overview's and the Shots page's differ), as the
// row observer saw it: a hidden row keeps its shot's real height, so the list's scroll length and a
// restored scroll position stay right. Forgotten when that list changes width. { w, last, heights }
const _shotRowStores = new Map();
function _shotRowStore(key) {
  let s = _shotRowStores.get(key);
  if (!s) { s = { w: 0, last: 0, lastContent: 0, pad: null, heights: new Map() }; _shotRowStores.set(key, s); }
  return s;
}
function ShotRowPlaceholders({ rowH }) {
  const style = rowH > 0 ? { height: rowH + "px" } : undefined;
  const out = [];
  for (let i = 0; i < _SHOT_ROW_PLACEHOLDERS; i++) out.push(<div key={i} className="shot-row-ph" style={style} aria-hidden="true"/>);
  return out;
}

function ShotThumb({ shot, hovered = false }) {
  const hue = window.__seqHue(shot.seq, 100);
  const bg = window.__seqGradient(hue);
  // v05l/m — Thumbnail image. Prefer `shot.video_poster` (the matching
  // frame for shots with a video — typically v00N_f1 next to video
  // v00N) so the <video poster=…> attribute can paint instantly on
  // every page revisit instead of waiting on the video element's own
  // metadata + decode pipeline. For shots without a video, fall back
  // to the legacy image_paths first-pass / selected. Either way the
  // URL is run through window.thumbUrl(…, 400) so the server-side
  // sharp middleware returns a downsized variant.
  const fullImg = shot.video_poster
    || (shot.image_paths && (shot.image_paths.selected || shot.image_paths.first_pass));
  const img = window.thumbUrl ? window.thumbUrl(fullImg, 400) : fullImg;
  // The resting thumbnail shows the video's own first frame via the
  // `#t=0.1` fragment (the browser seeks + paints it at load). When
  // the user enables "all", the video plays continuously; when "hover"
  // (default) the video only plays while the parent row is hovered;
  // when "none" the thumbnail is a static first frame only.
  const video = shot.video_path || null;
  const videoSrc = video
    ? (video.includes("#") ? video : video + "#t=0.1")
    : null;
  const autoplayMode = useThumbAutoplayMode();
  const videoRef = React.useRef(null);
  // 17 Sep 2026 — opening Paradise Found mounted ~240 row videos that each downloaded and seeked on
  // mount (146–422 mp4 requests per visit; the GPU froze the page for 1–15 s). A row video now loads
  // nothing until it is needed: the file is attached when the row comes near the visible part of the
  // list (its first frame is the resting thumbnail, as before; in "all" mode it plays there) or on a
  // hover-play. Until then the
  // poster shows and the element has no src (preload "none"). The manual seek back to the first
  // frame happens only after the video has played. The poster is NOT always the video's first frame
  // (SH0010: poster frame v119, video v001), which is why an on-screen row still loads its frame.
  const wrapRef = React.useRef(null);
  // seen: has come near the visible part of the list once (its first frame loads and stays);
  // near: is near it now. "All" plays only the rows that are near (118 videos attached at once
  // shared the browser's 6 connections, so on a cold cache only 2-3 of the visible rows moved).
  const [seen, setSeen] = React.useState(false);
  const [near, setNear] = React.useState(false);
  const watchNear = autoplayMode === "all";
  React.useEffect(() => {
    if (!video || !wrapRef.current || (seen && !watchNear)) return undefined;
    // the ROW is watched: the thumbnail inside a hidden (.is-far) row has no layout to intersect
    const target = (wrapRef.current.closest && wrapRef.current.closest(".shot-row")) || wrapRef.current;
    return _watchThumbNear(target, (isNear) => {
      if (isNear) setSeen(true);
      setNear(isNear);
    });
    // `seen` only matters until it turns true outside "all" mode
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [video, watchNear, watchNear || !seen]);
  const [playedFor, setPlayedFor] = React.useState(null);
  const played = !!video && playedFor === video;
  const wantSrc = !!video && (seen || played || (autoplayMode === "hover" && hovered));
  const preload = wantSrc ? "metadata" : "none";
  // a video that leaves the page (a project switch, a filter) stops downloading
  React.useEffect(() => {
    const el = videoRef.current;
    if (!el) return undefined;
    return () => {
      try {
        el.pause();
        if (el.getAttribute("src")) { el.removeAttribute("src"); el.load(); }
      } catch (e) {}
    };
  }, [video]);
  React.useEffect(() => {
    const el = videoRef.current;
    if (!el || !video || !wantSrc) return;
    const playSafe = () => {
      try { const p = el.play(); if (p && p.catch) p.catch(() => {}); } catch (e) {}
      if (!played) setPlayedFor(video);
    };
    const stopSafe = () => {
      try {
        el.pause();
        // Seek back to the first-frame position so the resting frame
        // matches what was shown before any hover-play.
        el.currentTime = 0.1;
      } catch (e) {}
    };
    if (autoplayMode === "all") { if (near) playSafe(); else { try { el.pause(); } catch (e) {} } }
    else if (autoplayMode === "hover" && hovered) playSafe();
    else if (played) stopSafe();
  }, [autoplayMode, hovered, video, wantSrc, near]);
  return (
    /* v07zz211 — placeholder is ALWAYS the cream card colour (CSS .shot-thumb),
       for image, video AND empty shots, so the photo appears to materialise on
       the card. The old per-sequence dark gradient is gone. */
    <div className="shot-thumb" ref={wrapRef}>
      {video ? (
        <video
          key={video}
          ref={videoRef}
          className="shot-thumb-img shot-thumb-video"
          src={wantSrc ? videoSrc : undefined}
          poster={img || undefined}
          muted
          loop
          playsInline
          preload={preload}
        />
      ) : (
        /* v07zz185 — fade the photo onto the card colour (the .shot-thumb bg
           is transparent when an image exists) instead of flashing the hue
           gradient. Instant when already warm. */
        img && <img
          className={"shot-thumb-img" + (window.__warmedThumbs && window.__warmedThumbs.has(img) ? " is-warm" : "")}
          src={img} alt={shot.id} loading="lazy"
          ref={(el) => { if (el && ((el.complete && el.naturalWidth > 0) || (window.__warmedThumbs && window.__warmedThumbs.has(img)))) el.classList.add("is-warm"); }}
          onLoad={(e) => { e.currentTarget.classList.add("is-loaded"); try { if (window.__warmedThumbs) window.__warmedThumbs.add(img); } catch (_) {} }}
        />
      )}
      <span className="shot-thumb-tag">{shot.id}</span>
    </div>
  );
}

// Stage-tied colour ramps for the small per-shot donut. Each entry defines
// a 3-stop SVG gradient that matches its STAGE_TINTS sibling so the ring,
// the pill, and the dot all read as the same hue family.
const RING_PALETTES = {
  UPSCALED:           { from: "var(--ring-upscaled-1)", mid: "var(--ring-upscaled-2)", to: "var(--ring-upscaled-3)" },
  "VIDEO-APPROVED":   { from: "var(--ring-video-hero-1)", mid: "var(--ring-video-hero-2)", to: "var(--ring-video-hero-3, var(--forest-7))" },
  "VIDEO-WIP":        { from: "var(--ring-video-wip-1)", mid: "var(--ring-video-wip-2)", to: "var(--ring-video-wip-3)" },
  "CONCEPT-APPROVED": { from: "var(--ring-hero-1)", mid: "var(--ring-hero-2)", to: "var(--ring-hero-3, var(--forest-8))" },
  "CONCEPT-WIP":      { from: "var(--ring-wip-1)", mid: "var(--ring-wip-2)", to: "var(--ring-wip-3)" },
  "FIRST-PASS":       { from: "var(--ring-first-pass-1)", mid: "var(--ring-first-pass-2)", to: "var(--ring-first-pass-3)" },
  PROMPT:             { from: "var(--ring-prompt-1)", mid: "var(--ring-prompt-2)", to: "var(--ring-prompt-3)" },
  PENDING:            { from: "var(--ring-pending-1)", mid: "var(--ring-pending-2)", to: "var(--ring-pending-3)" },
  ARCHIVE:            { from: "var(--ring-archive-1)", mid: "var(--ring-archive-2)", to: "var(--ring-archive-3)" },
};
// 16 Sep 2026 — Hugo: "doesnt seem to be animation here when changing status with the
// donut, it should be growing to the next step and the percentage number should be going
// through all the numbers to the target one as it's filling up, and ease in when it gets
// closer."
//
// Both the arc and the number are driven from ONE tweened value, so they can never drift
// apart — a CSS transition on the arc plus a separate timer on the number would.
// easeOutCubic is the "ease in as it gets closer" curve: it covers most of the distance
// early and settles gently onto the target.
//
// Two ways out, both of which jump straight to the value rather than animating:
//   • the tab is hidden — requestAnimationFrame does not run there, so a tween started in
//     a background tab would freeze part-way and the ring would sit on a wrong number
//     until the tab came back;
//   • the reader asked for reduced motion.
const _RING_TWEEN_MS = 850;
function useCountUp(target, key) {
  const [shown, setShown] = React.useState(target);
  const fromRef = React.useRef(target);
  React.useEffect(() => {
    const from = fromRef.current;
    if (from === target) return;
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced || document.hidden) { fromRef.current = target; setShown(target); return; }
    let raf = 0, t0 = 0;
    const ease = (t) => 1 - Math.pow(1 - t, 3);          // easeOutCubic
    const step = (now) => {
      if (!t0) t0 = now;
      const t = Math.min(1, (now - t0) / _RING_TWEEN_MS);
      setShown(from + (target - from) * ease(t));
      if (t < 1) raf = requestAnimationFrame(step);
      else fromRef.current = target;
    };
    raf = requestAnimationFrame(step);
    return () => { cancelAnimationFrame(raf); fromRef.current = target; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, key]);
  return shown;
}

function PercentRing({ pct = 0, stage = "PENDING", size = 56 }) {
  const palette = RING_PALETTES[stage] || RING_PALETTES.PENDING;
  const sw = 5.5;
  const r = (size - sw - 2) / 2;
  const c = 2 * Math.PI * r;
  const target = Math.max(0, Math.min(100, Number(pct) || 0));
  const live = useCountUp(target, stage);
  const dash = (live / 100) * c;
  const numSize = Math.round(size * 0.36);
  const pctSize = Math.round(size * 0.20);
  // One gradient per stage+size. It used to include the rounded percentage, which meant a
  // brand-new gradient element on every single frame of the tween.
  const gid = `pring-${stage}-${size}`;
  return (
    <svg className="pring" data-stage={stage} style={{ "--ring-mid": palette.mid }} viewBox={`0 0 ${size} ${size}`} width={size} height={size} shapeRendering="geometricPrecision">
      <defs>
        <linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%"   stopColor={palette.from}/>
          <stop offset="55%"  stopColor={palette.mid}/>
          <stop offset="100%" stopColor={palette.to}/>
        </linearGradient>
      </defs>
      {/* Cream/tan track — softer than the previous grey, matches the
          reference's warm light ring under the arc. */}
      <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="color-mix(in srgb, var(--card-border) 30%, transparent)" strokeWidth={sw}/>
      <circle cx={size/2} cy={size/2} r={r} fill="none"
        stroke={`url(#${gid})`}
        strokeWidth={sw} strokeLinecap="round"
        strokeDasharray={`${dash} ${c}`}
        transform={`rotate(-90 ${size/2} ${size/2})`}/>
      <text x="50%" y="50%" textAnchor="middle" dominantBaseline="central"
        fontFamily="var(--font-display)" fill="var(--ink)" letterSpacing="0.005em">
        <tspan fontSize={numSize} fontWeight="500">{Math.round(live)}</tspan><tspan fontSize={pctSize} fill="var(--ink-muted)">%</tspan>
      </text>
    </svg>
  );
}

// t06b — ShotKebabMenu retired. The component is gone from the tree;
// shot actions now flow only through the status pill dropdown (status
// changes) and future folder-watcher / webhook integrations.

// Status options shown in the inline pill dropdown — pipeline order top-to-bottom,
// with PENDING/ARCHIVE as bookends. Each option's `id` is the status key the
// backend expects on PATCH /api/shots/:id/status.
// v03a — display labels renamed to the new pipeline terminology.
// Stage ids (CONCEPT-WIP etc.) remain unchanged for backwards compat
// with stage_status / API contracts.
const STATUS_OPTIONS = [
  { id: "PENDING",          label: "Pending" },
  { id: "PROMPT",           label: "Prompt" },
  { id: "FIRST-PASS",       label: "First Pass" },
  { id: "CONCEPT-WIP",      label: "Frame WIP" },
  { id: "CONCEPT-APPROVED", label: "Frame Hero" },
  { id: "VIDEO-WIP",        label: "Video WIP" },
  { id: "VIDEO-APPROVED",   label: "Video Hero" },
  { id: "UPSCALED",         label: "4K Upscale" },
  { id: "ARCHIVE",          label: "Archive" },
];

// Map a stage_status key (used by the quick-action buttons) to the matching
// user-facing status id (used by the dropdown + the API).
const STAGE_TO_STATUS = {
  prompt:       "PROMPT",
  first_pass:   "FIRST-PASS",
  refinement:   "CONCEPT-WIP",
  hero:         "CONCEPT-APPROVED",
  video_prompt: "VIDEO-WIP",
  video:        "VIDEO-APPROVED",
  upscale:      "UPSCALED",
};

// v01a era — Inline editable status pill. v01f drops the dropdown
// entirely: status changes flow only through the shot detail modal.
// The pill is now a passive label on the row that doesn't intercept
// click events, so clicking the row anywhere (pill included) opens the
// shot modal. The onStatusChange prop is kept on the function signature
// for backwards compatibility with existing call sites; it just isn't
// invoked from here any more.
function StatusPill({ shot /* , onStatusChange — retained but unused per v01f */ }) {
  // v07zz366 — a cut shot shows OMITTED here (same stage-tag slot as FIRST PASS), per Hugo.
  const stage = (shot && shot.omitted) ? "OMITTED" : getCurrentStage(shot);
  const tint = STAGE_TINTS[stage] || STAGE_TINTS.PENDING;
  // v971 — RETAKE is a flag, not a stage: it rides BESIDE the pipeline pill so the
  // shot keeps showing how far it got while still being marked to shoot again.
  // v1070 — Hugo: "i need to be able to clear the Retake flag on the shot rows in the
  // Overview page, by clicking on it". A click on RETAKE clears it (PATCH …/retake
  // {retake:false}, the route the Notes page toggle uses) instead of opening the shot.
  // Optimistic like the row's Cut toggle: the pill goes at once and comes back if the
  // server says no. Only for roles that may change a shot's status (the route's gate);
  // for everyone else it stays a plain label.
  const canClearRetake = !window.hasPerm || window.hasPerm("change_shot_status");
  const [retakeClearing, setRetakeClearing] = React.useState(false);
  React.useEffect(() => { setRetakeClearing(false); }, [shot && shot.id, shot && shot.retake]);
  const retake = !!(shot && shot.retake) && !retakeClearing;
  const clearRetake = async (e) => {
    e.stopPropagation();
    e.preventDefault();
    if (!canClearRetake || !shot) return;
    setRetakeClearing(true);
    try {
      const f = window.authFetch || fetch;
      const r = await f(`/api/shots/${encodeURIComponent(shot.id)}/retake`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ retake: false }),
      });
      if (!r || !r.ok) throw new Error("HTTP " + (r && r.status));
      shot.retake = false;
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (err) {
      console.warn("[retake] could not clear the flag:", err && err.message);
      setRetakeClearing(false);
    }
  };
  return (
    <>
    {retake && (canClearRetake
      ? <span className="shot-pill shot-pill--retake shot-pill--retake-clear" role="button" tabIndex={0}
          title="Flagged for a retake. Click to clear the flag."
          onClick={clearRetake}
          onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") clearRetake(e); }}>
          <span className="shot-pill-dot"/><span className="shot-pill-label">RETAKE</span>
        </span>
      : <span className="shot-pill shot-pill--retake" title="Flagged for a retake"><span className="shot-pill-dot"/><span className="shot-pill-label">RETAKE</span></span>)}
    <span
      className="shot-pill shot-pill--static"
      data-stage={stage}
      style={{background: tint.bg, borderColor: tint.border, color: tint.color}}
      aria-label={`Status: ${tint.label || stage}`}
    >
      <span className="shot-pill-dot" style={{background: tint.dot}}/>
      <span className="shot-pill-label">{tint.label || stage}</span>
    </span>
    </>
  );
}

// ─── v07zz583 — reviewed-by chips + admin "Needs Reviewing" ────────────────
// Server (/api/data rowToShot) sends per shot: note_authors [{id,name,last_note_at}],
// last_version_at, needs_review, needs_review_at. Derivations (Hugo's rules):
//   • reviewed chip = authors whose LAST note is NEWER than the shot's last new
//     image/video version — someone ELSE's note never clears your mark; only a
//     NEW VERSION resets the chip.
//   • needs MY review = admin flagged the shot AND I have no note since the flag
//     (Markus comments → cleared for Markus, stays on for John).
// Timestamps are SQLite "YYYY-MM-DD HH:MM:SS" strings → lexicographic compare works.
function shotReviewers(shot) {
  const authors = Array.isArray(shot && shot.note_authors) ? shot.note_authors : [];
  const lastVer = (shot && shot.last_version_at) || null;
  return authors.filter(a => a && a.last_note_at && (!lastVer || String(a.last_note_at) > String(lastVer)));
}
function shotNeedsMyReview(shot) {
  if (!shot || !shot.needs_review) return false;
  const me = window.__currentUser && window.__currentUser.id;
  if (me == null) return false;
  const at = shot.needs_review_at || "";
  const authors = Array.isArray(shot.note_authors) ? shot.note_authors : [];
  const mine = authors.find(a => a && String(a.id) === String(me));
  return !(mine && mine.last_note_at && String(mine.last_note_at) > String(at));
}
function reviewerInitials(name) {
  const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return "?";
  return (parts[0][0] + (parts[1] ? parts[1][0] : "")).toUpperCase();
}
window.__shotNeedsMyReview = shotNeedsMyReview;   // ShotDetailModal reuses the same rule

// ─── v972 — SHARED QUICK-ACTION STAGE BUTTONS ───────────────────────────────
// The six little icon buttons from the shot row, lifted out so the Notes page
// can show the SAME bar in its own column (Hugo: "I want another column with
// the little buttons … just like they are in the shot list"). ShotRow passes its
// own onChange so its behaviour is untouched; a caller without one (the Notes
// page) gets the identical toggle-back logic applied here and PATCHes directly.
const SQA_STEP_BACK = {
  "FIRST-PASS": "PROMPT", "PROMPT": "PENDING", "CONCEPT-WIP": "FIRST-PASS",
  "CONCEPT-APPROVED": "CONCEPT-WIP", "VIDEO-WIP": "CONCEPT-APPROVED",
  "VIDEO-APPROVED": "VIDEO-WIP", "UPSCALED": "VIDEO-APPROVED",
};
function ShotQuickActions({ shot, onChange, withRetake = false }) {
  const [busy, setBusy] = React.useState(false);
  const canChangeStatus = !window.hasPerm || window.hasPerm("change_shot_status");
  if (!shot || !canChangeStatus) return null;
  const stage = getCurrentStage(shot);
  const patch = async (url, body) => {
    setBusy(true);
    try {
      const r = await (window.authFetch || fetch)(url, {
        method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      if (window.reloadAppData) await window.reloadAppData();
    } catch (e) { console.warn("[quick-action]", e.message); }
    finally { setBusy(false); }
  };
  const change = (target) => {
    if (onChange) return onChange(target);
    const statusKey = STAGE_TO_STATUS[target];
    const next = (stage === statusKey) ? (SQA_STEP_BACK[statusKey] || "PENDING") : statusKey;
    patch("/api/shots/" + shot.id + "/status", { status: next });
  };
  return (
    <div className="shot-quick-actions" onClick={(e) => e.stopPropagation()}>
          <button className={"sqa-btn sqa-fp" + (stage === "FIRST-PASS" ? " is-active" : "")} title="First Pass" aria-label="First Pass" onClick={() => change("first_pass")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="6" width="16" height="12" rx="2"/><circle cx="9" cy="12" r="2"/><path d="M4 18l4.5-4 3 3 4-3 4.5 4"/></svg>
          </button>
          <button className={"sqa-btn sqa-cw" + (stage === "CONCEPT-WIP" ? " is-active" : "")} title="Frame WIP" aria-label="Frame WIP" onClick={() => change("refinement")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 14.5-7.1"/><path d="M21 12a9 9 0 0 1-14.5 7.1"/><path d="M17 5h4V1"/><path d="M7 19H3v4"/></svg>
          </button>
          <button className={"sqa-btn sqa-ca" + (stage === "CONCEPT-APPROVED" ? " is-active" : "")} title="Frame Hero" aria-label="Frame Hero" onClick={() => change("hero")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>
          </button>
          <button className={"sqa-btn sqa-rd" + (stage === "VIDEO-WIP" ? " is-active" : "")} title="Video WIP" aria-label="Video WIP" onClick={() => change("video_prompt")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 4 19 12 5 20 5 4"/></svg>
          </button>
          <button className={"sqa-btn sqa-va" + (stage === "VIDEO-APPROVED" ? " is-active" : "")} title="Video Hero" aria-label="Video Hero" onClick={() => change("video")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>
          </button>
          <button className={"sqa-btn sqa-up" + (stage === "UPSCALED" ? " is-active" : "")} title="4K Upscale" aria-label="4K Upscale" onClick={() => change("upscale")}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14V6a2 2 0 0 1 2-2h8M20 10v8a2 2 0 0 1-2 2H10"/><path d="M14 4h6v6M10 20H4v-6"/></svg>
          </button>
      {withRetake && (
        <button className={"sqa-btn sqa-retake" + (shot.retake ? " is-active" : "")}
          title={shot.retake ? "Retake flag is ON — click to clear" : "Flag this shot for a retake"}
          aria-label="Retake" disabled={busy}
          onClick={() => patch("/api/shots/" + shot.id + "/retake", { retake: !shot.retake })}>
          <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
        </button>
      )}
    </div>
  );
}

function ShotRow({ shot, sequences, selected, onSelect, onOpen, onStageChange, onStatusChange, noteCount, isGenerating = false, batchMode = false }) {
  const stage = getCurrentStage(shot);
  const tint = STAGE_TINTS[stage];
  const pct = stagePct(shot);
  const seq = sequences.find(s => s.number === shot.seq);
  // v07zz350 — guard seq.slug. A sequence row can exist with a NULL slug (e.g. a freshly
  // duplicated/created shot whose seq isn't named yet), and `seq.slug.toUpperCase()` then threw
  // mid-render — being in the shot-ROW render path, that took down the WHOLE app via the root
  // error boundary ("Something hit a display error" after duplicating a shot). Append the slug
  // only when present; fall back to the shot's own seq number.
  const seqLabel = seq
    ? `SEQUENCE ${String(seq.number ?? shot.seq).padStart(2, "0")}${seq.slug ? " · " + String(seq.slug).toUpperCase() : ""}`
    : `SEQUENCE ${shot.seq}`;
  const v = shot.archive_footage ? "archive · stock footage" : shot.is_archive ? "archive · 4K scan" : "v003 · May 24, 2026";
  const change = (target) => onStageChange && onStageChange(shot.id, target);
  // v07zz278 — UI-hiding mirrors the server gates: the quick-action stage
  // bar needs change_shot_status; cut-from-edit (PATCH /omit) is producer+.
  const canChangeStatus = !window.hasPerm || window.hasPerm("change_shot_status");
  const canCutShots = !window.__effectiveRole || ["admin", "producer"].includes(window.__effectiveRole);
  // v07zz289 — link (visual continuation link). PATCH /api/shots/:id/link is edit_shots.
  const canLink = !window.hasPerm || window.hasPerm("edit_shots");
  // shot.linked_to → this is a continuation of that master; else if another shot
  // points here, this shot IS the master. Either way the pair shows a chain badge.
  const _allShots = (window.__appData && window.__appData.shots) || [];
  const linkMaster = shot.linked_to || null;
  const linkSlaveRow = linkMaster ? null : _allShots.find(s => s.linked_to === shot.id);
  const linkPartner = linkMaster || (linkSlaveRow && linkSlaveRow.id) || null;
  const linkIsMasterHere = !!linkSlaveRow && !linkMaster;   // this shot is the master of the pair
  // v898 — a master can own several cuts (SH0620 → SH0625 / SH0630 / SH0650); name them all.
  const linkCutIds = linkIsMasterHere ? _allShots.filter(s => s.linked_to === shot.id).map(s => s.id) : [];
  const unresolvedNotes = (noteCount && noteCount.unresolved) || 0;
  // v07zz583 — reviewed-by chip (✓ + initials of everyone who commented since the last
  // new version) + "needs MY review" accent when the admin flag is open for this user.
  const reviewers = shotReviewers(shot);
  const needsMyReview = shotNeedsMyReview(shot);
  // Row-level hover state — passed to ShotThumb so the video preview
  // plays while the user has the whole row hovered, not only the
  // thumbnail rectangle. Avoids the previously janky behaviour where
  // moving the cursor across the row's text would pause playback.
  const [hovered, setHovered] = React.useState(false);

  // v07zj/v07zk — Hugo: first-open of a shot on Railway felt slow.
  // Pre-fetch /api/asset-versions AND the hero image the moment the
  // user hovers a shot row. The fetch response lands in BROWSER
  // HTTP cache (Cache-Control respected) AND in our window-level
  // Map; the modal reads from both. Hero image preload via new
  // Image() puts the WebP variant in the browser's image cache so
  // the modal hero paints synchronously.
  //
  // No "PENDING" sentinel — let the browser dedupe identical
  // concurrent requests. Multiple hovers on the same row just fire
  // multiple fetches the second of which is an HTTP cache hit.
  const prefetchShot = React.useCallback(() => {
    if (!shot || !shot.id) return;
    const id = shot.id;
    window.__assetVersionCache = window.__assetVersionCache || new Map();
    const existing = window.__assetVersionCache.get(id);
    // Already have a real array cached? Skip entirely.
    if (Array.isArray(existing) && existing.length) return;
    // Inflight tracker keyed by id — dedupes overlapping prefetches
    // without writing a non-array sentinel into the data cache.
    window.__assetVersionInflight = window.__assetVersionInflight || new Set();
    // v07zz210 — NO debounce, NO concurrency cap. Hugo spent days killing the
    // gradient-placeholder flicker; warming the instant the cursor touches a row
    // (and letting the browser dedupe identical fetches) is what makes opens feel
    // instant. The earlier "perf" debounce/cap reintroduced the flicker — reverted.
    if (!window.__assetVersionInflight.has(id)) {
      window.__assetVersionInflight.add(id);
      (window.authFetch || fetch)(`/api/asset-versions?asset_id=${encodeURIComponent(id)}`)
        .then(r => r.ok ? r.json() : null)
        .then(d => {
          const versions = (d && d.versions) || [];
          window.__assetVersionCache.set(id, versions);
          try { sessionStorage.setItem("asset-versions:" + id, JSON.stringify(versions)); } catch (_) {}
          // v07zl — Hugo: hover prefetch was warming the hero but the
          // version-strip thumbnails still loaded one-by-one when the
          // modal opened. Chain: once the version list lands, preload
          // EVERY frame / hero / grid thumbnail at the same 320 px
          // width the strip will request. Browser drops these into
          // its image cache so the modal strip paints in one frame.
          if (window.thumbUrl) {
            for (const v of versions) {
              if (!v || !v.file_path) continue;
              if (v.kind !== "frame" && v.kind !== "hero" && v.kind !== "grid") continue;
              _warmThumb(window.thumbUrl(v.file_path, 320));
            }
            // v07zz40 — ALSO warm the exact image the modal will show as its
            // HERO, at the 800px width the modal requests. The loop above only
            // warms the 320px STRIP size; the modal hero is the active version's
            // frame at 800, which was never pre-warmed → cold /api/r2-thumb on
            // Railway (~1s). The modal picks the hero row first, else newest
            // frame/grid — warm that exact URL so the open is instant.
            const heroV = versions.find(v => v && v.kind === "hero" && v.file_path)
              || versions.find(v => v && (v.kind === "frame" || v.kind === "grid") && v.file_path);
            if (heroV) _warmThumb(window.thumbUrl(heroV.file_path, 800));
          }
        })
        .catch(() => {})
        .finally(() => { window.__assetVersionInflight.delete(id); });
    }
    // Hero image preload — same width the modal will request (800).
    const heroSrc = shot.video_poster
      || (shot.image_paths && (shot.image_paths.selected || shot.image_paths.first_pass));
    if (heroSrc && window.thumbUrl) {
      const img = new Image();
      img.src = window.thumbUrl(heroSrc, 800);
    }
  }, [shot]);

  // v07zz154 — "Cut from edit" toggle. Greys the row and excludes the shot
  // from batch generation. Optimistic + mutate the shared shot object so the
  // Generate batch (which reads the same objects) sees it without a reload.
  const [omitted, setOmitted] = React.useState(!!shot.omitted);
  const toggleOmit = async (e) => {
    e.stopPropagation();
    if (!canCutShots) return;
    const next = !omitted;
    setOmitted(next);
    shot.omitted = next;
    try {
      const f = window.authFetch || fetch;
      await f(`/api/shots/${encodeURIComponent(shot.id)}/omit`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ omitted: next }),
      });
    } catch (_) { /* optimistic; best-effort */ }
  };

  return (
    <div
      data-shot-id={shot.id}
      className={"shot-row" + (selected ? " selected" : "") + (batchMode && selected ? " batch-selected" : "") + ((shot.is_archive || shot.archive_footage) ? " is-archived" : "") + (omitted ? " is-omitted" : "") + (isGenerating ? " is-generating" : "") + (needsMyReview ? " needs-my-review" : "") + (shot.needs_review ? " is-review" : "") + (linkPartner ? (linkIsMasterHere ? " is-linked is-link-master" : " is-linked is-link-cut") : "")}
      onClick={() => onOpen && onOpen(shot)}
      onMouseEnter={() => { setHovered(true); prefetchShot(); }}
      onMouseLeave={() => setHovered(false)}
    >
      <ShotThumb shot={shot} hovered={hovered}/>
      <div className="shot-info">
        <div className="shot-eyebrow">
          {/* v898 — in its own span so it can shrink + ellipsize; tags and the right-edge
              actions keep their size and the eyebrow stays one line. */}
          <span className="shot-seq-label" title={typeof seqLabel === "string" ? seqLabel : undefined}>{seqLabel}</span>
          {shot.is_archive && <span className="shot-archived-tag">ARCHIVED</span>}
          {!shot.is_archive && shot.archive_footage && <span className="shot-archived-tag" title="Archive-footage placeholder — excluded from generation">ARCHIVE</span>}
          {/* v07zz583 — admin flagged this shot for review and YOU haven't commented since. */}
          {/* v898 — the tag shows on EVERY flagged row (gold wash + bar on the whole row); it
              turns solid when the flag is still waiting on YOUR note. */}
          {shot.needs_review && (
            <span className={"shot-review-tag" + (needsMyReview ? " is-mine" : "")}
              title={needsMyReview ? "Flagged 'Needs Reviewing' — leave a note on this shot to clear it for you" : "Flagged 'Needs Reviewing' — you have already commented since the flag"}>
              {needsMyReview ? "In review · you" : "In review"}
            </span>
          )}
          {/* v07zz366 — OMITTED now shows in the right-side stage pill (StatusPill), where FIRST PASS sits. */}
          {/* v07zz323 — group ALL row actions in one right-aligned flex container.
              Was: cut + edit + dup each had margin-left:auto, so in the flex eyebrow
              the free space split three ways and the buttons spread apart. One
              container with margin-left:auto keeps the whole cluster tight + right. */}
          <div className="shot-actions">
          {/* v07zz154 — cut-from-edit toggle (greys the row, excluded from batch).
              v07zz278 — hidden for sub-producer roles (PATCH /omit is producer+). */}
          {canCutShots && (
          <button
            type="button"
            className={"shot-cut-toggle" + (omitted ? " is-on" : "")}
            title={omitted ? "Restore shot (include in edit + batch)" : "Mark as cut from the edit (grey out + exclude from batch)"}
            aria-label={omitted ? "Restore shot" : "Cut shot from edit"}
            onClick={toggleOmit}
          >
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12"/>
            </svg>
          </button>
          )}
          {/* v07zz289 — link (continuation) button + jump-tag, next to the cut toggle. */}
          {canLink && (
          <button
            type="button"
            className={"shot-link-toggle" + (linkPartner ? " is-on" : "")}
            title={linkPartner ? `Linked with ${linkPartner} (${linkIsMasterHere ? "this is the master" : "this is a continuation cut"}) — click to manage` : "Link this shot to another as a continuation (2 cuts of one generation)"}
            aria-label="Link shot"
            onClick={(e) => { e.stopPropagation(); if (window.__openShotLinkPicker) window.__openShotLinkPicker(shot); }}
          >
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M9 12h6M10.5 8H8a4 4 0 0 0 0 8h2.5M13.5 8H16a4 4 0 0 1 0 8h-2.5"/>
            </svg>
          </button>
          )}
          {/* v07zz321 — Edit + Duplicate moved off the shot modal onto the row (next to cut/link). */}
          {(!window.hasPerm || window.hasPerm("edit_shots")) && (
          <button type="button" className="shot-cut-toggle shot-edit-toggle"
            title="Edit this shot's details — title, shot type, action, VO, location…"
            aria-label="Edit shot"
            onClick={(e) => { e.stopPropagation(); if (window.__openShotEditPicker) window.__openShotEditPicker(shot); }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
          </button>
          )}
          {/* v07zz535 — Copy this shot's VIDEO folder path (same action as the shot queue) so
              the Kling/Seedance "Save As" dialog can be pointed straight at it. Local-only. */}
          <button type="button" className="shot-cut-toggle shot-vidpath-toggle"
            title="Copy this shot's video folder path (paste into Kling/Seedance Save As)"
            aria-label="Copy video folder path"
            onClick={(e) => { e.stopPropagation(); if (window.__copyShotVideoFolder) window.__copyShotVideoFolder(shot.id); }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="3" width="12" height="18" rx="1.5"/><path d="M9 3v2h6V3"/><path d="M9 12h6M9 16h4"/></svg>
          </button>
          {/* v07zz325 — Duplicate POSTs /api/shots (REQ_PRODUCER_PLUS), so only show it to
              producer+ — matching AddShotButton. Edit (above) stays edit_shots since PATCH
              /api/shots/:id is edit_shots-gated. (Was: director/supervisor saw it + got a 403.) */}
          {["admin", "producer"].includes(window.__effectiveRole) && (
          <button type="button" className="shot-cut-toggle shot-dup-toggle"
            title="Duplicate this shot — copies all details into a new shot number"
            aria-label="Duplicate shot"
            onClick={(e) => { e.stopPropagation(); if (window.__openShotDupPicker) window.__openShotDupPicker(shot); }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
          </button>
          )}
          {/* v693 — Flag for review, ON/OFF (Hugo: "same type of buttons but that stays
              on or off"). Same flag this shot's modal already toggles — one source of
              truth, so flipping it here and opening the shot agree. Producer+ to match
              PATCH /api/shots/:id/needs-review (REQ_PRODUCER_PLUS); anyone below would
              just get a 403, so the button isn't shown to them.
              ON stamps needs_review_at server-side, which is what makes the flag clear
              PER PERSON as each reviewer comments — see _needsMyReview above. */}
          {["admin", "producer"].includes(window.__effectiveRole) && (
          <button type="button"
            className={"shot-cut-toggle shot-review-toggle" + (shot.needs_review ? " is-on" : "")}
            title={shot.needs_review
              ? "Flagged 'Needs Reviewing' (clears per person as they comment) — click to turn OFF for everyone"
              : "Flag this shot as 'Needs Reviewing' — reviewers see a REVIEW mark until they comment"}
            aria-label="Flag for review"
            aria-pressed={!!shot.needs_review}
            onClick={(e) => {
              e.stopPropagation();
              const next = !shot.needs_review;
              // Match SQLite's datetime('now') shape — needs_review_at is compared as a STRING.
              const nowSql = new Date().toISOString().slice(0, 19).replace("T", " ");
              if (window.__updateShotField) window.__updateShotField(shot.id, { needs_review: next, needs_review_at: next ? nowSql : (shot.needs_review_at || null) });
              (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}/needs-review`, {
                method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on: next }),
              }).then(() => { if (window.reloadAppData) window.reloadAppData(); }).catch(() => {});
            }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 21V4"/><path d="M4 4h12l-2 4 2 4H4"/></svg>
          </button>
          )}
          {/* v07zz326 — Delete shot. DELETE /api/shots/:id is REQ_PRODUCER_PLUS, so
              show it to producer+ only (matches Duplicate). Opens a styled confirm
              (invariant #22 — never native window.confirm). */}
          {["admin", "producer"].includes(window.__effectiveRole) && (
          <button type="button" className="shot-cut-toggle shot-del-toggle"
            title="Delete this shot (reversible — images are archived, the number can be reused)"
            aria-label="Delete shot"
            onClick={(e) => { e.stopPropagation(); if (window.__openShotDeletePicker) window.__openShotDeletePicker(shot); }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>
          </button>
          )}
          {linkPartner && (
            <button
              type="button"
              className={"shot-link-tag" + (linkIsMasterHere ? " is-master" : " is-cut")}
              title={linkIsMasterHere ? `Master of ${(linkCutIds.length ? linkCutIds : [linkPartner]).join(", ")} — click to open ${linkPartner}` : `Continuation of ${linkPartner} (the master) — click to open it`}
              onClick={(e) => { e.stopPropagation(); if (window.__nav && window.__nav.openShot) window.__nav.openShot(linkPartner); }}
            >
              <svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 12h6M10.5 8H8a4 4 0 0 0 0 8h2.5M13.5 8H16a4 4 0 0 1 0 8h-2.5"/></svg>
              {/* v898 — the tag names the ROLE, not just the partner: the master row reads
                  "MASTER · SH0280", the continuation reads "CONTINUES SH0270". */}
              {linkIsMasterHere ? `Master · ${linkPartner}${linkCutIds.length > 1 ? ` +${linkCutIds.length - 1}` : ""}` : `Continues ${linkPartner}`}
            </button>
          )}
          {/* v07zz583 — ✓ + initials of everyone who has reviewed (commented on) this shot
              since its last new image/video version. A new version resets the chip; other
              people's notes never clear YOUR mark. */}
          {reviewers.length > 0 && (
            <span className="shot-reviewed-chip"
              title={"Reviewed (commented) since the last new version: " + reviewers.map(r => r.name).join(", ")}>
              <svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>
              {reviewers.slice(0, 3).map(r => (
                <span key={r.id} className="src-init">{reviewerInitials(r.name)}</span>
              ))}
              {reviewers.length > 3 && <span className="src-init src-init--more">+{reviewers.length - 3}</span>}
            </span>
          )}
          {unresolvedNotes > 0 && (
            <span className="shot-notes-badge" title={`${unresolvedNotes} unresolved note${unresolvedNotes === 1 ? "" : "s"}`}>
              <svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/></svg>
              {unresolvedNotes}
            </span>
          )}
          </div>{/* /.shot-actions */}
        </div>
        <div className="shot-title">
          <span className="shot-id">{shot.id}</span>
          <span className="shot-dot">·</span>
          <span className="shot-frame">{shot.frame_title}</span>
        </div>
        <div className="shot-sub">{shot.shot_type}</div>
        <div className="shot-loc">{SHIcon.pin}<span>{shot.landscape}</span></div>
        {/* t06b — quick-action buttons hidden on archived shots so the row reads as
            read-only. Active shots show the full SQA bar. v07zz278 — also hidden for
            roles without change_shot_status (server gates POST/PATCH
            /api/shots/:id/status on that key).
            16 Sep 2026 — this row lives in the SAME column as the eyebrow's icon row,
            pinned to the bottom. They could never line up while they sat in different
            grid columns: the eyebrow row ended at the middle column's right edge and
            this one at the edge of a slot inside the right column. Same column, both
            right-aligned, one at the top and one at the bottom. */}
        <div className="shot-tools-bottom">
          {!shot.is_archive && !shot.archive_footage && canChangeStatus && (
          <ShotQuickActions shot={shot} onChange={change}/>
          )}
        </div>
      </div>
      <div className="shot-right">
        <div className="shot-right-main">
          <div className="shot-right-text">
            <div className="shot-right-pill">
              <StatusPill shot={shot} onStatusChange={onStatusChange}/>
            </div>
            <div className="shot-version shot-version--left">{v}</div>
          </div>
          <div className="shot-right-ring">
            <PercentRing pct={pct} stage={stage} size={72}/>
          </div>
        </div>
        {/* t06b — kebab "…" menu removed. Per-shot actions are out of
            scope for the manual UI; future actions will come via the
            folder watcher (t15) / Claude webhook (t17). */}
      </div>
    </div>
  );
}

function useShotsClickOutside(ref, onClose, active) {
  React.useEffect(() => {
    if (!active) return;
    const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    const key = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("mousedown", handler);
    document.addEventListener("keydown", key);
    return () => {
      document.removeEventListener("mousedown", handler);
      document.removeEventListener("keydown", key);
    };
  }, [ref, onClose, active]);
}

const SORT_OPTIONS = [
  { id: "seq",     label: "Sequence, then Shot ID" },
  { id: "id",      label: "Shot ID" },
  { id: "title",   label: "Frame title (A–Z)" },
  { id: "stage",   label: "Stage progress (most done first)" },
  // v07zz617 — Hugo: "show whatever shot has been updated the latest, with either a
  // new frame or a new video". Sorts by shot.last_media_at (the newest non-archived
  // frame/hero/video/upscale INSERT, computed server-side in /api/data).
  { id: "updated", label: "Latest update (new frame/video first)" },
];
// v01g — top-to-bottom order per the v01g spec: First Pass, Concept WIP,
// Concept Approved, Video WIP, Video Approved, Upscaled, Pending,
// Archival Footage. PROMPT was dropped from the v01g list (treated as
// part of Pending in the public filter UI).
// v07zz372 — PENDING first (Hugo). Order here = display order in the Filter menu's STAGES list.
const STAGE_FILTERS = ["PENDING", "FIRST-PASS", "CONCEPT-WIP", "CONCEPT-APPROVED", "VIDEO-WIP", "VIDEO-APPROVED", "UPSCALED", "OMITTED", "ARCHIVE"];
// v01g — default-on filter set: every stage except ARCHIVE. The FilterMenu
// is now a "show stages" toggle (not a "hide stages" inversion), so the
// default must be all-on to match Hugo's mental model of "everything
// visible until I hide something".
// v07zz370 — OMITTED (cut) shots are hidden by default like ARCHIVE; tick the pill to reveal them.
const STAGE_FILTERS_DEFAULT_ON = STAGE_FILTERS.filter(s => s !== "ARCHIVE" && s !== "OMITTED");

// v07zz367 — stages offered in the multi-select bar (sets every selected shot at once). `tint` keys
// into STAGE_TINTS for the pill colour; `target` is the stage_status key handleStatusChange expects.
const BATCH_STAGES = [
  { target: "first_pass",   tint: "FIRST-PASS",       label: "First Pass" },
  { target: "refinement",   tint: "CONCEPT-WIP",      label: "Frame WIP" },
  { target: "hero",         tint: "CONCEPT-APPROVED", label: "Frame Hero" },
  { target: "video_prompt", tint: "VIDEO-WIP",        label: "Video WIP" },
  { target: "video",        tint: "VIDEO-APPROVED",   label: "Video Hero" },
  { target: "upscale",      tint: "UPSCALED",         label: "4K Upscale" },
];

function SortMenu({ value, onChange }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  useShotsClickOutside(ref, () => setOpen(false), open);
  const current = SORT_OPTIONS.find(o => o.id === value) || SORT_OPTIONS[0];
  return (
    <div className="sf-anchor" ref={ref}>
      <button className={"pill-button" + (open ? " is-open" : "")} onClick={() => setOpen(o => !o)}>
        {SHIcon.sort}<span>Sort: {current.label.split(",")[0].split(" (")[0]}</span>
      </button>
      {open && (
        <div className="sf-menu">
          <div className="sf-eyebrow">SORT BY</div>
          {SORT_OPTIONS.map(o => (
            <button
              key={o.id}
              className={"sf-row" + (o.id === value ? " is-active" : "")}
              onClick={() => { onChange(o.id); setOpen(false); }}
            >
              <span className="sf-row-label">{o.label}</span>
              {o.id === value && <span className="sf-row-check">{SHIcon.check}</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function FilterMenu({
  stages, sequencesFilter, sequences,
  archivedCount = 0, stageCounts = {},
  onChangeStages, onChangeSequences, onClear,
  needsMyReview = false, onToggleNeedsMyReview, needsMyCount = 0,
}) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  useShotsClickOutside(ref, () => setOpen(false), open);
  // v01g — counter shows how many filters are *off* (hidden) plus any
  // sequence narrowing. When everything's at default (all-on except
  // ARCHIVE, no sequence filter) the badge stays hidden so the chip
  // reads "Filter" alone.
  const offCount = STAGE_FILTERS_DEFAULT_ON.filter(s => !stages.has(s)).length
                 + (stages.has("ARCHIVE") ? 1 : 0)   // ARCHIVE on counts too — it's a divergence from default
                 + (stages.has("OMITTED") ? 1 : 0);   // v07zz370 — likewise OMITTED (default-off now)
  const totalActive = offCount + sequencesFilter.size + (needsMyReview ? 1 : 0);   // v07zz583
  const toggleStage = (s) => {
    const n = new Set(stages); n.has(s) ? n.delete(s) : n.add(s); onChangeStages(n);
  };
  const toggleSeq = (n) => {
    const x = new Set(sequencesFilter); x.has(n) ? x.delete(n) : x.add(n); onChangeSequences(x);
  };
  return (
    <div className="sf-anchor" ref={ref}>
      <button className={"pill-button" + (open ? " is-open" : "") + (totalActive ? " has-active" : "")} onClick={() => setOpen(o => !o)}>
        {SHIcon.filter}<span>Filter{totalActive ? ` · ${totalActive}` : ""}</span>
      </button>
      {open && (
        <div className="sf-menu sf-menu--filter">
          {/* v07zz583 — "Needs my review": only shots the admin flagged that YOU haven't
              commented on since. Per-person — the producer and director each see their own. */}
          {onToggleNeedsMyReview && (
            <>
              <div className="sf-section">
                <button className={"sf-row sf-row--review" + (needsMyReview ? " is-active" : "")} onClick={() => onToggleNeedsMyReview()}>
                  <span className="sf-cb" aria-hidden="true">{needsMyReview && SHIcon.check}</span>
                  <span className="sf-review-label">Needs my review</span>
                  <span className="sf-row-count"> · {needsMyCount}</span>
                </button>
              </div>
              <div className="sf-divider"/>
            </>
          )}
          <div className="sf-section">
            {/* v01g — section is now a SHOW toggle (not a hide list).
                Each row's filled checkbox = stage visible in the list.
                Order matches STAGE_FILTERS top-to-bottom. */}
            <div className="sf-eyebrow">STAGES</div>
            {STAGE_FILTERS.map(s => {
              const tint = STAGE_TINTS[s];
              const on = stages.has(s);
              const isArchive = s === "ARCHIVE";
              return (
                <button key={s} className={"sf-row" + (on ? " is-active" : "")} onClick={() => toggleStage(s)}>
                  <span className="sf-cb" aria-hidden="true">{on && SHIcon.check}</span>
                  <span className="sf-pill-mini" style={{background: tint.bg, borderColor: tint.border, color: tint.color}}>
                    <span className="sf-pill-dot" style={{background: tint.dot}}/>{tint.label || s}
                  </span>
                  {/* v07zz372 — count of shots in each stage. */}
                  {(() => {
                    const c = (stageCounts[s] != null) ? stageCounts[s] : (isArchive ? archivedCount : null);
                    return (c != null) ? <span className="sf-row-count"> · {c}</span> : null;
                  })()}
                </button>
              );
            })}
          </div>
          <div className="sf-divider"/>
          <div className="sf-section">
            <div className="sf-eyebrow">SEQUENCE</div>
            <div className="sf-seq-grid">
              {sequences.map(seq => {
                const on = sequencesFilter.has(seq.number);
                return (
                  <button key={seq.number} className={"sf-seq-chip" + (on ? " is-active" : "")} onClick={() => toggleSeq(seq.number)}
                    title={seq.slug || seq.name || `Sequence ${seq.number}`}>
                    {String(seq.number).padStart(2, "0")}
                  </button>
                );
              })}
            </div>
          </div>
          {totalActive > 0 && (
            <>
              <div className="sf-divider"/>
              <button className="sf-row sf-clear" onClick={() => onClear()}>Reset filters</button>
            </>
          )}
        </div>
      )}
    </div>
  );
}

// v07zz299 — "Add shot" control for the Shots page header. Hugo picks the shot
// NUMBER; the server creates the row, derives the sequence from the neighbouring
// shots, and makes the grids/frames/video folder tree automatically. Producer+
// only (matches the POST /api/shots REQ_PRODUCER_PLUS gate).
function AddShotButton() {
  const [open, setOpen]   = React.useState(false);
  const [num, setNum]     = React.useState("");
  const [title, setTitle] = React.useState("");
  const [busy, setBusy]   = React.useState(false);
  const [err, setErr]     = React.useState(null);
  // v07zz321 — batch add: create N shots at once (start + count + step).
  const [mode, setMode]   = React.useState("single");   // "single" | "batch"
  const [count, setCount] = React.useState("5");
  const [step, setStep]   = React.useState("10");
  const [progress, setProgress] = React.useState(null);
  const wrapRef = React.useRef(null);
  // Reuse the Sort/Filter menu's click-outside + Escape hook so this popover
  // closes exactly like the others.
  useShotsClickOutside(wrapRef, () => setOpen(false), open);
  const canAdd = !window.__effectiveRole || ["admin", "producer"].includes(window.__effectiveRole);
  const suggestNext = () => {
    const shots = (window.__appData && window.__appData.shots) || [];
    let max = 0;
    for (const s of shots) { const m = /^SH(\d+)$/.exec(s.id || ""); if (m) max = Math.max(max, parseInt(m[1], 10)); }
    return String(max + 10).padStart(4, "0");
  };
  const openForm = () => { setNum(suggestNext()); setTitle(""); setErr(null); setOpen(true); };
  const submit = async () => {
    if (busy || !num.trim()) return;
    setBusy(true); setErr(null);
    try {
      let ep = ""; try { ep = localStorage.getItem("frameflow-active-episode") || ""; } catch (_) {}
      const r = await (window.authFetch || fetch)("/api/shots", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: num.trim(), frame_title: title.trim() || undefined, episode_id: ep || undefined }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || ("HTTP " + r.status));
      setOpen(false);
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };
  const submitBatch = async () => {
    if (busy) return;
    const start = parseInt(String(num).replace(/[^0-9]/g, ""), 10);
    const cnt = Math.max(1, Math.min(100, parseInt(count, 10) || 0));
    const stp = Math.max(1, parseInt(step, 10) || 10);
    if (!Number.isFinite(start)) { setErr("Enter a valid start number."); return; }
    setBusy(true); setErr(null);
    let ep = ""; try { ep = localStorage.getItem("frameflow-active-episode") || ""; } catch (_) {}
    const fails = [];
    for (let i = 0; i < cnt; i++) {
      const id = String(start + i * stp).padStart(4, "0");
      setProgress(`Creating SH${id} (${i + 1}/${cnt})…`);
      try {
        const r = await (window.authFetch || fetch)("/api/shots", {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ id, frame_title: title.trim() ? `${title.trim()} ${i + 1}` : undefined, episode_id: ep || undefined }),
        });
        if (!r.ok) { const j = await r.json().catch(() => ({})); fails.push(`SH${id}: ${j.error || r.status}`); }
      } catch (e) { fails.push(`SH${id}: ${e.message}`); }
    }
    setProgress(null); setBusy(false);
    if (fails.length) setErr(`${cnt - fails.length}/${cnt} created. Failed — ${fails.slice(0, 3).join("; ")}${fails.length > 3 ? "…" : ""}`);
    else setOpen(false);
    if (typeof window.reloadAppData === "function") window.reloadAppData();
  };
  if (!canAdd) return null;
  const onKey = (e) => { if (e.key === "Enter") (mode === "batch" ? submitBatch() : submit()); };
  return (
    <div className="sf-anchor" ref={wrapRef}>
      <button type="button" className={"pill-button pill-button--add" + (open ? " is-open" : "")}
        onClick={() => open ? setOpen(false) : openForm()}
        title="Add a new shot — pick its number; the sequence + folders are set up automatically">
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
        <span>Add shot</span>
      </button>
      {open && (
        <div className="sf-menu sf-menu--add">
          {/* v07zz321 — Single / Batch toggle. */}
          <div className="add-shot-modes">
            <button type="button" className={mode === "single" ? "is-on" : ""} onClick={() => { setMode("single"); setErr(null); }}>Single</button>
            <button type="button" className={mode === "batch" ? "is-on" : ""} onClick={() => { setMode("batch"); setErr(null); }}>Batch</button>
          </div>
          <label className="add-shot-label">
            <span>{mode === "batch" ? "Start at shot #" : "Shot number"}</span>
            <div className="add-shot-num">
              <span className="add-shot-num-prefix">SH</span>
              <input className="add-shot-input" type="text" value={num} autoFocus inputMode="numeric"
                onChange={(e) => setNum(e.target.value.replace(/[^0-9]/g, "").slice(0, 5))}
                onKeyDown={onKey} placeholder="0125"/>
            </div>
          </label>
          {mode === "batch" && (
            <div className="add-shot-batch-row">
              <label className="add-shot-label">
                <span>How many</span>
                <input className="add-shot-input" type="text" inputMode="numeric" value={count}
                  onChange={(e) => setCount(e.target.value.replace(/[^0-9]/g, "").slice(0, 3))} onKeyDown={onKey} placeholder="5"/>
              </label>
              <label className="add-shot-label">
                <span>Step</span>
                <input className="add-shot-input" type="text" inputMode="numeric" value={step}
                  onChange={(e) => setStep(e.target.value.replace(/[^0-9]/g, "").slice(0, 3))} onKeyDown={onKey} placeholder="10"/>
              </label>
            </div>
          )}
          <label className="add-shot-label">
            <span>Title <em>(optional{mode === "batch" ? ", numbered per shot" : ""})</em></span>
            <input className="add-shot-input" type="text" value={title}
              onChange={(e) => setTitle(e.target.value)} onKeyDown={onKey} placeholder="e.g. The Approach"/>
          </label>
          <div className="add-shot-hint">{mode === "batch"
            ? `Creates ${Math.max(1, Math.min(100, parseInt(count,10)||0))} shots from SH${(String(parseInt(num||"0",10)).padStart(4,"0"))}, +${Math.max(1, parseInt(step,10)||10)} each. Folders + sequences set up automatically.`
            : "Its sequence + grids / frames / video folders are created automatically."}</div>
          {progress && <div className="add-shot-hint">{progress}</div>}
          {err && <div className="add-shot-err">{err}</div>}
          <div className="add-shot-actions">
            <button type="button" className="add-shot-cancel" onClick={() => setOpen(false)} disabled={busy}>Cancel</button>
            {mode === "batch"
              ? <button type="button" className="add-shot-submit" onClick={submitBatch} disabled={busy || !num.trim()}>{busy ? "Adding…" : "Add shots"}</button>
              : <button type="button" className="add-shot-submit" onClick={submit} disabled={busy || !num.trim()}>{busy ? "Adding…" : "Add shot"}</button>}
          </div>
        </div>
      )}
    </div>
  );
}

function applySortAndFilter(shots, sortId, stages, seqFilter, needsMyOnly = false) {
  let out = shots.slice();
  // v07zz583 — "Needs my review": only shots the admin flagged that I haven't
  // commented on since the flag. Applied FIRST (an absolute narrowing axis).
  if (needsMyOnly) out = out.filter(shotNeedsMyReview);
  // v01g — `stages` now represents the SHOW set (toggling a button on
  // means "show shots at that stage"). When non-empty we keep only
  // shots whose current stage is in the set. PROMPT shots are folded
  // into PENDING for filter purposes since PROMPT isn't in the v01g
  // public stage list. An empty set means "show nothing" — the
  // FilterMenu's "Clear all filters" resets to the default-on Set so
  // the user never accidentally lands at zero-rows.
  if (stages.size > 0) {
    out = out.filter(s => {
      // v07zz359 — omitted (cut) shots filter under their OWN stage (OMITTED), not their
      // underlying stage, so they appear only when the OMITTED pill is toggled on.
      const st = getCurrentStage(s);
      const key = s.omitted ? "OMITTED" : (st === "PROMPT" ? "PENDING" : st);
      return stages.has(key);
    });
  } else {
    out = [];
  }
  if (seqFilter.size > 0) {
    out = out.filter(s => seqFilter.has(s.seq));
  }
  const cmpId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
  if (sortId === "id") out.sort(cmpId);
  else if (sortId === "title") out.sort((a, b) => (a.frame_title || "").localeCompare(b.frame_title || ""));
  else if (sortId === "stage") out.sort((a, b) => stagePct(b) - stagePct(a) || cmpId(a, b));
  // v07zz617 — newest media first (ISO strings compare lexicographically); shots with
  // no frames/videos yet sink to the bottom in shot-id order.
  else if (sortId === "updated") out.sort((a, b) => String(b.last_media_at || "").localeCompare(String(a.last_media_at || "")) || cmpId(a, b));
  else out.sort((a, b) => (a.seq - b.seq) || cmpId(a, b));
  return out;
}

// Plausible glyph placeholders per prop slug.
const PROP_GLYPHS = {
  "twain-cigar":      "🚬",
  "columbus-journal": "📖",
  "spanish-flag":     "🚩",
  "calusa-mask":      "🎭",
  "totem":            "🗿",
  "spanish-armor":    "🛡️",
  "pinta-rigging":    "⛵",
  "shell-mound":      "🐚",
};

// Hook: turns a horizontally-scrollable container into a drag-to-scroll surface.
function useDragScroll() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let isDown = false;
    let startX = 0;
    let startScroll = 0;
    let moved = false;
    const down = (e) => {
      // Don't drag when starting on an interactive element (buttons, links).
      if (e.target.closest("button, a, input, textarea")) return;
      isDown = true;
      moved = false;
      startX = e.pageX;
      startScroll = el.scrollLeft;
      el.style.cursor = "grabbing";
      el.style.userSelect = "none";
    };
    const move = (e) => {
      if (!isDown) return;
      const dx = e.pageX - startX;
      if (Math.abs(dx) > 4) moved = true;
      el.scrollLeft = startScroll - dx;
    };
    const up = () => {
      isDown = false;
      el.style.cursor = "grab";
      el.style.userSelect = "";
    };
    const click = (e) => {
      // suppress accidental click after a drag
      if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
    };
    const wheel = (e) => {
      // turn vertical wheel into horizontal scroll within these rows
      if (e.deltaY !== 0 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
        el.scrollLeft += e.deltaY;
        e.preventDefault();
      }
    };
    el.style.cursor = "grab";
    el.addEventListener("mousedown", down);
    window.addEventListener("mousemove", move);
    window.addEventListener("mouseup", up);
    el.addEventListener("click", click, true);
    el.addEventListener("wheel", wheel, { passive: false });
    return () => {
      el.removeEventListener("mousedown", down);
      window.removeEventListener("mousemove", move);
      window.removeEventListener("mouseup", up);
      el.removeEventListener("click", click, true);
      el.removeEventListener("wheel", wheel);
    };
  }, []);
  return ref;
}

function HScrollRow({ title, count, children }) {
  const ref = useDragScroll();
  // v07zz210 — Scroll-affordance: track whether the row can scroll left / right
  // so we can fade chevrons in at the live edge. The scrollbar is hidden
  // (drag-to-scroll), so without this there's no cue more cards exist off-screen.
  // Edge state recomputes on scroll + resize + content change; chevrons are
  // absolutely positioned so they never shift layout (invariant #20).
  const [edges, setEdges] = React.useState({ left: false, right: false });
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const recompute = () => {
      const maxScroll = el.scrollWidth - el.clientWidth;
      setEdges({ left: el.scrollLeft > 2, right: el.scrollLeft < maxScroll - 2 });
    };
    recompute();
    el.addEventListener("scroll", recompute, { passive: true });
    const ro = (typeof ResizeObserver !== "undefined") ? new ResizeObserver(recompute) : null;
    if (ro) ro.observe(el);
    window.addEventListener("resize", recompute);
    return () => {
      el.removeEventListener("scroll", recompute);
      if (ro) ro.disconnect();
      window.removeEventListener("resize", recompute);
    };
  }, [children]);
  const page = (dir) => {
    const el = ref.current;
    if (!el) return;
    el.scrollBy({ left: dir * Math.round(el.clientWidth * 0.8), behavior: "smooth" });
  };
  return (
    <section className={"assets-row-section" + (edges.left ? " can-left" : "") + (edges.right ? " can-right" : "")}>
      <div className="assets-row-head">
        <span className="ars-title">{title}</span>
        <span className="ars-count">{count}</span>
      </div>
      <div className="assets-hscroll-wrap">
        <button type="button" className="assets-hscroll-arrow assets-hscroll-arrow--left"
                aria-label="Scroll left" tabIndex={-1} onClick={() => page(-1)}>
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
        </button>
        <div className="assets-hscroll" ref={ref}>{children}</div>
        <button type="button" className="assets-hscroll-arrow assets-hscroll-arrow--right"
                aria-label="Scroll right" tabIndex={-1} onClick={() => page(1)}>
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 6l6 6-6 6"/></svg>
        </button>
      </div>
    </section>
  );
}

// Hash a string to a deterministic hue (0–360) so each character gets a stable
// distinct gradient placeholder when no portrait image is provided.
function hueFromString(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return h % 360;
}

function AssetsOverview() {
  const assets = (window.__appData && window.__appData.assets) || { characters: [], locations: [], props: [] };
  // v07zz210 — Hide archived + tombstoned (deleted:true) assets, matching
  // AssetsView (Views.jsx). GET /api/assets returns every row in data/assets.json
  // incl. soft-deleted tombstones; the full Assets page filters them but this
  // Overview tab was reading the raw global, so previously-deleted
  // characters/locations/props reappeared here only.
  const _live = (arr) => (Array.isArray(arr) ? arr : []).filter(a => a && !a.archived && !a.deleted);
  const characters = _live(assets.characters);
  const locations  = _live(assets.locations);
  const props      = _live(assets.props);
  // Open the existing AssetItemModal (defined globally in Views.jsx) directly
  // from the overview rows — no nav to the full Assets page.
  const [openItem, setOpenItem] = React.useState(null); // { kind, item }
  const open = (kind, item) => setOpenItem({ kind, item });

  // 15 Sep 2026 — a templated project: one row per category it declares (window.__projectCategories),
  // generic cards, the item modal keyed by the category id. Paradise Found keeps the three film-doc
  // rows below untouched. (All hooks sit above this branch, so the hook count never changes.)
  const _aoIsPF = !(typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  if (!_aoIsPF) {
    const cats = window.__projectCategories ? window.__projectCategories() : [];
    return (
      <div className="assets-overview">
        {cats.map(c => {
          const list = _live(assets[c.id]);
          return (
            <HScrollRow key={c.id} title={String(c.label || c.id).toUpperCase()} count={list.length}>
              {list.map(a => {
                const img = a.image || a.cover_url || null;
                return (
                  <article key={a.id || a.slug || a.name} className="prop-card prop-card--h glass interactive-card" onClick={() => open(c.id, a)}>
                    <div className="prop-thumb-square" style={img ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(img, 400) : img})`, backgroundSize: "cover", backgroundPosition: "center" } : undefined}>
                      {!img && <span className="prop-glyph">⬢</span>}
                    </div>
                    <div className="prop-body">
                      <div className="prop-name">{a.name}</div>
                      <div className="prop-type">{(typeof a.role === "string" && a.role) || (typeof a.type === "string" && a.type) || ""}</div>
                    </div>
                  </article>
                );
              })}
            </HScrollRow>
          );
        })}
        {openItem && window.AssetItemModal && (
          <window.AssetItemModal kind={openItem.kind} item={openItem.item} onClose={() => setOpenItem(null)}/>
        )}
      </div>
    );
  }

  return (
    <div className="assets-overview">
      <HScrollRow title="CHARACTERS" count={characters.length}>
        {characters.map(c => {
          const hue = hueFromString(c.id || c.name || "");
          const placeholderBg = `linear-gradient(160deg, oklch(0.55 0.10 ${hue}), oklch(0.42 0.08 ${(hue + 30) % 360}) 60%, oklch(0.32 0.06 ${(hue + 60) % 360}))`;
          return (
            <article key={c.id} className="character-card glass interactive-card character-card--h" onClick={() => open("character", c)}>
              <div className="character-thumb"
                style={c.image
                  ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(c.image, 400) : c.image})`, backgroundSize: "cover", backgroundPosition: "center 22%" }
                  : { background: placeholderBg }}>
                {!c.image && (
                  <span className="character-thumb-initials">{c.name.split(" ").slice(0,2).map(n => n[0]).join("")}</span>
                )}
              </div>
              <div className="character-body">
                <div className="character-name">{c.name}</div>
                <div className="character-role">{c.role}</div>
              </div>
            </article>
          );
        })}
      </HScrollRow>

      <HScrollRow title="LOCATIONS" count={locations.length}>
        {locations.map(l => (
          <article key={l.id} className="location-card glass interactive-card location-card--h" onClick={() => open("location", l)}>
            <div className="location-thumb" style={{background: window.__locGradient(l.hue)}}>
              <span className="location-thumb-tag">{l.scenes}</span>
            </div>
            <div className="location-body">
              <div className="location-name">{l.name}</div>
            </div>
          </article>
        ))}
      </HScrollRow>

      <HScrollRow title="PROPS" count={props.length}>
        {props.map(p => (
          <article key={p.id} className="prop-card prop-card--h glass interactive-card" onClick={() => open("prop", p)}>
            <div className="prop-thumb-square">
              <span className="prop-glyph">{PROP_GLYPHS[p.id] || "⬢"}</span>
            </div>
            <div className="prop-body">
              <div className="prop-name">{p.name}</div>
              <div className="prop-type">{p.type}{p.ref_count != null ? ` · ${p.ref_count} refs` : ""}</div>
              {p.notes && <div className="prop-notes">{p.notes}</div>}
            </div>
          </article>
        ))}
      </HScrollRow>

      {openItem && openItem.kind === "character" && window.CharacterDetailModal && (
        <window.CharacterDetailModal character={openItem.item} onClose={() => setOpenItem(null)}/>
      )}
      {openItem && openItem.kind !== "character" && window.AssetItemModal && (
        <window.AssetItemModal kind={openItem.kind} item={openItem.item} onClose={() => setOpenItem(null)}/>
      )}
    </div>
  );
}

// v07zm — Module-level set: tracks which shot ids have had their
// asset-versions prefetched. Survives across ShotsPanel re-mounts
// so navigating away and back doesn't kick off duplicate fetches.
const _shotPrefetchedSet = new Set();

// v07zz332 — preserve shotlist scroll across navigation. ShotsPanel is fully UNMOUNTED when the
// app `view` changes (App.jsx renders it behind `{view === "shots" && …}` and inside Overview),
// so the scroll offset is otherwise lost on return. Cache it at MODULE level (survives remount
// within the SPA session — same idiom as _shotPrefetchedSet), keyed by episode+tab so each
// shotlist context restores its own position.
const _shotsScrollMemo = new Map();   // `${episodeId}|${tab}` -> scrollTop
function _shotsScrollKey(tab) {
  let ep = ""; try { ep = localStorage.getItem("frameflow-active-episode") || ""; } catch (_) {}
  // 17 Sep 2026 - and the project: two projects can both have an "episode_01"
  return (window.__activeProjectId || "") + "|" + ep + "|" + (tab || "shots");
}

// v07zz280 — Persistent bulk-ZIP download. The state lives at MODULE level
// (keyed by scope: "shots" / "assets") so the button keeps reading "Zipping…"
// across page navigation — ShotsPanel/AssetsView unmount when you switch tabs,
// but the in-flight fetch + its state live here, not in component state, so
// leaving and coming back shows the real status (Hugo: "the Zipping button
// goes back to Download All when I navigate — should stick"). Exposed on
// window so AssetsView (Views.jsx, loaded after this file) shares it.
const _zipDl = { state: {}, msg: {}, subs: new Set() };
function _zipNotify() { _zipDl.subs.forEach(fn => { try { fn(); } catch (_) {} }); }
function _zipSet(scope, st, msg) { _zipDl.state[scope] = st; _zipDl.msg[scope] = msg || ""; _zipNotify(); }
async function runZipDownload(scope, url, fallbackName) {
  if (_zipDl.state[scope] === "busy") return;
  _zipSet(scope, "busy");
  try {
    const f = window.authFetch || fetch;
    const r = await f(url);
    if (!r.ok) {
      let m = "HTTP " + r.status;
      try { const j = await r.json(); if (j && j.error) m = j.error; } catch (_) {}
      throw new Error(m);
    }
    const blob = await r.blob();
    if (!blob || !blob.size) throw new Error("Empty response");
    const cd = r.headers.get("content-disposition") || "";
    const nm = (/filename="([^"]+)"/.exec(cd) || [, fallbackName || "export.zip"])[1];
    const u = URL.createObjectURL(blob);
    const a = document.createElement("a"); a.href = u; a.download = nm;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(u), 5000);
    _zipSet(scope, "idle");
  } catch (e) {
    // v07zz282 — surface the REAL reason (console + button tooltip) instead of
    // a generic "Failed", so a download problem is diagnosable at a glance.
    const why = (e && e.message) || "Network error";
    try { console.error("[zip-download] " + scope + " failed:", why, "→", url); } catch (_) {}
    _zipSet(scope, "err", why);
    setTimeout(() => { if (_zipDl.state[scope] === "err") _zipSet(scope, "idle"); }, 6000);
  }
}
window.runZipDownload = runZipDownload;
// Reusable pill that mirrors the module-level zip state for its scope.
// `url` may be a string or a () => string (computed at click time, e.g. to
// read the active episode fresh).
function ZipDownloadButton({ scope, url, fallbackName, title, idleLabel = "Download all", className = "" }) {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => { _zipDl.subs.add(force); return () => { _zipDl.subs.delete(force); }; }, []);
  const st = _zipDl.state[scope] || "idle";
  const errMsg = _zipDl.msg[scope] || "";
  return (
    <button type="button"
      className={"pill-button " + className + (st === "busy" ? " is-busy" : "") + (st === "err" ? " is-err" : "")}
      onClick={() => runZipDownload(scope, typeof url === "function" ? url() : url, fallbackName)}
      disabled={st === "busy"} title={st === "err" && errMsg ? ("Download failed: " + errMsg + " — click to retry") : title}>
      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
      <span>{st === "busy" ? "Zipping…" : st === "err" ? "Failed — retry" : idleLabel}</span>
    </button>
  );
}
window.ZipDownloadButton = ZipDownloadButton;

// v07zz289 — Shot link picker (visual continuation link). Opened from a row's
// link button via window.__openShotLinkPicker(shot). Centered modal: pick the
// partner shot + which side is the master. Module-level state so any row can open
// it without prop-drilling; reads the full shot list from window.__appData.shots.
let _linkPickerState = { open: false, shot: null };
const _linkPickerSubs = new Set();
function _notifyLinkPicker() { _linkPickerSubs.forEach(fn => { try { fn(); } catch (_) {} }); }
window.__openShotLinkPicker = (shot) => { _linkPickerState = { open: true, shot }; _notifyLinkPicker(); };

function ShotLinkPicker() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => { _linkPickerSubs.add(force); return () => _linkPickerSubs.delete(force); }, []);
  const src = _linkPickerState.shot;
  const open = _linkPickerState.open && !!src;
  const [q, setQ] = React.useState("");
  const [srcIsMaster, setSrcIsMaster] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  React.useEffect(() => { if (open) { setQ(""); setSrcIsMaster(false); } }, [open, src && src.id]);
  if (!open) return null;
  const close = () => { _linkPickerState = { open: false, shot: null }; _notifyLinkPicker(); };
  const all = (window.__appData && window.__appData.shots) || [];
  const srcMaster = src.linked_to || null;                                   // src is a continuation of srcMaster
  const srcSlave  = srcMaster ? null : all.find(s => s.linked_to === src.id); // src is the master of srcSlave
  const partner = srcMaster || (srcSlave && srcSlave.id) || null;
  const patch = (id, body) => (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(id)}/link`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
  const doLink = async (otherId) => {
    if (busy) return; setBusy(true);
    try {
      if (srcIsMaster) await patch(otherId, { linked_to: src.id });   // src is master → other is the continuation
      else await patch(src.id, { linked_to: otherId });               // src is the continuation of other
      if (window.reloadAppData) window.reloadAppData();
    } catch (_) {}
    setBusy(false); close();
  };
  const doUnlink = async () => {
    if (busy) return; setBusy(true);
    try {
      if (srcMaster) await patch(src.id, { linked_to: null });
      if (srcSlave) await patch(srcSlave.id, { linked_to: null });
      if (window.reloadAppData) window.reloadAppData();
    } catch (_) {}
    setBusy(false); close();
  };
  const ql = q.trim().toLowerCase();
  const list = all.filter(s => s.id !== src.id && !s.is_archive
      && (!ql || s.id.toLowerCase().includes(ql) || (s.frame_title || "").toLowerCase().includes(ql)))
    .slice(0, 250);
  const thumbOf = (s) => {
    const f = s.video_poster || (s.image_paths && (s.image_paths.selected || s.image_paths.first_pass));
    return f && window.thumbUrl ? window.thumbUrl(f, 160) : f;
  };
  return ReactDOM.createPortal(
    <div className="shotlink-overlay" onMouseDown={close}>
      <div className="shotlink-modal" onMouseDown={e => e.stopPropagation()}>
        <div className="shotlink-head">
          <div className="shotlink-title">Link <b>{src.id}</b>{src.frame_title ? <span className="shotlink-ft"> · {src.frame_title}</span> : null}</div>
          <button className="shotlink-x" onClick={close} aria-label="Close">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
          </button>
        </div>
        {partner ? (
          <div className="shotlink-current">
            <span><b>{src.id}</b> is linked with <b>{partner}</b> — {srcMaster ? `${partner} is the master` : `${src.id} is the master`}. Each shot keeps its own images &amp; status.</span>
            <button className="shotlink-unlink" onClick={doUnlink} disabled={busy}>Unlink</button>
          </div>
        ) : (
          <>
            <div className="shotlink-role">
              <span className="shotlink-rolelabel">{src.id} is the</span>
              <div className="shotlink-roleswitch">
                <button className={!srcIsMaster ? "is-on" : ""} onClick={() => setSrcIsMaster(false)}>continuation</button>
                <button className={srcIsMaster ? "is-on" : ""} onClick={() => setSrcIsMaster(true)}>master</button>
              </div>
              <span className="shotlink-rolehint">{srcIsMaster ? "pick the continuation cut →" : "pick its master shot →"}</span>
            </div>
            <input className="shotlink-search" placeholder="Search shot id or title…" value={q} onChange={e => setQ(e.target.value)} autoFocus />
            <div className="shotlink-list">
              {list.map(s => { const t = thumbOf(s); const isLinked = s.linked_to || all.some(x => x.linked_to === s.id);
                // v07zz291 — many shots are placeholders / have a missing local frame, so a
                // bare background-image left a wall of blank tiles ("missing a lot of links").
                // Use an <img> over a per-seq gradient so a broken/absent image falls back to
                // the gradient (looks intentional) instead of an empty box.
                const hue = ((s.seq || 1) * 23) % 360;
                const fallbackBg = `linear-gradient(150deg, oklch(0.46 0.05 ${hue}), oklch(0.66 0.06 ${hue + 40}))`;
                return (
                <button key={s.id} className="shotlink-item" disabled={busy} onClick={() => doLink(s.id)}>
                  <div className="shotlink-item-thumb" style={{ background: fallbackBg }}>
                    {t ? <img src={t} alt="" onError={(e) => { e.currentTarget.style.display = "none"; }} /> : null}
                  </div>
                  <div className="shotlink-item-meta"><span className="shotlink-item-id">{s.id}</span><span className="shotlink-item-ft">{s.frame_title || ""}</span></div>
                  {isLinked ? <span className="shotlink-item-tag">linked</span> : null}
                </button>
              ); })}
              {!list.length && <div className="shotlink-empty">No shots match.</div>}
            </div>
          </>
        )}
      </div>
    </div>,
    document.getElementById("modal-root") || document.body
  );
}
window.ShotLinkPicker = ShotLinkPicker;

// v07zz321 — Edit-shot form, opened from a row's Edit button via window.__openShotEditPicker(shot).
// Module-level singleton (mirrors ShotLinkPicker) so it works from the row WITHOUT the modal open.
// Reuses the .shotedit-* CSS. Optimistic via __updateShotField + PATCH /api/shots/:id, revert on fail.
const SHOT_EDIT_FIELDS = [
  { k: "frame_title",   label: "Title",                type: "text" },
  { k: "seq",           label: "Sequence #",           type: "number" },
  { k: "shot_type",     label: "Shot type",            type: "text" },
  { k: "action",        label: "Action / description", type: "area", rows: 3 },
  { k: "narration",     label: "Narration / VO",       type: "area", rows: 2 },
  { k: "species",       label: "Subject / species",    type: "text" },
  { k: "landscape",     label: "Location / landscape", type: "text" },
  { k: "weather_light", label: "Weather / light",      type: "text" },
  { k: "behaviour",     label: "Behaviour / notes",    type: "area", rows: 2 },
];
let _editPickerState = { open: false, shot: null };
const _editPickerSubs = new Set();
function _notifyEditPicker() { _editPickerSubs.forEach(fn => { try { fn(); } catch (_) {} }); }
window.__openShotEditPicker = (shot) => { _editPickerState = { open: true, shot }; _notifyEditPicker(); };
function ShotEditPicker() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => { _editPickerSubs.add(force); return () => _editPickerSubs.delete(force); }, []);
  const shot = _editPickerState.shot;
  const open = _editPickerState.open && !!shot;
  const [draft, setDraft] = React.useState({});
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState("");
  React.useEffect(() => {
    if (open) { const d = {}; for (const f of SHOT_EDIT_FIELDS) d[f.k] = shot[f.k] == null ? "" : String(shot[f.k]); setDraft(d); setErr(""); }
  }, [open, shot && shot.id]); // eslint-disable-line
  if (!open) return null;
  const close = () => { _editPickerState = { open: false, shot: null }; _notifyEditPicker(); };
  const commit = async () => {
    if (saving) return;
    const patch = {};
    for (const f of SHOT_EDIT_FIELDS) {
      if (f.k === "seq") continue;
      const v = draft[f.k] != null ? String(draft[f.k]) : "";
      if (v !== (shot[f.k] == null ? "" : String(shot[f.k]))) patch[f.k] = v;
    }
    const seqRaw = String(draft.seq == null ? "" : draft.seq).trim();
    const seqNum = parseInt(seqRaw, 10);
    if (seqRaw !== "" && Number.isFinite(seqNum) && seqNum !== shot.seq) patch.seq = seqNum;
    if (!Object.keys(patch).length) { close(); return; }
    const before = {}; for (const k of Object.keys(patch)) before[k] = shot[k];
    setSaving(true); setErr("");
    if (window.__updateShotField) window.__updateShotField(shot.id, patch);
    try {
      const r = await (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
      });
      if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error((e && e.error) || ("Save failed (" + r.status + ")")); }
      close();
    } catch (e) {
      if (window.__updateShotField) window.__updateShotField(shot.id, before);
      setErr((e && e.message) || "Save failed — try again.");
    } finally { setSaving(false); }
  };
  return ReactDOM.createPortal(
    <div className="shotedit-overlay" onMouseDown={() => { if (!saving) close(); }}>
      <div className="shotedit-modal" onMouseDown={(e) => e.stopPropagation()}>
        <div className="shotedit-head">
          <div className="shotedit-title">Edit <b>{shot.id}</b>{shot.frame_title ? <span className="shotedit-sub"> · {shot.frame_title}</span> : null}</div>
          <button className="shotedit-x" onClick={close} aria-label="Close"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
        </div>
        <div className="shotedit-body">
          {SHOT_EDIT_FIELDS.map((f) => (
            <label key={f.k} className={"shotedit-field" + (f.k === "seq" ? " shotedit-field--seq" : "")}>
              <span className="shotedit-label">{f.label}</span>
              {f.type === "area"
                ? <textarea className="shotedit-input" rows={f.rows || 2} value={draft[f.k] || ""} onChange={(e) => setDraft(d => ({ ...d, [f.k]: e.target.value }))} />
                : <input className="shotedit-input" type={f.type === "number" ? "number" : "text"} value={draft[f.k] || ""} onChange={(e) => setDraft(d => ({ ...d, [f.k]: e.target.value }))} onKeyDown={(e) => { if (e.key === "Enter" && f.type !== "number") { e.preventDefault(); commit(); } }} />}
            </label>
          ))}
        </div>
        {err ? <div className="shotedit-err">{err}</div> : null}
        <div className="shotedit-foot">
          <button className="shotedit-cancel" onClick={close} disabled={saving}>Cancel</button>
          <button className="shotedit-save" onClick={commit} disabled={saving}>{saving ? "Saving…" : "Save changes"}</button>
        </div>
      </div>
    </div>,
    document.getElementById("modal-root") || document.body
  );
}
window.ShotEditPicker = ShotEditPicker;

// v07zz321 — Duplicate-shot, opened from a row's Duplicate button via window.__openShotDupPicker(shot).
// Clones all metadata into a new numbered shot (no images), via POST /api/shots.
let _dupPickerState = { open: false, shot: null };
const _dupPickerSubs = new Set();
function _notifyDupPicker() { _dupPickerSubs.forEach(fn => { try { fn(); } catch (_) {} }); }
window.__openShotDupPicker = (shot) => { _dupPickerState = { open: true, shot }; _notifyDupPicker(); };
function ShotDupPicker() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => { _dupPickerSubs.add(force); return () => _dupPickerSubs.delete(force); }, []);
  const shot = _dupPickerState.shot;
  const open = _dupPickerState.open && !!shot;
  const [num, setNum] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  // v07zz326 — "Also copy all images" toggle. Default OFF (a duplicate is normally
  // a fresh empty shot that inherits only the text fields). When ON, the server
  // physically COPIES the source shot's live frames + hero + videos into the new
  // shot's own folder. The source shot is never touched.
  const [copyImages, setCopyImages] = React.useState(false);
  React.useEffect(() => {
    if (open) {
      const shots = (window.__appData && window.__appData.shots) || [];
      const nums = new Set(shots.map(s => parseInt(String(s.id).replace(/^SH/i, ""), 10)).filter(Number.isFinite));
      const curr = parseInt(String(shot.id).replace(/^SH/i, ""), 10) || 0;
      let sug = null; for (let n = curr + 1; n <= curr + 10; n++) { if (!nums.has(n)) { sug = n; break; } }
      if (sug == null) { let mx = 0; nums.forEach(n => { if (n > mx) mx = n; }); sug = mx + 10; }
      setNum("SH" + String(sug).padStart(4, "0")); setErr(""); setCopyImages(false);
    }
  }, [open, shot && shot.id]); // eslint-disable-line
  if (!open) return null;
  const close = () => { _dupPickerState = { open: false, shot: null }; _notifyDupPicker(); };
  const commit = async () => {
    if (busy) return;
    const raw = String(num || "").trim().toUpperCase().replace(/^SH/, "");
    if (!/^\d{1,5}$/.test(raw)) { setErr("Shot number must be digits — e.g. 0095 or SH0095."); return; }
    setBusy(true); setErr("");
    const payload = {
      id: raw, frame_title: shot.frame_title || "", action: shot.action || "", narration: shot.narration || "",
      shot_type: shot.shot_type || "", species: shot.species || "", landscape: shot.landscape || "",
      weather_light: shot.weather_light || "", behaviour: shot.behaviour || "", seq: shot.seq,
      // v07zz326 — when ON, the server copies the source shot's live images into the new shot.
      ...(copyImages ? { copy_images_from: shot.id } : {}),
    };
    try {
      const r = await (window.authFetch || fetch)("/api/shots", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error((j && j.error) || ("Create failed (" + r.status + ")"));
      // v07zz332 — copy-images is server-side; surface a 0-copy result for diagnosis (the
      // server now falls back to the displayed frame, so this should be rare).
      if (copyImages && j && j._copy_warning) console.warn("[duplicate] copy images:", j._copy_warning);
      close();
      if (window.reloadAppData) window.reloadAppData();
      const newId = (j && j.id) || ("SH" + raw.padStart(4, "0"));
      // v07zz329 — open the new shot ONLY once it's actually in the reloaded data. The old blind
      // 200ms timer often fired BEFORE reloadAppData() refetched /api/data — force-opening a shot
      // (especially a recreated/reactivated number) the client didn't have yet, so the modal
      // rendered against a missing shot. Poll until it lands (~6s max); if it never does, just
      // don't auto-open rather than crash.
      let _tries = 0;
      const _openWhenReady = () => {
        const have = ((window.__appData && window.__appData.shots) || []).some(s => s.id === newId);
        if (have) { if (window.__nav && window.__nav.openShot) window.__nav.openShot(newId); return; }
        if (_tries++ < 30) setTimeout(_openWhenReady, 200);
      };
      setTimeout(_openWhenReady, 150);
    } catch (e) { setErr((e && e.message) || "Couldn't duplicate — try a different number."); }
    finally { setBusy(false); }
  };
  return ReactDOM.createPortal(
    <div className="shotedit-overlay" onMouseDown={() => { if (!busy) close(); }}>
      <div className="shotedit-modal shotedit-modal--narrow" onMouseDown={(e) => e.stopPropagation()}>
        <div className="shotedit-head">
          <div className="shotedit-title">Duplicate <b>{shot.id}</b></div>
          <button className="shotedit-x" onClick={close} aria-label="Close"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
        </div>
        <div className="shotedit-body">
          <label className="shotedit-field">
            <span className="shotedit-label">New shot number</span>
            <input className="shotedit-input" autoFocus value={num} placeholder="e.g. SH0095"
              onChange={(e) => setNum(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { e.preventDefault(); close(); } }} />
          </label>
          {/* v07zz326 — "Also copy all images" toggle. Default OFF. */}
          <label className="shotdup-copytoggle" style={{ display: "flex", alignItems: "flex-start", gap: 9, cursor: busy ? "default" : "pointer", userSelect: "none" }}>
            <input type="checkbox" checked={copyImages} disabled={busy}
              onChange={(e) => setCopyImages(e.target.checked)}
              style={{ width: 16, height: 16, marginTop: 1, flex: "0 0 auto", cursor: busy ? "default" : "pointer", accentColor: "var(--blue-2)" }} />
            <span style={{ fontSize: "var(--fs-12-5)", fontWeight: "var(--fw-semi)", color: "var(--ink)", lineHeight: 1.4 }}>Also copy all images</span>
          </label>
          {/* Fixed min-height so toggling the checkbox doesn't shift the footer (invariant #20). */}
          <div className="shotedit-dupnote" style={{ minHeight: 54 }}>
            Copies the title, shot type, action, VO, location and every other detail from <b>{shot.id}</b> into the new shot.{" "}
            {copyImages
              ? <>It will also receive <b>copies</b> of {shot.id}'s live frames, hero and videos — <b>{shot.id} is left untouched</b>.</>
              : <><b>No images are copied</b> — the new shot starts empty.</>}
          </div>
        </div>
        {err ? <div className="shotedit-err">{err}</div> : null}
        <div className="shotedit-foot">
          <button className="shotedit-cancel" onClick={close} disabled={busy}>Cancel</button>
          <button className="shotedit-save" onClick={commit} disabled={busy}>{busy ? (copyImages ? "Duplicating + copying…" : "Duplicating…") : "Duplicate shot"}</button>
        </div>
      </div>
    </div>,
    document.getElementById("modal-root") || document.body
  );
}
window.ShotDupPicker = ShotDupPicker;

// v07zz326 — Delete-shot confirm, opened from a row's Delete button via
// window.__openShotDeletePicker(shot). Uses the in-app styled .confirm-delete-*
// modal pattern (NEVER native window.confirm — invariant #22). On confirm it
// calls DELETE /api/shots/:id, which SOFT-deletes the shot (sync-safe tombstone)
// and archives its asset_versions (reversible). The same number can be recreated
// afterwards — the create endpoint revives the tombstoned slot.
let _delPickerState = { open: false, shot: null };
const _delPickerSubs = new Set();
function _notifyDelPicker() { _delPickerSubs.forEach(fn => { try { fn(); } catch (_) {} }); }
window.__openShotDeletePicker = (shot) => { _delPickerState = { open: true, shot }; _notifyDelPicker(); };
function ShotDeletePicker() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => { _delPickerSubs.add(force); return () => _delPickerSubs.delete(force); }, []);
  const shot = _delPickerState.shot;
  const open = _delPickerState.open && !!shot;
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  React.useEffect(() => { if (open) { setBusy(false); setErr(""); } }, [open, shot && shot.id]);
  const close = () => { if (busy) return; _delPickerState = { open: false, shot: null }; _notifyDelPicker(); };
  const confirm = async () => {
    if (busy || !shot) return;
    setBusy(true); setErr("");
    try {
      const r = await (window.authFetch || fetch)("/api/shots/" + encodeURIComponent(shot.id), { method: "DELETE" });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error((j && j.error) || ("Delete failed (" + r.status + ")"));
      // If the deleted shot's modal is open, close it.
      try { if (window.__nav && window.__nav.openShot && window.__navigate) { /* no-op: modal closes on reload */ } } catch (_) {}
      _delPickerState = { open: false, shot: null }; _notifyDelPicker();
      if (window.reloadAppData) window.reloadAppData();
    } catch (e) { setErr((e && e.message) || "Couldn't delete the shot."); setBusy(false); }
  };
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape" && !busy) close(); if (e.key === "Enter" && !busy) confirm(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }); // eslint-disable-line
  if (!open) return null;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: 6300 }} onClick={() => !busy && close()}>
      <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
        <div className="confirm-delete-eyebrow" style={{ color: "var(--red-8)" }}>DELETE SHOT · REVERSIBLE</div>
        <div className="confirm-delete-title">Delete <strong>{shot.id}</strong>{shot.frame_title ? <> — {shot.frame_title}</> : ""}?</div>
        <div className="confirm-delete-body">
          <p>The shot is removed from the shotlist immediately.</p>
          <p style={{ background: "color-mix(in srgb, var(--amber-2) 10%, transparent)", padding: "8px 10px", borderRadius: "var(--r-xs)", border: "1px solid color-mix(in srgb, var(--amber-2) 30%, transparent)", color: "var(--warn-deep)", fontSize: "var(--fs-12-5)" }}>
            <strong>Recoverable.</strong> Its images are <em>archived</em>, not deleted — nothing is wiped from disk. You can re-create <strong>{shot.id}</strong> later and the number is reused cleanly.
          </p>
        </div>
        {err ? <div className="confirm-delete-body" style={{ color: "var(--red-2)", fontWeight: "var(--fw-semi)", fontSize: "var(--fs-12-5)", paddingTop: 0 }}>{err}</div> : null}
        <div className="confirm-delete-actions">
          <button type="button" className="admin-suspend-btn" onClick={close} disabled={busy}>Cancel</button>
          <button type="button" className="confirm-delete-btn" onClick={confirm} disabled={busy}>{busy ? "Deleting…" : "Delete shot"}</button>
        </div>
      </div>
    </div>
  ), document.getElementById("modal-root") || document.body);
}
window.ShotDeletePicker = ShotDeletePicker;

// ─── v968 — SHOT SEARCH ──────────────────────────────────────────────────────
// Hugo: "we need a search bar in the shots page. and it should be very good in
// terms of finding what we are looking for based on what i am typing."
// Scored, multi-field, multi-word. Every word you type must match SOMETHING
// (so more words = fewer, better results), each word scores against the field
// it hit, and the strongest matches float to the top. Fields are weighted: the
// shot id beats the title, the title beats the action line, and so on.
// Deliberately regex-literal-free — built from character codes so no escape can
// be mangled (the v964 heredoc lesson).
const _SS_NONWORD = new RegExp("[^a-z0-9]+", "g");
const _SS_MARKS = new RegExp("[" + String.fromCharCode(0x0300) + "-" + String.fromCharCode(0x036f) + "]", "g");
function _ssNorm(v) {
  return String(v == null ? "" : v).toLowerCase().normalize("NFD")
    .replace(_SS_MARKS, "").replace(_SS_NONWORD, " ").replace(new RegExp("  +", "g"), " ").trim();
}
// bounded edit distance — returns true when a and b differ by <= max edits
function _ssWithin(a, b, max) {
  const la = a.length, lb = b.length;
  if (Math.abs(la - lb) > max) return false;
  let prev = new Array(lb + 1);
  for (let j = 0; j <= lb; j++) prev[j] = j;
  for (let i = 1; i <= la; i++) {
    const cur = new Array(lb + 1);
    cur[0] = i;
    let best = cur[0];
    for (let j = 1; j <= lb; j++) {
      const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
      if (cur[j] < best) best = cur[j];
    }
    if (best > max) return false;
    prev = cur;
  }
  return prev[lb] <= max;
}
// does any WORD of the haystack start with, or nearly equal, the token?
function _ssWordHit(words, tok, fuzz) {
  for (let i = 0; i < words.length; i++) {
    const w = words[i];
    if (w === tok) return 3;          // whole word
    if (w.indexOf(tok) === 0) return 2;   // word starts with it
  }
  if (fuzz > 0) {
    for (let i = 0; i < words.length; i++) {
      if (_ssWithin(words[i], tok, fuzz)) return 1;   // one or two typos
    }
  }
  return 0;
}
// Build the searchable text for a shot ONCE, cached on a WeakMap keyed by the row.
const _ssCache = new WeakMap();
function _ssFields(shot, seqName) {
  let f = _ssCache.get(shot);
  if (f && f._seq === seqName) return f;
  const idNorm = _ssNorm(shot.id);
  const mk = (v) => { const n = _ssNorm(v); return { n, w: n ? n.split(" ") : [] }; };
  f = {
    _seq: seqName,
    id: mk(shot.id),
    idDigits: idNorm.replace(new RegExp("[^0-9]", "g"), ""),
    title: mk(shot.frame_title),
    seqName: mk(seqName),
    shotType: mk(shot.shot_type),
    action: mk(shot.action),
    narration: mk(shot.narration),
    landscape: mk(shot.landscape),
    light: mk(shot.weather_light),
    species: mk(shot.species),
    behaviour: mk(shot.behaviour),
  };
  _ssCache.set(shot, f);
  return f;
}
const _SS_IDTOK = new RegExp("^(sh)?[0-9]+$");
const _SS_WEIGHTS = [
  ["id", 14], ["title", 9], ["seqName", 5], ["species", 4],
  ["action", 4], ["landscape", 3], ["shotType", 3], ["narration", 3],
  ["behaviour", 2], ["light", 2],
];
// Score one shot against the typed words. Returns -1 when a word matches nothing
// (that shot is out), otherwise the summed score.
function _ssScore(shot, tokens, seqName) {
  const f = _ssFields(shot, seqName);
  let total = 0;
  for (let t = 0; t < tokens.length; t++) {
    const tok = tokens[t];
    // A shot id ("sh1250") or a bare number is EXACT by nature — fuzzing it made
    // SH0250 and SH1050 come back for "SH1250". No typo tolerance on those.
    const looksLikeId = _SS_IDTOK.test(tok);
    const fuzz = looksLikeId ? 0 : (tok.length >= 7 ? 2 : (tok.length >= 4 ? 1 : 0));
    let best = 0;
    // a bare number is almost always a shot number
    if (f.idDigits && tok.length >= 2 && tok.charCodeAt(0) >= 48 && tok.charCodeAt(0) <= 57) {
      if (f.idDigits === tok) best = 14 * 4;
      else if (f.idDigits.indexOf(tok) === 0) best = 14 * 3;
      else if (f.idDigits.indexOf(tok) > -1) best = 14 * 1.5;
    }
    for (let i = 0; i < _SS_WEIGHTS.length; i++) {
      const key = _SS_WEIGHTS[i][0], w = _SS_WEIGHTS[i][1];
      const fld = f[key];
      if (!fld || !fld.n) continue;
      let hit = _ssWordHit(fld.w, tok, fuzz);
      let sc = hit === 3 ? w * 3 : hit === 2 ? w * 2 : hit === 1 ? w * 0.6 : 0;
      if (!sc && fld.n.indexOf(tok) > -1) sc = w * 1.2;   // mid-word substring
      if (sc > best) best = sc;
    }
    if (!best) return -1;   // this word matched nothing — drop the shot
    total += best;
  }
  return total;
}
function applyShotSearch(list, query, seqNameOf) {
  const q = _ssNorm(query);
  if (!q) return list;
  const tokens = q.split(" ").filter(Boolean);
  if (!tokens.length) return list;
  const scored = [];
  for (let i = 0; i < list.length; i++) {
    const sc = _ssScore(list[i], tokens, seqNameOf ? seqNameOf(list[i]) : "");
    if (sc >= 0) scored.push({ s: list[i], sc, i });
  }
  // strongest first; ties keep the list's existing (sorted) order
  scored.sort((a, b) => (b.sc - a.sc) || (a.i - b.i));
  return scored.map(x => x.s);
}

function ShotsPanel({ shots = [], sequences = [], onOpenShot, onOpenSequence, openShotId, showAssetsTab = false, onShotStatusChange, noteCounts = {}, generatingSet = null,
  // 17 Sep 2026 - pending: a project switch is on its way (placeholder rows); loadError: the project
  // in the header did not load ("failed") or has not answered yet ("slow") - a retry line shows
  pending = false, loadError = null }) {
  // v07perf — Hugo: "it always goes back to Shots when going back from
  // another menu. the whole point was to always be able to see the tab
  // was first on the list." Initialize the landing tab from the user's
  // saved overview_tabs order: take the first entry that's actually
  // visible right now (Assets is permission-gated via showAssetsTab).
  // Falls back to "shots" if nothing's saved or the saved order is
  // unusable. The initializer runs every mount, so navigating away
  // and back re-lands on the user's preferred first tab.
  const [tab, setTab] = React.useState(() => {
    try {
      const u = window.__currentUser;
      const userOrder = u && u.preferences && u.preferences.overview_tabs;
      if (Array.isArray(userOrder) && userOrder.length) {
        for (const t of userOrder) {
          if (t === "assets" && !showAssetsTab) continue;
          if (t === "shots" || t === "sequences" || t === "assets") return t;
        }
      }
    } catch (_) {}
    return "shots";
  });

  // v07zz320 — rename a sequence inline (Sequences tab). PATCH /api/sequences/:number
  // updates the slug; the shot rows read the slug from /api/data so reloadAppData
  // propagates the new name to every row. Gated by manage_episodes (admin bypasses).
  const canRenameSeq = !window.hasPerm || window.hasPerm("manage_episodes");
  const [renamingSeq, setRenamingSeq] = React.useState(null);   // seq.number being renamed
  const [seqNameDraft, setSeqNameDraft] = React.useState("");
  const commitSeqRename = async (number) => {
    const name = (seqNameDraft || "").trim();
    setRenamingSeq(null);
    if (!name) return;
    const epId = (window.__appData && window.__appData.episode && window.__appData.episode.id) || undefined;
    try {
      const r = await (window.authFetch || fetch)(`/api/sequences/${number}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, episode_id: epId }),
      });
      if (r.ok && window.reloadAppData) window.reloadAppData();
    } catch (_) {}
  };

  // v07zm — Bulk background prefetch of asset-versions when the
  // shotlist mounts. Uses requestIdleCallback so the warm-up runs
  // when the main thread is free; no impact on initial paint.
  // Hugo's "click before hover" scenario: shot data is now warm
  // even without an explicit hover gesture.
  //
  // v07zz32 — Also warm the modal hero image (width 800) when the
  // asset-versions response lands. On Railway, the modal hero fetch
  // was a cold /api/r2-thumb hit (~300-500 ms to fetch from R2 +
  // encode) the first time a shot was opened — even after the
  // asset-versions metadata was cached. Firing `new Image()` for the
  // hero src puts the WebP variant in the browser's image cache so
  // the modal paints synchronously on first click. Capped at the
  // first 40 shots (most likely to be opened) to keep the warm-up
  // bandwidth bounded.
  React.useEffect(() => {
    if (!Array.isArray(shots) || shots.length === 0) return;
    let cancelled = false;
    const idleCb = window.requestIdleCallback || ((fn) => setTimeout(fn, 200));
    // v07perf — On Railway (no WATCH_PATH server-side, so no shot has a
    // /local/ URL), the bulk prefetch was firing /api/asset-versions
    // for every one of ~176 shots and the hero-warm-up was firing
    // /api/r2-thumb at width 800 for the first 40. On a residential
    // connection that's an audible ~3-5 s of cold network activity
    // before the page settles. Detect Railway by checking whether ANY
    // shot in the loaded set has a /local/ URL — if not, cap to the
    // first 20 prefetches and skip the hero warm entirely. Local stays
    // unchanged.
    const hasAnyLocal = shots.some(s => {
      const sel = s && s.image_paths && s.image_paths.selected;
      return typeof sel === "string" && sel.startsWith("/local/");
    });
    const isRailway = !hasAnyLocal;
    const PREFETCH_CAP = isRailway ? 20 : shots.length;
    const HERO_WARM_BUDGET = isRailway ? 0 : 40;
    // v07zz40 — Warm the modal HERO at 800 for the first N shots AFTER the
    // versions land (so we warm the exact version-frame URL the modal shows,
    // not the shotlist image). Runs on Railway too (was 0) so a no-hover open
    // isn't a cold ~1s r2-thumb fetch — budget-capped to keep egress bounded.
    const HERO_V_WARM_BUDGET = isRailway ? 16 : 60;
    // v07perf2 — Switched from per-shot serial fetches to ONE batched
    // call to /api/asset-versions/batch. better-sqlite3 is synchronous,
    // so 176 individual /api/asset-versions requests each blocked the
    // Node event loop while their CTE ran — total page load time was
    // dominated by that queue (~80 s on local even though every
    // response was a 304/0.1 kB cache hit). The batch endpoint runs
    // ONE SQL query for all asset_ids and returns a versions_by_asset_id
    // map. Cache shape is identical to the single endpoint so existing
    // consumers (ShotDetailModal, GridDetailModal) keep working
    // unchanged.
    idleCb(async () => {
      if (cancelled) return;
      window.__assetVersionCache = window.__assetVersionCache || new Map();
      // First pass: hero warm-ups (independent of asset-versions fetch
      // — fires `new Image()` straight away so width-800 WebPs land in
      // the browser cache while the batch fetch is in flight).
      const idsToFetch = [];
      for (let idx = 0; idx < shots.length && idx < PREFETCH_CAP; idx++) {
        const s = shots[idx];
        if (!s || !s.id || _shotPrefetchedSet.has(s.id)) continue;
        _shotPrefetchedSet.add(s.id);
        if (idx < HERO_WARM_BUDGET && window.thumbUrl) {
          const heroSrc = s.video_poster
            || (s.image_paths && (s.image_paths.selected || s.image_paths.first_pass));
          if (heroSrc) _warmThumb(window.thumbUrl(heroSrc, 800));
        }
        if (Array.isArray(window.__assetVersionCache.get(s.id))) continue;
        idsToFetch.push(s.id);
      }
      if (cancelled || idsToFetch.length === 0) return;
      // Second pass: batched asset-versions fetch. Chunked at 100 IDs
      // per call to stay well under the server's MAX_IDS (500) cap and
      // keep each CTE bounded. On a typical project this is 1-2 round-
      // trips total instead of 176.
      const BATCH_CHUNK = 100;
      const fetcher = window.authFetch || fetch;
      let heroVWarmed = 0;   // v07zz40 — count of modal-hero (800) warms so far
      for (let i = 0; i < idsToFetch.length; i += BATCH_CHUNK) {
        if (cancelled) return;
        const chunk = idsToFetch.slice(i, i + BATCH_CHUNK);
        const url = "/api/asset-versions/batch?asset_ids="
          + chunk.map(encodeURIComponent).join(",");
        try {
          const r = await fetcher(url);
          if (!r.ok) continue;
          const d = await r.json();
          const map = (d && d.versions_by_asset_id) || {};
          for (const id of chunk) {
            const versions = Array.isArray(map[id]) ? map[id] : [];
            window.__assetVersionCache.set(id, versions);
            try { sessionStorage.setItem("asset-versions:" + id, JSON.stringify(versions)); } catch (_) {}
            // v07zz40 — warm the exact modal-hero URL (version frame @800).
            if (heroVWarmed < HERO_V_WARM_BUDGET && window.thumbUrl) {
              const hv = versions.find(v => v && v.kind === "hero" && v.file_path)
                || versions.find(v => v && (v.kind === "frame" || v.kind === "grid") && v.file_path);
              if (hv) { _warmThumb(window.thumbUrl(hv.file_path, 800)); heroVWarmed++; }
            }
          }
        } catch (_) { /* network blip — leave un-cached, hover/open paths still work */ }
      }
    });
    return () => { cancelled = true; };
  }, [shots]);

  const [selected, setSelected] = React.useState(new Set());
  const [sortId, setSortId] = React.useState("id");
  // v01g — initial filter Set = every stage except ARCHIVE (Archival
  // Footage) which is OFF by default. The Set is now a SHOW list, not a
  // HIDE list, so this matches Hugo's "all stages on, archival
  // footage hidden until I ask" mental model.
  const [stageFilter, setStageFilter] = React.useState(() => new Set(STAGE_FILTERS_DEFAULT_ON));
  const [seqFilter, setSeqFilter] = React.useState(new Set());
  // Local stage overrides (id → updated stage_status). Mutates window.__appData.shots in place
  // so other views (TopBar donut, Pipeline) see the change.
  const [, bumpVersion] = React.useState(0);
  // v07zz372 — accept either an id OR a shot object. The row's onClick passes the whole shot
  // (onOpen(shot)) while checkboxes pass an id; before, objects went into the Set so the count
  // ticked up but `selected.has(s.id)` never matched → no gold ring, and batch status / dispatch
  // (which expect ids) silently did nothing.
  const toggle = (idOrShot) => setSelected(prev => {
    const id = (idOrShot && typeof idOrShot === "object") ? idOrShot.id : idOrShot;
    const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
  });

  // v05k — JS virtualisation. The CSS-only `content-visibility: auto`
  // approach wasn't actually skipping off-screen rows in this app's
  // layout (probably because Chromium's heuristic decided the rows
  // were still "near" enough to be relevant). When the sidebar
  // collapses, that meant all ~150 rows reflowed per frame and the
  // animation stuttered.
  // Fix: an IntersectionObserver watches every .shot-row and toggles
  // an `.is-far` class on rows that aren't within ~600px of the
  // viewport. The CSS for `.is-far` is `content-visibility: hidden`
  // — that's the aggressive form the browser MUST honour. Rows enter
  // / leave the active-render set as the user scrolls, with a
  // generous buffer so they're already laid out before they actually
  // come into view.
  const shotsListRef = React.useRef(null);
  const rowStore = _shotRowStore(showAssetsTab ? "overview" : "page");
  React.useEffect(() => {
    const root = shotsListRef.current;
    if (!root || typeof IntersectionObserver === "undefined") return;
    // 17 Sep 2026 - the root is the LIST, which scrolls inside itself: with the window as root the
    // 600px buffer never applied (rows outside the list's box are clipped), so every row was
    // hidden until it was already on screen
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        // a row that was fully drawn when measured: its shot's height is kept (see _shotRowStores)
        const h = e.boundingClientRect.height;
        if (h > 0 && !e.target.classList.contains("is-far")) {
          // a hidden row's height is its CONTENT height (contain-intrinsic-size) plus its own
          // padding and border, which are read once per list width
          if (rowStore.pad == null) {
            const cs = getComputedStyle(e.target);
            rowStore.pad = ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0);
          }
          const id = e.target.getAttribute("data-shot-id");
          const content = Math.round(Math.max(0, h - rowStore.pad) * 100) / 100;
          if (id) rowStore.heights.set(id, content);
          rowStore.last = Math.round(h * 100) / 100;
          rowStore.lastContent = content;
        }
        e.target.classList.toggle("is-far", !e.isIntersecting);
      }
    }, { root, rootMargin: _SHOT_ROW_NEAR_PX + "px 0px" });
    // a list that changes width has rows of other heights: forget the ones kept
    let ro = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver((entries) => {
        const w = Math.round(entries[entries.length - 1].contentRect.width);
        if (rowStore.w && w !== rowStore.w) { rowStore.heights.clear(); rowStore.last = 0; rowStore.lastContent = 0; rowStore.pad = null; }
        rowStore.w = w;
      });
      ro.observe(root);
    }
    // Observe every shot row currently in the DOM. MutationObserver
    // re-attaches when the list changes (filter, sort, new shot).
    const attach = () => {
      root.querySelectorAll(".shot-row").forEach(r => io.observe(r));
    };
    attach();
    const mo = new MutationObserver(() => {
      io.disconnect();
      attach();
    });
    mo.observe(root, { childList: true });
    return () => { io.disconnect(); mo.disconnect(); if (ro) ro.disconnect(); };
  }, []);
  // v07zz332 — preserve shotlist scroll across navigation, WITHOUT yanking an active scroll.
  // `_scrollUserTookOver` flips true once the user is genuinely driving the scroller; while it's
  // true the restore is a no-op, so a data refresh (a status click, or the ~5s SSE reloadAppData
  // when a teammate is active — both hand `shots` a NEW array ref) can't re-run the restore and
  // pull the list backward mid-fling. The ref is fresh on every mount + reset on tab change, so
  // navigating away and back always restores the saved offset. (Adversarial-review fix.)
  const _scrollUserTookOver = React.useRef(false);
  const _scrollLastProgrammatic = React.useRef(0);
  // 17 Sep 2026 - while a project switch shows placeholder rows the list is short, so the browser
  // clamps its scroll: that is neither the user scrolling nor a position worth saving
  const pendingRef = React.useRef(pending);
  pendingRef.current = pending;
  React.useEffect(() => { _scrollUserTookOver.current = false; }, [tab]);
  // A project switch: the new project's rows open where that project's list was left, or at the top
  // (the panel stays mounted, so once the user had scrolled, a switch kept whatever offset the
  // previous project's list happened to have - Paradise Found came back 80-215px down instead of at
  // its own place).
  const switchedRef = React.useRef(false);
  React.useLayoutEffect(() => {
    if (!pending) return;
    _scrollUserTookOver.current = false;
    switchedRef.current = true;
  }, [pending]);
  // 17 Sep 2026 - A BIG BATCH OF NEW ROWS (a project or an episode arriving) starts hidden, decided
  // before the browser first draws it: entering Paradise Found used to style, lay out and paint all
  // 239 rows in one 200-525 ms frame, and the observer hid ~233 of them only afterwards. The new
  // rows are hidden here (a hidden row is a box of the last measured row height, cheap to lay out),
  // the saved scroll position is restored (next effect), and the rows near it are shown again (the
  // effect after that); the observer takes over from there. Only once a real row has been measured
  // (the first list of a page load is drawn in full, as before), so the hidden rows' height is right.
  const freshRowsRef = React.useRef(null);
  React.useLayoutEffect(() => {
    freshRowsRef.current = null;
    const root = shotsListRef.current;
    if (!root || tab !== "shots" || pending) return;
    const fresh = [];
    for (const r of root.children) {
      if (r.__ftRowSeen || !r.classList.contains("shot-row")) continue;
      r.__ftRowSeen = true;
      fresh.push(r);
    }
    if (fresh.length < _SHOT_ROW_BATCH || !(rowStore.last > 0)) return;
    for (const r of fresh) {
      const h = rowStore.heights.get(r.getAttribute("data-shot-id"));
      if (h > 0) r.style.setProperty("--shot-row-est-h", h + "px");
      r.classList.add("is-far");
    }
    freshRowsRef.current = fresh;
  }, [tab, shots, pending]);
  // RESTORE before paint (no flash-to-top). Re-runs on data change so late/streamed rows still
  // land at the saved offset — but only until the user takes over. Single set, no retry: rows
  // always render into the DOM (virtualisation is paint-only — .is-far → content-visibility:hidden
  // + contain-intrinsic-size reserves each 96px row), so scrollHeight is valid immediately.
  React.useLayoutEffect(() => {
    if (_scrollUserTookOver.current || pending) return;
    const el = shotsListRef.current;
    if (!el) return;
    const want = _shotsScrollMemo.get(_shotsScrollKey(tab)) || 0;
    const afterSwitch = switchedRef.current;
    switchedRef.current = false;
    if ((want > 0 || afterSwitch) && Math.abs(el.scrollTop - want) > 2) {
      _scrollLastProgrammatic.current = Date.now();   // mark our own write so its echo scroll isn't read as "user"
      el.scrollTop = want;
    }
  }, [tab, shots, pending]);
  // ...and the hidden new rows near the (restored) visible part are shown before the first paint.
  // All positions are read first (one layout of mostly hidden rows), then the classes change.
  React.useLayoutEffect(() => {
    const fresh = freshRowsRef.current;
    freshRowsRef.current = null;
    const root = shotsListRef.current;
    if (!fresh || !root) return;
    const top = root.scrollTop - _SHOT_ROW_NEAR_PX;
    const bottom = root.scrollTop + root.clientHeight + _SHOT_ROW_NEAR_PX;
    const near = [];
    for (const r of fresh) {
      const y = r.offsetTop;
      if (y + r.offsetHeight >= top && y <= bottom) near.push(r);
    }
    for (const r of near) r.classList.remove("is-far");
  }, [tab, shots, pending]);
  // SAVE on scroll (rAF-throttled) + detect genuine user interaction: any scroll that fires well
  // after our last programmatic write means the user is driving (covers wheel, trackpad, touch AND
  // scrollbar drag), so stop auto-restoring. The save always runs so the latest position persists.
  React.useEffect(() => {
    const el = shotsListRef.current;
    if (!el) return;
    let raf = 0;
    const onScroll = () => {
      if (pendingRef.current) return;
      if (Date.now() - _scrollLastProgrammatic.current > 120) _scrollUserTookOver.current = true;
      if (raf) return;
      raf = requestAnimationFrame(() => { raf = 0; _shotsScrollMemo.set(_shotsScrollKey(tab), el.scrollTop); });
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => { el.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [tab]);
  // v01g — "Reset filters" returns to the default-on Set (all stages
  // visible except Archival Footage) rather than the legacy empty
  // Set, which under the inverted logic means "show nothing".
  const clearFilters = () => { setStageFilter(new Set(STAGE_FILTERS_DEFAULT_ON)); setSeqFilter(new Set()); };

  // Persist a status change to the backend (PATCH /api/shots/:id/status). The
  // local state has already been updated optimistically by the caller — if the
  // request fails we just log; a real revert would need a snapshot/rollback.
  const persistStatus = (id, statusKey) => {
    (window.authFetch || fetch)(`/api/shots/${id}/status`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ status: statusKey }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .catch(err => console.warn(`[status] could not persist ${id} → ${statusKey}:`, err.message));
  };

  // Status changes preferentially flow up to App.jsx (immutable replacement on
  // the shots array — that's what makes derived stats like the donut and the
  // top-bar columns recalculate live). If no App-level handler was provided
  // (e.g. shots-only view, or a future tree where this panel is reused), fall
  // back to the legacy in-place mutation + bumpVersion path so the local UI
  // still updates. Either path also PATCHes the backend.
  const handleStatusChange = (id, statusKey) => {
    if (onShotStatusChange) {
      onShotStatusChange(id, statusKey);
    } else {
      const shot = shots.find(s => s.id === id);
      if (shot) {
        const order = ["prompt", "first_pass", "refinement", "hero", "video_prompt", "video", "upscale"];
        if (statusKey === "ARCHIVE") {
          shot.is_archive = true;
        } else if (statusKey === "PENDING") {
          shot.stage_status = order.reduce((acc, k) => { acc[k] = "pending"; return acc; }, {});
          shot.is_archive = false;
        } else {
          const stage = ({
            "PROMPT": "prompt",
            "FIRST-PASS": "first_pass",
            "CONCEPT-WIP": "refinement",
            "CONCEPT-APPROVED": "hero",
            "VIDEO-WIP": "video_prompt",
            "VIDEO-APPROVED": "video",
            "UPSCALED": "upscale",
          })[statusKey];
          if (stage) {
            const targetIdx = order.indexOf(stage);
            shot.stage_status = shot.stage_status || {};
            order.forEach((k, i) => { shot.stage_status[k] = i <= targetIdx ? "done" : "pending"; });
            shot.is_archive = false;
          }
        }
        bumpVersion(v => v + 1);
      }
    }
    persistStatus(id, statusKey);
  };

  // Quick-action buttons still call this with raw stage keys. Translate to a
  // status key and route through handleStatusChange so both code paths share
  // the same persistence + propagation logic.
  //
  // v07zz56 — Hugo: "unclicking First Pass button still doesnt work on the
  // quick actions". The buttons used to be one-way (click first_pass → set
  // to FIRST-PASS, click again → still FIRST-PASS). Now they TOGGLE: if
  // the shot is already at that stage, drop one step back. The step-back
  // order matches the stage progression so First Pass → Prompt → Pending,
  // Frame WIP → First Pass, etc.
  const handleStageChange = (id, target) => {
    const statusKey = STAGE_TO_STATUS[target];
    if (!statusKey) return;
    const shot = shots.find(s => s.id === id);
    if (shot && getCurrentStage(shot) === statusKey) {
      // Toggle off: step back through the pipeline.
      const STEP_BACK = {
        "FIRST-PASS":       "PROMPT",
        "PROMPT":           "PENDING",
        "CONCEPT-WIP":      "FIRST-PASS",
        "CONCEPT-APPROVED": "CONCEPT-WIP",
        "VIDEO-WIP":        "CONCEPT-APPROVED",
        "VIDEO-APPROVED":   "VIDEO-WIP",
        "UPSCALED":         "VIDEO-APPROVED",
      };
      const back = STEP_BACK[statusKey] || "PENDING";
      handleStatusChange(id, back);
      return;
    }
    handleStatusChange(id, statusKey);
  };

  // v07zz367 — multi-select: SET every selected shot to the chosen stage at once (no per-shot
  // toggle — clicking "Frame Hero" makes them all Frame Hero).
  const batchSetStage = (target) => {
    const statusKey = STAGE_TO_STATUS[target];
    if (!statusKey || selected.size === 0) return;
    for (const id of Array.from(selected)) handleStatusChange(id, statusKey);
  };
  // v07zz372 — Omitted (cut from the edit) + Archive for the whole selection.
  const batchOmit = async () => {
    if (selected.size === 0) return;
    for (const id of Array.from(selected)) {
      const shot = shots.find(s => s.id === id); if (shot) shot.omitted = true;
      try { await (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(id)}/omit`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ omitted: true }) }); } catch (_) {}
    }
    bumpVersion(v => v + 1);
    setBatchMode(false); setSelected(new Set());
  };
  const batchArchive = () => {
    if (selected.size === 0) return;
    for (const id of Array.from(selected)) handleStatusChange(id, "ARCHIVE");
    setBatchMode(false); setSelected(new Set());
  };
  // v1007 — LINK THE SELECTION TO ONE MASTER. Hugo: "i need to be able to select
  // multiple shots in the shots page and link them to one master". Linking already
  // existed per-shot (PATCH /api/shots/:id/link {linked_to}), but only one pair at a
  // time via drag. The MASTER is the FIRST shot in the selection by shot id — the
  // lowest number, which is the one that reads as the master in a run of continuation
  // cuts — and it is named on the button so there is no guessing and no dialog.
  const batchLinkMaster = React.useMemo(() => {
    if (selected.size < 2) return null;
    return Array.from(selected).sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }))[0];
  }, [selected]);
  const batchLink = async () => {
    const master = batchLinkMaster;
    if (!master || selected.size < 2) return;
    const fetcher = window.authFetch || fetch;
    for (const id of Array.from(selected)) {
      if (id === master) continue;
      const shot = shots.find(s => s.id === id); if (shot) shot.linked_to = master;
      try {
        await fetcher(`/api/shots/${encodeURIComponent(id)}/link`, {
          method: "PATCH", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ linked_to: master }),
        });
      } catch (_) {}
    }
    // The master itself must not be a continuation of anything else, or the run breaks.
    const m = shots.find(s => s.id === master);
    if (m && m.linked_to) {
      m.linked_to = null;
      try {
        await fetcher(`/api/shots/${encodeURIComponent(master)}/link`, {
          method: "PATCH", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ linked_to: null }),
        });
      } catch (_) {}
    }
    bumpVersion(v => v + 1);
    setBatchMode(false); setSelected(new Set());
  };
  // v1007 — and the reverse: clear the link on everything selected.
  const batchUnlink = async () => {
    if (selected.size === 0) return;
    const fetcher = window.authFetch || fetch;
    for (const id of Array.from(selected)) {
      const shot = shots.find(s => s.id === id); if (shot) shot.linked_to = null;
      try {
        await fetcher(`/api/shots/${encodeURIComponent(id)}/link`, {
          method: "PATCH", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ linked_to: null }),
        });
      } catch (_) {}
    }
    bumpVersion(v => v + 1);
    setBatchMode(false); setSelected(new Set());
  };

  // t06b — manual create / rename / archive handlers were retired.
  // Backend routes (POST /api/shots, PATCH /api/shots/:id, PATCH /api/shots/
  // :id/archive) remain in server.js for future use by the folder watcher
  // (t15) and Claude webhook (t17), but the UI no longer exposes them.

  // v01g — ARCHIVE is now a regular entry in stageFilter (toggling its
  // row in the FilterMenu shows / hides archival footage). No separate
  // showArchived axis needed.
  // v07zz583 — "Needs my review" filter axis (session state; per-person).
  const [needsMyFilter, setNeedsMyFilter] = React.useState(false);
  const visibleShots = React.useMemo(
    () => applySortAndFilter(shots, sortId, stageFilter, seqFilter, needsMyFilter),
    [shots, sortId, stageFilter, seqFilter, needsMyFilter]
  );
  // v968 — the search box. Runs AFTER sort+filter, so it narrows whatever the
  // filters already chose; scoring puts the best match on top.
  const [query, setQuery] = React.useState("");
  const seqNameOf = React.useCallback((sh) => {
    const q = (sequences || []).find(x => x.number === sh.seq);
    return q ? (q.slug || q.name || "") : "";
  }, [sequences]);
  const shownShots = React.useMemo(
    () => applyShotSearch(visibleShots, query, seqNameOf),
    [visibleShots, query, seqNameOf]
  );
  const archivedCount = React.useMemo(() => shots.filter(s => s.is_archive).length, [shots]);
  // v07zz372 — per-stage shot counts for the Filter menu (same keying as applySortAndFilter).
  const stageCounts = React.useMemo(() => {
    const c = {};
    for (const s of shots) {
      const st = getCurrentStage(s);
      const key = s.omitted ? "OMITTED" : (st === "PROMPT" ? "PENDING" : st);
      c[key] = (c[key] || 0) + 1;
    }
    return c;
  }, [shots]);

  // Pad to 20 sequences using the same labels as the Sequences view.
  // 15 Sep 2026 — the pilot's sequence slugs + the synthetic padding are Paradise Found's; a
  // templated project lists only the sequences it really has.
  const _seqIsPF = !(typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const SEQ_LABELS_FALLBACK = {
    1: "pastAmerica", 8: "columbus1492_curlews", 11: "archive",
    14: "caribbean1500s_bahamas", 15: "caribbean1500s_deck", 19: "florida1513",
  };
  const seqsPadded = (() => {
    if (!_seqIsPF) return sequences.slice().sort((a, b) => a.number - b.number);
    const used = new Set(sequences.map(s => s.number));
    const pad = [];
    for (let n = 1; n <= 20; n++) {
      if (!used.has(n)) {
        pad.push({
          number: n,
          slug: SEQ_LABELS_FALLBACK[n] || `sequence_${String(n).padStart(2, "0")}`,
          shot_count: 5 + (n * 3 % 9),
        });
      }
    }
    return [...sequences, ...pad].sort((a, b) => a.number - b.number);
  })();
  const seqStats = seqsPadded.map(seq => {
    const seqShots = shots.filter(s => s.seq === seq.number);
    const done = seqShots.filter(s => s.stage_status && s.stage_status.hero === "done").length;
    return { ...seq, _shots: seqShots, _done: done };
  });
  // v07zw — Batch mode: when ON, clicking a shot row toggles selection
  // instead of opening the modal. A floating action bar shows the count
  // + "Send to generation" button.
  const [batchMode, setBatchMode] = React.useState(false);
  const selectAll = () => setSelected(new Set((visibleShotsRef.current || []).map(s => s.id)));
  const selectNone = () => setSelected(new Set());
  const visibleShotsRef = React.useRef([]);
  // When batch mode toggles off, clear the selection so reopening doesn't surprise.
  React.useEffect(() => { if (!batchMode) setSelected(new Set()); }, [batchMode]);

  // v07perf — Hugo: "I want to be able to change the tabs order of the
  // categories in the overview panel so if I want to have Assets first
  // and always have them visible first on the overview page, i can have
  // that." Per-user tab order, persisted in users.preferences.overview_tabs
  // (cross-device because users syncs). Native HTML5 drag-and-drop on
  // each tab button. Defaults reconcile against the canonical list so
  // a stale saved order that's missing a tab still surfaces it.
  const DEFAULT_TAB_ORDER = ["shots", "sequences", "assets"];
  const TAB_LABEL = { shots: "Shots", sequences: "Sequences", assets: "Assets" };
  const reconcileTabOrder = (saved) => {
    const base = Array.isArray(saved) && saved.length ? saved.filter(t => DEFAULT_TAB_ORDER.includes(t)) : [];
    for (const t of DEFAULT_TAB_ORDER) if (!base.includes(t)) base.push(t);
    return base;
  };
  const [tabOrder, setTabOrder] = React.useState(() =>
    reconcileTabOrder(window.__currentUser && window.__currentUser.preferences && window.__currentUser.preferences.overview_tabs)
  );
  const [dragTab, setDragTab] = React.useState(null);
  const [dropTarget, setDropTarget] = React.useState(null);
  const saveTabOrder = (newOrder) => {
    setTabOrder(newOrder);
    if (window.__currentUser) {
      window.__currentUser.preferences = window.__currentUser.preferences || {};
      window.__currentUser.preferences.overview_tabs = newOrder;
    }
    (window.authFetch || fetch)("/api/users/me/preferences", {
      method: "PATCH",
      body: JSON.stringify({ overview_tabs: newOrder }),
    }).catch(err => console.warn("[tab-order] save failed:", err.message));
  };
  // Filter to those actually rendered (Assets is permission-gated).
  const visibleTabs = tabOrder.filter(t => t !== "assets" || showAssetsTab);

  return (
    <section className="shots-panel glass">
      {/* v07zz289 — shot link picker, mounted once (portaled to #modal-root) */}
      <ShotLinkPicker />
      <ShotEditPicker />
      <ShotDupPicker />
      <ShotDeletePicker />
      <div className="shots-header">
        <div className="tabs">
          {visibleTabs.map(t => {
            // v07perf (round 2) — Hugo: "dragging a tab from left to
            // right doesnt work" + "I dont like the squared dotted
            // outline." Two fixes:
            //  - Asymmetric drop: the previous logic always inserted
            //    BEFORE the target, so dragging RIGHT (src idx < tgt
            //    idx) put the tab at target idx in the filtered array,
            //    which after the splice landed it AT THE SAME spot
            //    (or one to the left) — looked like nothing happened.
            //    Now we detect direction: drag right → insert AFTER
            //    target; drag left → insert BEFORE target.
            //  - Outline: dashed rectangle was crude. Replaced with a
            //    soft gold glow (box-shadow + inset ring) that hugs
            //    the existing pill shape; no harsh edges.
            const isDragging = dragTab === t;
            const isDropTarget = dropTarget === t && dragTab && dragTab !== t;
            // v07perf (round 3) — Hugo: "get rid of both the gold
            // shadow and the square outline. only leave the text of
            // the hovered on tab to be gold." Stripped everything
            // except a colour swap. Dragged tab stays a bit faded so
            // it reads as "in transit" without any colour tint.
            const dragStyle = isDragging
              ? { opacity: 0.45, cursor: "grabbing" }
              : {};
            const targetStyle = isDropTarget
              ? { color: "var(--amber)", transition: "color var(--dur-1) ease" }
              : {};
            return (
              <button
                key={t}
                className={"tab" + (tab === t ? " active" : "") + (isDragging ? " is-dragging" : "") + (isDropTarget ? " is-drop-target" : "")}
                onClick={() => setTab(t)}
                draggable={true}
                onDragStart={(e) => {
                  setDragTab(t);
                  setDropTarget(null);
                  try { e.dataTransfer.effectAllowed = "move"; } catch (_) {}
                }}
                onDragOver={(e) => {
                  if (dragTab && dragTab !== t) {
                    e.preventDefault();
                    try { e.dataTransfer.dropEffect = "move"; } catch (_) {}
                    if (dropTarget !== t) setDropTarget(t);
                  }
                }}
                onDragLeave={() => {
                  setDropTarget(prev => prev === t ? null : prev);
                }}
                onDrop={(e) => {
                  e.preventDefault();
                  if (!dragTab || dragTab === t) { setDragTab(null); setDropTarget(null); return; }
                  const srcIdx = tabOrder.indexOf(dragTab);
                  const tgtIdx = tabOrder.indexOf(t);
                  const without = tabOrder.filter(x => x !== dragTab);
                  // Drag rightward → drop AFTER target. Drag leftward →
                  // drop BEFORE target. This is what makes both
                  // directions feel correct.
                  const baseInsertAt = without.indexOf(t);
                  const insertAt = srcIdx < tgtIdx ? baseInsertAt + 1 : baseInsertAt;
                  without.splice(insertAt, 0, dragTab);
                  saveTabOrder(without);
                  setDragTab(null);
                  setDropTarget(null);
                }}
                onDragEnd={() => { setDragTab(null); setDropTarget(null); }}
                title={isDropTarget
                  ? `Drop here to place ${TAB_LABEL[dragTab]} ${tabOrder.indexOf(dragTab) < tabOrder.indexOf(t) ? "after" : "before"} ${TAB_LABEL[t]}`
                  : `${TAB_LABEL[t]} (drag to reorder)`}
                style={{
                  cursor: dragTab ? (isDragging ? "grabbing" : "copy") : "grab",
                  ...dragStyle,
                  ...targetStyle,
                }}
              >
                {TAB_LABEL[t]}
              </button>
            );
          })}
        </div>
        {/* v07zz51 — Batch button removed from Overview + Shots header
            per Hugo's 28 May feedback. The batch-mode state + selection
            logic stays in the component for future re-enablement (the
            dispatcher modal is still wired in); only the toggle button
            is hidden. Sort/Filter only apply to the shot list — faded
            on other tabs. */}
        <div className={"pill-row" + (tab === "shots" ? "" : " is-faded")}>
          {/* v968 — search. Type anything: a shot number, a title, a word from the
              action, a species, a location, a sequence name. Every word you add
              narrows it further, and small typos still find the shot. */}
          <div className="shots-search">
            <svg className="shots-search-ico" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
            <input type="search" className="shots-search-input" value={query}
              placeholder="Search shots — number, title, action, species…"
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setQuery(""); } }}
              aria-label="Search shots"/>
            {query && (
              <button type="button" className="shots-search-clear" title="Clear the search (Esc)"
                onClick={() => setQuery("")}>×</button>
            )}
            {query && (
              <span className="shots-search-count">{shownShots.length}</span>
            )}
          </div>
          <SortMenu value={sortId} onChange={setSortId}/>
          <FilterMenu
            stages={stageFilter}
            sequencesFilter={seqFilter}
            sequences={sequences}
            archivedCount={archivedCount}
            stageCounts={stageCounts}
            onChangeStages={setStageFilter}
            onChangeSequences={setSeqFilter}
            needsMyReview={needsMyFilter}
            onToggleNeedsMyReview={() => setNeedsMyFilter(v => !v)}
            needsMyCount={applySortAndFilter(shots, sortId, stageFilter, seqFilter, false).filter(shotNeedsMyReview).length /* v07zz591 — count within the ACTIVE stage/seq filters so the badge matches the rows you'd actually see */}
            onClear={() => { setNeedsMyFilter(false); clearFilters(); }}
          />
          {/* v07zz366 — multi-select: toggles batch mode, then clicking shot rows picks them
              (the batch-action bar appears at the bottom). Shots tab only. */}
          {tab === "shots" && (
            <button type="button" className={"pill-button" + (batchMode ? " is-open" : "")}
              title={batchMode ? "Exit multi-select" : "Select multiple shots — then click rows to pick them"}
              onClick={() => setBatchMode(v => !v)}>
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 11l3 3L20 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
              <span>{batchMode ? "Done" : "Select"}</span>
            </button>
          )}
          {/* v07zz280 — one-click ZIP of every shot's current best still.
              Persistent across navigation (module-level state, see
              ZipDownloadButton). Episode read fresh at click time. */}
          <ZipDownloadButton
            scope="shots"
            className="shots-dl-all"
            fallbackName="shots.zip"
            title="Download a ZIP of every shot's current image (picked/hero/latest)"
            url={() => { let ep = ""; try { ep = localStorage.getItem("frameflow-active-episode") || ""; } catch (_) {} return "/api/export/shots-zip" + (ep ? ("?episode=" + encodeURIComponent(ep)) : ""); }}
          />
          {/* v07zz299 — add a shot directly from the Shots page (choose the number). */}
          <AddShotButton />
        </div>
      </div>
      {/* --shot-row-est-h: the height of a hidden row whose shot was never measured (the last real
          row measured); a measured shot's row carries its own */}
      <div className="shots-list" ref={shotsListRef} style={rowStore.lastContent > 0 ? { "--shot-row-est-h": rowStore.lastContent + "px" } : undefined}>
        {tab === "shots" && pending && <ShotRowPlaceholders rowH={rowStore.last}/>}
        {tab === "shots" && !pending && loadError && (
          <div className="shots-empty">
            {loadError === "slow" ? "This project has not loaded yet." : "This project did not load."}{" "}
            <button className="shots-empty-link" onClick={() => { if (window.__retryProjectLoad) window.__retryProjectLoad(); }}>Try again</button>
          </div>
        )}
        {tab === "shots" && !pending && !loadError && shownShots.length === 0 && (
          query
            ? <div className="shots-empty">Nothing matches “{query}”. <button className="shots-empty-link" onClick={() => setQuery("")}>Clear search</button></div>
            : <div className="shots-empty">No shots match the current filters. <button className="shots-empty-link" onClick={clearFilters}>Clear filters</button></div>
        )}
        {tab === "shots" && (() => { visibleShotsRef.current = shownShots; return null; })()}
        {tab === "shots" && shownShots.map(s => (
          <ShotRow key={s.id} shot={s} sequences={sequences}
            selected={selected.has(s.id) || (!batchMode && s.id === openShotId)}
            batchMode={batchMode}
            onSelect={toggle}
            onOpen={batchMode ? toggle : onOpenShot}
            onStageChange={handleStageChange}
            onStatusChange={handleStatusChange}
            noteCount={noteCounts[s.id]}
            isGenerating={!!(generatingSet && generatingSet.has(s.id))}/>
        ))}
        {tab === "sequences" && (
          <div className="sequences-grid sequences-grid--cinema">
            {seqStats.map(seq => {
              const pct = seq._shots.length ? Math.round((seq._done / seq._shots.length) * 100) : 0;
              const h = window.__seqHue(seq.number, (n) => 40 + (n*23) % 280);
              const bg = window.__seqGradient(h);
              // v07zz362 — pickable cover: the chosen shot's frame, else the first shot with an image, else gradient.
              const _covShot = seq.cover_shot_id ? seq._shots.find(s => s.id === seq.cover_shot_id) : null;
              const _covFb = seq._shots.slice().sort((a,b)=>(a.id||"").localeCompare(b.id||"",undefined,{numeric:true})).find(s => s.image_paths && (s.image_paths.selected || s.image_paths.first_pass));
              const _cs = _covShot || _covFb;
              const _cimg = _cs && _cs.image_paths && (_cs.image_paths.selected || _cs.image_paths.first_pass);
              const coverSrc = _cimg ? (window.thumbUrl ? window.thumbUrl(_cimg, 560) : _cimg) : null;
              const isRenaming = renamingSeq === seq.number;
              return (
                <div key={seq.number} className={"sequence-card glass interactive-card" + (isRenaming ? " is-renaming" : "")}
                  role={isRenaming ? undefined : "button"} tabIndex={isRenaming ? undefined : 0}
                  onClick={isRenaming ? undefined : () => onOpenSequence && onOpenSequence(seq.number)}
                  onKeyDown={isRenaming ? undefined : (e) => { if (e.key === "Enter") onOpenSequence && onOpenSequence(seq.number); }}>
                  <div className="sc-thumb sc-thumb--cinema" style={coverSrc ? { backgroundImage: `url(${coverSrc})`, backgroundSize: "cover", backgroundPosition: "center" } : { background: bg }}>
                    {!coverSrc && <span className="sc-thumb-num">{String(seq.number).padStart(2,"0")}</span>}
                    {seq._shots.length > 0 && <span className="sc-done-pill">{seq._done}/{seq._shots.length} done</span>}
                    {canRenameSeq && !isRenaming && (
                      <span className="sc-rename-btn" title="Rename this sequence"
                        onClick={(e) => { e.stopPropagation(); setSeqNameDraft(seq.slug || ""); setRenamingSeq(seq.number); }}>
                        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
                      </span>
                    )}
                  </div>
                  <div className="sc-body">
                    <div className="sc-eyebrow">SEQUENCE {String(seq.number).padStart(2,"0")} · {seq.shot_count} SHOTS</div>
                    {isRenaming ? (
                      <input className="sc-slug-edit" autoFocus value={seqNameDraft}
                        placeholder="Sequence name…"
                        onClick={(e) => e.stopPropagation()}
                        onChange={(e) => setSeqNameDraft(e.target.value)}
                        onBlur={() => commitSeqRename(seq.number)}
                        onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitSeqRename(seq.number); } else if (e.key === "Escape") { e.preventDefault(); setRenamingSeq(null); } }} />
                    ) : (
                      <div className="sc-slug">{seq.slug}</div>
                    )}
                    <div className="sc-progress"><div className="sc-progress-fill" style={{width: `${pct}%`}}/></div>
                  </div>
                </div>
              );
            })}
          </div>
        )}
        {tab === "assets" && (
          <AssetsOverview/>
        )}
      </div>

      {/* v07zw — Floating action bar when in batch mode + at least one
          selection. Anchors to the bottom of the panel. */}
      {batchMode && tab === "shots" && (
        <div className="batch-action-bar">
          <span className="batch-action-count">
            <strong>{selected.size}</strong> shot{selected.size === 1 ? "" : "s"} selected
          </span>
          <button type="button" className="batch-action-link" onClick={selectAll}>Select all visible</button>
          <button type="button" className="batch-action-link" onClick={selectNone} disabled={selected.size === 0}>Clear</button>
          {/* v07zz367 — set the stage for every selected shot at once. */}
          {(!window.hasPerm || window.hasPerm("change_shot_status")) && (
            <div className="batch-stage-set">
              <span className="batch-stage-label">Set status:</span>
              {BATCH_STAGES.map(st => { const t = STAGE_TINTS[st.tint] || STAGE_TINTS.PENDING; return (
                <button key={st.target} type="button" className="batch-stage-pill" disabled={selected.size === 0}
                  title={`Set all ${selected.size} selected shot${selected.size === 1 ? "" : "s"} to ${st.label}`}
                  onClick={() => batchSetStage(st.target)}
                  style={{ borderColor: t.border, color: t.color }}>
                  <span className="batch-stage-dot" style={{ background: t.dot }}/>{st.label}
                </button>
              ); })}
              {/* v07zz372 — also Omit (cut from edit) or Archive the whole selection. */}
              {(() => { const t = STAGE_TINTS.OMITTED || STAGE_TINTS.PENDING; return (
                <button type="button" className="batch-stage-pill" disabled={selected.size === 0}
                  title={`Mark all ${selected.size} selected as Omitted (cut from the edit)`}
                  onClick={batchOmit} style={{ borderColor: t.border, color: t.color }}>
                  <span className="batch-stage-dot" style={{ background: t.dot }}/>Omitted
                </button>
              ); })()}
              {(() => { const t = STAGE_TINTS.ARCHIVE || STAGE_TINTS.PENDING; return (
                <button type="button" className="batch-stage-pill" disabled={selected.size === 0}
                  title={`Archive all ${selected.size} selected (hide from the active list)`}
                  onClick={batchArchive} style={{ borderColor: t.border, color: t.color }}>
                  <span className="batch-stage-dot" style={{ background: t.dot }}/>Archive
                </button>
              ); })()}
            </div>
          )}
          {/* v1007 — LINK the whole selection to one master. The master is the lowest
              shot id in the selection and is named on the button, so what will happen is
              readable before you click and there is no dialog to get in the way. */}
          {(!window.hasPerm || window.hasPerm("edit_shots")) && (
            <div className="batch-link-set">
              <button type="button" className="batch-link-btn" disabled={selected.size < 2}
                title={batchLinkMaster
                  ? `Make ${batchLinkMaster} the master and link the other ${selected.size - 1} shot${selected.size - 1 === 1 ? "" : "s"} to it as continuation cuts`
                  : "Select two or more shots to link them"}
                onClick={batchLink}>
                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round">
                  <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
                </svg>
                {batchLinkMaster ? `Link to ${batchLinkMaster}` : "Link"}
              </button>
              <button type="button" className="batch-link-btn batch-link-btn--off" disabled={selected.size === 0}
                title={`Remove the link from all ${selected.size} selected shot${selected.size === 1 ? "" : "s"}`}
                onClick={batchUnlink}>Unlink</button>
            </div>
          )}
          {/* v1020 — fill the selected shots' blank tiles from the cut. The same
              component the shot modal uses, so the two cannot behave differently. */}
          {(!window.hasPerm || window.hasPerm("upload_assets")) && window.EditStillButton && (
            <window.EditStillButton shotIds={Array.from(selected)} label="Stills from edit"
              className="esb-btn--onbar"
              onDone={() => { bumpVersion(v => v + 1); }}/>
          )}
          <span style={{ flex: 1 }}/>
          {/* 23 Sep 2026 — Multi-dispatch REMOVED (Hugo's image-mode decisions): the "Send to
              generation" button and its batch window left the app; the window's
              file is in _archive/removed-2026-09-23-image-modes/. The Agent's queue_generations
              still sends batches through /api/generate/batch. */}
        </div>
      )}
    </section>
  );
}

// ─── v971 — SHARED SHOT-STATUS MENU ─────────────────────────────────────────
// Hugo: "in the Notes page, i need to be able to change the shot status right
// there from the line … I need all the same status buttons that are on the shot
// rows in the shots page" + "i also need to be able to label a shot as Retake".
// Exported on window so the Notes page renders the IDENTICAL control instead of
// a lookalike: same six stages (BATCH_STAGES), same colours (STAGE_TINTS), same
// PATCH /api/shots/:id/status the shot rows use. Retake and Cut are FLAGS and
// keep their own endpoints.
function ShotStatusMenu({ shotId, onChanged }) {
  const [open, setOpen] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const ref = React.useRef(null);
  useShotsClickOutside(ref, () => setOpen(false), open);
  const shots = (window.__appData && window.__appData.shots) || [];
  const shot = shots.find(x => x.id === shotId) || null;
  if (!shotId) return null;
  const stage = shot ? getCurrentStage(shot) : null;
  const tint = (stage && STAGE_TINTS[stage]) || null;
  const call = async (url, body) => {
    setBusy(true);
    try {
      const r = await (window.authFetch || fetch)(url, {
        method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      if (window.reloadAppData) await window.reloadAppData();
      if (onChanged) onChanged();
    } catch (e) {
      console.warn("[shot-status]", e.message);
    } finally { setBusy(false); setOpen(false); }
  };
  const setStage = (target) => call("/api/shots/" + shotId + "/status", { status: STAGE_TO_STATUS[target] });
  const toggleRetake = () => call("/api/shots/" + shotId + "/retake", { retake: !(shot && shot.retake) });
  const toggleOmit = () => call("/api/shots/" + shotId + "/omit", { omitted: !(shot && shot.omitted) });
  return (
    <span className="ssm-anchor" ref={ref} onClick={(e) => e.stopPropagation()}>
      <button type="button" className={"ssm-pill" + (open ? " is-open" : "") + (busy ? " is-busy" : "")}
        title={shot ? ("Change the status of " + shotId) : ("Shot " + shotId + " is not in this episode")}
        disabled={!shot || busy}
        style={tint ? { borderColor: tint.border, color: tint.color } : null}
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}>
        {tint && <span className="ssm-dot" style={{ background: tint.dot }}/>}
        <span className="ssm-label">{shot && shot.retake ? "RETAKE" : (tint ? tint.label : "STATUS")}</span>
        <span className="ssm-chev">▾</span>
      </button>
      {open && (
        <div className="ssm-menu">
          <div className="ssm-eyebrow">SET STATUS</div>
          {BATCH_STAGES.map(st => {
            const t = STAGE_TINTS[st.tint] || {};
            const on = stage === STAGE_TO_STATUS[st.target];
            return (
              <button key={st.target} type="button" className={"ssm-row" + (on ? " is-active" : "")}
                onClick={(e) => { e.stopPropagation(); setStage(st.target); }}>
                <span className="ssm-dot" style={{ background: t.dot }}/>
                <span className="ssm-row-label">{st.label}</span>
                {on && <span className="ssm-check">✓</span>}
              </button>
            );
          })}
          <div className="ssm-sep"/>
          <button type="button" className={"ssm-row ssm-row--flag" + (shot && shot.retake ? " is-active" : "")}
            onClick={(e) => { e.stopPropagation(); toggleRetake(); }}>
            <span className="ssm-dot ssm-dot--retake"/>
            <span className="ssm-row-label">Retake{shot && shot.retake ? " — on" : ""}</span>
            {shot && shot.retake && <span className="ssm-check">✓</span>}
          </button>
          <button type="button" className={"ssm-row ssm-row--flag" + (shot && shot.omitted ? " is-active" : "")}
            onClick={(e) => { e.stopPropagation(); toggleOmit(); }}>
            <span className="ssm-dot ssm-dot--cut"/>
            <span className="ssm-row-label">Cut from edit{shot && shot.omitted ? " — on" : ""}</span>
            {shot && shot.omitted && <span className="ssm-check">✓</span>}
          </button>
        </div>
      )}
    </span>
  );
}

// v975 — applyShotSearch is exported so the Add-from-library picker can search
// frames by the words in their SHOT (title / action / narration / species …),
// not just by shot id. One engine, one behaviour, both places.
Object.assign(window, { ShotsPanel, ShotRow, getCurrentStage, stagePct, hasAnyImage, STAGE_TINTS, ShotStatusMenu, ShotQuickActions, StatusPill, applyShotSearch });   // v973 — StatusPill for the Notes column
