/* global React */

// v07zz38 — Asset-kind vocabulary helpers. The codebase mixes SINGULAR
// item-types ("character", "animal", "location", "prop", "ref") with PLURAL
// collection/category names ("characters", …). Code that maps singular→plural
// with a bare `{...}[kind]` returns undefined (silent no-op) if handed the
// plural form — that's the class of bug behind "the button just doesn't show".
// These two helpers accept EITHER form and normalize, so a comparison or
// mapping can never silently miss. They DON'T rename or migrate anything —
// the DB, on-disk folders and R2 keys are untouched; this only makes the
// in-memory comparisons tolerant. Also exposed on window for other files.
function assetKindSingular(k) {
  const s = String(k || "").toLowerCase().trim();
  return ({ characters: "character", animals: "animal", locations: "location", props: "prop", refs: "ref", references: "ref" })[s] || s;
}
function assetKindCategory(k) {
  const s = String(k || "").toLowerCase().trim();
  // singular → plural; anything already plural (or music/vo/etc.) passes through.
  return ({ character: "characters", animal: "animals", location: "locations", prop: "props", ref: "refs", reference: "refs" })[s] || s;
}
if (typeof window !== "undefined") {
  window.assetKindSingular = assetKindSingular;
  window.assetKindCategory = assetKindCategory;
}

// ── v07zz240 — Asset workflow status, the asset-side analogue of the shot
// status pill: First Pass → WIP (waiting for approval) → Retake → Approved.
// Backed by assets.json `status` (POST /api/assets/:kind/:id/status). Colours
// mirror the shot STAGE_TINTS family (cream pill, coloured border + dot).
const ASSET_STATUSES = [
  { id: "pending",    label: "Pending",    border: "var(--st-pending-2)", text: "var(--st-pending-2-ink)", dot: "var(--st-pending-2)" },
  { id: "first_pass", label: "First Pass", border: "var(--st-first-pass)", text: "var(--st-first-pass-ink)", dot: "var(--st-first-pass)" },
  { id: "wip",        label: "WIP",        border: "var(--st-wip)", text: "var(--st-wip-ink)", dot: "var(--st-wip)" },
  { id: "retake",     label: "Retake",     border: "var(--st-retake)", text: "var(--st-retake-ink)", dot: "var(--st-retake)" },
  { id: "approved",   label: "Approved",   border: "var(--st-hero)", text: "var(--st-hero-ink)", dot: "var(--st-hero)" },
];
const ASSET_STATUS_MAP = Object.fromEntries(ASSET_STATUSES.map(s => [s.id, s]));
const normAssetStatus = (s) => (ASSET_STATUS_MAP[String(s || "").toLowerCase()] ? String(s).toLowerCase() : "first_pass");
// v07zz280 — an asset that hasn't started (no shots assigned, status still the
// default First Pass) reads as "Pending" — work hasn't begun. An explicitly-set
// status (WIP / Retake / Approved / Pending) is always respected. shotCount is the
// number of shots the asset appears in (0 = nothing produced yet).
const effAssetStatus = (status, shotCount) => {
  const raw = normAssetStatus(status);
  if (raw !== "first_pass") return raw;
  return (Number(shotCount) > 0) ? "first_pass" : "pending";
};

// Static at-a-glance pill (cards). Missing status → First Pass.
function AssetStatusPill({ status, className }) {
  const s = ASSET_STATUS_MAP[normAssetStatus(status)];
  return (
    <span className={"asset-status-pill" + (className ? " " + className : "")} style={{ borderColor: s.border, color: s.text }} title={"Status: " + s.label}>
      <span className="asset-status-dot" style={{ background: s.dot }} />{s.label}
    </span>
  );
}

// Interactive segmented control (modals). 4 mini-pills; click to set + persist.
// Optimistic via onChange; reverts onChange(prev) if the POST fails.
function AssetStatusControl({ kind, id, status, onChange }) {
  // Internal state so the active pill updates instantly even in modals that
  // mutate the item object without re-rendering (AssetItemModal). Re-seeds if
  // the parent swaps the asset or refreshes it with a new status.
  const [cur, setCur] = React.useState(() => normAssetStatus(status));
  const [busy, setBusy] = React.useState(false);
  React.useEffect(() => { setCur(normAssetStatus(status)); }, [status, id]);
  const set = (next) => {
    if (next === cur || busy) return;
    const prev = cur;
    setCur(next); setBusy(true);
    if (onChange) onChange(next); // optimistic
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/assets/${encodeURIComponent(assetKindSingular(kind))}/${encodeURIComponent(id)}/status`, {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }),
    })
      .then(r => { if (!r.ok) throw new Error("status " + r.status); })
      .catch(e => { console.warn("[asset-status] revert:", e.message); setCur(prev); if (onChange) onChange(prev); })
      .finally(() => setBusy(false));
  };
  return (
    <div className="asset-status-seg" role="group" aria-label="Asset status">
      <div className="asset-status-seg-cap">Status</div>
      <div className="asset-status-seg-row">
        {ASSET_STATUSES.map(s => (
          <button key={s.id} type="button"
            className={"asset-status-seg-btn" + (cur === s.id ? " is-on" : "")}
            style={cur === s.id ? { borderColor: s.border, color: s.text } : undefined}
            onClick={() => set(s.id)} disabled={busy} title={s.label}>
            <span className="asset-status-dot" style={{ background: s.dot }} />{s.label}
          </button>
        ))}
      </div>
    </div>
  );
}
if (typeof window !== "undefined") {
  window.ASSET_STATUSES = ASSET_STATUSES;
  window.ASSET_STATUS_MAP = ASSET_STATUS_MAP;
  window.normAssetStatus = normAssetStatus;
  window.AssetStatusPill = AssetStatusPill;
  window.AssetStatusControl = AssetStatusControl;
}

const SEQ_HUE_VIEW = { 1: 32, 2: 28, 3: 18, 6: 200, 7: 30, 8: 70, 9: 80, 11: 220, 13: 180, 14: 195, 15: 210, 19: 145, 20: 95, 22: 110 };

function seqGradient(seq) {
  const h = SEQ_HUE_VIEW[seq] || 100;
  return `linear-gradient(155deg, oklch(0.42 0.05 ${h}), oklch(0.62 0.07 ${h+30}) 60%, oklch(0.78 0.05 ${h+60}))`;
}
function locGradient(hue) {
  return `linear-gradient(155deg, oklch(0.42 0.06 ${hue}), oklch(0.62 0.08 ${hue+30}) 55%, oklch(0.80 0.05 ${hue+60}))`;
}

/* ─────────────────────────── SEQUENCES (21:9 thumbs) ─────────────────────────── */

function SequencesGrid({ sequences = [], shots = [], onOpenSequence }) {
  const list = sequences.slice().sort((a,b) => a.number - b.number);
  return (
    <section className="sequences-view">
      <div className="sv-head">
        <div className="sv-title">SEQUENCES <span className="sv-count">({list.length})</span></div>
        <div className="pill-row">
          <button className="pill-button">Sort</button>
          <button className="pill-button">Filter</button>
        </div>
      </div>
      <div className="sequences-grid sequences-grid--cinema">
        {list.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;
          const pct = seqShots.length ? Math.round((done/seqShots.length)*100) : 0;
          // v03f — first-shot thumbnail. Look for the lowest-id shot in
          // this sequence with an image_paths entry; use it as the card
          // background. Falls back to the gradient placeholder when no
          // shots have images yet.
          const sortedSeqShots = seqShots.slice().sort((a, b) => (a.id || "").localeCompare(b.id || "", undefined, { numeric: true }));
          // v07zz362 — prefer the picked cover shot, else the first shot with an image.
          const coverShot = seq.cover_shot_id ? seqShots.find(s => s.id === seq.cover_shot_id) : null;
          const pickShot = coverShot || sortedSeqShots.find(s => s.image_paths && (s.image_paths.selected || s.image_paths.first_pass));
          const thumbImg = pickShot && pickShot.image_paths && (pickShot.image_paths.selected || pickShot.image_paths.first_pass);
          return (
            <button key={seq.number} className="sequence-card glass" onClick={() => onOpenSequence && onOpenSequence(seq.number)}>
              <div className="sc-thumb sc-thumb--cinema" style={thumbImg ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(thumbImg, 560) : thumbImg})`, backgroundSize: "cover", backgroundPosition: "center" } : { background: seqGradient(seq.number) }}>
                {!thumbImg && <span className="sc-thumb-num">{String(seq.number).padStart(2,"0")}</span>}
                {seqShots.length > 0 && <span className="sc-done-pill">{done}/{seqShots.length} done</span>}
              </div>
              <div className="sc-body">
                <div className="sc-eyebrow">SEQUENCE {String(seq.number).padStart(2,"0")} · {seq.shot_count} SHOTS</div>
                <div className="sc-slug">{seq.slug}</div>
                <div className="sc-progress"><div className="sc-progress-fill" style={{width: `${pct}%`}}/></div>
              </div>
            </button>
          );
        })}
      </div>
    </section>
  );
}

function SequenceDetailView({ sequence, shots, onBack, onOpenShot }) {
  if (!sequence) return null;
  const seqShots = shots.filter(s => s.seq === sequence.number);
  const done = seqShots.filter(s => s.stage_status && s.stage_status.hero === "done").length;
  const pct = seqShots.length ? Math.round((done/seqShots.length)*100) : 0;
  return (
    <section className="sequence-detail glass">
      <button className="sd-back" onClick={onBack}>
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 6l-6 6 6 6"/></svg>
        <span>Back to sequences</span>
      </button>
      <div className="sd-head">
        <div className="sd-num">SEQUENCE {String(sequence.number).padStart(2,"0")}</div>
        <div className="sd-slug">{sequence.slug}</div>
        <div className="sd-meta">{sequence.shot_count} shots · {done}/{seqShots.length || sequence.shot_count} hero done</div>
      </div>
      <div className="sd-progress">
        <div className="sd-progress-fill" style={{width: `${pct}%`}}/>
        <span className="sd-progress-num">{pct}%</span>
      </div>
      <div className="shot-thumb-grid">
        {seqShots.length === 0 && <div className="sd-empty">No shots seeded yet for this sequence.</div>}
        {seqShots.map(shot => (
          <button key={shot.id} className="shot-thumb-tile" onClick={() => onOpenShot && onOpenShot(shot.id)}
            style={{background: seqGradient(shot.seq)}}>
            <span className="stt-id">{shot.id}</span>
            <span className="stt-title">{shot.frame_title}</span>
          </button>
        ))}
      </div>
    </section>
  );
}

/* ─────────────────────────── SCRIPT ─────────────────────────── */

function ScriptView({ script, shots = [], sequences = [] }) {
  // 15 Sep 2026 — PROJECT-AWARE. Paradise Found (_isPF) keeps its single
  // "ep00 · Pilot — Paradise Found" row, its episode_00_pilot VO / screenplay
  // fetches and the TWAIN speaker special-case exactly as before. Any other
  // project builds its rows from window.__episodes (the project's containers —
  // "Music videos" for Trøpé), fetches VO / screenplay for the ACTIVE container
  // only (and not at all when it has none), and says "No music videos yet"
  // with no Paradise Found path in the empty states.
  const _isPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _epRows = _isPF ? [] : (Array.isArray(window.__episodes) ? window.__episodes : ((window.__appData && Array.isArray(window.__appData.episodes)) ? window.__appData.episodes : []));
  const _activeEpId = _isPF ? "ep00" : ((window.__appData && window.__appData.episode && window.__appData.episode.id) || (_epRows[0] && _epRows[0].id) || null);
  const _containerWord = (plural) => (window.__containerWord ? window.__containerWord(plural) : (plural ? "Episodes" : "Episode"));
  // The screenplay empty-state example path: Paradise Found's pilot, else the
  // template's container folder ("…\work\videos\<music video>\brief\screenplay.txt").
  const _spPathHint = (() => {
    if (_isPF) return "…\\work\\episodes\\episode_00_pilot\\brief\\screenplay.txt";
    const row = (window.__activeProjectRow && window.__activeProjectRow()) || {};
    const folder = String((row.container && row.container.folder) || "work").replace(/\//g, "\\");
    return "…\\" + folder + "\\<" + _containerWord(false).toLowerCase() + ">\\brief\\screenplay.txt";
  })();
  const [tab, setTab] = React.useState("episodes");
  const [openEpisode, setOpenEpisode] = React.useState(_activeEpId);
  const [version, setVersion] = React.useState("v03");
  // v07zz49 — Character filter. Click a character pill to highlight
  // only their lines (dim everything else). Hugo: "if I want to see
  // only the lines Columbus is saying, I can do that very fast."
  const [characterFilter, setCharacterFilter] = React.useState(null);
  // v07zz65 — HOOKS ORDER FIX: every hook in this component MUST be
  // called BEFORE the early-return below. The earlier layout put
  // `if (!script) return null` between hook #4 and hooks #5-9,
  // which meant on the first render (no script) only 4 hooks fired,
  // then once data.script populated 9 hooks fired → React detected
  // the count change and crashed ScriptView with the cryptic
  // "Cannot read properties of undefined (reading 'length')" stack
  // Hugo saw on the live site. Lifting all hooks above the guard
  // makes the hook count constant across renders.
  const [scriptVersions, setScriptVersions] = React.useState([]);
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    // 24 Sep 2026 (G7) — every project: the server reads the ACTIVE project's own _tracker shotlist history.
    fetcher("/api/script/versions")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const arr = (d && d.versions) || [];
        setScriptVersions(arr.map(v => String(v.label || v.id || "v?")));
      })
      .catch(() => setScriptVersions([]));
  }, []);
  // v07zz551 — ingested VO-paragraph script (data/vo-scripts/<ep>.json via
  // scripts/ingest-vo-script.py). When present it REPLACES the per-line scene list:
  // consecutive same-speaker scenes render as ONE copyable paragraph with a
  // "Scenes NNN–NNN · first title → last title" note (Hugo, 2026-07-12). The live
  // shotlist stays untouched — this is a Script-page-only source. Hooks live above
  // the early-return guard (hook count must stay constant — see v07zz65).
  const [voScript, setVoScript] = React.useState(null);
  const [copiedPara, setCopiedPara] = React.useState(null);
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    // 15 Sep 2026 — the active container's id on a templated project; nothing to ask when it has none.
    if (!_isPF && !_activeEpId) { setVoScript(null); return; }
    fetcher(_isPF ? "/api/script/vo/episode_00_pilot" : "/api/script/vo/" + encodeURIComponent(_activeEpId))
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (d && d.found && Array.isArray(d.paragraphs) && d.paragraphs.length) setVoScript(d); })
      .catch(() => {});
  }, [_isPF ? "ep00" : _activeEpId]);
  const copyParagraph = (idx, text) => {
    try {
      navigator.clipboard.writeText(text).then(() => {
        setCopiedPara(idx);
        setTimeout(() => setCopiedPara(c => (c === idx ? null : c)), 1400);
      }).catch(() => {});
    } catch (_) {}
  };
  // v07zz66 — Hugo: "we need to go back and parse all of the VO lines
  // and characters form the shotlist themselves rather than the
  // vo-scripts text". The brief-file parsing path is retired; scenes
  // are now derived 100% from each shot's `narration` field (see
  // liveScenes below). Empty placeholder state is kept so the hook
  // count remains constant in case a brief path is added back later.
  const [briefScenes, setBriefScenes] = React.useState([]);
  const [briefSpeakers, setBriefSpeakers] = React.useState([]);
  // 24 Sep 2026 (G7 review) — ANOTHER project only: its active container's brief folder and its
  // Script / VO text documents (GET /api/script/brief/<container>, parsed server-side) become the
  // scenes and speaker pills; Paradise Found never asks, so its page keeps the shotlist parse.
  React.useEffect(() => {
    if (_isPF) return;
    setBriefScenes([]); setBriefSpeakers([]);
    if (!_activeEpId) return;
    let alive = true;
    (window.authFetch || fetch)("/api/script/brief/" + encodeURIComponent(_activeEpId))
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!alive || !d) return;
        setBriefScenes(Array.isArray(d.scenes) ? d.scenes : []);
        setBriefSpeakers(Array.isArray(d.speakers) ? d.speakers : []);
      })
      .catch(() => {});
    return () => { alive = false; };
  }, [_isPF ? "ep00" : _activeEpId]);

  // v07zz — Screenplay tab. Renders a PROPER screenplay-format file
  // (scene headings, character cues, parentheticals, dialogue, action)
  // dropped at <WATCH>/work/episodes/<ep>/brief/screenplay.txt. Separate
  // from the shotlist-derived Episodes tab. Hook lives above the
  // early-return guard so the hook count stays constant.
  const [screenplay, setScreenplay] = React.useState({ found: false, elements: [] });
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    if (!_isPF && !_activeEpId) { setScreenplay({ found: false, elements: [] }); return; }
    fetcher(_isPF ? "/api/script/screenplay/episode_00_pilot" : "/api/script/screenplay/" + encodeURIComponent(_activeEpId))
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (d) setScreenplay(d); })
      .catch(() => {});
  }, [_isPF ? "ep00" : _activeEpId]);

  // v07zz49 — Parse character + line attribution from each shot's
  // narration field. Format the new shotlist uses:
  //   TWAIN (VO): A heavenly place. A world of small miracles.
  //   COLUMBUS: We have made landfall.
  // We split on the SPEAKER pattern (UPPERCASE name followed by
  // optional (VO/OS/OFF) and a colon) and capture both the speaker
  // and the line text.
  // v07zz72 — Moved ABOVE the `if (!script) return null` guard so the
  // useMemo for liveScenes / speakers (below) also lives above the
  // guard. Hook count must be constant across renders.
  //
  // v07zz90 — LINE ALLOCATION REWRITE #2. Hugo: "the Narrator pill was
  // completely removed instead of fixing its line attribution. I need a
  // pill that says Narrator with ONLY the Narrator's lines." Verified
  // directly against the latest shotlist
  // (W:\…\episode_00_pilot\shotlist.xlsx → already ingested into the DB):
  //
  //   • Bare "VO:" / "VO" labels (73 in the xlsx) are the documentary
  //     NARRATOR — third-person voice-over ("Their captain — one
  //     Christopher Columbus — had made a serious error").
  //   • "TWAIN:" (direct address) and "TWAIN (VO):" are Mark Twain
  //     speaking in the FIRST person ("I'm Mark Twain… a recreation").
  //     These are NOT the narrator.
  //   • "COLUMBUS:" / "Audubon:" are distinct named speakers.
  //
  // The previous rewrite made two mistakes:
  //   (1) it mapped bare "VO:" to "TWAIN VO" (folding the narrator INTO
  //       Twain), so the NARRATOR pill vanished; and
  //   (2) it carried the speaker forward only WITHIN a single shot's
  //       narration cell. But the shotlist labels a speaker on the FIRST
  //       shot of a speech block and leaves the following shots/rows
  //       UNLABELLED — the speech continues across many consecutive
  //       shots. Per-shot carry-forward therefore lost the speaker on
  //       every continuation row and dumped them into the default.
  //
  // Fix: bare "VO" → NARRATOR; carry the last speaker forward ACROSS
  // shots in script order (liveScenes threads `carry` shot-to-shot via
  // the returned carryOut). Only EXPLICIT labels (a colon, or a (VO)
  // qualifier) switch the speaker — a bare capitalised word that merely
  // STARTS a narration sentence (e.g. "Columbus returned with 17 ships")
  // is NOT treated as a label, so descriptive narration stays NARRATOR.
  const NARRATOR_IDENTITY = "NARRATOR";
  const SCRIPT_SKIP_WORDS = ["MUSIC", "START", "END", "SCENE", "INT", "EXT", "CUT", "FADE", "ACTION", "NOTE", "NOTES", "VO"];
  // Detect an explicit speaker label at the start of a line. Returns
  // { key, rest } (key = the pill name) or null when the line carries no
  // label. `roster` lets a line that is JUST a bare speaker name (e.g.
  // "TWAIN" on its own — a stray switch marker) be recognised.
  const detectLabel = (ln, roster) => {
    // Bare "VO" / "VO:" prefix → the narrator's voice-over.
    let m = ln.match(/^VO\b\s*:?\s*([\s\S]*)$/i);
    if (m) return { key: "NARRATOR", rest: m[1].trim() };
    m = ln.match(/^([A-Z][A-Za-z.'-]*(?:\s+[A-Za-z.'-]+)*?)\s*(?:\(([^)]*)\))?\s*(:)?\s*([\s\S]*)$/);
    if (!m) return null;
    const name = m[1].trim().toUpperCase();
    const qual = m[2] ? m[2].trim() : "";
    const colon = !!m[3];
    const rest = (m[4] || "").trim();
    if (SCRIPT_SKIP_WORDS.includes(name)) return null;
    const looksVO = /\bVO\b|VOICE|O\.?S|OFF/i.test(qual);
    // Explicit label = a colon OR a (VO)-style qualifier.
    if (colon || looksVO) {
      const cleanRest = rest.replace(/^\([^)]*\)\s*/, "").trim(); // drop leading stage direction
      let key;
      // 15 Sep 2026 — the TWAIN VO / DIRECT split is Paradise Found's cast; elsewhere TWAIN is any name.
      if (_isPF && name === "TWAIN") key = looksVO ? "TWAIN VO" : "TWAIN DIRECT";
      else key = looksVO ? `${name} VO` : name;
      return { key, rest: cleanRest };
    }
    // A line that is JUST a rostered name (no spoken text) is a stray
    // switch marker — switch the speaker, emit nothing.
    if (!rest && roster && roster.has(name)) {
      return { key: (_isPF && name === "TWAIN") ? "TWAIN DIRECT" : name, rest: "" };
    }
    return null;
  };
  // Parse ONE shot's narration. `carryIn` is the speaker still talking
  // from the previous shot; returns the parsed lines plus `carryOut`
  // (the speaker still talking at the end of this shot) so the caller
  // can thread continuity across the whole episode in script order.
  const parseScriptLines = (narration, roster, carryIn) => {
    let carry = carryIn || NARRATOR_IDENTITY;
    if (!narration) return { lines: [], carryOut: carry };
    const rawLines = narration.split(/\n/).map(l => l.trim()).filter(Boolean);
    const out = [];
    for (const ln of rawLines) {
      const d = detectLabel(ln, roster);
      if (d) {
        carry = d.key;
        const txt = (d.rest || "").replace(/^\([^)]*\)\s*/, "").trim();
        if (txt) out.push({ speaker: d.key, qualifier: null, line: txt });
        // label-only line → just switches the speaker, no spoken text.
      } else {
        const stripped = ln.replace(/^\([^)]*\)\s*/, "").trim();
        if (stripped) out.push({ speaker: carry, qualifier: null, line: stripped });
      }
    }
    return { lines: out, carryOut: carry };
  };

  // v07zz72 — Performance: liveScenes + speakerCounts used to be
  // allocated on EVERY render — for a 176-shot project that meant a
  // re-parse of 176 narration blocks + a fresh `new Map()` + sort
  // every time any prop changed (including the character filter
  // toggle). Wrapped in a single useMemo keyed on `shots` so the
  // expensive work only runs when the shot list actually changes.
  const { liveScenes, _shotlistSpeakers } = React.useMemo(() => {
    // v07zz90 — Sort into script order (by numeric shot id, e.g. SH0010,
    // SH0020 …) so the cross-shot speaker carry-forward follows the
    // actual narration flow. The shotlist ingester numbers shots in row
    // order, so numeric id order == script order.
    const active = (shots || []).filter(s => !s.is_archive).slice()
      .sort((a, b) => {
        const na = parseInt(String(a.id).replace(/\D/g, ""), 10) || 0;
        const nb = parseInt(String(b.id).replace(/\D/g, ""), 10) || 0;
        return na - nb;
      });
    // First pass: roster of names that appear WITH an explicit colon
    // label anywhere in the shotlist (TWAIN, COLUMBUS, AUDUBON). Used so
    // a stray bare "TWAIN" switch-marker line is recognised.
    const roster = new Set();
    for (const s of active) {
      const lines = (s.narration || "").split(/\n/).map(l => l.trim()).filter(Boolean);
      for (const ln of lines) {
        const m = ln.match(/^([A-Z][A-Za-z.'-]*(?:\s+[A-Za-z.'-]+)*?)\s*(?:\([^)]*\))?\s*:/);
        if (m) { const nm = m[1].trim().toUpperCase(); if (!SCRIPT_SKIP_WORDS.includes(nm)) roster.add(nm); }
      }
    }
    // Second pass: parse in script order, threading the carry-forward
    // speaker shot-to-shot so a speech block that spans many unlabelled
    // continuation shots keeps its speaker.
    let carry = NARRATOR_IDENTITY;
    const scenes = [];
    for (const s of active) {
      const res = parseScriptLines(s.narration || "", roster, carry);
      carry = res.carryOut;
      if (res.lines.length > 0) {
        scenes.push({
          id: s.id,
          heading: s.frame_title || "(untitled)",
          seq: s.seq,
          shot_id: s.id,
          shot_type: s.shot_type || "",
          lines: res.lines,
        });
      }
    }

    // Roster of speakers across the whole script — pills.
    const speakerCounts = new Map();
    for (const sc of scenes) {
      for (const ln of sc.lines) {
        if (!ln.speaker) continue;
        speakerCounts.set(ln.speaker, (speakerCounts.get(ln.speaker) || 0) + 1);
      }
    }
    // v07zz71 — Group same-base-name speakers together (TWAIN VO and
    // TWAIN DIRECT both have base TWAIN). Group order = highest count
    // in the group; within a group sort by count desc.
    const raw = [...speakerCounts.entries()]
      .map(([name, count]) => ({ name, count, base: name.split(" ")[0] }));
    const groupMaxCount = new Map();
    for (const sp of raw) {
      const prev = groupMaxCount.get(sp.base) || 0;
      if (sp.count > prev) groupMaxCount.set(sp.base, sp.count);
    }
    const sortedSpeakers = raw
      .sort((a, b) => {
        const aMax = groupMaxCount.get(a.base) || 0;
        const bMax = groupMaxCount.get(b.base) || 0;
        if (aMax !== bMax) return bMax - aMax;
        if (a.base !== b.base) return a.base.localeCompare(b.base);
        return b.count - a.count;
      })
      .map(({ name, count }) => ({ name, count }));
    return { liveScenes: scenes, _shotlistSpeakers: sortedSpeakers };
  }, [shots]);
  // v07zz64 — Speaker pills: brief-parsed speakers take priority when
  // the briefs exist; otherwise fall back to shotlist-derived counts.
  // v07zz551 — when the ingested VO script is active, pills derive from its
  // paragraphs (count = paragraphs per speaker). Plain compute, not a hook.
  const _voSpeakers = voScript ? (() => {
    const m = new Map();
    for (const p of voScript.paragraphs) m.set(p.speaker, (m.get(p.speaker) || 0) + 1);
    return [...m.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
  })() : null;
  const speakers = _voSpeakers || (briefSpeakers.length > 0 ? briefSpeakers : _shotlistSpeakers);
  // v07zz72 — Early-return moved here, AFTER every hook. Hook count
  // is now constant across null↔defined renders.
  if (!script) return null;

  // v07zz65 — Hooks for scriptVersions / briefScenes / briefSpeakers
  // were hoisted to the top of the component (above the early-return
  // guard). See v07zz65 comment up there.

  // v07zz64 — Hugo: "you did not parse the new script from those
  // files and filled in the script section and each characters."
  // Brief-parsed scenes take priority. Fall back to shotlist parse
  // → script.scenes seed.
  const finalScenes = briefScenes.length > 0
    ? briefScenes
    : (liveScenes.length > 0 ? liveScenes : (script.scenes || []));
  const episodes = _isPF ? [
    { id: "ep00", num: "00", title: "Pilot — Paradise Found",
      scenes: finalScenes,
      status: "active",
      versions: scriptVersions.length > 0 ? scriptVersions : ["current"] },
  ] : _epRows.map((r, i) => ({
    // 15 Sep 2026 — the project's own containers; only the active one carries the parsed scenes.
    id: r.id,
    num: String(Number.isFinite(Number(r.episode_number)) ? Number(r.episode_number) : i).padStart(2, "0"),
    title: r.title || r.id,
    scenes: r.id === _activeEpId ? finalScenes : [],
    status: r.status || "active",
    versions: r.id === _activeEpId && scriptVersions.length > 0 ? scriptVersions : ["current"],
  }));

  return (
    <section className="view-page">
      <div className="vp-head">
        <div>
          {(_isPF || script.modified)
            ? <div className="vp-eyebrow">SCRIPT · MODIFIED {script.modified}</div>
            : <div className="vp-eyebrow">SCRIPT</div>}
          {/* 15 Sep 2026 — a project with no script yet (Trøpé) has no title: String() keeps the page up */}
          <div className="vp-title">{String(script.title || "").split(" — ")[0]}</div>
        </div>
        <div className="vp-tabs">
          <button className={"vp-tab" + (tab === "episodes" ? " is-active" : "")} onClick={() => setTab("episodes")}>Episodes</button>
          <button className={"vp-tab" + (tab === "screenplay" ? " is-active" : "")} onClick={() => setTab("screenplay")}>Screenplay</button>
          <button className={"vp-tab" + (tab === "shotlist" ? " is-active" : "")} onClick={() => setTab("shotlist")}>Shotlist</button>
        </div>
      </div>

      {tab === "episodes" && (
        <div className="ep-list">
          {/* 15 Sep 2026 — a templated project with no container yet: the container word, no Paradise Found row. */}
          {!_isPF && episodes.length === 0 && (
            <div className="ep-empty">No {_containerWord(true).toLowerCase()} yet.</div>
          )}
          {episodes.map(ep => {
            const isOpen = openEpisode === ep.id;
            return (
              <div key={ep.id} className={"ep-row glass" + (isOpen ? " is-open" : "")}>
                <button className="ep-row-head" onClick={() => setOpenEpisode(isOpen ? null : ep.id)}>
                  <div className="ep-num">{_isPF ? "EP " + ep.num : ep.num}</div>
                  <div className="ep-title-block">
                    <div className="ep-title">{ep.title}</div>
                    <div className="ep-meta">
                      {voScript
                        ? <>{voScript.scene_count} scenes · {voScript.paragraph_count} paragraphs · {voScript.label || "VO script"} ·</>
                        : <>{ep.scenes.length} scenes · {ep.versions.length || 0} version{ep.versions.length !== 1 ? "s" : ""} ·</>}
                      <span className={"ep-status ep-status--" + ep.status}> {ep.status}</span>
                    </div>
                  </div>
                  <div className="ep-versions">
                    {/* v03h — oldest version on the LEFT, newest on the RIGHT.
                        Active selection defaults to the latest (last after sort). */}
                    {ep.versions.length > 0 && ep.versions.slice().sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).map(v => (
                      <button key={v}
                        className={"ep-ver-pill" + (version === v && isOpen ? " is-active" : "")}
                        onClick={(e) => { e.stopPropagation(); setVersion(v); setOpenEpisode(ep.id); }}>{v}</button>
                    ))}
                  </div>
                  <svg className="ep-chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"/></svg>
                </button>

                {isOpen && (
                  <div className="ep-body">
                    {/* v07zz62 — Hugo: "I asked you to use those
                        briefs as GUIDE to PARSE FROM and rebuild
                        the script sections like you had before.
                        Not add some stupod block of text." The
                        SOURCE SCRIPTS picker + body is removed.
                        briefScripts is still fetched (above) and
                        used as a parser data source on the server
                        side so brief content can enrich the
                        per-shot parsed scenes; nothing renders
                        directly in the UI. */}
                    {/* v07zz49 — Character filter row. Click a pill to
                        highlight that character's lines and dim
                        everything else. Click again or "All" to clear.
                        Sticky position so it stays at the top of the
                        script as Hugo scrolls. */}
                    {speakers.length > 0 && (
                      <div className="script-filter-bar">
                        <button
                          className={"script-filter-pill" + (characterFilter === null ? " is-active" : "")}
                          onClick={() => setCharacterFilter(null)}
                          type="button">All <span className="script-filter-count">{voScript ? `${voScript.paragraph_count} paragraphs` : `${(_isPF ? liveScenes : ep.scenes).length} scenes`}</span></button>
                        {speakers.map(sp => (
                          <button
                            key={sp.name}
                            className={"script-filter-pill" + (characterFilter === sp.name ? " is-active" : "")}
                            onClick={() => setCharacterFilter(characterFilter === sp.name ? null : sp.name)}
                            type="button">{sp.name} <span className="script-filter-count">{sp.count}</span></button>
                        ))}
                      </div>
                    )}
                    {ep.scenes.length === 0 && (
                      <div className="ep-empty">{_isPF
                        ? <>Script for this episode is in {ep.status}. No scenes yet.</>
                        : `No scenes yet for this ${_containerWord(false).toLowerCase()}.`}</div>
                    )}
                    {/* v07zz551 — VO-paragraph view (the ingested Prompt Script).
                        Consecutive same-speaker scenes = ONE copyable paragraph with a
                        "Scenes NNN–NNN · first → last title" note. Replaces the
                        per-line list when data/vo-scripts/<ep>.json exists. */}
                    {voScript && ep.id === _activeEpId && voScript.paragraphs
                      .map((p, i) => ({ ...p, _idx: i }))
                      .filter(p => !characterFilter || p.speaker === characterFilter)
                      .map(p => (
                        <div key={p._idx} className="script-scene script-para">
                          <div className="script-scene-head">
                            <span className="script-scene-num">{String(p._idx + 1).padStart(2, "0")}</span>
                            <span className="script-scene-heading">{p.title_from || "(untitled)"}{p.title_to && p.title_to !== p.title_from ? " → " + p.title_to : ""}</span>
                            <span className="script-scene-seq">
                              {p.scene_from === p.scene_to
                                ? `SCENE ${String(p.scene_from).padStart(3, "0")}`
                                : `SCENES ${String(p.scene_from).padStart(3, "0")}–${String(p.scene_to).padStart(3, "0")}`}
                              {` · ${p.scenes.length} shot${p.scenes.length === 1 ? "" : "s"}`}
                            </span>
                            <button type="button"
                              className={"script-para-copy" + (copiedPara === p._idx ? " is-copied" : "")}
                              onClick={() => copyParagraph(p._idx, p.text)}
                              title="Copy this paragraph to the clipboard">
                              {copiedPara === p._idx ? "Copied ✓" : "Copy"}
                            </button>
                          </div>
                          <div className="script-beat">
                            <div className="script-speaker">{p.speaker}</div>
                            <p className="script-line script-para-text">{p.text}</p>
                          </div>
                        </div>
                      ))}
                    {/* v07zz51 — Speaker filter behaviour changed. Hugo:
                        "if we click on Columbus, we would see only
                        columbus' lines, one after the other, not
                        greyed out." Now: when a filter is active, we
                        FILTER OUT scenes that don't include that speaker
                        AND within each remaining scene only render the
                        matching lines. No more dim/grey-out. */}
                    {!voScript && ep.scenes
                      .filter(sc => !characterFilter || (sc.lines || []).some(ln => ln && ln.speaker === characterFilter))
                      .map((sc, i) => {
                        const visibleLines = (sc.lines || []).filter(ln =>
                          !characterFilter || (typeof ln !== "string" && ln.speaker === characterFilter)
                        );
                        return (
                          <div key={sc.id} className="script-scene">
                            <div className="script-scene-head">
                              <span className="script-scene-num">{String(i + 1).padStart(2, "0")}</span>
                              <span className="script-scene-heading">{sc.heading}</span>
                              <span className="script-scene-seq">{sc.shot_id ? sc.shot_id : (!_isPF && sc.seq == null) ? "" : `SEQ ${String(sc.seq).padStart(2,"0")}`}</span>
                            </div>
                            {visibleLines.map((ln, li) => {
                              if (typeof ln === "string") {
                                return (
                                  <React.Fragment key={li}>
                                    {sc.speaker && li === 0 && <div className="script-speaker">{sc.speaker}</div>}
                                    <p className="script-line">{ln}</p>
                                  </React.Fragment>
                                );
                              }
                              return (
                                <div key={li} className="script-beat">
                                  {ln.speaker && (
                                    <div className="script-speaker">
                                      {ln.speaker}{ln.qualifier ? ` (${ln.qualifier})` : ""}
                                    </div>
                                  )}
                                  <p className="script-line">{ln.line}</p>
                                </div>
                              );
                            })}
                          </div>
                        );
                      })}
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}

      {tab === "screenplay" && (
        <div className="sp-page glass">
          {!screenplay.found && (
            <div className="sp-empty">
              <div className="sp-empty-title">No screenplay file yet</div>
              <p>Drop a <code>screenplay.txt</code> (or a <code>.fountain</code> file) into this {_isPF ? "episode" : _containerWord(false).toLowerCase()}'s brief folder and it appears here, formatted:</p>
              {/* 15 Sep 2026 — the example path is Paradise Found's; a templated project shows its container folder */}
              <p className="sp-empty-path"><code>{_spPathHint}</code></p>
              <p>Standard screenplay format — scene headings (<code>INT./EXT.</code>), a character name in CAPS on its own line, an optional <code>(V.O.)</code> parenthetical, then the dialogue, with action paragraphs in between.</p>
            </div>
          )}
          {screenplay.found && (
            <div className="sp-doc">
              {(screenplay.elements || []).map((el, i) => {
                if (el.type === "title") return <div key={i} className="sp-title">{String(el.text).split("\n").map((l, k) => <div key={k}>{l}</div>)}</div>;
                if (el.type === "scene") return <div key={i} className="sp-scene">{el.text}</div>;
                if (el.type === "transition") return <div key={i} className="sp-transition">{el.text}</div>;
                if (el.type === "super") return <div key={i} className="sp-super">{el.text}</div>;
                if (el.type === "character") return <div key={i} className="sp-character">{el.text}</div>;
                if (el.type === "parenthetical") return <div key={i} className="sp-paren">{el.text}</div>;
                if (el.type === "dialogue") return <div key={i} className="sp-dialogue">{el.text}</div>;
                return <p key={i} className="sp-action">{el.text}</p>;
              })}
            </div>
          )}
        </div>
      )}

      {tab === "shotlist" && (() => {
        // v03i / fix — 21:9 thumb ratio (cinema standard for Paradise Found).
        // 15 Sep 2026 — a templated project uses its own aspect ratio (projects.aspect_ratio).
        const aspect = _isPF ? "21:9" : (window.__projectAspectRatio || "21:9");
        // Detect a latin binomial pattern (Genus species) to split species
        // out of the Characters column.
        const isSpeciesName = (s) => /\b[A-Z][a-z]+\s+[a-z]+\b/.test(String(s || ""));
        const stageTintFor = (s) => {
          const SP = (window.STAGE_TINTS) || {};
          const stage = window.getCurrentStage ? window.getCurrentStage(s) : "PENDING";
          return { stage, tint: SP[stage] || SP.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: stage } };
        };
        return (
          <div className="shotlist-page glass">
            {/* v03i — 10 columns: ID · Thumb · Seq · Frame Title · Type ·
                Characters · Species · Location · Assets · Status.
                Header row gets rounded corners via .shotlist-row--head. */}
            <div className="shotlist-grid shotlist-grid--v03i" data-aspect={aspect}>
              <div className="shotlist-row shotlist-row--head">
                <span>ID</span><span>Thumb</span><span>Seq</span><span>Frame Title</span><span>Type</span>
                <span>Characters</span><span>Species</span><span>Location</span><span>Assets</span><span>Status</span>
              </div>
              {shots.slice(0, 80).map(s => {
                const img = s.image_paths && (s.image_paths.selected || s.image_paths.first_pass);
                // v03i — split: latin binomial → Species column; everything else → Characters column.
                const speciesGuess = isSpeciesName(s.species) ? s.species : null;
                const charactersText = !speciesGuess && s.species ? s.species : (s.behaviour || "—");
                const location = s.landscape || "—";
                // v03i / v04w — Assets column. Round avatar list for each
                // reference_assets slug. Archival shots get a film-strip
                // glyph in their first avatar slot.
                const refSlugs = Array.isArray(s.reference_assets) ? s.reference_assets : [];
                const archivalAvatar = s.is_archive ? [{ kind: "archival", label: "Archival footage" }] : [];
                const avatars = [...archivalAvatar, ...refSlugs.slice(0, 3 - archivalAvatar.length).map(slug => ({ kind: "ref", slug }))];
                const extraCount = Math.max(0, (refSlugs.length + archivalAvatar.length) - avatars.length);
                const { tint } = stageTintFor(s);
                // v04v — clickable rows: row click opens the shot popup
                // via window.__nav.openShot. Inner thumb click also opens
                // (no event isolation needed). cursor:pointer applied via CSS.
                const onRowClick = () => { if (window.__nav && window.__nav.openShot) window.__nav.openShot(s.id); };
                return (
                  <div key={s.id} className="shotlist-row shotlist-row--click" onClick={onRowClick} role="button" tabIndex={0}>
                    <span className="shotlist-id">{s.id}</span>
                    <div className="shotlist-thumb" style={{background: seqGradient(s.seq), aspectRatio: "var(--project-aspect)"}} title={s.frame_title}>
                      {/* v07zz31 — Mipmap: shotlist rows are 56×32 px, so
                          width-160 covers 2× retina with the smallest
                          pre-generated mipmap. Previously this loaded
                          1-3 MB full-res originals on every Shotlist
                          open — ~80 rows × multi-MB = noticeable lag. */}
                      {img && <img src={window.thumbUrl ? window.thumbUrl(img, 160) : img} alt={s.id} className="shotlist-thumb-img" loading="lazy"/>}
                      {/* v07zz60 — Hugo: "why is the placeholder shot
                          thumbnail saying SH10 instead of SH0010?"
                          Show the full canonical id so it matches
                          the SHOT column on the same row. */}
                      {!img && <span>{s.id}</span>}
                    </div>
                    <span>{String(s.seq).padStart(2,"0")}</span>
                    <span>{s.frame_title}</span>
                    <span className="shotlist-muted">{s.shot_type || "—"}</span>
                    <span className="shotlist-muted">{charactersText}</span>
                    <span className="shotlist-muted">{speciesGuess || "—"}</span>
                    <span className="shotlist-muted">{location}</span>
                    {/* v04w — round avatar stack for references + archival glyph. */}
                    <span className="shotlist-refs">
                      {avatars.length === 0 ? <span className="shotlist-muted">—</span> : (
                        <>
                          {avatars.map((a, i) => (
                            <span key={a.slug || `arc-${i}`} className="shotlist-ref-avatar" title={a.label || a.slug}>
                              {a.kind === "archival" ? "🎞" : (a.slug || "?").charAt(0).toUpperCase()}
                            </span>
                          ))}
                          {extraCount > 0 && <span className="shotlist-ref-more">+{extraCount}</span>}
                        </>
                      )}
                    </span>
                    {/* v03i — status uses the exact same pill component / colours / typography as the shot row pills. */}
                    <span>
                      <span className="shot-pill shot-pill--static" style={{background: tint.bg, borderColor: tint.border, color: tint.color}}>
                        <span className="shot-pill-dot" style={{background: tint.dot}}/>
                        <span className="shot-pill-label">{tint.label}</span>
                      </span>
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        );
      })()}
    </section>
  );
}

/* ─────────────────────────── SCHEDULE — big calendar ─────────────────────────── */

const SCHED_MONTHS_LONG = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const SCHED_DOW_LONG = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
const SCHED_DOW_SHORT = ["SUN","MON","TUE","WED","THU","FRI","SAT"];

// v1086 — SCHEDULE REVISIONS: the "Move" box of a stage card. Hugo: "a way to move dates in
// the schedule ... things keep getting pushed". Start and end boxes, - / + a day or a week on
// the end, the "move the stages after it too" switch (on), a reason for the list of changes,
// and a line that says what will move before you save. PATCH /api/schedule/phases does the
// same sum when it saves (server.js) — keep the two in step.
function _schedMovePreview(schedule, phaseId, start, end, ripple) {
  const ms = (x) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(x || "")); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : NaN; };
  if (!Number.isFinite(ms(start)) || !Number.isFinite(ms(end)) || ms(end) < ms(start)) return null;
  const phases = ((schedule && schedule.phases) || []).filter(p => p && p.id).map(p => ({ id: p.id, name: p.name, start: p.start, end: p.end }));
  const ph = phases.find(p => p.id === phaseId);
  if (!ph) return null;
  const lastEnd = () => phases.reduce((mx, x) => ((!mx || ms(x.end) > ms(mx)) ? x.end : mx), null);
  const oldEnd = ph.end;
  const days = window.schedDayDiff(oldEnd, end);
  const lastBefore = lastEnd();
  const moved = [];
  if (ripple && days) {
    for (const x of phases) {
      if (x === ph || !(ms(x.start) > ms(oldEnd))) continue;
      x.start = window.schedAddDays(x.start, days);
      x.end = window.schedAddDays(x.end, days);
      moved.push(x);
    }
  }
  ph.start = start;
  ph.end = end;
  const shift = window.schedDayDiff(lastBefore, lastEnd());
  return { days, moved, endFrom: schedule.project_end, endTo: Number.isFinite(shift) ? window.schedAddDays(schedule.project_end, shift) : lastEnd() };
}
function ScheduleMoveModal({ schedule, phaseId, onClose }) {
  const phase = ((schedule && schedule.phases) || []).find(p => p && p.id === phaseId) || null;
  const [start, setStart] = React.useState(phase ? phase.start : "");
  const [end, setEnd] = React.useState(phase ? phase.end : "");
  const [ripple, setRipple] = React.useState(true);
  const [reason, setReason] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape" && !busy) onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [busy, onClose]);
  if (!phase) return null;
  const fmt = window.schedFmtDay || ((x) => x);
  const sgn = (n) => (n > 0 ? "+" : n < 0 ? "−" : "") + Math.abs(n);
  const short = (n) => String(n || "").replace(/^Phase\s*[\d.]+\s*[\u2014\u2013-]\s*/i, "");
  const base = window.baselineSchedule ? window.baselineSchedule(schedule) : null;
  const bp = base ? base.phases.find(p => p.id === phase.id) : null;
  const pv = _schedMovePreview(schedule, phase.id, start, end, ripple);
  const same = start === phase.start && end === phase.end;
  const bump = (n) => { setEnd(x => window.schedAddDays(x, n)); setErr(""); };
  let line, bad = false;
  if (err) { line = err; bad = true; }
  else if (!pv) { line = "The end must be on or after the start."; bad = true; }
  else if (same) line = "Pick the new dates. The line here tells you what moves before you save.";
  else {
    const parts = [pv.days
      ? `${short(phase.name)} ends ${fmt(end)} (${sgn(pv.days)} days)`
      : `${short(phase.name)} ${fmt(start)} → ${fmt(end)}`];
    if (pv.moved.length) parts.push(`${pv.moved.map(x => short(x.name)).join(", ")} ${sgn(pv.days)} days`);
    parts.push(pv.endTo !== pv.endFrom ? `delivery ${fmt(pv.endFrom)} → ${fmt(pv.endTo)}` : `delivery stays ${fmt(pv.endFrom)}`);
    if (base && base.project_end) {
      const o = window.schedDayDiff(base.project_end, pv.endTo);
      parts.push(o ? `${sgn(o)} days on the original ${fmt(base.project_end)}` : `on the original ${fmt(base.project_end)}`);
    }
    line = parts.join(" · ");
  }
  const save = async () => {
    if (!pv || same || busy) return;
    setBusy(true); setErr("");
    try {
      const r = await (window.authFetch || fetch)("/api/schedule/phases", {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ phase_id: phase.id, start, end, ripple, reason: reason.trim() }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || ("HTTP " + r.status));
      if (window.reloadAppData) window.reloadAppData();
      onClose();
    } catch (e) { setErr(e.message || "Could not save."); setBusy(false); }
  };
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }} onClick={() => !busy && onClose()}>
      <div className="confirm-delete-modal glass sched-move-modal" onClick={(e) => e.stopPropagation()}>
        <div className="confirm-delete-eyebrow sched-move-eyebrow">MOVE A STAGE</div>
        <div className="confirm-delete-title">{phase.name}</div>
        <div className="sched-move-now">
          Now {fmt(phase.start)} → {fmt(phase.end)}
          {bp ? ` · original ${fmt(bp.start)} → ${fmt(bp.end)}` : " · not in the original plan"}
        </div>
        <div className="sched-move-row">
          <label className="sched-move-field">
            <span>Start</span>
            <input type="date" value={start} disabled={busy} onChange={(e) => { setStart(e.target.value); setErr(""); }}/>
          </label>
          <label className="sched-move-field">
            <span>End</span>
            <input type="date" value={end} disabled={busy} onChange={(e) => { setEnd(e.target.value); setErr(""); }}/>
          </label>
          <div className="sched-move-bumps" role="group" aria-label="Move the end date">
            <button type="button" disabled={busy} onClick={() => bump(-7)}>−1 week</button>
            <button type="button" disabled={busy} onClick={() => bump(-1)}>−1 day</button>
            <button type="button" disabled={busy} onClick={() => bump(1)}>+1 day</button>
            <button type="button" disabled={busy} onClick={() => bump(7)}>+1 week</button>
          </div>
        </div>
        <button type="button" className={"char-voice-switch sched-move-ripple" + (ripple ? " is-on" : "")}
          role="switch" aria-checked={ripple} disabled={busy} onClick={() => setRipple(x => !x)}>
          <span className="char-voice-switch-track"><span className="char-voice-switch-knob"/></span>
          <span className="char-voice-switch-txt">Move the stages after it too</span>
        </button>
        <label className="confirm-delete-label" htmlFor="sched-move-reason">Why (it shows in the list of changes)</label>
        <input id="sched-move-reason" className="confirm-delete-input sched-move-reason" value={reason} maxLength={300}
          placeholder="e.g. more video retakes" disabled={busy}
          onChange={(e) => setReason(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") save(); }}/>
        {/* Always three lines of room, so the box never changes height (invariant #20). */}
        <div className={"sched-move-preview" + (bad ? " is-bad" : "")}>{line}</div>
        <div className="confirm-delete-actions">
          <button type="button" className="admin-suspend-btn" onClick={onClose} disabled={busy}>Cancel</button>
          <button type="button" className="confirm-delete-btn sched-move-save" onClick={save} disabled={busy || same || !pv}>{busy ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </div>
  ), portalRoot);
}

// 24 Sep 2026 (G7 review) - another project's Schedule starts with no stages and no payment
// milestones (its own settings row "schedule"), so the page needs a way to create both: this box
// adds a stage (PATCH /api/schedule/phases with a name - the Move box and the "Original ..." line
// work on it from then on) or a payment milestone (POST /api/schedule/milestones). It reuses the
// Move box's .confirm-delete-* / .sched-move-* look; the status line always keeps its room (#20).
const _SCHED_STAGE_SWATCHES = ["#7FA85E", "#D4A574", "#6FA3C7", "#B58BD6", "#E08A6C", "#9A9A8A"];
function _schedIsoToday() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }
function ScheduleAddModal({ kind, schedule, onClose }) {
  const isStage = kind === "stage";
  const phases = ((schedule && schedule.phases) || []).filter(Boolean);
  const addDays = window.schedAddDays || ((x) => x);
  const firstDay = React.useMemo(() => {
    const ends = phases.map(p => String(p.end || "")).filter(s => /^\d{4}-\d{2}-\d{2}$/.test(s)).sort();
    if (isStage && ends.length) return addDays(ends[ends.length - 1], 1);
    return (schedule && schedule.project_start) || _schedIsoToday();
  }, []);   // the suggestion is taken once, when the box opens
  const [name, setName] = React.useState("");
  const [start, setStart] = React.useState(firstDay);
  const [end, setEnd] = React.useState(() => (isStage ? addDays(firstDay, 6) : ""));
  const [color, setColor] = React.useState(_SCHED_STAGE_SWATCHES[phases.length % _SCHED_STAGE_SWATCHES.length]);
  const [amount, setAmount] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape" && !busy) onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [busy, onClose]);
  const ISO = /^\d{4}-\d{2}-\d{2}$/;
  const fmt = window.schedFmtDay || ((x) => x);
  const nm = name.trim();
  const datesOk = ISO.test(start) && (!isStage || (ISO.test(end) && end >= start));
  const ok = !!nm && datesOk;
  const days = isStage && datesOk && window.schedDayDiff ? window.schedDayDiff(start, end) + 1 : 0;
  const amt = parseInt(String(amount || "").replace(/[^0-9]/g, ""), 10) || 0;
  let line, bad = false;
  if (err) { line = err; bad = true; }
  else if (!datesOk) { line = isStage ? "The end must be on or after the start." : "Pick the date."; bad = true; }
  else if (!nm) line = isStage ? "Name the stage. It shows as a card below the calendar and as a bar on it." : "Name the payment. Only people who may see money see it.";
  else line = isStage
    ? `${nm} · ${fmt(start)} → ${fmt(end)} · ${days} day${days === 1 ? "" : "s"}`
    : `${nm} · ${fmt(start)}${amt ? ` · $${amt.toLocaleString()}` : ""}`;
  const save = async () => {
    if (!ok || busy) return;
    setBusy(true); setErr("");
    try {
      const r = isStage
        ? await (window.authFetch || fetch)("/api/schedule/phases", {
            method: "PATCH", headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name: nm, start, end, color }),
          })
        : await (window.authFetch || fetch)("/api/schedule/milestones", {
            method: "POST", headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name: nm, date: start, amount: amt }),
          });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || ("HTTP " + r.status));
      if (window.reloadAppData) window.reloadAppData();
      onClose();
    } catch (e) { setErr("Could not add it: " + (e.message || "unknown error")); setBusy(false); }
  };
  const onEnter = (e) => { if (e.key === "Enter") save(); };
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }} onClick={() => !busy && onClose()}>
      <div className="confirm-delete-modal glass sched-move-modal sched-add-modal" onClick={(e) => e.stopPropagation()}>
        <div className="confirm-delete-eyebrow sched-move-eyebrow">{isStage ? "ADD A STAGE" : "ADD A PAYMENT MILESTONE"}</div>
        <label className="confirm-delete-label" htmlFor="sched-add-name">Name</label>
        <input id="sched-add-name" className="confirm-delete-input sched-move-reason" value={name} maxLength={120} autoFocus
          placeholder={isStage ? "e.g. Treatment, Shoot, Edit" : "e.g. Label advance"} disabled={busy}
          onChange={(e) => { setName(e.target.value); setErr(""); }} onKeyDown={onEnter}/>
        <div className="sched-move-row sched-add-row">
          <label className="sched-move-field">
            <span>{isStage ? "Start" : "Date"}</span>
            <input type="date" value={start} disabled={busy} onChange={(e) => { setStart(e.target.value); setErr(""); }}/>
          </label>
          {isStage && (
            <label className="sched-move-field">
              <span>End</span>
              <input type="date" value={end} disabled={busy} onChange={(e) => { setEnd(e.target.value); setErr(""); }}/>
            </label>
          )}
          {isStage ? (
            <div className="sched-move-field">
              <span>Colour</span>
              <div className="sched-add-swatches" role="radiogroup" aria-label="Stage colour">
                {_SCHED_STAGE_SWATCHES.map(c => (
                  <button key={c} type="button" role="radio" aria-checked={color === c} aria-label={"Colour " + c}
                    className={"sched-add-swatch" + (color === c ? " is-on" : "")} style={{ background: c }}
                    disabled={busy} onClick={() => setColor(c)}/>
                ))}
              </div>
            </div>
          ) : (
            <label className="sched-move-field">
              <span>Amount ($)</span>
              <input type="text" inputMode="numeric" value={amount} disabled={busy} placeholder="0"
                onChange={(e) => { setAmount(e.target.value.replace(/[^0-9]/g, "")); setErr(""); }} onKeyDown={onEnter}/>
            </label>
          )}
        </div>
        <div className={"sched-move-preview" + (bad ? " is-bad" : "")} role="status">{line}</div>
        <div className="confirm-delete-actions">
          <button type="button" className="admin-suspend-btn" onClick={onClose} disabled={busy}>Cancel</button>
          <button type="button" className="confirm-delete-btn sched-move-save" onClick={save} disabled={busy || !ok}>{busy ? "Saving…" : "Add"}</button>
        </div>
      </div>
    </div>
  ), portalRoot);
}

function ScheduleView({ schedule }) {
  // v06p — Budget/cost visibility now driven by the `view_budget`
  // permission instead of a hard-coded role list. Admin bypasses.
  const _ctx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const _role  = (_ctx && _ctx.user && _ctx.user.role) || null;
  const _perms = (_ctx && _ctx.user && _ctx.user.permissions) || {};
  // v07zz340 — `window.__hideMoney` (Settings → Hide all money figures) suppresses every amount
  // even for admins/budget roles. Folded in here so the schedule's phase + milestone amounts vanish.
  const canSeeCosts = (_role === "admin" || !!_perms.view_budget) && !window.__hideMoney;
  // v07zz278 — Untangle money-visibility from schedule-management.
  // `view_budget` gates SEEING amounts; `manage_schedule` gates the WRITE
  // controls (add deliverable event, cycle payment status, edit amount).
  // Editing an amount needs both — you can't edit money you can't see.
  const canManageSched = _role === "admin" || !!_perms.manage_schedule;
  // 24 Sep 2026 (G7) — another project's own schedule works here too (its settings row "schedule").
  // Outside Paradise Found: no hover tooltips (Hugo), and the header reads the project's own dates.
  const _schedPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _tip = (t) => (_schedPF ? t : undefined);
  // v07perf — Hugo: "I need to be able to tick the ones that have
  // been paid, and also maybe modify the amounts." Optimistic overlay
  // of in-flight edits keyed by milestone id, so a click → status
  // flip / amount type → blur feels instant. Failed PATCH reverts
  // the overlay entry. canSeeCosts gates both view + edit (only
  // producer+ has view_budget).
  const [milestoneEdits, setMilestoneEdits] = React.useState({});
  const patchMilestone = React.useCallback((id, patch) => {
    setMilestoneEdits(prev => ({ ...prev, [id]: { ...(prev[id] || {}), ...patch } }));
    (window.authFetch || fetch)(`/api/schedule/milestones/${encodeURIComponent(id)}`, {
      method: "PATCH",
      body: JSON.stringify(patch),
    })
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .catch(err => {
        console.warn("[milestone] patch failed:", err.message);
        setMilestoneEdits(prev => {
          const next = { ...prev };
          delete next[id];
          return next;
        });
      });
  }, []);
  // v07perf — Hugo: 4 stages. Cycle order is the natural lifecycle
  // forward — not_ready → upcoming (due) → paid (completed) → late
  // (overdue) → back to not_ready. Each click advances one step;
  // wraps round so you can flick through to whichever state you
  // need.
  const cycleStatus = (current) => current === "not_ready" ? "upcoming"
                                  : current === "upcoming" ? "paid"
                                  : current === "paid"     ? "late"
                                  : current === "late"     ? "not_ready"
                                  : "upcoming"; // unknown → start the cycle
  // v07zz72 — HOOKS-ORDER FIX: every useState below was previously
  // declared AFTER the `if (!schedule) return null` guard, which meant
  // React saw a different hook count on the null vs loaded renders →
  // exactly the same class of crash that took down ScriptView before
  // v07zz65. All hooks must run on EVERY render. Lifted them above
  // the guard.
  const [cursor, setCursor] = React.useState(() => {
    const d = new Date();
    return new Date(d.getFullYear(), d.getMonth(), 1);
  });
  const [hoveredPhase, setHoveredPhase] = React.useState(null);
  const [moveFor, setMoveFor] = React.useState(null);   // v1086 — the stage in the Move box
  const [addKind, setAddKind] = React.useState(null);   // 24 Sep 2026 (G7 review) — "stage" | "milestone" (another project)
  const [openEvent, setOpenEvent] = React.useState(null); // event for popup
  // v07zz60 — Add-milestone popover state. v07zz62 — Hugo wants the
  // popover to render NEAR the clicked cell (not as a fullscreen
  // modal), but visually above any phase bars / lines. We store
  // { date, anchor } where anchor is the cell's viewport rect at
  // click time, then portal the popover to position:fixed at the
  // top-right corner of that rect with z-index above everything.
  const [addOnDate, setAddOnDate] = React.useState(null);
  const [addAnchor, setAddAnchor] = React.useState(null);
  // v07zz72 — Hoisted above the `if (!schedule) return null` guard so
  // the hook count stays constant regardless of whether schedule has
  // loaded yet. Same fix that v07zz65 applied to ScriptView.
  const [addName, setAddName] = React.useState("");
  const [addAmount, setAddAmount] = React.useState("");
  const [adding, setAdding] = React.useState(false);
  const [addErr, setAddErr] = React.useState("");   // 24 Sep 2026 (G7) — the popover's inline error line
  const submitAddMilestone = React.useCallback((dateStr) => {
    const name = String(addName || "").trim();
    if (!name || adding) return;
    const amount = parseInt(String(addAmount || "").replace(/[^0-9]/g, ""), 10) || 0;
    setAdding(true);
    setAddErr("");
    // v07zz66 — Hugo: "Create Milestones on the calendar creates a
    // PAYMENT milestones for some reason. NO NO NO, I want to
    // create an Event, or a asset deliverable event, nothing to do
    // with the payment milestones." Switched the POST target to
    // /api/schedule/events which writes into schedule.events (not
    // schedule.milestones). The Amount field still flows for
    // back-compat but is ignored server-side for events.
    (window.authFetch || fetch)("/api/schedule/events", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: name, date: dateStr, type: "deliverable", summary: amount ? `Amount: $${amount}` : "" }),
    })
      .then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(new Error(`HTTP ${r.status}: ${t}`))))
      .then(() => {
        setAddOnDate(null);
        setAddAnchor(null);
        setAddName("");
        setAddAmount("");
        // Refresh schedule data so the new milestone appears.
        if (window.reloadAppData) window.reloadAppData();
      })
      .catch(err => {
        console.warn("[milestone] add failed:", err.message);
        // 24 Sep 2026 (G7) — a line inside the popover, never a native alert (Hugo). The server's own
        // sentence when it sent one ("HTTP 400: {"error":"…"}"), else the raw message.
        let msg = String(err.message || "");
        try { const j = JSON.parse(msg.replace(/^HTTP \d+:\s*/, "")); if (j && j.error) msg = j.error; } catch (_) {}
        setAddErr("Could not add it: " + msg);
      })
      .finally(() => setAdding(false));
  }, [addName, addAmount, adding]);

  // v07zz72 — Hoisted ABOVE the `if (!schedule) return null` guard
  // for hook-count constancy. These three (state + effect + memos)
  // used to live mid-component which made them post-guard hooks.
  const [calEvents, setCalEvents] = React.useState([]);
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    fetcher("/api/calendar/events")
      .then(r => r.ok ? r.json() : null)
      .then(d => setCalEvents((d && d.events) || []))
      .catch(() => setCalEvents([]));
  }, []);
  const mappedCalEvents = React.useMemo(() => (calEvents || []).map(e => ({
    date: e.date,
    type: e.type === "ics" ? "weekly" : (e.type || "event"),
    label: e.title || "Event",
    summary: e.sub || "",
    lead: "Calendar",
    utc: e.utc,
    // v07zz60 — Carry the meeting URL through so the event modal's
    // "Join meeting" button can link to it. Hugo: "still no link to
    // join meeting when opening the card". parseICS extracts URL +
    // auto-detects Meet/Zoom/Teams from the description; the API
    // returns it in `e.url` — we just have to forward it.
    url: e.url || null,
  })), [calEvents]);
  const allEventsMemo = React.useMemo(
    () => [...((schedule && schedule.events) || []), ...mappedCalEvents],
    [schedule && schedule.events, mappedCalEvents]
  );
  // v07zz72 — `if (!schedule)` guard moved here, AFTER every hook
  // declaration. From this point onward we can use plain non-hook
  // logic safely; the JSX return is right below.
  if (!schedule) return null;
  // v07zz55 — TODAY was hardcoded to May 6, 2026 as a demo anchor.
  // Now use the real system date so the calendar shows today's date
  // (May 28 etc.) as TODAY and Phase 1's bar lands where it should.
  const TODAY = new Date();
  TODAY.setHours(0, 0, 0, 0);

  const y = cursor.getFullYear(), mo = cursor.getMonth();
  const ndays = new Date(y, mo + 1, 0).getDate();
  const offset = new Date(y, mo, 1).getDay();
  // v07zz61 — Hugo: "Calendar is not showing previous month's days
  // greyed out like any calendar usually does". Fill the lead/trail
  // cells with real Date objects from the prev/next month and tag
  // them with isOutOfMonth so the cell can render dim.
  const cells = [];
  const prevMonthDays = new Date(y, mo, 0).getDate();
  for (let i = 0; i < offset; i++) {
    const day = prevMonthDays - offset + 1 + i;
    cells.push({ date: new Date(y, mo - 1, day), out: true });
  }
  for (let d = 1; d <= ndays; d++) cells.push({ date: new Date(y, mo, d), out: false });
  let trailingDay = 1;
  while (cells.length % 7 !== 0) {
    cells.push({ date: new Date(y, mo + 1, trailingDay++), out: true });
  }
  const rows = Math.ceil(cells.length / 7);

  const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();

  // v07zz72 — calEvents state + fetch effect + mappedCalEvents + allEvents
  // memos all live ABOVE the schedule-null guard now. allEventsMemo is
  // the hoisted version. Reuse it under the local name `allEvents`.
  const allEvents = allEventsMemo;
  const eventOnDate = (date) => allEvents.find(e => sameDay(new Date(e.date), date));

  // Build per-row phase segments — each week-row produces N continuous bars,
  // where each bar starts at the leftmost active day in that row and spans
  // until the phase ends (or the row ends, whichever first).
  // v07zz61 — cells now hold { date, out } objects instead of Date|null,
  // so we read c.date when computing whether a cell falls in a phase.
  // v07zz64 — Hugo: "why do I have gaps of one day in between phases?"
  // Root cause: `new Date("2026-05-27")` parses as UTC midnight. In a
  // non-UTC timezone that's a different LOCAL date — so the day
  // comparison rejected the boundary day. Parse as local-midnight by
  // appending "T00:00:00" before constructing the Date, then compare
  // by Y/M/D components instead of timestamps so DST shifts and tz
  // offsets can't drop boundary days.
  const _parseLocalDate = (s) => new Date(String(s || "") + "T00:00:00");
  const _ymd = (d) => d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
  const buildBars = () => {
    const bars = []; // {row, col, span, phase, lane}
    schedule.phases.forEach((p, pIdx) => {
      const psK = _ymd(_parseLocalDate(p.start));
      const peK = _ymd(_parseLocalDate(p.end));
      let runStart = -1;
      cells.forEach((c, i) => {
        const d = c && c.date;
        const dK = d ? _ymd(d) : -1;
        const inPhase = d && dK >= psK && dK <= peK;
        if (inPhase && runStart < 0) runStart = i;
        if ((!inPhase || (i + 1) % 7 === 0) && runStart >= 0) {
          const endIdx = inPhase ? i : i - 1;
          const row = Math.floor(runStart / 7);
          const col = runStart % 7;
          const span = endIdx - runStart + 1;
          bars.push({ row, col, span, phase: p, lane: pIdx });
          runStart = inPhase && (i + 1) % 7 === 0 ? -1 : -1;
        }
      });
    });
    return bars;
  };
  const bars = buildBars();

  const shiftMonth = (delta) => setCursor(prev => new Date(prev.getFullYear(), prev.getMonth() + delta, 1));
  const today = () => setCursor(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1));
  // v1086 — the original plan (schedule.baseline) next to the dates in force, and the moves.
  const _fmtD = window.schedFmtDay || ((x) => String(x || "—"));
  const _dd = window.schedDayDiff || (() => 0);
  const _sgn = (n) => (n > 0 ? "+" : n < 0 ? "−" : "") + Math.abs(n);
  const _short = (id) => { const ph = (schedule.phases || []).find(x => x && x.id === id); return ph ? String(ph.name).replace(/^Phase\s*[\d.]+\s*[\u2014\u2013-]\s*/i, "") : id; };
  const _base = window.baselineSchedule ? window.baselineSchedule(schedule) : null;
  const _baseById = new Map(((_base && _base.phases) || []).map(x => [x.id, x]));
  const _origEnd = _base ? _base.project_end : null;
  const _slip = _origEnd ? _dd(_origEnd, schedule.project_end) : 0;
  const _changes = Array.isArray(schedule.changes) ? schedule.changes.slice().reverse() : [];

  return (
    <section className="view-page schedule-view">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">PRODUCTION SCHEDULE</div>
          {_schedPF
            ? <div className="vp-title">10-Week Build · {schedule.project_start} → {schedule.project_end}</div>
            : <div className="vp-title">{(schedule.project_start || schedule.project_end) ? `Schedule · ${schedule.project_start || "no start"} → ${schedule.project_end || "no deadline"}` : "Schedule · no dates yet"}</div>}
        </div>
      </div>

      {(
        <>
          <div className="big-cal big-cal--overlay glass">
            <div className="big-cal-head">
              <div className="big-cal-title">{SCHED_MONTHS_LONG[mo]} {y}</div>
              <div className="big-cal-nav">
                <button className="big-cal-nav-btn" onClick={() => shiftMonth(-1)}>‹</button>
                <button className="big-cal-today" onClick={today}>Today</button>
                <button className="big-cal-nav-btn" onClick={() => shiftMonth(1)}>›</button>
              </div>
            </div>
            <div className="big-cal-dow">
              {SCHED_DOW_SHORT.map(d => <div key={d}>{d}</div>)}
            </div>
            <div className="big-cal-grid-wrap">
              <div className="big-cal-grid" style={{gridTemplateRows: `repeat(${rows}, var(--cal-row-h, 130px))`}}>
                {cells.map((c, i) => {
                  const day = c.date;
                  const isOut = !!c.out;
                  const evt = isOut ? null : eventOnDate(day);
                  const isToday = !isOut && sameDay(day, TODAY);
                  const isWeekend = day.getDay() === 0 || day.getDay() === 6;
                  // v07zz60 — Milestones inline on their date.
                  const cellDateStr = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`;
                  const milestonesOnDate = isOut ? [] : (schedule.milestones || []).filter(m => m && m.date === cellDateStr);
                  return (
                    <div key={i} className={"big-cal-cell" + (isToday ? " is-today" : "") + (isWeekend ? " is-weekend" : "") + (isOut ? " is-out-of-month" : "")}>
                      <div className="big-cal-num">{day.getDate()}</div>
                      {/* v01p — explicit TODAY label so the highlighted day reads at a glance. */}
                      {isToday && <span className="big-cal-today-tag" aria-hidden="true">TODAY</span>}
                      {/* v07zz61 — + button only on in-month cells.
                          v07zz278 — gated on manage_schedule (adds a
                          deliverable EVENT, not money) — not view_budget. */}
                      {!isOut && canManageSched && (
                        <button
                          type="button"
                          className="big-cal-add-btn"
                          aria-label={`Add milestone on ${cellDateStr}`}
                          title={_tip(`Add milestone on ${cellDateStr}`)}
                          onClick={(e) => {
                            e.stopPropagation();
                            // v07zz62 — Capture the cell's rect so the
                            // portal popover renders anchored to its
                            // top-right corner.
                            const cell = e.currentTarget.closest(".big-cal-cell");
                            const r = cell ? cell.getBoundingClientRect() : null;
                            setAddAnchor(r ? { top: r.top, left: r.left, right: r.right, bottom: r.bottom, width: r.width } : null);
                            setAddOnDate(cellDateStr);
                            setAddName("");
                            setAddAmount("");
                            setAddErr("");
                          }}
                        >+</button>
                      )}
                      {milestonesOnDate.map(m => {
                        // Phase-begin milestones get the colour of their
                        // phase + an inline anchor look so they read as
                        // "this is where Phase X starts".
                        const phaseMatch = (schedule.phases || []).find(p => p && p.id === m.phase_id);
                        const isPhase = !!phaseMatch;
                        const style = isPhase ? { background: phaseMatch.color || "var(--leaf)", color: "var(--ink-on-leaf)" } : undefined;
                        return (
                          <button
                            key={m.id}
                            className={"big-cal-event big-cal-event--milestone" + (isPhase ? " big-cal-event--phase-inline" : "")}
                            style={style}
                            onClick={() => setOpenEvent({
                              type: isPhase ? "phase" : "milestone",
                              label: m.name,
                              date: m.date,
                              summary: isPhase
                                ? phaseMatch.deliverables.join(" · ")
                                : (canSeeCosts && m.amount > 0 ? `Amount: $${m.amount.toLocaleString()}` : ""),
                              lead: isPhase ? "Hugo" : null,
                              milestone_id: m.id,
                              kind: m.kind,
                            })}>
                            {m.name}
                          </button>
                        );
                      })}
                      {evt && (
                        <button
                          className={"big-cal-event big-cal-event--" + evt.type}
                          onClick={() => setOpenEvent(evt)}>
                          {evt.label}
                        </button>
                      )}
                    </div>
                  );
                })}
              </div>
              {/* Phase bars overlay — same grid spec as cells, absolute on top */}
              <div className="big-cal-bars-layer" style={{gridTemplateRows: `repeat(${rows}, var(--cal-row-h, 130px))`}}>
                {bars.map((b, i) => {
                  const dim = hoveredPhase && hoveredPhase !== b.phase.id;
                  const lifted = hoveredPhase === b.phase.id;
                  return (
                    <div key={i}
                      className={"big-cal-bar" + (dim ? " is-dim" : "") + (lifted ? " is-lifted" : "")}
                      style={{
                        gridRow: b.row + 1,
                        gridColumn: `${b.col + 1} / span ${b.span}`,
                        background: b.phase.color,
                        ['--lane']: b.lane,
                      }}
                      onMouseEnter={() => setHoveredPhase(b.phase.id)}
                      onMouseLeave={() => setHoveredPhase(null)}
                      title={_tip(b.phase.name)}>
                      {/* v07zz64 — Hugo: "put the title of each phase
                          row at the start of the row, not only at
                          the start of the left one." Drop the
                          span > 2 gate — every segment shows the
                          label, truncated via CSS if needed. */}
                      <span className="big-cal-bar-label">{b.phase.name}</span>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>

          {/* Phase cards (was Phases tab) — hover to highlight bars in calendar */}
          {/* v07zz62 — Hugo: "whaat the hell happened to this page???
              Where are all the Phase cards at the bottom?" Restoring
              the standalone phase-grid cards row. The "Begin: Phase X"
              milestones I added are gone (filtered out of
              schedule.json) so they no longer pollute the Payment
              Milestones strip or render as chips inside the
              calendar cells. */}
          {/* 24 Sep 2026 (G7 review) — another project builds its own stages here (it starts with none). */}
          {!_schedPF && (
            <div className="sched-stage-head">
              <span className="sched-changes-title">STAGES</span>
              {canManageSched && (
                <button type="button" className="phase-card-move sched-add-btn" onClick={() => setAddKind("stage")}>+ Add stage</button>
              )}
            </div>
          )}
          <div className="phase-grid">
            {!_schedPF && schedule.phases.length === 0 && (
              <article className="phase-card glass sched-stage-empty">
                <div className="phase-card-name">No stages yet</div>
                <div className="sched-change-empty">{canManageSched ? "Add the first one with + Add stage. Each stage shows here and as a bar on the calendar." : "The producer has not planned the stages yet."}</div>
              </article>
            )}
            {schedule.phases.map(p => (
              <article key={p.id}
                className={"phase-card glass interactive-card" + (hoveredPhase === p.id ? " is-hover" : "") + (hoveredPhase && hoveredPhase !== p.id ? " is-dim" : "")}
                onMouseEnter={() => setHoveredPhase(p.id)}
                onMouseLeave={() => setHoveredPhase(null)}>
                <div className="phase-card-color" style={{background: p.color, boxShadow: `0 0 18px ${p.color}80`}}/>
                <div className="phase-card-name">{p.name}</div>
                {/* v1086 — Move sits at the end of the dates line (in the card's corner it covered the
                    name). It opens a box, so the page does not move. */}
                <div className="phase-card-dates">
                  <span>{p.start} → {p.end}</span>
                  {canManageSched && (
                    <button type="button" className="phase-card-move" title={_tip("Move the dates of this stage")}
                      onClick={(e) => { e.stopPropagation(); setMoveFor(p.id); }}>Move</button>
                  )}
                </div>
                <div className="phase-card-meta">{p.weeks} weeks</div>
                {/* v1086 — this stage against the original plan. One line on every card, so they keep the same height. */}
                {_base && (() => {
                  const bp = _baseById.get(p.id);
                  if (!bp) return <div className="phase-card-orig is-new">Not in the original plan</div>;
                  const d = _dd(bp.end, p.end);
                  if (!d && bp.start === p.start) return <div className="phase-card-orig">On the original dates</div>;
                  return <div className={"phase-card-orig" + (d > 0 ? " is-late" : "")} title={_tip(`Original ${bp.start} → ${bp.end}`)}>Original {_fmtD(bp.start)} → {_fmtD(bp.end)} · {_sgn(d)} days</div>;
                })()}
                {canSeeCosts && <div className="phase-card-amt">${p.amount.toLocaleString()}</div>}
                <div className="phase-card-cap">DELIVERABLES</div>
                <ul className="phase-card-list">
                  {p.deliverables.map((d, i) => <li key={i}>{d}</li>)}
                </ul>
              </article>
            ))}
          </div>

          {/* v1086 — SCHEDULE CHANGES: the delivery date against the original plan, then every
              move, newest first (the two moves made by hand in the file come from its history). */}
          {(_origEnd || _changes.length > 0) && (
            <div className="sched-changes glass">
              <div className="sched-changes-head">
                <span className="sched-changes-title">SCHEDULE CHANGES</span>
                {_origEnd && (
                  <span className={"sched-changes-total" + (_slip > 0 ? " is-late" : "")}>
                    Delivery {_fmtD(schedule.project_end)} · original {_fmtD(_origEnd)} · {_slip ? `${_sgn(_slip)} days` : "on time"}
                  </span>
                )}
              </div>
              {_changes.length === 0 ? (
                <div className="sched-change-empty">No moves yet.</div>
              ) : (
                <div className="sched-changes-list">
                  {_changes.map((c, i) => {
                    const dEnd = (c.end_from && c.end_to) ? _dd(c.end_from, c.end_to) : 0;
                    return (
                      <div key={String(c.at || "") + i} className="sched-change">
                        <div className="sched-change-when">{_fmtD(String(c.at || "").slice(0, 10))}<span>{c.by || "—"}</span></div>
                        <div className="sched-change-what">
                          <div>
                            <strong>{c.phase_name || _short(c.phase)}</strong>
                            {c.from && c.to ? ` ${_fmtD(c.from.start)} → ${_fmtD(c.from.end)} became ${_fmtD(c.to.start)} → ${_fmtD(c.to.end)}` : ""}
                            {Array.isArray(c.moved) && c.moved.length ? ` · ${c.moved.map(_short).join(", ")} moved too` : ""}
                          </div>
                          {c.reason && <div className="sched-change-why">{c.reason}</div>}
                        </div>
                        <div className={"sched-change-days" + (dEnd > 0 ? " is-late" : "")} title={_tip("How far the delivery date moved")}>
                          <b>{dEnd ? `${_sgn(dEnd)} d` : "0 d"}</b>
                          <span>{c.end_from && c.end_to && c.end_from !== c.end_to ? `delivery ${_fmtD(c.end_from)} → ${_fmtD(c.end_to)}` : "delivery the same"}</span>
                        </div>
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          )}

          {/* Milestones strip at the bottom. v07zz164 — gated on canSeeCosts
              (admin OR view_budget permission, which defaults to producer +
              admin only) so directors / leads / artists / reviewers never see
              the payment milestones card. Configurable via Admin → Permissions
              → "View budget / prices". */}
          {canSeeCosts && (
          <div className="phase-milestones glass">
            {_schedPF ? <div className="phase-milestones-head">PAYMENT MILESTONES</div> : (
              <div className="phase-milestones-head sched-ms-head">
                <span>PAYMENT MILESTONES</span>
                {canManageSched && (
                  <button type="button" className="phase-card-move sched-add-btn" onClick={() => setAddKind("milestone")}>+ Add payment milestone</button>
                )}
              </div>
            )}
            {!_schedPF && schedule.milestones.length === 0 && (
              <div className="sched-change-empty">No payment milestones yet.</div>
            )}
            {schedule.milestones.map(_m => {
              // Apply optimistic overlay so a click feels instant.
              const m = { ..._m, ...(milestoneEdits[_m.id] || {}) };
              // v07perf — 4-stage visual status:
              //   not_ready  grey ○   project hasn't reached this gate yet
              //   upcoming   amber ●  due / current
              //   paid       green ✓  completed
              //   late       red ✕    overdue
              // Inline styles so the look is obvious without depending
              // on CSS that may not be loaded yet. The status badge
              // is a 22px round chip with the icon centered.
              // v07perf — Hugo: "I dont like the Due one, it should be
              // amber and have an icon that is more adequate. The Late
              // icon should be an exclamation mark, not a cross."
              //   Due → small clock face (circle + hands) — conveys
              //         time-sensitivity without alarming.
              //   Late → bold ! character — Hugo's pick.
              // not_ready and Completed unchanged (they already worked).
              const DueClockIcon = (
                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <circle cx="12" cy="12" r="8.5"/>
                  <path d="M12 7v5l3.2 2"/>
                </svg>
              );
              // 17 Sep 2026 - the icon's colour is a class per status (.sched-milestone-status--<status>,
              // views-pages.css), so a skin can reach it; an unknown status draws as not_ready.
              const STATUS_VISUAL = {
                not_ready: { icon: "○",          bg: "color-mix(in srgb, var(--grey-3) 18%, transparent)",  border: "color-mix(in srgb, var(--grey-3) 55%, transparent)", label: "Not yet ready" },
                upcoming:  { icon: DueClockIcon, bg: "color-mix(in srgb, var(--gold-3) 22%, transparent)",   border: "color-mix(in srgb, var(--gold-3) 85%, transparent)",  label: "Due" },
                paid:      { icon: "✓",          bg: "color-mix(in srgb, var(--ok) 22%, transparent)",   border: "color-mix(in srgb, var(--ok) 85%, transparent)",  label: "Completed" },
                late:      { icon: "!",          bg: "color-mix(in srgb, var(--danger-soft) 22%, transparent)",    border: "color-mix(in srgb, var(--danger-soft) 85%, transparent)",   label: "Late" },
              };
              const v = STATUS_VISUAL[m.status] || STATUS_VISUAL.not_ready;
              return (
                <div key={m.id} className={"sched-milestone sched-milestone--" + m.status}>
                  {/* v07perf — Click cycles not_ready→upcoming→paid→late.
                      Gated on canSeeCosts so non-budget viewers can't
                      toggle. The chip carries colour + label in its title
                      so the meaning is obvious. */}
                  <div
                    className={"sched-milestone-status sched-milestone-status--" + (STATUS_VISUAL[m.status] ? m.status : "not_ready")}
                    onClick={canManageSched ? () => patchMilestone(m.id, { status: cycleStatus(m.status) }) : undefined}
                    title={_tip(canManageSched
                      ? `${v.label} · click to advance`
                      : v.label)}
                    style={{
                      display: "inline-flex",
                      alignItems: "center",
                      justifyContent: "center",
                      width: 22,
                      height: 22,
                      borderRadius: "50%",
                      background: v.bg,
                      border: `1.5px solid ${v.border}`,
                      fontWeight: "var(--fw-bold)",
                      fontSize: "var(--fs-body)",
                      lineHeight: 1,
                      cursor: canManageSched ? "pointer" : "default",
                      userSelect: "none",
                      transition: "transform 80ms ease",
                    }}
                  >{v.icon}</div>
                  <div className="sched-milestone-name">{m.name}</div>
                  <div className="sched-milestone-date">{m.date}</div>
                  {canSeeCosts && (
                    <div className="sched-milestone-amt">
                      {"$"}
                      <span
                        contentEditable={canManageSched}
                        suppressContentEditableWarning={true}
                        onBlur={(e) => {
                          const raw = String(e.currentTarget.textContent || "").replace(/[,\s$]/g, "");
                          const v = parseInt(raw, 10);
                          if (Number.isFinite(v) && v >= 0 && v !== m.amount) {
                            patchMilestone(m.id, { amount: v });
                          } else {
                            // Revert visual to current value on bad input
                            e.currentTarget.textContent = m.amount.toLocaleString();
                          }
                        }}
                        onKeyDown={(e) => {
                          if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); }
                          if (e.key === "Escape") { e.currentTarget.textContent = m.amount.toLocaleString(); e.currentTarget.blur(); }
                        }}
                        /* v07zz68 — Hugo: "i tried modifying the
                            payment milestones amount and the font
                            wasnt locked, when I pasted it, it
                            pasted the wrong font format". The
                            browser's default paste behaviour
                            includes formatting (HTML/Word styles).
                            Intercept the paste, strip everything
                            except digits, and insert as a plain
                            text node so it inherits the surrounding
                            font. Same idea for `drop` to catch
                            drag-and-drop. */
                        onPaste={(e) => {
                          e.preventDefault();
                          const raw = (e.clipboardData || window.clipboardData).getData("text/plain") || "";
                          const cleaned = raw.replace(/[^0-9]/g, "");
                          // Use modern execCommand fallback; most
                          // browsers still support insertText on a
                          // contenteditable element.
                          document.execCommand("insertText", false, cleaned);
                        }}
                        onDrop={(e) => {
                          e.preventDefault();
                          const raw = e.dataTransfer && e.dataTransfer.getData("text/plain") || "";
                          const cleaned = raw.replace(/[^0-9]/g, "");
                          document.execCommand("insertText", false, cleaned);
                        }}
                        style={{
                          outline: "none",
                          borderBottom: "1px dashed color-mix(in srgb, var(--gold-19) 35%, transparent)",
                          padding: "0 3px",
                          minWidth: "48px",
                          display: "inline-block",
                          cursor: canManageSched ? "text" : "default",
                          /* v07zz68 — force pasted content to inherit
                             the parent's typography. Without these
                             rules an inserted node can carry its
                             own font / weight / color across. */
                          font: "inherit",
                          color: "inherit",
                          lineHeight: "inherit",
                          letterSpacing: "inherit",
                        }}
                        title={_tip("Click to edit amount · Enter to save · Esc to cancel")}
                      >{m.amount.toLocaleString()}</span>
                      {" "}
                      <span className="sched-milestone-pct">({m.pct}%)</span>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
          )}
        </>
      )}

      {moveFor && <ScheduleMoveModal schedule={schedule} phaseId={moveFor} onClose={() => setMoveFor(null)}/>}
      {!_schedPF && addKind && <ScheduleAddModal kind={addKind} schedule={schedule} onClose={() => setAddKind(null)}/>}
      {openEvent && (() => {
        // v07zz64 — Hugo: "meeting cards pop up behavoiur not
        // consistent with other pop ups, looks like only the mid
        // section is getting blurred/darkend, should match other pop
        // up behaviour." The old .evt-modal-backdrop wasn't a true
        // viewport-covering backdrop (it sat inside the schedule
        // section's stacking context). Switching to the shared
        // .modal-backdrop class via a portal at #modal-root gives
        // the same full-viewport dim + blur as every other modal in
        // the app.
        const portalRoot = document.getElementById("modal-root") || document.body;
        return ReactDOM.createPortal((
        <div className="modal-backdrop" onClick={() => setOpenEvent(null)}>
          <div className="evt-modal glass" onClick={(e) => e.stopPropagation()}>
            {/* v07zz59 — Reuse the standard .modal-close-btn so the
                X matches the rest of the app's modals (the legacy
                .evt-modal-close was hand-styled and stuck out). */}
            <button className="modal-close-btn" onClick={() => setOpenEvent(null)} aria-label="Close">
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
            </button>
            <div className="evt-modal-eyebrow">{(openEvent.type || "event").toUpperCase()}</div>
            <div className="evt-modal-title">{openEvent.label}</div>
            <div className="evt-modal-meta">
              <span className="evt-modal-date">{new Date(openEvent.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}</span>
              {openEvent.lead && <span className="evt-modal-lead">Lead: {openEvent.lead}</span>}
            </div>
            <div className="evt-modal-body">{openEvent.summary || openEvent.description || "No summary available."}</div>
            {/* v07zz59 — Join link for ICS events that carry a URL
                (auto-extracted by parseICS — Meet / Zoom / Teams). */}
            {openEvent.url && (
              <a href={openEvent.url}
                 target="_blank" rel="noreferrer noopener"
                 className="evt-modal-join">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{marginRight: 8, verticalAlign: -2}}>
                  <path d="M10 14a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07l-1 1"/><path d="M14 10a5 5 0 0 0-7.07 0l-3 3a5 5 0 1 0 7.07 7.07l1-1"/>
                </svg>
                Join meeting ↗
              </a>
            )}
          </div>
        </div>
        ), portalRoot);
      })()}
      {/* v07zz62 — Hugo: "i like what you had before but it didnt pop
          up ABOVE the calendar, it was behind the lines. Revert to
          that placement but update the fact that the pop up is over
          everything and not getting hidden behind lines or other
          things." So: render the popover as a small portal anchored
          to the cell's viewport rect with position:fixed and a high
          z-index that puts it above the phase bars layer + event
          chips. No fullscreen modal backdrop. Click outside (the
          transparent overlay) closes. */}
      {addOnDate && addAnchor && (() => {
        const portalRoot = document.getElementById("modal-root") || document.body;
        // Anchor at cell's top-right corner, with a small offset so
        // it doesn't cover the + button itself. Clamp to viewport so
        // the popover never goes off-screen.
        const W = 220, H = 170;
        let left = addAnchor.right - W + 6;
        if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
        if (left < 8) left = 8;
        let top = addAnchor.top + 28;
        if (top + H > window.innerHeight - 8) top = addAnchor.top - H - 6;
        if (top < 8) top = 8;
        return ReactDOM.createPortal((
          <>
            {/* Invisible overlay that captures click-outside-to-close
                without dimming the calendar behind it. */}
            <div
              onClick={() => setAddOnDate(null)}
              style={{position:"fixed", inset:0, background:"transparent", zIndex: "var(--z-crop)"}}
            />
            <div
              className="big-cal-add-popover"
              onClick={(e) => e.stopPropagation()}
              style={{position:"fixed", top, left, width: W, zIndex: "var(--z-crop-panel)", margin: 0}}
            >
              {/* v07zz66 — Calendar event creator (NOT a payment
                  milestone). Amount field removed; events have no
                  cost field. */}
              <div className="big-cal-add-head">New event</div>
              <input
                type="text"
                className="big-cal-add-input"
                placeholder={_schedPF ? "e.g. Columbus approval" : "e.g. Artwork approval"}
                value={addName}
                autoFocus
                onChange={e => { setAddName(e.target.value); setAddErr(""); }}
                onKeyDown={e => {
                  if (e.key === "Enter") submitAddMilestone(addOnDate);
                  if (e.key === "Escape") setAddOnDate(null);
                }}
              />
              {/* 24 Sep 2026 (G7) — always there, so the popover never grows when an error shows (#20). */}
              <div className="big-cal-add-err" role="status" style={{ minHeight: "1.3em", fontSize: "var(--fs-xs)", lineHeight: 1.3, color: "var(--danger-soft)" }}>{addErr}</div>
              <div className="big-cal-add-actions">
                <button type="button" className="big-cal-add-cancel" onClick={() => setAddOnDate(null)}>Cancel</button>
                <button type="button" className="big-cal-add-save" disabled={!addName.trim() || adding} onClick={() => submitAddMilestone(addOnDate)}>
                  {adding ? "Saving…" : "Add"}
                </button>
              </div>
            </div>
          </>
        ), portalRoot);
      })()}
    </section>
  );
}

/* ─────────────────────────── ASSETS ─────────────────────────── */

// v07zn — Per-category asset creation modal. Each kind gets a
// tailored form: characters have name + role + bio; animals have
// name + latin name; locations have name + scenes; props have
// name + type; refs have name + type. Submission hits
// POST /api/assets/create which creates the disk folder + appends
// to data/assets.json. Returns the created asset to the caller so
// the view can splice it in without a full reload.
const ASSET_KIND_LABELS = {
  character: { title: "Character", placeholder: "e.g. Mark Twain" },
  animal:    { title: "Animal",    placeholder: "e.g. Monarch Butterfly" },
  location:  { title: "Location",  placeholder: "e.g. Missouri Farm" },
  prop:      { title: "Prop",      placeholder: "e.g. Steam Engine" },
  ref:       { title: "Reference", placeholder: "e.g. Costume study v3" },
  music:     { title: "Music track", placeholder: "e.g. Sequence 02 score" },
  vo:        { title: "VO clip",   placeholder: "e.g. Narrator intro" },
};

function CreateAssetModal({ kind, onClose, onCreated }) {
  // 15 Sep 2026 — on a templated project `kind` is one of the project's own
  // category ids (characters / instruments / wardrobe …): title from the
  // category label, a neutral placeholder, and the category's declared fields
  // (row.asset_categories[].fields when the server sends them) instead of the
  // film-doc role / latin / scenes / type inputs. Paradise Found keeps its
  // labels, placeholders and fields exactly as before.
  const _isPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _cat = (!_isPF && window.__projectCategories) ? (window.__projectCategories().find(c => c.id === kind) || null) : null;
  const _catFields = _cat && Array.isArray(_cat.fields) ? _cat.fields.filter(f => f && f.key && f.key !== "notes" && f.key !== "name") : [];
  const [extra, setExtra] = React.useState({});   // templated project: { [field.key]: value }
  const label = _cat
    ? { title: (window.__projectCategoryLabel ? window.__projectCategoryLabel(kind, true) : _cat.label), placeholder: "" }
    : (ASSET_KIND_LABELS[kind] || { title: "Asset", placeholder: "" });
  const [name, setName] = React.useState("");
  // Category-specific fields. We collect them all up front; the
  // server only persists the ones it recognises.
  const [role, setRole] = React.useState("");        // characters
  const [latin, setLatin] = React.useState("");      // animals
  const [scenes, setScenes] = React.useState("");    // locations
  const [type, setType] = React.useState("");        // props / refs
  const [notes, setNotes] = React.useState("");      // all
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  // v07zq — AI suggest state.
  const [suggesting, setSuggesting] = React.useState(false);
  const [suggestErr, setSuggestErr] = React.useState(null);

  const portalRoot = document.getElementById("modal-root") || document.body;

  // v07zq — Call /api/assets/suggest-fields with the current name and
  // populate any empty form fields with the model's suggestions.
  // Fields the user has already typed are left alone — we don't
  // clobber their input.
  const suggestWithAI = async () => {
    const trimmed = name.trim();
    if (!trimmed || suggesting) return;
    setSuggesting(true); setSuggestErr(null);
    const fetcher = window.authFetch || fetch;
    const PLURAL = {
      character: "characters", animal: "animals", location: "locations",
      prop: "props", ref: "refs", music: "music", vo: "vo",
    };
    const serverCategory = _cat ? kind : (PLURAL[kind] || kind);
    // v07zr — Hugo: first call almost always returned "Error
    // fetching" (likely a Railway proxy / Gemini cold-start
    // timeout). Retry once silently before surfacing the error.
    const callOnce = async () => {
      const r = await fetcher("/api/assets/suggest-fields", {
        method: "POST",
        body: JSON.stringify({ category: serverCategory, name: trimmed }),
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok || !body.ok) throw new Error(body.error || `HTTP ${r.status}`);
      return body.fields || {};
    };
    try {
      let f;
      try {
        f = await callOnce();
      } catch (firstErr) {
        // One quiet retry. Gemini Flash cold-start can land >5 s
        // the first time after deploy; a second call almost always
        // succeeds quickly. If the second one fails too we surface
        // the second error so the user sees the real cause.
        await new Promise(r => setTimeout(r, 500));
        f = await callOnce();
      }
      // Only overwrite empty fields — respect anything Hugo already typed.
      if (_cat) {
        // 15 Sep 2026 — templated project: fill whichever declared fields came back empty.
        setExtra(prev => {
          const next = { ...prev };
          for (const fd of _catFields) { const v = f[fd.key]; if (typeof v === "string" && v && !String(next[fd.key] || "").trim()) next[fd.key] = v; }
          return next;
        });
      }
      if (kind === "character" && !role.trim()  && f.role)   setRole(f.role);
      if (kind === "animal"    && !latin.trim() && f.latin)  setLatin(f.latin);
      if (kind === "location"  && !scenes.trim()&& f.scenes) setScenes(f.scenes);
      if ((kind === "prop" || kind === "ref") && !type.trim() && f.type) setType(f.type);
      if (!notes.trim() && f.notes) setNotes(f.notes);
    } catch (e) {
      setSuggestErr(e.message);
    } finally {
      setSuggesting(false);
    }
  };

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [onClose]);

  const submit = async (e) => {
    e && e.preventDefault();
    const trimmed = name.trim();
    if (!trimmed || busy) return;
    setBusy(true); setErr(null);
    const fetcher = window.authFetch || fetch;
    // v07zn — Server endpoint expects PLURAL category names; map.
    const PLURAL = {
      character: "characters", animal: "animals", location: "locations",
      prop: "props", ref: "refs", music: "music", vo: "vo",
    };
    const serverCategory = _cat ? kind : (PLURAL[kind] || kind);
    // v07zp — Send ALL category-specific fields so the server can
    // persist them into data/assets.json. Previously the modal
    // patched fields locally only, so they vanished on reload.
    const extraFields = {};
    // 15 Sep 2026 — templated project: the category's declared fields, as typed.
    if (_cat) for (const fd of _catFields) { const v = String(extra[fd.key] || "").trim(); if (v) extraFields[fd.key] = v; }
    if (kind === "character") extraFields.role = role.trim();
    if (kind === "animal")    extraFields.latin = latin.trim();
    if (kind === "location")  extraFields.scenes = scenes.trim();
    if (kind === "prop")      extraFields.type = type.trim();
    if (kind === "ref")       extraFields.type = type.trim();
    if (notes.trim())         extraFields.notes = notes.trim();
    try {
      const r = await fetcher("/api/assets/create", {
        method: "POST",
        body: JSON.stringify({
          category: serverCategory,
          name: trimmed,
          fields: extraFields,
        }),
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok || !body.ok) throw new Error(body.error || `HTTP ${r.status}`);
      // Patch any fields the server didn't echo back, so the card
      // renders the values the user typed immediately.
      const created = { ...body.asset, ...extraFields };
      onCreated && onCreated(created);
    } catch (e2) {
      setErr(e2.message);
      setBusy(false);
    }
  };

  /* v07zu — Same drag-cancel fix as EditAssetModal. */
  const _backdropDownRefCreate = React.useRef(false);
  const onBackdropMouseDownCreate = (e) => {
    _backdropDownRefCreate.current = (e.target === e.currentTarget);
  };
  const onBackdropClickCreate = (e) => {
    if (_backdropDownRefCreate.current && e.target === e.currentTarget) onClose();
    _backdropDownRefCreate.current = false;
  };

  return ReactDOM.createPortal((
    <div
      className="modal-backdrop"
      onMouseDown={onBackdropMouseDownCreate}
      onClick={onBackdropClickCreate}
    >
      <div
        className="create-asset-modal glass"
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-modal="true"
      >
        <button className="modal-close-btn" aria-label="Close" onClick={onClose}>×</button>
        <header className="create-asset-head">
          <div className="create-asset-eyebrow">NEW {label.title.toUpperCase()}</div>
          <div className="create-asset-title">Create {label.title.toLowerCase()}</div>
          <div className="create-asset-sub">
            {_cat ? (
              <React.Fragment>Registers it in this project's <code>{_cat.label.toLowerCase()}</code> catalog. Add reference images to it afterwards.</React.Fragment>
            ) : (
              <React.Fragment>
                Adds a folder under <code>WATCH_PATH/work/episodes/&lt;ep&gt;/assets/{kind === "ref" ? "refs" : kind + "s"}/&lt;slug&gt;/</code>
                {" "}and registers it in the catalog. Drop reference files into the folder afterwards.
              </React.Fragment>
            )}
          </div>
        </header>

        <form className="create-asset-form" onSubmit={submit}>
          <label className="create-asset-field">
            <span className="create-asset-label">Name</span>
            <input
              type="text"
              autoFocus
              placeholder={label.placeholder}
              value={name}
              onChange={(e) => setName(e.target.value)}
              disabled={busy}
              maxLength={120}
            />
          </label>

          {/* v07zq — Suggest with AI. Pulls role / latin / scenes /
              type / notes from Gemini Flash based on the name + the
              project context. Only fills empty fields, so the user's
              own input is never clobbered. */}
          <div className="create-asset-suggest-row">
            <button
              type="button"
              className="create-asset-suggest-btn"
              onClick={suggestWithAI}
              disabled={!name.trim() || suggesting || busy}
              title={!name.trim() ? "Type a name first" : "Auto-fill empty fields with AI suggestions"}
            >
              <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M9 11l3-7 3 7 7 3-7 3-3 7-3-7-7-3z"/>
              </svg>
              {suggesting ? "Asking…" : "Suggest with AI"}
            </button>
            {suggestErr && <span className="create-asset-suggest-err">{suggestErr}</span>}
          </div>

          {kind === "character" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Role</span>
              <input
                type="text"
                placeholder="e.g. Narrator, Antagonist"
                value={role}
                onChange={(e) => setRole(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
          )}

          {kind === "animal" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Latin name <span className="create-asset-hint">(optional)</span></span>
              <input
                type="text"
                placeholder="e.g. Danaus plexippus"
                value={latin}
                onChange={(e) => setLatin(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
          )}

          {kind === "location" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Scenes / sequences <span className="create-asset-hint">(optional)</span></span>
              <input
                type="text"
                placeholder="e.g. Sequence 02"
                value={scenes}
                onChange={(e) => setScenes(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
          )}

          {(kind === "prop" || kind === "ref") && (
            <label className="create-asset-field">
              <span className="create-asset-label">Type <span className="create-asset-hint">(optional)</span></span>
              <input
                type="text"
                placeholder={kind === "prop" ? "e.g. Tool, Vessel, Weapon" : "e.g. Visual style, Costume, Set"}
                value={type}
                onChange={(e) => setType(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
          )}

          {/* 15 Sep 2026 — templated project: the category's declared fields (text → input,
              longtext → textarea). Nothing here for Paradise Found. */}
          {_cat && _catFields.map(fd => (
            <label key={fd.key} className="create-asset-field">
              <span className="create-asset-label">{fd.label || fd.key} <span className="create-asset-hint">(optional)</span></span>
              {fd.type === "longtext" ? (
                <textarea rows={3} value={extra[fd.key] || ""} onChange={(e) => setExtra(prev => ({ ...prev, [fd.key]: e.target.value }))} disabled={busy} maxLength={1000} />
              ) : (
                <input type="text" value={extra[fd.key] || ""} onChange={(e) => setExtra(prev => ({ ...prev, [fd.key]: e.target.value }))} disabled={busy} maxLength={120} />
              )}
            </label>
          ))}

          <label className="create-asset-field">
            <span className="create-asset-label">Notes <span className="create-asset-hint">(optional)</span></span>
            <textarea
              rows={3}
              placeholder="Description / reference notes / casting notes…"
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
              disabled={busy}
              maxLength={1000}
            />
          </label>

          {err && <div className="create-asset-err">{err}</div>}

          <div className="create-asset-actions">
            <button type="button" className="create-asset-cancel" onClick={onClose} disabled={busy}>Cancel</button>
            <button type="submit" className="create-asset-submit" disabled={!name.trim() || busy}>
              {busy ? "Creating…" : `Create ${label.title.toLowerCase()}`}
            </button>
          </div>
        </form>
      </div>
    </div>
  ), portalRoot);
}
// v07zz185 — Export so the Generate page can open the SAME create-asset
// popup inline (Hugo: "Add new asset" must pop up here, not navigate away).
window.CreateAssetModal = CreateAssetModal;

/* v07zw — Styled confirmation modal for archiving an asset. Same
   visual treatment as ConfirmDeleteAssetModal but with archive-
   appropriate copy. No "type DELETE" gate since archive is
   reversible — one click confirm. Portal-mounted so it floats
   above the parent asset modal cleanly. */
function ConfirmArchiveAssetModal({ kind, asset, busy, onCancel, onConfirm }) {
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape" && !busy) onCancel();
      if (e.key === "Enter" && !busy) onConfirm();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [busy, onCancel, onConfirm]);
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal)" }} onClick={() => !busy && onCancel()}>
      <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
        <div className="confirm-delete-eyebrow" style={{ color: "var(--warn-deep)" }}>ARCHIVE {kind.toUpperCase()} · REVERSIBLE</div>
        <div className="confirm-delete-title">Archive <strong>{asset.name}</strong>?</div>
        <div className="confirm-delete-body">
          <p>This {kind} will be hidden from the Assets page immediately.</p>
          <ul className="confirm-delete-list">
            <li><strong>Name:</strong> {asset.name}</li>
            <li><strong>Kind:</strong> {kind}</li>
          </ul>
          <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> An admin can restore it any time from
            <em> Admin → Archived Assets</em>, or permanently delete it from there.
          </p>
        </div>
        <div className="confirm-delete-actions">
          <button type="button" className="admin-suspend-btn" onClick={onCancel} disabled={busy}>Cancel</button>
          <button
            type="button"
            className="confirm-delete-btn"
            style={{ background: "color-mix(in srgb, var(--amber-2) 95%, transparent)", borderColor: "color-mix(in srgb, var(--amber-2-line) 85%, transparent)", color: "var(--ink-on-amber)" }}
            onClick={onConfirm}
            disabled={busy}
          >{busy ? "Archiving…" : "Archive"}</button>
        </div>
      </div>
    </div>
  ), portalRoot);
}

// v07zz190 — Styled confirm for deleting a single gallery image. Matches the
// other delete/archive confirm modals (replaces the ugly native window.confirm
// Hugo flagged). Portal-mounted above the asset modal.
function ConfirmDeleteImageModal({ name, busy, onCancel, onConfirm }) {
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape" && !busy) onCancel();
      if (e.key === "Enter" && !busy) onConfirm();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [busy, onCancel, onConfirm]);
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }} onClick={() => !busy && onCancel()}>
      <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
        <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>REMOVE FROM ASSET</div>
        <div className="confirm-delete-title">Remove this image{name ? <> from <strong>{name}</strong></> : ""}?</div>
        <div className="confirm-delete-body">
          <p>The image is removed from the asset, but its working copy is kept in <strong>WIP</strong> — nothing is deleted from disk, and you can re-promote it later.</p>
        </div>
        <div className="confirm-delete-actions">
          <button type="button" className="admin-suspend-btn" onClick={onCancel} disabled={busy}>Cancel</button>
          <button type="button" className="confirm-delete-btn" onClick={onConfirm} disabled={busy}>{busy ? "Removing…" : "Remove"}</button>
        </div>
      </div>
    </div>
  ), portalRoot);
}
window.ConfirmDeleteImageModal = ConfirmDeleteImageModal;

// Archived-images panel for an asset (character / animal / location / prop).
// Lists soft-deleted gallery images (asset_references is_wip=2) and lets you
// restore them back into the asset, all inside the asset modal. Rendered as an
// OVERLAY portal so toggling it never shifts the modal layout (invariant #20).
// Restore is a soft flip (is_wip → 0/1) server-side — never resurrects deletes.
function AssetArchivedPanel({ cat, slug, name, onClose, onRestored }) {
  const [items, setItems] = React.useState(null);   // null = loading
  const [busyId, setBusyId] = React.useState(null);
  const fetcher = window.authFetch || fetch;
  const base = `/api/assets/${cat}/${encodeURIComponent(slug)}`;
  React.useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = await fetcher(`${base}/archived`);
        const j = await r.json().catch(() => ({}));
        if (alive) setItems(Array.isArray(j.items) ? j.items : []);
      } catch (_) { if (alive) setItems([]); }
    })();
    return () => { alive = false; };
  }, [base]);   // eslint-disable-line react-hooks/exhaustive-deps
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  const restore = async (it) => {
    if (busyId) return;
    setBusyId(it.id);
    try {
      const r = await fetcher(`${base}/restore-reference`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: it.id }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setItems(prev => (prev || []).filter(x => x.id !== it.id));
      if (onRestored) onRestored();
    } catch (e) { console.warn("[restore-reference]", e.message); }
    finally { setBusyId(null); }
  };
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }} onClick={onClose}>
      <div className="asset-archived-modal glass" onClick={(e) => e.stopPropagation()}>
        <div className="asset-archived-head">
          <div>
            <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>ARCHIVED IMAGES</div>
            <div className="confirm-delete-title">{name ? <>Archived — <strong>{name}</strong></> : "Archived images"}</div>
          </div>
          <button type="button" className="asset-archived-x" onClick={onClose} aria-label="Close">×</button>
        </div>
        <div className="asset-archived-body">
          {items === null ? <div className="asset-archived-empty">Loading…</div>
            : items.length === 0 ? <div className="asset-archived-empty">No archived images for this asset.</div>
            : <div className="asset-archived-grid">
                {items.map(it => (
                  <div key={it.id} className="asset-archived-tile">
                    <div className="asset-archived-thumb" style={{ backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(it.url, 240) : it.url})` }} title={it.filename || ""} />
                    <button type="button" className="asset-archived-restore" disabled={busyId === it.id} onClick={() => restore(it)}>
                      {busyId === it.id ? "Restoring…" : "↩ Restore"}
                    </button>
                  </div>
                ))}
              </div>}
        </div>
      </div>
    </div>
  ), portalRoot);
}
window.AssetArchivedPanel = AssetArchivedPanel;

// v07zr — Edit existing asset's text fields. Same layout as the
// CreateAssetModal so the user is in familiar territory, but
// pre-populated with the current values and submits via PATCH to
// /api/assets/:kind/:id/fields. Opens from the Edit button in the
// top-right of each asset modal's header.
function EditAssetModal({ kind, asset, onClose, onSaved }) {
  // 24 Sep 2026 (G5) — a project category (Trope's characters, instruments ...): its singular label
  // ("Edit character") and the fields it declares (role, wardrobe, played_by ...), as CreateAssetModal
  // shows them. Paradise Found keeps its labels and fields exactly as before.
  const _isPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _cat = (!_isPF && window.__projectCategories) ? (window.__projectCategories().find(c => c.id === kind) || null) : null;
  const _catFields = _cat && Array.isArray(_cat.fields) ? _cat.fields.filter(f => f && f.key && f.key !== "notes" && f.key !== "name") : [];
  const [extra, setExtra] = React.useState(() => {
    const o = {};
    for (const f of _catFields) o[f.key] = (asset && typeof asset[f.key] === "string") ? asset[f.key] : "";
    return o;
  });
  const label = _cat
    ? { title: (window.__projectCategoryLabel ? window.__projectCategoryLabel(kind, true) : _cat.label) }
    : (ASSET_KIND_LABELS[kind] || { title: "Asset" });
  const [name, setName]     = React.useState(asset.name || "");
  const [role, setRole]     = React.useState(asset.role || "");
  const [latin, setLatin]   = React.useState(asset.latin || "");
  const [scenes, setScenes] = React.useState(asset.scenes || "");
  const [type, setType]     = React.useState(asset.type || "");
  const [notes, setNotes]   = React.useState(asset.notes || "");
  const [busy, setBusy]     = React.useState(false);
  const [err, setErr]       = React.useState(null);
  const portalRoot = document.getElementById("modal-root") || document.body;

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [onClose]);

  const submit = async (e) => {
    e && e.preventDefault();
    if (busy) return;
    setBusy(true); setErr(null);
    const id = asset.id || asset.slug || asset.folder;
    const fetcher = window.authFetch || fetch;
    const fields = { name: name.trim() };
    if (kind === "character") fields.role = role.trim();
    if (kind === "animal")    fields.latin = latin.trim();
    if (kind === "location")  fields.scenes = scenes.trim();
    if (kind === "prop")      fields.type = type.trim();
    if (kind === "ref")       fields.type = type.trim();
    fields.notes = notes.trim();
    if (_cat) for (const f of _catFields) fields[f.key] = String(extra[f.key] || "").trim();   // 24 Sep 2026 (G5)
    try {
      const r = await fetcher(`/api/assets/${kind}/${encodeURIComponent(id)}/fields`, {
        method: "PATCH",
        body: JSON.stringify({ fields }),
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok || !body.ok) throw new Error(body.error || `HTTP ${r.status}`);
      // Mutate window.__appData.assets in place so the underlying
      // card / modal stays in sync without a reload.
      try {
        const PLURAL = {
          character: "characters", animal: "animals", location: "locations",
          prop: "props", ref: "refs", music: "music", vo: "vo",
        };
        const k = assetKindCategory(kind);
        if (k && window.__appData && Array.isArray(window.__appData.assets[k])) {
          const arr = window.__appData.assets[k];
          const i = arr.findIndex(a => a.id === id || a.slug === id || a.folder === id);
          if (i >= 0) arr[i] = { ...arr[i], ...body.asset };
        }
        window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed"));
      } catch (_) {}
      onSaved && onSaved(body.asset);
    } catch (e2) {
      setErr(e2.message);
      setBusy(false);
    }
  };

  /* v07zu — Drag-cancel fix. Hugo: selecting text inside an input
     and releasing OUTSIDE the modal would close it because the
     backdrop's onClick fired. Track the mousedown target — only
     close if BOTH mousedown AND mouseup landed on the backdrop. */
  const _backdropDownRef = React.useRef(false);
  const onBackdropMouseDown = (e) => {
    _backdropDownRef.current = (e.target === e.currentTarget);
  };
  const onBackdropClick = (e) => {
    if (_backdropDownRef.current && e.target === e.currentTarget) onClose();
    _backdropDownRef.current = false;
  };

  return ReactDOM.createPortal((
    /* v07zt — Render the edit modal ABOVE the parent asset modal.
       .char-modal-backdrop has z-index: 1000; .modal-backdrop only
       has 300, so without an override the edit modal opens behind
       its parent and is invisible/unclickable. */
    <div
      className="modal-backdrop modal-backdrop--above-asset"
      style={{ zIndex: "var(--z-menu-backdrop)" }}
      onMouseDown={onBackdropMouseDown}
      onClick={onBackdropClick}
    >
      <div
        className="create-asset-modal glass"
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-modal="true"
      >
        <button className="modal-close-btn" aria-label="Close" onClick={onClose}>×</button>
        <header className="create-asset-head">
          <div className="create-asset-eyebrow">EDIT {label.title.toUpperCase()}</div>
          <div className="create-asset-title">Edit {label.title.toLowerCase()}</div>
        </header>
        <form className="create-asset-form" onSubmit={submit}>
          <label className="create-asset-field">
            <span className="create-asset-label">Name</span>
            <input type="text" value={name} onChange={(e) => setName(e.target.value)} disabled={busy} maxLength={120}/>
          </label>
          {kind === "character" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Role</span>
              <input type="text" value={role} onChange={(e) => setRole(e.target.value)} disabled={busy} maxLength={120}/>
            </label>
          )}
          {kind === "animal" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Latin name</span>
              <input type="text" value={latin} onChange={(e) => setLatin(e.target.value)} disabled={busy} maxLength={120}/>
            </label>
          )}
          {kind === "location" && (
            <label className="create-asset-field">
              <span className="create-asset-label">Scenes / sequences</span>
              <input type="text" value={scenes} onChange={(e) => setScenes(e.target.value)} disabled={busy} maxLength={120}/>
            </label>
          )}
          {(kind === "prop" || kind === "ref") && (
            <label className="create-asset-field">
              <span className="create-asset-label">Type</span>
              <input type="text" value={type} onChange={(e) => setType(e.target.value)} disabled={busy} maxLength={120}/>
            </label>
          )}
          {/* 24 Sep 2026 (G5) — a project category's declared fields (text → input, longtext → textarea). */}
          {_cat && _catFields.map(fd => (
            <label key={fd.key} className="create-asset-field">
              <span className="create-asset-label">{fd.label || fd.key}</span>
              {fd.type === "longtext" ? (
                <textarea rows={3} value={extra[fd.key] || ""} onChange={(e) => setExtra(prev => ({ ...prev, [fd.key]: e.target.value }))} disabled={busy} maxLength={1000}/>
              ) : (
                <input type="text" value={extra[fd.key] || ""} onChange={(e) => setExtra(prev => ({ ...prev, [fd.key]: e.target.value }))} disabled={busy} maxLength={120}/>
              )}
            </label>
          ))}
          <label className="create-asset-field">
            <span className="create-asset-label">Notes</span>
            <textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} disabled={busy} maxLength={1000}/>
          </label>
          {err && <div className="create-asset-err">{err}</div>}
          <div className="create-asset-actions">
            <button type="button" className="create-asset-cancel" onClick={onClose} disabled={busy}>Cancel</button>
            {/* v07zw — Archive button removed from this modal. The
                corner-icon archive button on the asset detail modal
                is now the primary path + uses the styled
                ConfirmArchiveAssetModal. Keeping it here would
                duplicate the action and split the UX. */}
            <button type="submit" className="create-asset-submit" disabled={!name.trim() || busy}>
              {busy ? "Saving…" : "Save changes"}
            </button>
          </div>
        </form>
      </div>
    </div>
  ), portalRoot);
}

// v07ze — App-root-mounted asset modal. Opens from anywhere via
// window.__openAssetItem(kind, id) — mirrors how openShot opens a
// shot modal as an overlay regardless of the current view. This is
// what notification clicks for asset entities use so the user
// doesn't have to navigate to the Assets page first.
function GlobalAssetModal() {
  const [openItem, setOpenItem]           = React.useState(null); // {kind, item}
  const [openCharacterId, setOpenCharId]  = React.useState(null);

  const resolve = React.useCallback((kind, id) => {
    if (!id) return null;
    const data = (window.__appData && window.__appData.assets) || {};
    const PLURAL = {
      character: "characters", animal: "animals", location: "locations",
      prop: "props", ref: "refs",
    };
    const key = assetKindCategory(kind);
    if (!key) return null;
    const pool = Array.isArray(data[key]) ? data[key] : [];
    return pool.find(a =>
      a.id === id || a.slug === id || a.folder === id || a.name === id,
    ) || null;
  }, []);

  React.useEffect(() => {
    const open = (kind, id) => {
      if (kind === "character") {
        setOpenItem(null);
        setOpenCharId(id);
        return;
      }
      const found = resolve(kind, id);
      if (found) { setOpenItem({ kind, item: found }); setOpenCharId(null); }
    };
    window.__openAssetItem = open;
    const evtHandler = (e) => {
      const d = (e && e.detail) || {};
      if (d && d.kind && d.id) open(d.kind, d.id);
    };
    window.addEventListener("paradise-open-asset-modal", evtHandler);
    return () => {
      if (window.__openAssetItem === open) delete window.__openAssetItem;
      window.removeEventListener("paradise-open-asset-modal", evtHandler);
    };
  }, [resolve]);

  const character = openCharacterId
    ? (((window.__appData && window.__appData.assets && window.__appData.assets.characters) || [])
        .find(c => c.id === openCharacterId) || null)
    : null;

  return (
    <>
      {openItem && (
        <AssetItemModal
          key={(openItem.item && (openItem.item.id || openItem.item.slug || openItem.item.folder || openItem.item.name)) || "no-item"}
          kind={openItem.kind}
          item={openItem.item}
          onClose={() => setOpenItem(null)}
        />
      )}
      {openCharacterId && character && (
        <CharacterDetailModal
          character={character}
          onClose={() => setOpenCharId(null)}
        />
      )}
    </>
  );
}

// v07zw — Hoisted so the arrow-nav handler can iterate through it
// when assets.animals is empty (fallback list used by the Animals tab).
const PARADISE_ANIMALS = [
  { slug: "passenger-pigeons",     name: "Passenger Pigeon",      latin: "Ectopistes migratorius" },
  { slug: "eskimo-curlews",        name: "Eskimo Curlew",         latin: "Numenius borealis" },
  { slug: "monarch-butterflies",   name: "Monarch Butterfly",     latin: "Danaus plexippus" },
  { slug: "american-bison",        name: "American Bison",        latin: "Bison bison" },
  { slug: "green-turtles",         name: "Green Turtle",          latin: "Chelonia mydas" },
  { slug: "atlantic-salmon",       name: "Atlantic Salmon",       latin: "Salmo salar" },
  { slug: "queen-conch",           name: "Queen Conch",           latin: "Aliger gigas" },
  { slug: "lightning-whelks",      name: "Lightning Whelk",       latin: "Sinistrofulgur perversum" },
  { slug: "herons-egrets-ibises",  name: "Wading bird colony",    latin: "Mixed species" },
  { slug: "chickens",              name: "Chicken",               latin: "Gallus gallus domesticus" },
];

// v07zz460 — Generic drag-reorder for the asset cards on any category tab
// (characters / animals / locations / props). Keyed to one category at a time
// (the active tab). The chosen order is applied optimistically (override) and
// POSTed to /api/assets/:cat/reorder, which stamps sort_order + updated_at on the
// assets.json rows so it wins the bisync last-write-wins; GET /api/assets sorts
// every category by sort_order. Returns:
//   ordered          — the list in the current (custom) order
//   cardProps(item)  — spread onto the card <article>: HTML5 drag handlers
//   cls(item)        — extra className: " asset-card--dragging" / " asset-card--drop"
// A plain click still fires the card's own onClick (open modal); only a drag reorders.
function useAssetReorder(category, list) {
  const idOf = (a) => String((a && (a.id || a.slug || a.folder || a.name)) || "");
  const [dragId, setDragId] = React.useState(null);
  const [dropId, setDropId] = React.useState(null);
  const [override, setOverride] = React.useState(null);
  // Each category has its own order — drop the optimistic override when the tab changes
  // (reloadAppData has already persisted it, so the server order shows on the new tab).
  React.useEffect(() => { setOverride(null); setDragId(null); setDropId(null); }, [category]);
  const arr = Array.isArray(list) ? list : [];
  const ordered = React.useMemo(() => {
    if (!override) return arr;
    const byId = new Map(arr.map(a => [idOf(a), a]));
    const out = [];
    for (const id of override) { const a = byId.get(id); if (a) { out.push(a); byId.delete(id); } }
    for (const a of arr) if (byId.has(idOf(a))) out.push(a);   // items added since the override
    return out;
  }, [arr, override]);
  const persist = React.useCallback((ids) => {
    if (!category) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/assets/${encodeURIComponent(category)}/reorder`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ order: ids }),
    })
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
      .then(() => { try { window.reloadAppData && window.reloadAppData(); } catch (_) {} })
      .catch(() => setOverride(null));   // revert optimistic order on failure
  }, [category]);
  const onDrop = React.useCallback((targetId) => {
    setDragId(cur => {
      if (!cur || cur === targetId) return null;
      const ids = ordered.map(idOf);
      const from = ids.indexOf(cur);
      if (from < 0) return null;
      ids.splice(from, 1);
      const at = ids.indexOf(targetId);
      ids.splice(at < 0 ? ids.length : at, 0, cur);
      setOverride(ids);
      persist(ids);
      return null;
    });
    setDropId(null);
  }, [ordered, persist]);
  const cardProps = (item) => {
    const id = idOf(item);
    return {
      draggable: true,
      onDragStart: (e) => { setDragId(id); try { e.dataTransfer.effectAllowed = "move"; } catch (_) {} },
      onDragOver: (e) => { e.preventDefault(); if (dragId && dropId !== id) setDropId(id); },
      onDragLeave: () => setDropId(cur => (cur === id ? null : cur)),
      onDrop: (e) => { e.preventDefault(); onDrop(id); },
      onDragEnd: () => { setDragId(null); setDropId(null); },
    };
  };
  const cls = (item) => {
    const id = idOf(item);
    return (dragId === id ? " asset-card--dragging" : "") + (dropId === id && dragId && dragId !== id ? " asset-card--drop" : "");
  };
  return { ordered, cardProps, cls };
}

// v819 — "put me back where I was" on the Assets page. Hugo: switching between
// Locations / Props and back dumped him at the top of the grid instead of on the
// asset he'd just been in, and leaving the page reset the tab to Characters.
// Both the active tab and the last asset opened PER TAB persist. localStorage
// (not module state) so it survives a reload too, and per-tab so Props remembers
// its own place independently of Locations.
const _ASSETS_TAB_KEY = "filmtracker.assetsTab";
const _ASSETS_LAST_OPEN_KEY = "filmtracker.assetsLastOpen";
function _assetsReadLastOpen() {
  try {
    const o = JSON.parse(localStorage.getItem(_ASSETS_LAST_OPEN_KEY) || "{}");
    return (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
  } catch (_) { return {}; }
}
function _assetsRememberOpen(tab, id) {
  if (!tab || !id) return;
  try {
    const o = _assetsReadLastOpen();
    if (o[tab] === String(id)) return;
    o[tab] = String(id);
    localStorage.setItem(_ASSETS_LAST_OPEN_KEY, JSON.stringify(o));
  } catch (_) {}
}
function AssetsView({ assets: rawAssets }) {
  // v01k — single-level category tabs. The t07b outer media-type tabs
  // (Images / Video / VO·Narration / Music) are dropped; VO and Music
  // are now siblings of Characters/Locations/Props/Refs/Archival.
  // v07zw — Filter out archived items from every category so the
  // standard tabs only show "live" assets. Producers archive via the
  // asset-detail modal; admins restore or hard-delete from
  // Admin → Archived Assets.
  // v821 — ALWAYS hand the render a full shape. The tab row below reads
  // assets.characters.length / .locations.length / .props.length unguarded, so a
  // payload missing any of them took the whole app down with "Cannot read
  // properties of undefined (reading 'length')" — and because that payload is
  // cached in sessionStorage, the crash card's Reload button replayed it forever.
  // App.jsx now normalises at the source too; this is the belt to that braces.
  // 15 Sep 2026 — PROJECT-AWARE (Hugo: only what the active project declares).
  // _isPF = Paradise Found (template_id null): every branch below keeps its old
  // path untouched. A templated project gets ONE tab per asset category the
  // project declares (window.__projectCategories: row.asset_categories when the
  // server sends it, else the keys /api/assets returned — e.g. characters /
  // locations / props / instruments for Trøpé) and a generic card grid per
  // category; the film-doc extras (References / Archival / Historical Refs /
  // VO / Music, the PARADISE_ANIMALS fallback, the merge-locations tool) and
  // their /api/archival + /api/historical-refs + /api/external-refs fetches are
  // Paradise Found only.
  const _isPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _cats = _isPF ? [] : (window.__projectCategories ? window.__projectCategories() : []);
  const _catIds = _cats.map(c => c.id);
  const assets = React.useMemo(() => {
    const src = (rawAssets && typeof rawAssets === "object" && !Array.isArray(rawAssets)) ? rawAssets : {};
    const out = { ...src };
    for (const key of ["characters", "animals", "locations", "props", "refs"]) {
      if (!Array.isArray(out[key])) out[key] = [];
      if (Array.isArray(rawAssets && rawAssets[key])) {
        // v07zw — Hide both archived and tombstoned (deleted: true)
        // assets. Tombstones live in assets.json so the bidi sync
        // can propagate the deletion without the peer re-adding the row.
        out[key] = rawAssets[key].filter(a => a && !a.archived && !a.deleted);
      }
    }
    // 15 Sep 2026 — the project's own categories get the same live filter.
    for (const key of _catIds) {
      if (["characters", "animals", "locations", "props", "refs"].includes(key)) continue;
      out[key] = Array.isArray(rawAssets && rawAssets[key]) ? rawAssets[key].filter(a => a && !a.archived && !a.deleted) : [];
    }
    return out;
  }, [rawAssets, _catIds.join("|")]);
  // v07zz140 — Deep-link the active tab. Activity-feed clicks set
  // window.__assetsInitialTab (e.g. "refs" for an external reference, or
  // "characters" for a character asset ref) and fire "paradise-assets-tab",
  // so clicking a reference notification lands on the right tab INSIDE the
  // tracker instead of opening a raw image in a new browser tab.
  // 15 Sep 2026 — a templated project's valid tabs are its category ids only.
  // 24 Sep 2026 (G5) — every project also has VO / Narration (its own takes, AudioPage).
  // 24 Sep 2026 (G6) — and References / Archival / Historical Refs (its own work/references libraries).
  const _projLibTabs = _isPF ? [] : ["refs", "archival", "historical"].filter(t => !_catIds.includes(t));
  const _VALID_ASSET_TABS = _isPF ? ["characters", "animals", "locations", "props", "refs", "archival", "historical", "vo", "music"] : [..._catIds, ..._projLibTabs, ...(_catIds.includes("vo") ? [] : ["vo"])];
  const _firstTab = _isPF ? "characters" : (_catIds[0] || "characters");
  const [tab, setTab] = React.useState(() => {
    const t = (typeof window !== "undefined") && window.__assetsInitialTab;
    if (t && _VALID_ASSET_TABS.includes(t)) { try { delete window.__assetsInitialTab; } catch (_) {} return t; }
    // v819 — a deep-link still wins; otherwise resume the tab he left on
    // instead of snapping back to Characters every time he re-opens Assets.
    try {
      const saved = localStorage.getItem(_ASSETS_TAB_KEY);
      if (saved && _VALID_ASSET_TABS.includes(saved)) return saved;
    } catch (_) {}
    return _firstTab;
  });
  // v819 — persist the active tab.
  React.useEffect(() => {
    try { localStorage.setItem(_ASSETS_TAB_KEY, tab); } catch (_) {}
  }, [tab]);
  // 15 Sep 2026 — switching project while on Assets can leave a tab the new
  // project doesn't declare (Animals → Trøpé): snap to its first category.
  React.useEffect(() => {
    if (_VALID_ASSET_TABS.length && !_VALID_ASSET_TABS.includes(tab)) setTab(_firstTab);
  }, [_VALID_ASSET_TABS.join("|"), tab]);  // eslint-disable-line react-hooks/exhaustive-deps
  React.useEffect(() => {
    const onTab = (e) => {
      const t = e && e.detail && e.detail.tab;
      if (t && _VALID_ASSET_TABS.includes(t)) setTab(t);
    };
    window.addEventListener("paradise-assets-tab", onTab);
    return () => window.removeEventListener("paradise-assets-tab", onTab);
  }, []);
  const [openCharacter, setOpenCharacter] = React.useState(null);
  const [openItem, setOpenItem] = React.useState(null); // {kind, item}
  // v819 — remember which asset he was in, per tab. One effect instead of
  // touching all six open-a-card call sites.
  React.useEffect(() => {
    const id = openCharacter
      || (openItem && openItem.item && (openItem.item.id || openItem.item.slug))
      || null;
    if (id) _assetsRememberOpen(tab, id);
  }, [openCharacter, openItem, tab]);
  // v819 — coming back to a tab (switching Locations→Props→Locations, or
  // re-opening the Assets page) scrolls the card he was last in back into view
  // and rings it briefly, instead of dumping him at the top of the grid.
  // scrollIntoView on the CARD rather than a saved scrollTop, so it works
  // whatever ancestor actually scrolls. Retries for ~1s because the cards can
  // render a beat after the tab flips (the async category fetches).
  React.useEffect(() => {
    let want = null;
    try { want = _assetsReadLastOpen()[tab] || null; } catch (_) {}
    if (!want) return;
    let tries = 0, timer = null, done = false, marked = null;
    const attempt = () => {
      if (done) return;
      let el = null;
      try { el = document.querySelector('[data-asset-card-id="' + String(want).replace(/["\\]/g, "\\$&") + '"]'); } catch (_) {}
      if (el) {
        done = true; marked = el;
        try { el.scrollIntoView({ block: "center", inline: "nearest" }); } catch (_) {}
        el.classList.add("asset-card-resumed");
        timer = setTimeout(() => { try { el.classList.remove("asset-card-resumed"); } catch (_) {} }, 1800);
        return;
      }
      if (++tries < 12) timer = setTimeout(attempt, 80);
    };
    timer = setTimeout(attempt, 0);
    return () => {
      done = true;
      if (timer) clearTimeout(timer);
      if (marked) { try { marked.classList.remove("asset-card-resumed"); } catch (_) {} }
    };
  }, [tab]);
  // v07zz460 — Drag-reorder cards on ANY asset tab (characters / animals / locations /
  // props). One generic hook keyed to the active tab; the chosen order persists to
  // data/assets.json via POST /api/assets/:cat/reorder (stamps sort_order + updated_at so
  // it survives the assets.json bisync), and GET /api/assets sorts every category by
  // sort_order. reorder.ordered = the list in custom order; reorder.cardProps(item) =
  // the drag handlers for a card; reorder.cls(item) = the dragging/drop-target classes.
  const _reorderCat = (_isPF ? ["characters", "animals", "locations", "props"] : _catIds).includes(tab) ? tab : null;
  const reorder = useAssetReorder(_reorderCat, _reorderCat ? (assets && assets[_reorderCat]) : null);
  // v07zz61 — Cream bar fully removed. The fade-into-tabs effect is
  // pure CSS via .vp-tab-fade (a sticky gradient strip below the
  // tabs row). No JS scroll listener needed.
  // v07zn — Asset creation modal kind ("character"/"animal"/etc) or null.
  const [creatingKind, setCreatingKind] = React.useState(null);
  // v07zv — Most-recent nav direction for the swipe animation
  // ("next" | "prev" | null). The backdrop reads it as a data-attr
  // and the CSS keyframe slides the new modal in from that side.
  const [navDir, setNavDir] = React.useState(null);
  // v07zz444 — Merge duplicate LOCATIONS. mergeMode turns the location cards into a
  // multi-select: the first pick (or a ★-chosen card) is the KEEP primary, the rest
  // fold their shot ranges into it and soft-delete. Backend: POST /api/assets/location/merge.
  const [mergeMode, setMergeMode] = React.useState(false);
  const [mergeSel, setMergeSel] = React.useState([]);          // selected location ids, in pick order
  const [mergePrimary, setMergePrimary] = React.useState(null); // the KEEP id
  const [mergeBusy, setMergeBusy] = React.useState(false);
  const [mergeConfirm, setMergeConfirm] = React.useState(false);
  const [mergeErr, setMergeErr] = React.useState(null);
  const exitMerge = () => { setMergeMode(false); setMergeSel([]); setMergePrimary(null); setMergeConfirm(false); setMergeErr(null); };
  const toggleMergePick = (id) => {
    const has = mergeSel.includes(id);
    const next = has ? mergeSel.filter(x => x !== id) : [...mergeSel, id];
    setMergeSel(next);
    if (has) { if (mergePrimary === id) setMergePrimary(next[0] || null); }
    else if (!mergePrimary) setMergePrimary(id);
  };
  const doMerge = () => {
    const merge_ids = mergeSel.filter(x => x !== mergePrimary);
    if (!mergePrimary || !merge_ids.length) return;
    setMergeBusy(true); setMergeErr(null);
    const fetcher = window.authFetch || fetch;
    fetcher("/api/assets/location/merge", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ primary_id: mergePrimary, merge_ids }),
    })
      .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || "HTTP " + r.status))))
      .then(() => { try { window.reloadAppData && window.reloadAppData(); } catch (_) {} exitMerge(); })
      .catch(err => { setMergeErr(err.message || "Merge failed."); setMergeConfirm(false); })
      .finally(() => setMergeBusy(false));
  };
  // Leaving the Locations tab abandons any in-progress merge selection.
  React.useEffect(() => { if (tab !== "locations" && mergeMode) exitMerge(); }, [tab]);  // eslint-disable-line react-hooks/exhaustive-deps
  // v07zs — Refs tab uses a file-import flow instead of the create
  // modal: + button triggers a file picker, picked files open the
  // shared UploadModal pre-set to type=reference.
  const refsFileInputRef = React.useRef(null);
  const [refsPending, setRefsPending] = React.useState(null);
  const handleRefsFiles = (files) => {
    if (!files || !files.length) return;
    // Pre-seed each row with kind=reference + a generic category.
    const arr = Array.from(files).map(f => ({
      file: f,
      kind: "reference",
      asset_category: "visual_style",
      asset_slug: "",
      descriptor: "",
      shot_id: "",
      status: "pending",
      progress: 0,
      error: null,
      result: null,
    }));
    setRefsPending(arr);
  };
  // v07zl — Bump counter forces re-render when window.__appData.assets
  // is mutated by an AssetLockStat toggle. The card pill now reflects
  // the locked state immediately after the user flips the star.
  const [, bumpRender] = React.useState(0);
  React.useEffect(() => {
    const fn = () => bumpRender(n => n + 1);
    window.addEventListener("paradise-asset-lock-changed", fn);
    return () => window.removeEventListener("paradise-asset-lock-changed", fn);
  }, []);
  // v06q — Archival batches fetched from /api/archival (folder scan).
  // Each batch is one card on the Archival tab; opening one shows the
  // full set of frames in the AssetReviewModal so the user can browse
  // through every still in that archive show.
  const [archival, setArchival] = React.useState([]);
  // v07zw — Track whether the archival fetch has resolved so the
  // tab header can render the count only once it's known. Without
  // this the tab pill flips from "Archival" → "Archival 12" mid-load
  // and visually shifts.
  const [archivalLoaded, setArchivalLoaded] = React.useState(false);
  // v07zz56 — Historical Refs (mirror of Archival, separate folder)
  const [historical, setHistorical] = React.useState([]);
  const [historicalLoaded, setHistoricalLoaded] = React.useState(false);
  // v07zz189 — External References are now REAL foldered libraries (scanned
  // from references/external-refs/), mirroring Historical. Replaces the old
  // hardcoded mock cards.
  const [external, setExternal] = React.useState([]);
  const [externalLoaded, setExternalLoaded] = React.useState(false);
  // Bump to re-fetch the ref-folder lists after a create/upload.
  const [refsReload, setRefsReload] = React.useState(0);
  React.useEffect(() => {
    // 24 Sep 2026 (G6) — every project: the server scans the active project's own libraries
    // (Paradise Found's episode folders, another project's work/references/…).
    const fetcher = window.authFetch || fetch;
    fetcher("/api/archival")
      .then(r => r.ok ? r.json() : null)
      .then(d => { setArchival((d && d.batches) || []); setArchivalLoaded(true); })
      .catch(() => { setArchival([]); setArchivalLoaded(true); });
    fetcher("/api/historical-refs")
      .then(r => r.ok ? r.json() : null)
      .then(d => { setHistorical((d && d.batches) || []); setHistoricalLoaded(true); })
      .catch(() => { setHistorical([]); setHistoricalLoaded(true); });
    fetcher("/api/external-refs")
      .then(r => r.ok ? r.json() : null)
      .then(d => { setExternal((d && d.batches) || []); setExternalLoaded(true); })
      .catch(() => { setExternal([]); setExternalLoaded(true); });
  }, [refsReload, _isPF]);
  // v07zz214 — Re-scan the ref-folder lists when a batch changes (cover star,
  // crop, or a new Add Reference upload). MediaView already listens for this;
  // AssetsView needs it too or the card counts go stale after an upload.
  React.useEffect(() => {
    const fn = () => setRefsReload(v => v + 1);
    window.addEventListener("paradise-archival-refresh", fn);
    return () => window.removeEventListener("paradise-archival-refresh", fn);
  }, []);
  // v07zz189 — Create a new reference folder (external / historical) and
  // immediately upload image(s) into it so it appears as a card. Drives the
  // "+" button on the References + Historical Refs tabs.
  const _refFolderInput = React.useRef(null);
  const _pendingFolder = React.useRef(null); // { scope, folder }
  // v07zz236 — styled in-app folder-name modal (was native window.prompt, which
  // Hugo flagged as the ugly grey popup — invariant #22).
  const [refFolderModal, setRefFolderModal] = React.useState(null); // { scope } | null
  const [refFolderName, setRefFolderName] = React.useState("");
  const [refFolderBusy, setRefFolderBusy] = React.useState(false);
  const createRefFolder = (scope) => { setRefFolderName(""); setRefFolderModal({ scope }); };
  const submitRefFolder = () => {
    const m = refFolderModal; if (!m) return;
    const name = (refFolderName || "").trim();
    if (!name) return;
    setRefFolderBusy(true);
    const fetcher = window.authFetch || fetch;
    fetcher("/api/refs/folder", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ scope: m.scope, name }),
    }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
      .then(() => {
        // Folder created — now ask for images to drop into it so the card shows.
        _pendingFolder.current = { scope: m.scope, folder: name };
        setRefFolderModal(null); setRefFolderBusy(false);
        if (_refFolderInput.current) _refFolderInput.current.click();
      })
      .catch(e => { console.warn("[create-folder]", e.message); setRefFolderBusy(false); setRefFolderModal(null); setRefsReload(v => v + 1); });
  };
  const onRefFolderFiles = (fileList) => {
    const files = Array.from(fileList || []);
    const pend = _pendingFolder.current;
    _pendingFolder.current = null;
    if (_refFolderInput.current) _refFolderInput.current.value = "";
    if (!pend || !files.length) { setRefsReload(v => v + 1); return; } // empty folder still created
    const fd = new FormData();
    fd.append("scope", pend.scope);
    fd.append("folder", pend.folder);
    for (const f of files) fd.append("files", f);
    const fetcher = window.authFetch || fetch;
    fetcher("/api/refs/folder-upload", { method: "POST", body: fd })
      .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
      .then(() => setRefsReload(v => v + 1))
      .catch(e => { console.warn("[ref-folder-upload]", e.message); setRefsReload(v => v + 1); });
  };

  // v07zd — Consume the pending-asset hint set by the notification
  // bell. When the user clicks "John changed status" on a character
  // note, the bell parks the target asset on window.__pendingAsset
  // and switches the view to "assets" — we pick it up here, switch
  // to the right tab, then open the modal.
  React.useEffect(() => {
    if (!assets) return;
    const consume = () => {
      const pending = window.__pendingAsset;
      if (!pending) return;
      window.__pendingAsset = null;
      const kindToTab = {
        character: "characters", animal: "animals", location: "locations",
        prop: "props", ref: "refs", archival: "archival",
      };
      // 15 Sep 2026 — a templated project resolves the hint against ITS categories
      // (kind may already be a category id, e.g. "instruments").
      const targetTab = _isPF
        ? (kindToTab[pending.kind] || "characters")
        : (_catIds.includes(pending.kind) ? pending.kind : (_catIds.includes(kindToTab[pending.kind]) ? kindToTab[pending.kind] : _firstTab));
      setTab(targetTab);
      if (pending.id) {
        if (pending.kind === "character" && _isPF) {
          setOpenCharacter(pending.id);
        } else {
          // Look up the matching asset in the catalog and open it.
          const pool = assets[targetTab] || [];
          const match = pool.find(a =>
            a.id === pending.id || a.slug === pending.id ||
            a.folder === pending.id || a.name === pending.id,
          );
          if (match) setOpenItem({ kind: _isPF ? pending.kind : targetTab, item: match });
        }
      }
    };
    consume();
    window.addEventListener("paradise-open-asset", consume);
    return () => window.removeEventListener("paradise-open-asset", consume);
  }, [assets]);

  if (!assets) return null;

  return (
    <section className="view-page assets-view">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">ASSETS</div>
          <div className="vp-title">Assets</div>
        </div>
      </div>

      {/* v01k — single category-tab row. VO + Music sit alongside the
          image-asset categories instead of behind a separate
          media-type top row. */}
      <div className="vp-tabs">
        {/* 15 Sep 2026 — a templated project: one tab per declared category, nothing else. */}
        {!_isPF && _cats.map(c => (
          <button key={c.id} className={"vp-tab" + (tab === c.id ? " is-active" : "")} onClick={() => setTab(c.id)}>
            {c.label} <span className="vp-tab-count">{(assets[c.id] || []).length}</span>
          </button>
        ))}
        {/* 24 Sep 2026 (G6) — References / Archival / Historical Refs: the project's own
            work/references/{external,archival-stills,historical} (same cards as Paradise Found). */}
        {_projLibTabs.includes("refs") && (
          <button className={"vp-tab" + (tab === "refs" ? " is-active" : "")} onClick={() => setTab("refs")}>
            References <span className="vp-tab-count">{externalLoaded ? external.length : ""}</span>
          </button>
        )}
        {_projLibTabs.includes("archival") && (
          <button className={"vp-tab" + (tab === "archival" ? " is-active" : "")} onClick={() => setTab("archival")}>
            Archival <span className="vp-tab-count">{archivalLoaded ? archival.length : ""}</span>
          </button>
        )}
        {_projLibTabs.includes("historical") && (
          <button className={"vp-tab" + (tab === "historical" ? " is-active" : "")} onClick={() => setTab("historical")}>
            Historical Refs <span className="vp-tab-count">{historicalLoaded ? historical.length : ""}</span>
          </button>
        )}
        {/* 24 Sep 2026 (G5) — VO / Narration: the project's own takes (/api/audio reads its DB). */}
        {!_isPF && !_catIds.includes("vo") && (
          <button className={"vp-tab" + (tab === "vo" ? " is-active" : "")} onClick={() => setTab("vo")}>
            VO / Narration
          </button>
        )}
        {_isPF && (<React.Fragment>
        <button className={"vp-tab" + (tab === "characters" ? " is-active" : "")} onClick={() => setTab("characters")}>
          Characters <span className="vp-tab-count">{assets.characters.length}</span>
        </button>
        {/* v03g — Animals tab for the docu's animal-species references. */}
        <button className={"vp-tab" + (tab === "animals" ? " is-active" : "")} onClick={() => setTab("animals")}>
          Animals <span className="vp-tab-count">{(assets.animals || []).length}</span>
        </button>
        <button className={"vp-tab" + (tab === "locations" ? " is-active" : "")} onClick={() => setTab("locations")}>
          Locations <span className="vp-tab-count">{assets.locations.length}</span>
        </button>
        <button className={"vp-tab" + (tab === "props" ? " is-active" : "")} onClick={() => setTab("props")}>
          Props <span className="vp-tab-count">{assets.props.length}</span>
        </button>
        <button className={"vp-tab" + (tab === "refs" ? " is-active" : "")} onClick={() => setTab("refs")}>
          References <span className="vp-tab-count">{externalLoaded ? external.length : ""}</span>
        </button>
        <button className={"vp-tab" + (tab === "archival" ? " is-active" : "")} onClick={() => setTab("archival")}>
          {/* v07zw — Always render the count span so its reserved
              min-width prevents the "Archival → Archival N" layout
              shift when the async fetch resolves. */}
          Archival <span className="vp-tab-count">{archivalLoaded ? archival.length : ""}</span>
        </button>
        <button className={"vp-tab" + (tab === "historical" ? " is-active" : "")} onClick={() => setTab("historical")}>
          Historical Refs <span className="vp-tab-count">{historicalLoaded ? historical.length : ""}</span>
        </button>
        <button className={"vp-tab" + (tab === "vo" ? " is-active" : "")} onClick={() => setTab("vo")}>
          VO / Narration
        </button>
        <button className={"vp-tab" + (tab === "music" ? " is-active" : "")} onClick={() => setTab("music")}>
          Music
        </button>
        </React.Fragment>)}
        {/* v07zz281 — Right-aligned action group inside the STICKY tab row:
            bulk Download (always) + the create "+" button. Lives here (not in
            the scrolling .vp-head title) so the Download stays visible while
            the card grid scrolls. The group is margin-left:auto; the + inside
            it drops its own auto-margin (CSS) so the two sit adjacent. */}
        <div className="vp-tabs-actions">
          {/* Bulk DOWNLOAD: one ZIP of every asset's cover image
              (characters/animals/locations/props), per-category folders.
              Persistent "Zipping…" state shared with the Shots button. */}
          {window.ZipDownloadButton && (
            <window.ZipDownloadButton
              scope="assets"
              className="assets-dl-all assets-dl-tab"
              fallbackName="assets.zip"
              title={!_isPF ? undefined : "Download a ZIP of every asset's cover image (by category)"}
              url="/api/export/assets-zip"
            />
          )}
          {/* v07zz444 — Merge duplicate locations. Toggles a multi-select mode on the
              location cards (locations tab only). Gold when active; the floating merge
              bar + styled confirm handle the actual merge. */}
          {/* 24 Sep 2026 (G6) — also for any project with a locations category (no hover tooltip there). */}
          {tab === "locations" && (_isPF || _catIds.includes("locations")) && (
            <button
              className={"vp-tab-add-btn vp-tab-merge-btn" + (mergeMode ? " is-active" : "")}
              type="button"
              aria-label="Merge duplicate locations"
              title={!_isPF ? undefined : mergeMode ? "Exit merge mode" : "Merge duplicate locations — pick the cards that are the same place"}
              onClick={() => { if (mergeMode) exitMerge(); else setMergeMode(true); }}
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7"/><path d="M8.5 6H13a3 3 0 0 1 3 3v.5"/><path d="M8.5 18H13a3 3 0 0 0 3-3v-.5"/></svg>
            </button>
          )}
          {/* v07zn/v07zp/v07zz61 — + button per category. Opens a
              CreateAssetModal (or a new ref folder). Hidden on tabs where
              creation isn't relevant (archival is disk-discovered). */}
          {/* 15 Sep 2026 — templated project: + on every category tab, kind = the category id. */}
          {!_isPF && _catIds.includes(tab) && (
            <button
              className="vp-tab-add-btn"
              type="button"
              aria-label={`Add ${(window.__projectCategoryLabel ? window.__projectCategoryLabel(tab, true) : tab).toLowerCase()}`}
              onClick={() => setCreatingKind(tab)}
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <path d="M12 5v14M5 12h14"/>
              </svg>
            </button>
          )}
          {/* 24 Sep 2026 (G6) — another project: + on References / Historical Refs creates a folder in its own work/references. */}
          {!_isPF && (tab === "refs" || tab === "historical") && _projLibTabs.includes(tab) && (
            <button
              className="vp-tab-add-btn"
              type="button"
              aria-label="Add reference folder"
              onClick={() => createRefFolder(tab === "refs" ? "external" : "historical")}
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <path d="M12 5v14M5 12h14"/>
              </svg>
            </button>
          )}
          {_isPF && ["characters","animals","locations","props","refs","historical","vo","music"].includes(tab) && (
            <button
              className="vp-tab-add-btn"
              type="button"
              aria-label={`Add ${tab === "refs" || tab === "historical" ? "reference folder" : tab === "vo" ? "VO clip" : tab.slice(0, -1)}`}
              title={tab === "refs" ? "New external reference folder" : tab === "historical" ? "New historical reference folder" : `Add new ${tab === "vo" ? "VO clip" : tab.slice(0, -1)}`}
              onClick={() => {
                // v07zz189 — References + Historical Refs create real FOLDERS
                // (then upload images into them), instead of the old single-file
                // UploadModal. Other categories use the structured CreateAssetModal.
                if (tab === "refs") { createRefFolder("external"); return; }
                if (tab === "historical") { createRefFolder("historical"); return; }
                setCreatingKind(
                  tab === "characters" ? "character" :
                  tab === "animals"    ? "animal" :
                  tab === "locations"  ? "location" :
                  tab === "props"      ? "prop" :
                  tab === "music"      ? "music" :
                  tab === "vo"         ? "vo" : null
                );
              }}
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <path d="M12 5v14M5 12h14"/>
              </svg>
            </button>
          )}
        </div>
      </div>
      {/* v07zz62 — .vp-scroll is the scroll container with the fade
          mask so cards dissolve under the tabs as they scroll up.
          Tabs row above sits OUTSIDE this container so it never
          scrolls and is never masked. */}
      <div className="vp-scroll">

      {_isPF && tab === "characters" && (
        <div className="characters-grid">
          {reorder.ordered.map(c => {
            // v07zz182 — instant-when-warm: if the thumb is already in the
            // browser cache (pre-warmed by App.jsx), render it already-lit
            // so it never replays the 240ms gradient→image fade. The old
            // el.complete check is unreliable under /local/* no-cache
            // revalidation, which is why we also consult __warmedThumbs.
            const cThumb = c.image ? (window.thumbUrl ? window.thumbUrl(c.image, 560) : c.image) : null;
            const cWarm = !!(cThumb && window.__warmedThumbs && window.__warmedThumbs.has(cThumb));
            return (
            <article key={c.id} data-asset-card-id={c.id} className={"character-card glass interactive-card" + reorder.cls(c)} {...reorder.cardProps(c)} onClick={() => setOpenCharacter(c.id)}>
              {/* v07zo/v07zp — Character thumb: gradient base with
                  ALWAYS-rendered initials so cards without an image
                  (or with a broken image URL) show the JS-style
                  letter card consistently. The <img> overlay
                  fades in over the initials when the bitmap loads;
                  if the load fails (onError), the img stays
                  invisible and the initials remain visible. */}
              <div
                className="character-thumb"
                /* v07zz185 — Hugo: don't flash a gradient under a loading image.
                   When there's an image the placeholder is the CARD colour
                   (transparent → the card shows through) so the photo just
                   fades onto the card. Gradient + initials only when there's
                   genuinely no image. */
                style={{ background: c.image ? "transparent" : `linear-gradient(160deg, oklch(0.55 0.04 60), oklch(0.40 0.04 30))` }}
              >
                {!c.image && <span className="character-thumb-initials">{c.name.split(" ").slice(0,2).map(n => n[0]).join("")}</span>}
                {c.image && (
                  <img
                    className={"character-thumb-img" + (cWarm ? " is-loaded is-cached" : "")}
                    src={cThumb}
                    alt={c.name}
                    loading="lazy"
                    decoding="async"
                    draggable={false}
                    ref={(el) => {
                      if (!el) return;
                      if (cWarm || (el.complete && el.naturalWidth > 0)) {
                        el.classList.add("is-loaded");
                        el.classList.add("is-cached");
                      }
                    }}
                    onLoad={(e) => {
                      e.currentTarget.classList.add("is-loaded");
                      try { if (window.__warmedThumbs && cThumb) window.__warmedThumbs.add(cThumb); } catch (_) {}
                    }}
                    onError={(e) => { /* leave img invisible; initials remain visible */ }}
                  />
                )}
                {/* v07zz280 — status pill moved next to the card title (below); the
                    lock star was removed now that the status pill carries the state. */}
              </div>
              <div className="character-body">
                <div className="asset-card-titlerow">
                  <div className="character-name">{c.name}</div>
                  <AssetStatusPill status={effAssetStatus(c.status, c.appearances)} className="asset-status-pill--title" />
                </div>
                <div className="character-role">{c.role}</div>
                <div className="character-stats">
                  <span><strong>{c.appearances}</strong> shots</span>
                  <span><strong>{c.ref_passes}</strong> ref passes</span>
                </div>
                <div className="character-notes">{c.notes}</div>
              </div>
            </article>
            );
          })}
        </div>
      )}

      {/* v03g / v04an / v04f — Animals grid. Uses assets.animals from
          data/tracker.json when populated; otherwise falls back to the
          full Paradise Found species list from docs/paradise-found-
          pipeline.md so the tab is always populated. Each card shows
          shot count derived from shot.species LIKE-match.
          v07zw — PARADISE_ANIMALS hoisted into the outer AssetsView
          scope (see top of function) so the arrow-nav handler can
          paginate through it when assets.animals is empty. */}
      {_isPF && tab === "animals" && (() => {
        // v07zz460 — real assets get the reordered list + drag handles; the hardcoded
        // PARADISE_ANIMALS fallback (shown only when no animals exist yet) isn't draggable.
        const animalsReal = !!(assets.animals && assets.animals.length);
        const list = animalsReal ? reorder.ordered : PARADISE_ANIMALS;
        const shotsFor = (animal) => {
          const allShots = (window.__appData && window.__appData.shots) || [];
          const key = (animal.name || animal.slug || "").toLowerCase().split(" ")[0];
          if (!key) return [];
          return allShots.filter(s => (s.species || "").toLowerCase().includes(key));
        };
        return (
          <div className="locations-grid">
            {list.map(a => {
              const linkedShots = shotsFor(a);
              return (
                <article key={a.slug || a.id} data-asset-card-id={a.id || a.slug} className={"location-card glass interactive-card" + (animalsReal ? reorder.cls(a) : "")} {...(animalsReal ? reorder.cardProps(a) : {})} onClick={() => setOpenItem({kind: "animal", item: { ...a, shots: linkedShots.map(s => s.id) }})}>
                  {/* v07zz380 — animal frames are 4:5 portrait (Hugo), unlike the
                      21:9 prop frames. The --portrait modifier overrides aspect. */}
                  <div className="prop-frame prop-frame--portrait" style={a.image ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(a.image, 560) : a.image})`, backgroundSize: "cover", backgroundPosition: "center", borderStyle: "solid" } : {}}>
                  </div>
                  <div className="location-body">
                    <div className="asset-card-titlerow">
                      <div className="location-name">{a.name}</div>
                      <AssetStatusPill status={effAssetStatus(a.status, linkedShots.length)} className="asset-status-pill--title" />
                    </div>
                    <div className="location-meta">{a.latin || a.species_latin || ""}{linkedShots.length ? ` · ${linkedShots.length} shot${linkedShots.length === 1 ? "" : "s"}` : ""}</div>
                  </div>
                </article>
              );
            })}
          </div>
        );
      })()}

      {_isPF && tab === "locations" && (
        <div className={"locations-grid locations-grid--big" + (mergeMode ? " is-merge-mode" : "")}>
          {reorder.ordered.map(l => {
            const picked = mergeMode && mergeSel.includes(l.id);
            const isPrimary = mergeMode && mergePrimary === l.id;
            return (
            <article key={l.id} data-asset-card-id={l.id}
              className={"location-card glass interactive-card" + (picked ? " is-merge-pick" : "") + (isPrimary ? " is-merge-primary" : "") + (mergeMode ? "" : reorder.cls(l))}
              {...(mergeMode ? {} : reorder.cardProps(l))}
              onClick={() => mergeMode ? toggleMergePick(l.id) : setOpenItem({kind: "location", item: l})}>
              {/* v07zz443 — show the promoted/generated image when the location has one
                  (l.image comes from /api/assets, resolved to the cover), falling back to
                  the hue gradient placeholder. Was hard-coded to the gradient, so generated
                  location images never appeared on the cards. Mirrors the character/prop cards. */}
              <div className="location-thumb" style={l.image
                ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(l.image, 560) : l.image})`, backgroundSize: "cover", backgroundPosition: "center" }
                : { background: locGradient(l.hue) }}>
                <span className="location-thumb-tag">{l.scenes}</span>
                {picked && <span className={"location-merge-badge" + (isPrimary ? " is-primary" : "")}>{isPrimary ? "★ KEEP" : "MERGE IN"}</span>}
                {picked && !isPrimary && (
                  <button type="button" className="location-merge-keep" title="Keep this one instead (make it the primary)"
                    onClick={(e) => { e.stopPropagation(); setMergePrimary(l.id); }}>★ Keep this</button>
                )}
              </div>
              <div className="location-body">
                <div className="asset-card-titlerow">
                  <div className="location-name">{l.name}</div>
                  <AssetStatusPill status={effAssetStatus(l.status, (Array.isArray(l.shots) ? l.shots.length : 0) || (parseInt(l.scenes, 10) || 0))} className="asset-status-pill--title" />
                </div>
                <div className="location-meta">{l.scenes}</div>
              </div>
            </article>
            );
          })}
        </div>
      )}

      {_isPF && tab === "props" && (
        <div className="props-grid">
          {reorder.ordered.map(p => (
            <article key={p.id} data-asset-card-id={p.id}
              className={"prop-card glass interactive-card" + reorder.cls(p)}
              {...reorder.cardProps(p)}
              onClick={() => setOpenItem({kind: "prop", item: p})}>
              {/* v07zz381 — 4:5 portrait frame (matches Animals) so the empty
                  placeholder reads as a proper card, not a thin 21:9 strip. If
                  p.image is present use it as the frame background. */}
              <div className="prop-frame prop-frame--portrait" style={p.image ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(p.image, 560) : p.image})`, backgroundSize: "cover", backgroundPosition: "center" } : {}}>
              </div>
              <div className="prop-body">
                {/* v07zz456 — dropped the "Hero prop · N refs" meta line and the
                    italic description; the card now shows just the name + status
                    pill, matching the Animals/Locations cards in the same grid. */}
                <div className="asset-card-titlerow">
                  <div className="prop-name">{p.name}</div>
                  <AssetStatusPill status={effAssetStatus(p.status, (Array.isArray(p.shots) ? p.shots.length : 0))} className="asset-status-pill--title" />
                </div>
              </div>
            </article>
          ))}
        </div>
      )}

      {/* v07zz189 — References tab now shows REAL external-ref folders (each
          subfolder of references/external-refs/ is a card). Create folders +
          upload via the "+" button. */}
      {(_isPF || _projLibTabs.includes("refs")) && tab === "refs" && (
        external.length === 0 ? (
          <div className="archive-empty glass">
            No external references yet. Hit the <strong>+</strong> above to create a folder and drop in images
            (mood boards, style frames, costume / vessel / location references) — each folder becomes a card.
          </div>
        ) : (
          <div className="locations-grid">
            {external.map(r => {
              const coverIsPdf = /\.pdf(?:$|\?)/i.test(String(r.cover || ""));
              return (
              <article key={r.id} className="location-card glass interactive-card"
                onClick={() => setOpenItem({
                  kind: "ref",
                  item: {
                    ...r,
                    type: `${r.count} reference${r.count === 1 ? "" : "s"}`,
                    scenes: r.name,
                    notes: `Reference folder: ${r.name}. ${r.count} file${r.count === 1 ? "" : "s"}.`,
                    references: (r.items || []).map(it => ({ ...it })),
                    image: r.cover,
                  },
                })}>
                <div className="location-thumb"
                  style={r.cover && !coverIsPdf
                    ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(r.cover, 560) : r.cover})`, backgroundSize: "cover", backgroundPosition: r.coverFocus || "center" }
                    : { background: locGradient(((r.name || "").charCodeAt(0) * 19) % 360) }}>
                  <span className="location-thumb-tag">{r.count} item{r.count === 1 ? "" : "s"}</span>
                </div>
                <div className="location-body">
                  <div className="location-name">{r.name}</div>
                  <div className="location-meta">{r.count} reference{r.count === 1 ? "" : "s"}</div>
                </div>
              </article>
              );
            })}
          </div>
        )
      )}
      {/* v07zz189 — hidden input that uploads into a just-created ref folder. */}
      <input ref={_refFolderInput} type="file" accept="image/*" multiple style={{ display: "none" }}
        onChange={(e) => onRefFolderFiles(e.target.files)}/>

      {/* v07zz236 — styled folder-name modal (replaces native window.prompt). */}
      {refFolderModal && ReactDOM.createPortal((
        <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }} onClick={() => !refFolderBusy && setRefFolderModal(null)}>
          <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
            <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>{refFolderModal.scope === "historical" ? "NEW HISTORICAL REFERENCE FOLDER" : "NEW REFERENCE FOLDER"}</div>
            <div className="confirm-delete-title">Name this folder</div>
            <div className="confirm-delete-body">
              <input autoFocus type="text" value={refFolderName}
                placeholder={refFolderModal.scope === "historical" ? "e.g. Columbus portraits" : "e.g. Costume refs"}
                onChange={(e) => setRefFolderName(e.target.value)}
                onKeyDown={(e) => { if (e.key === "Enter") submitRefFolder(); if (e.key === "Escape") setRefFolderModal(null); }}
                style={{ width: "100%", boxSizing: "border-box", padding: "9px 12px", borderRadius: "var(--r-sm)", border: "1px solid color-mix(in srgb, var(--ink-muted) 40%, transparent)", background: "color-mix(in srgb, var(--white) 65%, transparent)", color: "var(--ink)", fontSize: "var(--fs-14)", marginTop: 2 }}/>
            </div>
            <div className="confirm-delete-actions">
              <button type="button" className="admin-suspend-btn" onClick={() => setRefFolderModal(null)} disabled={refFolderBusy}>Cancel</button>
              <button type="button" className="confirm-delete-btn" style={{ background: "var(--leaf-2)" }} onClick={submitRefFolder} disabled={refFolderBusy || !refFolderName.trim()}>{refFolderBusy ? "Creating…" : "Create & add images"}</button>
            </div>
          </div>
        </div>
      ), document.getElementById("modal-root") || document.body)}

      {/* v07zz444 — Merge action bar (fixed, bottom) shown while merge mode is on. */}
      {mergeMode && ReactDOM.createPortal((
        <div className="assets-merge-bar">
          <div className="assets-merge-bar-info">
            {mergeSel.length === 0
              ? <span>Pick the location cards that are the same place — the <strong>first is kept</strong>, the rest merge into it.</span>
              : <span><strong>{mergeSel.length}</strong> selected · keeping <strong>“{(assets.locations.find(l => l.id === mergePrimary) || {}).name || "—"}”</strong>{mergeSel.length > 1 ? ` · merging in ${mergeSel.length - 1}` : ""}</span>}
            {mergeErr && <span className="assets-merge-err">{mergeErr}</span>}
          </div>
          <div className="assets-merge-bar-actions">
            <button type="button" className="admin-suspend-btn" onClick={exitMerge} disabled={mergeBusy}>Cancel</button>
            <button type="button" className="confirm-delete-btn" style={{ background: "var(--leaf-2)" }}
              disabled={mergeBusy || mergeSel.length < 2 || !mergePrimary}
              onClick={() => setMergeConfirm(true)}>Merge{mergeSel.length >= 2 ? ` ${mergeSel.length - 1}` : ""} →</button>
          </div>
        </div>
      ), document.getElementById("modal-root") || document.body)}

      {mergeConfirm && ReactDOM.createPortal((
        <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-3)" }} onClick={() => !mergeBusy && setMergeConfirm(false)}>
          <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
            <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>MERGE LOCATIONS</div>
            <div className="confirm-delete-title">Merge {mergeSel.length - 1} location{mergeSel.length - 1 === 1 ? "" : "s"} into “{(assets.locations.find(l => l.id === mergePrimary) || {}).name || "—"}”?</div>
            <div className="confirm-delete-body">
              <p>Their shot ranges combine into the kept location and the others are hidden from the list. This is reversible — the merged ones are soft-deleted, not erased.</p>
            </div>
            <div className="confirm-delete-actions">
              <button type="button" className="admin-suspend-btn" onClick={() => setMergeConfirm(false)} disabled={mergeBusy}>Cancel</button>
              <button type="button" className="confirm-delete-btn" style={{ background: "var(--leaf-2)" }} onClick={doMerge} disabled={mergeBusy}>{mergeBusy ? "Merging…" : "Merge"}</button>
            </div>
          </div>
        </div>
      ), document.getElementById("modal-root") || document.body)}

      {(_isPF || _projLibTabs.includes("archival")) && tab === "archival" && (
        archival.length === 0 ? (
          <div className="archive-empty glass">
            No archival stills found. Drop files under
            <code style={{margin: "0 4px"}}>{_isPF ? "<WATCH_PATH>/work/episodes/<ep>/references/archival-stills/" : "work/references/archival-stills/"}</code>
            {_isPF ? (<>
            named like <code>PF_T0166_Sharks of Hawaii_001743_0010926575.jpg</code> —
            files sharing the same <code>PF_T&lt;NNNN&gt;</code> tag are grouped into one batch automatically.
            </>) : "— each sub-folder becomes one batch."}
          </div>
        ) : (
          <div className="locations-grid">
            {archival.map(r => (
              <article key={r.id} className="location-card glass interactive-card"
                onClick={() => setOpenItem({
                  kind: "archival",
                  // v06q — map archival batch items[] → AssetItemModal's
                  // `references` shape so the gallery strip below the
                  // hero shows every frame in the batch.
                  item: {
                    ...r,
                    type: `${r.count} frames`,
                    scenes: r.name,
                    notes: `Archive batch: ${r.name}. ${r.count} frames pulled from ${r.folder}.`,
                    references: (r.items || []).map(it => ({ ...it })),
                    image: r.cover,
                  },
                })}>
                <div className="location-thumb"
                  style={r.cover
                    ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(r.cover, 560) : r.cover})`, backgroundSize: "cover", backgroundPosition: r.coverFocus || "center", filter: "grayscale(0.25)" }
                    : { background: locGradient(((r.name || "").charCodeAt(0) * 17) % 360), filter: "grayscale(0.4)" }}>
                  <span className="location-thumb-tag">{r.count} item{r.count === 1 ? "" : "s"}</span>
                </div>
                <div className="location-body">
                  <div className="location-name">{r.name}</div>
                  <div className="location-meta">{r.count} archival frame{r.count === 1 ? "" : "s"}</div>
                </div>
              </article>
            ))}
          </div>
        )
      )}

      {/* v07zz56 — Historical Refs tab mirrors Archival's layout
          exactly. Source folder is <ep>/references/historical-refs/. */}
      {(_isPF || _projLibTabs.includes("historical")) && tab === "historical" && (
        historical.length === 0 ? (
          <div className="archive-empty glass">
            No historical reference imagery yet. Drop files under
            <code style={{margin: "0 4px"}}>{_isPF ? "<WATCH_PATH>/work/episodes/<ep>/references/historical-refs/" : "work/references/historical/"}</code>.
            Organise by subfolder (e.g. <code>1492-Spanish-Ships/</code>) — each subfolder becomes a batch card.
          </div>
        ) : (
          <div className="locations-grid">
            {historical.map(r => {
              // v07zz79 — When a batch has only PDFs, r.cover is a
              // PDF URL — using it as background-image renders nothing.
              // Fall through to the gradient placeholder in that case.
              const coverIsPdf = /\.pdf(?:$|\?)/i.test(String(r.cover || ""));
              return (
              <article key={r.id} className="location-card glass interactive-card"
                onClick={() => setOpenItem({
                  kind: "historical",
                  item: {
                    ...r,
                    type: `${r.count} references`,
                    scenes: r.name,
                    notes: `Historical batch: ${r.name}. ${r.count} files pulled from ${r.folder}.`,
                    references: (r.items || []).map(it => ({ ...it })),
                    image: r.cover,
                  },
                })}>
                <div className="location-thumb"
                  style={r.cover && !coverIsPdf
                    ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(r.cover, 560) : r.cover})`, backgroundSize: "cover", backgroundPosition: r.coverFocus || "center", filter: "sepia(0.3)" }
                    : { background: locGradient(((r.name || "").charCodeAt(0) * 23) % 360), filter: "sepia(0.5)" }}>
                  <span className="location-thumb-tag">{r.count} item{r.count === 1 ? "" : "s"}</span>
                </div>
                <div className="location-body">
                  <div className="location-name">{r.name}</div>
                  <div className="location-meta">{r.count} historical reference{r.count === 1 ? "" : "s"}</div>
                </div>
              </article>
              );
            })}
          </div>
        )
      )}

      {/* v01k — VO / Narration as a sibling category tab. AudioPage
          already renders character-voice + narration sections + the
          Spotify-style mini-player. */}
      {_isPF && tab === "vo" && window.AudioPage && (
        <div className="assets-vo-wrap">
          <window.AudioPage/>
        </div>
      )}
      {/* 24 Sep 2026 (G5) — the same page for another project, on its own audio_assets. */}
      {!_isPF && tab === "vo" && !_catIds.includes("vo") && window.AudioPage && (
        <div className="assets-vo-wrap">
          <window.AudioPage/>
        </div>
      )}

      {/* v01k — Music placeholder. Will populate from WATCH_PATH/music
          once the folder watcher gains the audio passthrough. */}
      {_isPF && tab === "music" && (
        <div className="assets-empty">
          <div className="assets-empty-title">Music tracks</div>
          <div className="assets-empty-sub">Score and music cues will appear here once cleared and uploaded.</div>
        </div>
      )}

      {/* 15 Sep 2026 — TEMPLATED PROJECT: one generic grid per declared category
          (card = name / cover image when there is one / status pill / the
          category's first text fields). Drag-reorder + the item modal work the
          same as the film-doc grids; the kind handed to the modal is the
          category id itself. */}
      {!_isPF && _catIds.includes(tab) && (() => {
        const cat = _cats.find(c => c.id === tab) || { id: tab, label: tab };
        const list = reorder.ordered;
        const fieldDefs = Array.isArray(cat.fields) ? cat.fields : null;
        const metaOf = (a) => {
          // the first short text field the template declares (role / played_by / …),
          // else the classic role / type line; never the long description.
          if (fieldDefs) {
            for (const f of fieldDefs) {
              if (!f || f.type === "longtext" || f.key === "notes") continue;
              const v = a[f.key]; if (typeof v === "string" && v.trim()) return v;
            }
            return "";
          }
          return (typeof a.role === "string" && a.role) || (typeof a.type === "string" && a.type) || "";
        };
        if (!list.length) {
          return (
            <div className="archive-empty glass">
              No {cat.label.toLowerCase()} yet. Hit the <strong>+</strong> above to add the first one.
            </div>
          );
        }
        return (
          <div className={"props-grid" + (mergeMode && tab === "locations" ? " is-merge-mode" : "")}>
            {list.map(a => {
              const key = a.id || a.slug || a.name;
              const img = a.image || a.cover_url || null;
              // 24 Sep 2026 (G6) — the merge-duplicate-locations picker, as on Paradise Found's location cards.
              const _mm = mergeMode && tab === "locations";
              const picked = _mm && mergeSel.includes(a.id);
              const isPrimary = _mm && mergePrimary === a.id;
              return (
                <article key={key} data-asset-card-id={a.id || a.slug}
                  className={"prop-card glass interactive-card" + (picked ? " is-merge-pick" : "") + (isPrimary ? " is-merge-primary" : "") + (_mm ? "" : reorder.cls(a))}
                  {...(_mm ? {} : reorder.cardProps(a))}
                  onClick={() => _mm ? toggleMergePick(a.id) : setOpenItem({ kind: tab, item: a })}>
                  {/* 16 Sep 2026 — vector artwork (a logo) is shown whole: `cover` cut a wordmark down to its middle letters */}
                  <div className="prop-frame prop-frame--portrait" style={img ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(img, 560) : img})`, backgroundSize: /\.svg(\?|$)/i.test(img) ? "contain" : "cover", backgroundRepeat: "no-repeat", backgroundPosition: "center" } : {}}>
                    {!img && <span className="character-thumb-initials">{String(a.name || "").split(" ").slice(0, 2).map(n => n[0]).join("")}</span>}
                    {picked && <span className={"location-merge-badge" + (isPrimary ? " is-primary" : "")}>{isPrimary ? "★ KEEP" : "MERGE IN"}</span>}
                    {picked && !isPrimary && (
                      <button type="button" className="location-merge-keep"
                        onClick={(e) => { e.stopPropagation(); setMergePrimary(a.id); }}>★ Keep this</button>
                    )}
                  </div>
                  <div className="prop-body">
                    <div className="asset-card-titlerow">
                      <div className="prop-name">{a.name}</div>
                      <AssetStatusPill status={effAssetStatus(a.status, (Array.isArray(a.shots) ? a.shots.length : 0))} className="asset-status-pill--title" />
                    </div>
                    {/* always rendered so a card never changes height when a field is filled in */}
                    <div className="character-role">{metaOf(a) || " "}</div>
                  </div>
                </article>
              );
            })}
          </div>
        );
      })()}

      </div>{/* /.vp-scroll — modals live outside the scroll/mask */}

      {openItem && (
        <AssetItemModal
          /* v07zu — Key on item.id so the modal fully remounts when
             the user paginates. Without this internal state persists
             from the previous item and the arrow buttons stop
             working after the first click. */
          key={(openItem.item && (openItem.item.id || openItem.item.slug || openItem.item.folder || openItem.item.name)) || "no-item"}
          kind={openItem.kind}
          item={openItem.item}
          onClose={() => { setOpenItem(null); setNavDir(null); }}
          navDir={navDir}
          /* v07zt/v07zv — Paginate within the current kind's list.
             Uses functional setState so each click reads the LATEST
             openItem (not whatever was captured at first render).
             Without this, the second click was operating on the
             original item again and bouncing back to it. */
          onNavigate={(dir) => {
            setNavDir(dir > 0 ? "next" : "prev");
            setOpenItem(curr => {
              if (!curr || !curr.item) return curr;
              const PLURAL = {
                animal: "animals", location: "locations", prop: "props",
              };
              let list;
              if (curr.kind === "archival") list = archival;
              // v07zz81 — Historical Refs uses the same batch shape as
              // Archival; the arrows previously no-op'd because this
              // branch was missing (list stayed undefined → early-out).
              else if (curr.kind === "historical") list = historical;
              else if (curr.kind === "ref") list = refs;
              else if (curr.kind === "animal") {
                // v07zw — Mirror the render-side fallback so the arrows
                // paginate through PARADISE_ANIMALS when assets.animals
                // is empty (was the cause of "arrows don't work on
                // Animals at all").
                list = (assets.animals && assets.animals.length) ? assets.animals : PARADISE_ANIMALS;
              } else if (!_isPF && _catIds.includes(curr.kind)) {
                // 15 Sep 2026 — templated project: the kind IS the category id.
                list = assets[curr.kind];
              } else {
                const k = PLURAL[curr.kind];
                list = k ? assets[k] : null;
              }
              if (!Array.isArray(list) || list.length < 2) return curr;
              const cid = curr.item.id, csl = curr.item.slug, cfd = curr.item.folder, cnm = curr.item.name;
              const idx = list.findIndex(a =>
                (cid && a.id === cid) ||
                (csl && a.slug === csl) ||
                (cfd && a.folder === cfd) ||
                (cnm && a.name === cnm)
              );
              if (idx < 0) return curr;
              const nextIdx = (idx + dir + list.length) % list.length;
              const nextItem = list[nextIdx];
              if (curr.kind === "archival") {
                return {
                  kind: "archival",
                  item: {
                    ...nextItem,
                    type: `${nextItem.count} frames`,
                    scenes: nextItem.name,
                    notes: `Archive batch: ${nextItem.name}. ${nextItem.count} frames pulled from ${nextItem.folder}.`,
                    references: (nextItem.items || []).map(it => ({ ...it })),
                    image: nextItem.cover,
                  },
                };
              }
              // v07zz81 — Historical Refs: reconstruct the same shape
              // the card click handler builds (line ~2101) so the modal
              // doesn't go blank after pagination.
              if (curr.kind === "historical") {
                return {
                  kind: "historical",
                  item: {
                    ...nextItem,
                    type: `${nextItem.count} references`,
                    scenes: nextItem.name,
                    notes: `Historical batch: ${nextItem.name}. ${nextItem.count} files pulled from ${nextItem.folder}.`,
                    references: (nextItem.items || []).map(it => ({ ...it })),
                    image: nextItem.cover,
                  },
                };
              }
              return { kind: curr.kind, item: nextItem };
            });
          }}
        />
      )}

      {openCharacter && (
        <CharacterDetailModal
          /* v07zu — Key on the character id so paginating fully
             remounts the modal (clears galleryIdx, voice state,
             edit toggle, etc.). Without this the arrow buttons
             feel broken after the first click. */
          key={openCharacter}
          character={assets.characters.find(c => c.id === openCharacter)}
          onClose={() => { setOpenCharacter(null); setNavDir(null); }}
          navDir={navDir}
          /* v07zs/v07zv — Functional setState so the closure always
             reads the latest openCharacter id, not whatever was
             captured at first render. */
          onNavigate={(dir) => {
            setNavDir(dir > 0 ? "next" : "prev");
            setOpenCharacter(curr => {
              const list = assets.characters;
              if (!Array.isArray(list) || list.length < 2) return curr;
              const idx = list.findIndex(c => c.id === curr);
              if (idx < 0) return curr;
              const next = (idx + dir + list.length) % list.length;
              return list[next].id;
            });
          }}
        />
      )}

      {/* v07zn — Per-category asset creation modal. Opens when the
          + button next to the tabs row is clicked. */}
      {/* v07zs — Hidden file input + UploadModal for the Refs tab's
          + button. When the user picks files, refsPending becomes
          an array of pre-seeded upload rows; the shared UploadModal
          (from FooterRow.jsx, mounted via window.UploadModal) takes
          over from there. */}
      <input
        type="file"
        ref={refsFileInputRef}
        multiple
        accept=".png,.jpg,.jpeg,.webp,.tiff,.mp4,.mov,image/*,video/*"
        style={{ display: "none" }}
        onChange={(e) => { handleRefsFiles(e.target.files); e.target.value = ""; }}
      />
      {refsPending && window.UploadModal && (
        <window.UploadModal
          files={refsPending.map(it => it.file)}
          onClose={() => setRefsPending(null)}
          onComplete={() => { /* nothing extra; UploadModal already broadcasts */ }}
        />
      )}
      {creatingKind && (
        <CreateAssetModal
          kind={creatingKind}
          onClose={() => setCreatingKind(null)}
          onCreated={(asset) => {
            // Update window.__appData.assets so the new card renders
            // without a full page refresh.
            const PLURAL = {
              character: "characters", animal: "animals", location: "locations",
              prop: "props", ref: "refs",
            };
            // 15 Sep 2026 — templated project: creatingKind is already the category id.
            const key = _isPF ? PLURAL[creatingKind] : (_catIds.includes(creatingKind) ? creatingKind : PLURAL[creatingKind]);
            if (key && window.__appData && window.__appData.assets) {
              const arr = window.__appData.assets[key] || [];
              window.__appData.assets[key] = [...arr, asset];
            }
            setCreatingKind(null);
            // Fan event so the AssetsView's render bump fires too.
            try {
              window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed"));
            } catch (_) {}
          }}
        />
      )}
    </section>
  );
}

// v07zz252 — WIP shortlist gallery (3-tier funnel: Generations → WIP → Approved).
// Shows the asset's is_wip=3 candidates (sent over from the Generate page's
// "Send to WIP" action). From here Hugo picks the winner: Approve copies it onto
// the asset as a real reference (reuses POST /promote) and clears it out of WIP;
// ↩ returns it to the Generations pool; ✕ discards (soft, recoverable). Hidden
// entirely when the asset has no WIP candidates, so it never adds empty chrome.
const _CAT_FOR_KIND = { character: "characters", animal: "animals", location: "locations", prop: "props" };
function AssetWipGallery({ category, slug, onChanged, onZoom }) {
  const [items, setItems] = React.useState(null);   // null = loading
  const [busy, setBusy] = React.useState(0);
  const [approved, setApproved] = React.useState(() => new Set()); // v07zz253 — badge, don't remove
  const [collapsed, setCollapsed] = React.useState(true);  // v07zz369 — Hugo: WIP tray collapsed by default; click the header to expand
  // v07zz424 — Hugo: hover a WIP thumbnail to see a big floating preview. Reuses the
  // shared useHoverPreview hook (eager global from src/useHoverPreview.jsx),
  // so it inherits the 350ms open delay + auto-hide on click/scroll for free.
  const { hoverBind: wipHoverBind, portal: wipHoverPortal } = useHoverPreview();
  const load = React.useCallback(() => {
    if (!category || !slug) { setItems([]); return; }
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/assets/${encodeURIComponent(category)}/${encodeURIComponent(slug)}/wip?stage=wip`)
      .then(r => r.ok ? r.json() : null)
      .then(d => setItems((d && d.items) || []))
      .catch(() => setItems([]));
  }, [category, slug]);
  React.useEffect(() => { load(); }, [load]);
  // Live refresh when a gen is sent to WIP (or a WIP item moves) elsewhere.
  React.useEffect(() => {
    const on = (e) => { const m = (e && e.detail) || {}; if (m.type === "asset.wip_stage" || m.type === "asset_wip_added" || m.type === "asset.promoted") load(); };
    window.addEventListener("paradise-sse", on);
    return () => window.removeEventListener("paradise-sse", on);
  }, [load]);
  const act = async (id, what) => {
    const fetcher = window.authFetch || fetch;
    const base = `/api/assets/${encodeURIComponent(category)}/${encodeURIComponent(slug)}`;
    setBusy(id);
    try {
      if (what === "approve") {
        // v07zz253 — Hugo: COPY onto the asset, NEVER move/clear the WIP original.
        // /promote already copies the file (the wip/ original + its /local URL stay
        // put), so presentation slides that link to the WIP image keep resolving.
        // We DON'T touch the WIP row's tier — it stays in the shortlist, badged
        // "Approved", so it remains selectable everywhere.
        await fetcher(`${base}/promote`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reference_id: id }) });
        setApproved(prev => { const n = new Set(prev); n.add(id); return n; });
        if (onChanged) onChanged();
        if (window.reloadAppData) window.reloadAppData();
        return;   // keep the item in the WIP gallery
      } else if (what === "return") {
        await fetcher(`${base}/wip-stage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reference_id: id, stage: "generation" }) });
      } else if (what === "discard") {
        await fetcher(`${base}/wip/${encodeURIComponent(id)}`, { method: "DELETE" });   // 24 Sep 2026 — "disk:<file>" ids
      }
      setItems(prev => (prev || []).filter(x => x.id !== id));   // return/discard remove from WIP
      if (onChanged) onChanged();
      if (window.reloadAppData) window.reloadAppData();
    } catch (_) {} finally { setBusy(0); }
  };
  if (!items || !items.length) return null;   // loading or empty → render nothing
  const tu = window.thumbUrl || ((s) => s);
  return (
    <div className={"asset-wip-gallery" + (collapsed ? " is-collapsed" : "")}>
      <button type="button" className="asset-wip-head" onClick={() => setCollapsed(c => !c)} aria-expanded={!collapsed} title={collapsed ? "Expand WIP tray" : "Collapse WIP tray"}>
        <svg className="asset-wip-chevron" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18l6-6-6-6"/></svg>
        <span>WIP — pick &amp; approve</span>
        <span className="asset-wip-count">{items.length}</span>
      </button>
      {!collapsed && (
      <div className="asset-wip-grid">
        {items.map(it => {
          const isApproved = approved.has(it.id);
          return (
          <div key={it.id} className={"asset-wip-card" + (isApproved ? " is-approved" : "")}>
            {/* v07zz257 — thumb shows the image's NATURAL aspect ratio (no square
                crop) and is a button: click zooms into the modal's shared Lightbox. */}
            <button type="button" className="asset-wip-thumb" title="Hover to preview · click to zoom" {...wipHoverBind(it.url)}
              onClick={() => onZoom && onZoom(it.url)}>
              <img src={tu(it.url, 400)} alt="" loading="eager"/>
              {isApproved && <span className="asset-wip-badge">✓ Approved</span>}
              <span className="asset-wip-zoom" aria-hidden="true">⤢</span>
            </button>
            <div className="asset-wip-acts">
              {/* v07zz258 — Hugo: just a green tick to approve (no text button). */}
              <button type="button" className="asset-wip-btn approve" disabled={busy === it.id || isApproved}
                title={isApproved ? "Approved onto the asset" : "Approve onto the asset"} aria-label="Approve" onClick={() => act(it.id, "approve")}>
                <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>
              </button>
              <button type="button" className="asset-wip-btn" disabled={busy === it.id} title="Return to Generations" aria-label="Return to Generations" onClick={() => act(it.id, "return")}>↩</button>
              <button type="button" className="asset-wip-btn danger" disabled={busy === it.id} title="Discard" aria-label="Discard" onClick={() => act(it.id, "discard")}>✕</button>
            </div>
          </div>
          );
        })}
      </div>
      )}
      {wipHoverPortal}
    </div>
  );
}
window.AssetWipGallery = AssetWipGallery;

// v07zz380 — Hero-aspect cache (asset id → isWide). The reshape modals
// (CharacterDetailModal + AssetItemModal) seed their heroWide from here so a
// re-opened asset opens at the right shape with NO resize/zoom; the hero <img>
// onLoad fills it. A cache miss defaults to false (normal/portrait width), so a
// portrait asset never flashes wide-then-narrow on open.
const _assetHeroAspect = new Map();
// v706 — mirrors ASSET_FUNNEL_ROLES in server.js. The UI gate MUST match the server
// gate or people see buttons that silently 403 (3 directors + 1 editor did). `!r` fails
// open so BYPASS_AUTH local dev is unchanged.
const ASSET_FUNNEL_ROLES = ["admin", "producer", "tester", "supervisor", "director", "lead", "editor", "artist"];
function canMoveAssetRefs() {
  const r = window.__effectiveRole || (window.__currentUser && window.__currentUser.role);
  return !r || ASSET_FUNNEL_ROLES.includes(r);
}
function CharacterDetailModal({ character: characterProp, onClose, onNavigate, navDir }) {
  // v06p — Local mirror of the character record so the modal can
  // re-fetch fresh data (enriched URLs with up-to-date mtime
  // cache-busters, fresh references[] list after a file change)
  // without forcing the whole AssetsView to reload. Falls back
  // to the prop when no refresh has happened yet.
  const [character, setCharacter] = React.useState(characterProp);
  const [refreshing, setRefreshing] = React.useState(false);
  // v07zz287 — phone tier: drop the Direction-Presets manager, surface NOTES
  // inline (like the PDF deck), and lift ROLE IN STORY to the top.
  const _uiTier = window.useUiTier ? window.useUiTier() : "";
  const isMobile = _uiTier === "s";
  // mobile swipe between assets (arrows hidden on phone); onNavigate(±1) wraps.
  const _swipeRef = window.useSwipeNav ? window.useSwipeNav({
    onPrev: () => onNavigate && onNavigate(-1),
    onNext: () => onNavigate && onNavigate(1),
    enabled: isMobile,
  }) : undefined;
  React.useEffect(() => { setCharacter(characterProp); }, [characterProp && characterProp.id]);
  const refreshCharacter = React.useCallback(async (opts = {}) => {
    if (!characterProp || !characterProp.id) return;
    if (!opts.silent) setRefreshing(true);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher(`/api/assets/character/${encodeURIComponent(characterProp.id)}`);
      if (!r.ok) return;
      const j = await r.json();
      if (j && j.character) setCharacter(j.character);
    } catch (_) {}
    finally { if (!opts.silent) setRefreshing(false); }
  }, [characterProp && characterProp.id]);
  // Run a silent refresh whenever the modal opens for a character.
  React.useEffect(() => { refreshCharacter({ silent: true }); }, [refreshCharacter]);

  const [galleryIdx, setGalleryIdx] = React.useState(0);
  const [addLibOpen, setAddLibOpen] = React.useState(false); // v705 — "+ FROM LIBRARY" strip tile
  const [zoomSrc, setZoomSrc] = React.useState(null); // v07zz184 — full-screen lightbox for the main asset image
  const [deleting, setDeleting] = React.useState(false); // v07zz188 — delete ANY reference image
  const [orderOverride, setOrderOverride] = React.useState(null); // v07zz188 — optimistic drag order (filenames)
  const [pendingDelete, setPendingDelete] = React.useState(null); // v07zz190 — styled delete-confirm target
  const _dragFrom = React.useRef(null);
  const [dropTarget, setDropTarget] = React.useState(null); // v07zz238 — gold drop-line {idx, side}
  React.useEffect(() => { setOrderOverride(null); }, [characterProp && characterProp.id]);
  // v705 — HERO | WIP | ARCHIVED switch on the gallery strip, identical to the shot
  // modal's FRAMES strip and to AssetItemModal's (see the long comment there). These
  // hooks MUST stay above the `if (!character) return null` guard below — the modal
  // renders null before the first fetch resolves, and a hook after the guard changes
  // the hook count between renders (the v07zz72 crash class).
  const _charSlug = characterProp && (characterProp.id || characterProp.slug);
  const _charBase = _charSlug ? `/api/assets/characters/${encodeURIComponent(_charSlug)}` : null;
  const [stripFunnel, setStripFunnel] = React.useState("hero");
  const [wipRefs, setWipRefs] = React.useState(null);      // null = not fetched yet
  const [archRefs, setArchRefs] = React.useState(null);
  const [funnelBusy, setFunnelBusy] = React.useState(null);
  const [funnelErr, setFunnelErr] = React.useState("");   // v706 — failures were console-only
  React.useEffect(() => { setStripFunnel("hero"); setWipRefs(null); setArchRefs(null); setFunnelErr(""); }, [_charSlug]);
  const _loadBucket = React.useCallback((which) => {
    if (!_charBase) return;
    const url = which === "wip" ? `${_charBase}/wip?stage=all` : `${_charBase}/archived`;
    const set = which === "wip" ? setWipRefs : setArchRefs;
    (window.authFetch || fetch)(url)
      .then(r => r.ok ? r.json() : null)
      // v706 — a promoted gen is in HERO now; leaving it in WIP too made ★ look inert.
      .then(d => set(((d && d.items) || []).filter(r => r && r.url && !(which === "wip" && r.promoted))))
      .catch(() => set([]));
  }, [_charBase]);
  // v706 — prefetch BOTH on open so the counts are honest before you touch anything.
  React.useEffect(() => {
    if (wipRefs === null) _loadBucket("wip");
    if (archRefs === null) _loadBucket("archived");
  }, [wipRefs, archRefs, _loadBucket]);
  // v818 — same live refresh as AssetItemModal: a picture dropped into this
  // character's folder on disk lands in the strip without a manual reload.
  React.useEffect(() => {
    const fn = (e) => {
      const m = (e && e.detail) || {};
      if (m.type !== "asset_reference_added") return;
      if (!_charSlug || String(m.slug || "") !== String(_charSlug)) return;
      if (m.is_wip) _loadBucket("wip"); else refreshCharacter({ silent: true });
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [_charSlug, _loadBucket, refreshCharacter]);
  // v07zz121 — Only admins may CHANGE a character's voice; everyone else can
  // still HEAR/PLAY it. (Hugo.)
  const isAdmin = (window.__effectiveRole || (window.__currentUser && window.__currentUser.role)) === "admin";
  const [voicePlaying, setVoicePlaying] = React.useState(false);
  // v07y — Hugo: notes drawer instead of inline. Click "NOTES" pill
  // (right of the stats row) → drawer slides in from the right edge
  // of the modal panel. Close with the X inside the drawer.
  const [notesDrawerOpen, setNotesDrawerOpen] = React.useState(false);
  // v07zz380 — when the hero image is widescreen (≥ 16:9) the whole modal grows
  // wider + more landscape so the picture gets far more room (Hugo: "21:9 image →
  // maximise the image space"). Seed from the per-asset aspect CACHE (not the
  // project aspect) so a portrait asset opens narrow and NEVER flashes wide-then-
  // narrow, and a re-opened asset opens at exactly the right shape (no resize, no
  // zoom). The <img> onLoad fills the cache the first time it's seen.
  const [heroWide, setHeroWide] = React.useState(() => {
    try { const k = characterProp && (characterProp.id || characterProp.slug); return k ? !!_assetHeroAspect.get(k) : false; } catch (_) { return false; }
  });
  // v07zz376 — the reshape transition is OFF until the first hero image has loaded
  // and settled heroWide, so a FRESH open snaps straight to the right shape (no
  // animated resize / image zoom on open). Only flips after that animate.
  const [modalAnim, setModalAnim] = React.useState(false);
  const firstHeroLoadRef = React.useRef(false);
  // v07zr — Edit modal toggle for the character's text fields.
  const [editingChar, setEditingChar] = React.useState(false);
  // v07zw — Styled archive-confirmation modal (replaces browser confirm()).
  const [archivingChar, setArchivingChar] = React.useState(false);
  const [archiveBusy, setArchiveBusy] = React.useState(false);
  const [charNoteCount, setCharNoteCount] = React.useState(0);
  // Fetch the count so the NOTES pill can show a badge even when
  // the drawer is closed. Refreshes on paradise-sse note_added.
  React.useEffect(() => {
    if (!characterProp || !characterProp.id) return;
    const refresh = () => {
      const fetcher = window.authFetch || fetch;
      fetcher(`/api/notes?entity_type=asset_character&entity_id=${encodeURIComponent(characterProp.id)}&include_resolved=true`)
        .then(r => r.ok ? r.json() : null)
        .then(d => setCharNoteCount((d && d.notes) ? d.notes.length : 0))
        .catch(() => {});
    };
    refresh();
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (msg && (msg.type === "note_added" || msg.type === "note_resolved")) refresh();
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [characterProp && characterProp.id]);
  // v06p — Voice assignment state. `assignedVoiceId` mirrors the
  // value persisted in data/assets.json. The dropdown reads from
  // `voices` (fetched once when the modal mounts) and any change
  // hits POST /api/assets/character/:id/voice immediately.
  const [voices, setVoices] = React.useState([]);
  const [voicesErr, setVoicesErr] = React.useState(null);
  const [assignedVoiceId, setAssignedVoiceId] = React.useState(character ? (character.voice_id || "") : "");
  const [assignedVoiceName, setAssignedVoiceName] = React.useState(character ? (character.voice_name || "") : "");
  // v07zz — Voice on/off. Many characters never get a voice, so the VOICE +
  // direction-preset sections are just clutter. When unset, default to ON
  // only if a voice is already assigned (auto-hides voiceless characters).
  const _voiceDefault = (c) => (c && c.voice_enabled != null) ? !!c.voice_enabled : !!(c && c.voice_id);
  const [voiceEnabled, setVoiceEnabled] = React.useState(() => _voiceDefault(character));
  const [savingVoiceToggle, setSavingVoiceToggle] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [saveErr, setSaveErr] = React.useState(null);
  const [previewLoading, setPreviewLoading] = React.useState(false);
  const [previewErr, setPreviewErr] = React.useState(null);
  const previewAudioRef = React.useRef(null);
  // v06p — Direction-preset manager state. `directions` is the
  // current saved array (mirrors character.voice_directions);
  // `draft` tracks an in-progress edit (label + body) for the
  // inline add/edit form. `suggesting` flags an AI-suggest call.
  const [directions, setDirections] = React.useState(
    Array.isArray(character && character.voice_directions) ? character.voice_directions : []
  );
  const [draftLabel, setDraftLabel] = React.useState("");
  const [draftBody, setDraftBody] = React.useState("");
  const [editingId, setEditingId] = React.useState(null);
  // v06p — Show/hide the inline add form on demand. Starts collapsed
  // so the empty state isn't cluttered with a form the user hasn't
  // asked for yet.
  const [adding, setAdding] = React.useState(false);
  const [savingDir, setSavingDir] = React.useState(false);
  const [savingDirErr, setSavingDirErr] = React.useState(null);
  const [suggesting, setSuggesting] = React.useState(false);
  const [suggestErr, setSuggestErr] = React.useState(null);
  // v06p — Inline delete confirm. `confirmDeleteId` holds the
  // preset id pending a confirmation; clicking × on a row sets
  // this and the row morphs into a "Delete this preset? [Cancel]
  // [Delete]" strip instead of triggering the browser's native
  // confirm dialog.
  const [confirmDeleteId, setConfirmDeleteId] = React.useState(null);
  // v06p — AI suggest modal. `showSuggestPrompt` toggles a small
  // popover with a hint textarea; submitting passes the hint to
  // the server alongside the character bio.
  const [showSuggestPrompt, setShowSuggestPrompt] = React.useState(false);
  const [suggestHint, setSuggestHint] = React.useState("");
  const [suggestCount, setSuggestCount] = React.useState(4);
  // v07zz232 — wheel-scroll (was drag-scroll, which fought drag-to-reorder).
  // The arrow buttons still call scrollBy on galleryEl.current.
  const galleryEl = React.useRef(null);
  const galleryRef = React.useCallback((el) => _attachWheelScroll(el, galleryEl), []);

  // v06p — Reset state if the modal is reused for a different character.
  React.useEffect(() => {
    setAssignedVoiceId(character ? (character.voice_id || "") : "");
    setAssignedVoiceName(character ? (character.voice_name || "") : "");
    setVoiceEnabled(_voiceDefault(character));
    setSaveErr(null);
    setPreviewErr(null);
    setDirections(Array.isArray(character && character.voice_directions) ? character.voice_directions : []);
    setDraftLabel("");
    setDraftBody("");
    setEditingId(null);
    setAdding(false);
    setSavingDirErr(null);
    setSuggestErr(null);
    setConfirmDeleteId(null);
    setShowSuggestPrompt(false);
    setSuggestHint("");
  }, [character && character.id]);

  // v06p — Persist a new directions[] array back to the server. The
  // PUT endpoint replaces the whole list so we just send the
  // current state.
  const persistDirections = async (next) => {
    if (!character) return;
    setSavingDir(true);
    setSavingDirErr(null);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher(`/api/assets/character/${encodeURIComponent(character.id)}/voice-directions`, {
        method: "PUT",
        body: JSON.stringify({ presets: next }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const j = await r.json();
      const out = Array.isArray(j.voice_directions) ? j.voice_directions : next;
      setDirections(out);
      if (character) character.voice_directions = out;
    } catch (e) { setSavingDirErr(e.message || String(e)); }
    finally { setSavingDir(false); }
  };

  const startEdit = (p) => {
    setEditingId(p.id);
    setDraftLabel(p.label || "");
    setDraftBody(p.body || "");
  };
  const cancelEdit = () => {
    setEditingId(null);
    setDraftLabel("");
    setDraftBody("");
    setAdding(false);
  };
  const saveDraft = async () => {
    const label = draftLabel.trim();
    const body  = draftBody.trim();
    if (!label || !body) return;
    let next;
    if (editingId) {
      next = directions.map(p => p.id === editingId ? { ...p, label, body } : p);
    } else {
      next = [...directions, { id: `vd-${Date.now().toString(36)}`, label, body }];
    }
    await persistDirections(next);
    cancelEdit();
  };
  const requestDelete = (id) => setConfirmDeleteId(id);
  const cancelDelete = () => setConfirmDeleteId(null);
  const confirmDelete = async () => {
    if (!confirmDeleteId) return;
    const next = directions.filter(p => p.id !== confirmDeleteId);
    await persistDirections(next);
    setConfirmDeleteId(null);
  };
  const suggestDirections = async () => {
    if (!character) return;
    setSuggesting(true);
    setSuggestErr(null);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher("/api/voiceover/suggest-directions", {
        method: "POST",
        body: JSON.stringify({
          character_id:   character.id,
          character_name: character.name,
          role:           character.role || "",
          notes:          character.notes || "",
          // v06p — Optional hint from the user describing exactly
          // what feel they want for these presets. e.g. "I want
          // him to sound weary and resigned, like an old man at
          // the end of his life looking back."
          hint:           (suggestHint || "").trim(),
          count: Math.max(1, Math.min(8, parseInt(suggestCount, 10) || 4)),
        }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const j = await r.json();
      const suggested = (j.presets || []).map((p, i) => ({
        id: `vd-${Date.now().toString(36)}-${i}`,
        label: p.label,
        body:  p.body,
      }));
      if (!suggested.length) throw new Error("Gemini returned no presets.");
      // Merge into existing list (don't blow away manual entries).
      const merged = [...directions];
      for (const p of suggested) {
        if (!merged.some(x => x.label.toLowerCase() === p.label.toLowerCase())) merged.push(p);
      }
      await persistDirections(merged.slice(0, 12));
      // Collapse the prompt popover after a successful run.
      setShowSuggestPrompt(false);
    } catch (e) { setSuggestErr(e.message || String(e)); }
    finally { setSuggesting(false); }
  };

  // v06p — Fetch available ElevenLabs voices for the dropdown.
  React.useEffect(() => {
    if (!character) return;
    const fetcher = window.authFetch || fetch;
    fetcher("/api/voiceover/voices")
      .then(r => r.ok ? r.json() : Promise.reject(r.statusText || `HTTP ${r.status}`))
      .then(d => setVoices(Array.isArray(d.voices) ? d.voices : []))
      .catch(e => setVoicesErr(String(e && e.message ? e.message : e)));
  }, [character && character.id]);

  // v06p — Pause any in-flight preview when the modal closes / unmounts.
  React.useEffect(() => () => {
    if (previewAudioRef.current) {
      try { previewAudioRef.current.pause(); } catch (_) {}
      previewAudioRef.current = null;
    }
  }, []);

  const saveVoice = async (newVoiceId) => {
    if (!character) return;
    setSaving(true);
    setSaveErr(null);
    try {
      const fetcher = window.authFetch || fetch;
      const matched = voices.find(v => v.voice_id === newVoiceId);
      const r = await fetcher(`/api/assets/character/${encodeURIComponent(character.id)}/voice`, {
        method: "POST",
        body: JSON.stringify({
          voice_id: newVoiceId || "",
          voice_name: matched ? matched.name : "",
          voice_preview_url: matched ? (matched.preview_url || "") : "",
        }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setAssignedVoiceId(newVoiceId || "");
      setAssignedVoiceName(matched ? matched.name : "");
      // Patch in-memory data so reopening the modal doesn't show stale info.
      if (character) {
        character.voice_id = newVoiceId || "";
        character.voice_name = matched ? matched.name : "";
        character.voice_preview_url = matched ? (matched.preview_url || "") : "";
      }
    } catch (e) { setSaveErr(e.message || String(e)); }
    finally { setSaving(false); }
  };

  // v07zz — Toggle the VOICE section on/off for this character. Persists
  // voice_enabled via the generic asset fields-patch (writes assets.json,
  // syncs via the _assets_json blob). Optimistic so the UI flips instantly.
  const toggleVoiceEnabled = async () => {
    if (!character || savingVoiceToggle) return;
    const next = !voiceEnabled;
    setVoiceEnabled(next);
    setSavingVoiceToggle(true);
    try { character.voice_enabled = next; } catch (_) {}
    try { const arr = window.__appData && window.__appData.assets && window.__appData.assets.characters; if (arr) { const it = arr.find(x => x.id === character.id); if (it) it.voice_enabled = next; } } catch (_) {}
    try {
      const fetcher = window.authFetch || fetch;
      await fetcher(`/api/assets/character/${encodeURIComponent(character.id)}/fields`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ fields: { voice_enabled: next } }),
      });
    } catch (_) { /* optimistic; assets.json write is best-effort */ }
    finally { setSavingVoiceToggle(false); }
  };

  // v07zz123 — Robust play. Ignores the benign "The play() request was
  // interrupted by a call to pause()" AbortError that fires when a rapid
  // re-click / re-render supersedes the audio, and only mutates state for
  // the CURRENT audio. Kills the scary error message + the "takes a couple
  // of tries" feel.
  const _playSafely = (audio) => {
    const p = audio.play();
    if (p && typeof p.then === "function") {
      p.then(() => { if (previewAudioRef.current === audio) setVoicePlaying(true); })
       .catch(e => {
         if (e && (e.name === "AbortError" || /interrupted by a call to pause/i.test(String(e.message || "")))) return;
         if (previewAudioRef.current === audio) { setPreviewErr(`Preview playback failed: ${e.message}`); setVoicePlaying(false); }
       });
    } else if (previewAudioRef.current === audio) {
      setVoicePlaying(true);
    }
  };
  const playPreview = async () => {
    if (!assignedVoiceId) return;
    // Stop any currently-playing preview.
    if (previewAudioRef.current) {
      try { previewAudioRef.current.pause(); } catch (_) {}
      previewAudioRef.current = null;
      setVoicePlaying(false);
    }
    // Prefer the cached preview_url on the voice object — free, instant.
    const matched = voices.find(v => v.voice_id === assignedVoiceId);
    const previewUrl = matched && matched.preview_url ? matched.preview_url : (character && character.voice_preview_url) || null;
    setPreviewErr(null);
    if (previewUrl) {
      const audio = new Audio(previewUrl);
      previewAudioRef.current = audio;
      audio.addEventListener("ended", () => setVoicePlaying(false));
      _playSafely(audio);
      return;
    }
    // No cached preview → POST /api/voiceover/preview to TTS a short line.
    setPreviewLoading(true);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher("/api/voiceover/preview", {
        method: "POST",
        body: JSON.stringify({
          voice_id: assignedVoiceId,
          text: `Hi, I'm ${character.name}. This is a quick voice preview.`,
        }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const audio = new Audio(url);
      previewAudioRef.current = audio;
      audio.addEventListener("ended", () => { setVoicePlaying(false); URL.revokeObjectURL(url); });
      _playSafely(audio);
    } catch (e) { setPreviewErr(e.message || String(e)); }
    finally { setPreviewLoading(false); }
  };

  const stopPreview = () => {
    if (previewAudioRef.current) {
      try { previewAudioRef.current.pause(); } catch (_) {}
      previewAudioRef.current = null;
    }
    setVoicePlaying(false);
  };
  // v06m — backdrop close requires the mousedown AND mouseup to BOTH
  // land on the backdrop. Stops gallery drags from dismissing the
  // modal when the user's pointer release happens outside the panel.
  const backdropDownRef = React.useRef(false);
  const onBackdropMouseDown = (e) => { backdropDownRef.current = (e.target === e.currentTarget); };
  const onBackdropClick = (e) => {
    if (backdropDownRef.current && e.target === e.currentTarget) onClose();
    backdropDownRef.current = false;
  };
  React.useEffect(() => {
    // v07zs — Arrow keys paginate through the character list when
    // onNavigate is provided. Esc still closes.
    const onKey = (e) => {
      if (e.key === "Escape") { onClose(); return; }
      if (onNavigate) {
        if (e.key === "ArrowLeft")  onNavigate(-1);
        if (e.key === "ArrowRight") onNavigate(1);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, onNavigate]);
  // v07zz72 — HOOKS-ORDER FIX: the gallery preload effect was below
  // the `if (!character) return null` guard, which meant React saw a
  // different hook count when character toggled null↔defined → the
  // same crash class that hit ScriptView pre-v07zz65. Lifted the
  // effect above the guard and made it bail internally when character
  // is null. Same behaviour, no crash.
  React.useEffect(() => {
    if (!character || !window.thumbUrl) return;
    const refs = Array.isArray(character.references) ? character.references : [];
    const galleryUrls = refs.length > 0
      ? refs.map(r => r.url)
      : (character.image ? [character.image] : []);
    for (const src of galleryUrls) {
      if (!src) continue;
      const img = new Image();
      img.src = window.thumbUrl(src, 800);
    }
  }, [character]);
  if (!character) return null;

  // v06f — Hugo: gallery is now built from the FULL reference set the
  // server scanned out of the character's folder under WATCH_PATH
  // (see /api/assets in server.js). The face shot is always first
  // because the server pre-sorted refs that way. Falls back to the
  // single static `image` when references[] is empty (deployed-without-
  // WATCH_PATH case).
  // v07zz252 — main gallery shows ONLY approved references (root files). Raw
  // generations + WIP candidates live under wip/ (r.is_wip path-flag): gens on the
  // Generate page, WIP in the AssetWipGallery below. This is the de-clutter Hugo
  // asked for — the modal no longer mixes every raw gen into the asset's gallery.
  const _allRefs = Array.isArray(character.references) ? character.references : [];
  const _approvedRefs = _allRefs.filter(r => !r.is_wip);
  // Hedge: only hide the wip/ images once the asset HAS approved references —
  // never leave an un-promoted asset's modal suddenly empty (no surprise loss).
  const refs = _approvedRefs.length ? _approvedRefs : _allRefs;
  // v705 — the strip's HERO | WIP | ARCHIVED bucket. Swapping `oref` swaps the whole
  // strip: gallery, filenames and activeRef all derive from it.
  // v706 — counts come from the SAME lists the tiles do (null until fetched). The old
  // disk-scan hint could disagree with what the bucket actually showed.
  const _funnelCounts = {
    hero: refs.length,
    wip: wipRefs ? wipRefs.length : null,
    archived: archRefs ? archRefs.length : null,
  };
  // v07zz188 — apply the optimistic drag order (filenames) over the server
  // order so a reorder shows instantly; the order is persisted + re-fetched.
  const oref = (() => {
    if (stripFunnel === "wip") return wipRefs || [];
    if (stripFunnel === "archived") return archRefs || [];
    if (!orderOverride || !refs.length) return refs;
    const byName = new Map(refs.map(r => [r.filename, r]));
    const seen = new Set(); const out = [];
    for (const n of orderOverride) { const r = byName.get(n); if (r && !seen.has(n)) { out.push(r); seen.add(n); } }
    for (const r of refs) if (!seen.has(r.filename)) out.push(r);
    return out;
  })();
  // v705 — the single-image fallback belongs to HERO only; an empty WIP/ARCHIVED
  // bucket must render as empty, not as the character's cover image.
  const gallery = oref.length > 0
    ? oref.map(r => r.url)
    : ((stripFunnel === "hero" && character.image) ? [character.image] : []);
  const galleryFilenames = oref.length > 0 ? oref.map(r => r.filename) : [];
  const activeImg = gallery[galleryIdx] || null;
  const activeFile = galleryFilenames[galleryIdx] || null;
  const RevealBtn = window.FileActionBtns || window.RevealInFolderBtn;
  // v705 — one handler for every tile move; source = the tab you're on.
  const moveCharRef = async (entry, to) => {
    if (!entry || !_charBase || funnelBusy) return;
    if (!canMoveAssetRefs()) { setFunnelErr("You don't have permission to move asset images."); return; }
    setFunnelBusy(entry.id || entry.filename); setFunnelErr("");
    const fetcher = window.authFetch || fetch;
    const post = (p, body) => fetcher(`${_charBase}${p}`, {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
    });
    try {
      let r;
      if (stripFunnel === "wip" && to === "hero") r = await post("/promote", { reference_id: entry.id });
      else if (stripFunnel === "wip" && to === "archived") r = await fetcher(`${_charBase}/wip/${encodeURIComponent(entry.id)}`, { method: "DELETE" });
      else if (stripFunnel === "archived" && to === "hero") r = await post("/restore-reference", { id: entry.id });
      else if (stripFunnel === "hero" && to === "wip") r = await post("/unpromote", { url: entry.url, filename: entry.filename });
      else if (stripFunnel === "hero" && to === "archived") r = await post("/delete-reference", { url: entry.url, filename: entry.filename });
      else return;
      const j = await r.json().catch(() => ({}));
      if (r && !r.ok) throw new Error((j && j.error) || `HTTP ${r.status}`);
      setOrderOverride(null); setGalleryIdx(0);
      // v706 — refetch in place (not set-to-null) so the strip doesn't flash empty.
      _loadBucket("wip"); _loadBucket("archived");
      await refreshCharacter({ silent: true });
      // v867 — STAY WHERE HE IS. Same reversal as the asset modal: the strip no longer
      // jumps to the bucket the image landed in. Hugo sorts in runs, and being thrown out
      // of HERO after every move cost a click back each time. The character modal and the
      // asset modal are separate components with their own copies of this handler — a fix
      // to one is only half a fix (same trap as the two shot-gen drawers).
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) {
      console.warn("[asset-funnel]", e.message);
      setFunnelErr(e.message || "That didn't work.");
    }
    finally { setFunnelBusy(null); }
  };
  // v07zz39/v705 — un-promote ("send back to WIP") is now the ◐ button on each
  // thumbnail, handled by moveCharRef above; the standalone active-image version
  // and its `unpromoting` flag are gone with the icon-row buttons.
  const activeRef = (oref && oref[galleryIdx]) || null;
  // v07zz188 — Delete ANY gallery image (works regardless of how it was added).
  // v07zz190 — styled-confirm-driven delete (no native window.confirm). The
  // trash button opens ConfirmDeleteImageModal; this runs on confirm.
  const doDeleteRef = async (ref) => {
    if (!ref || deleting) return;
    const slug = character.id || character.slug;
    if (!slug) { setPendingDelete(null); return; }
    setDeleting(true);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher(`/api/assets/characters/${encodeURIComponent(slug)}/delete-reference`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url: ref.url, filename: ref.filename }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setPendingDelete(null);
      setOrderOverride(null);
      setGalleryIdx(0);
      setWipRefs(null); setArchRefs(null);   // v706 — it's in ARCHIVED now
      await refreshCharacter();
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) {
      // v706 — invariant #22: no native alert; the error shows in the funnel row.
      // (This modal has no optimistic hide, so there's nothing to un-hide.)
      console.warn("[delete-reference]", e.message);
      setFunnelErr("Couldn't remove that image: " + e.message);
    }
    finally { setDeleting(false); }
  };
  // v07zz188 — Persist a drag reorder of the gallery (optimistic + synced).
  const reorderRefsTo = (from, to) => {
    if (from == null || to == null || from === to) return;
    const names = oref.map(r => r.filename).filter(Boolean);
    if (names.length !== oref.length) return;
    const next = names.slice();
    const [m] = next.splice(from, 1);
    next.splice(to, 0, m);
    const prev = orderOverride; // v07zz238 — revert this optimistic order if the save fails
    setOrderOverride(next);
    setGalleryIdx(to);
    const slug = character.id || character.slug;
    if (!slug) { setOrderOverride(prev); return; }
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/assets/characters/${encodeURIComponent(slug)}/reorder`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ order: next }),
    }).then(r => { if (!r.ok) { setOrderOverride(prev); throw new Error("reorder " + r.status); } return refreshCharacter({ silent: true }); })
      .catch(e => { setOrderOverride(prev); console.warn("[reorder]", e.message); });
  };

  // v07zv → v07zz72 — Preload of the gallery at width 800 used to be a
  // post-guard useEffect here. It's now hoisted above the early-return
  // guard (search for v07zz72 above) so the hook count stays constant.

  // v04e — render via portal (same reason as AssetItemModal).
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className={"char-modal-backdrop" + (heroWide ? " char-modal-backdrop--imgwide" : "")}
         data-nav-dir={navDir || undefined}
         onMouseDown={onBackdropMouseDown}
         onClick={onBackdropClick}>
      {/* v07zs — Prev/next arrows OUTSIDE the modal panel so the
          user can paginate through the character list without
          closing + re-opening. Click stopPropagation prevents the
          backdrop-close handler from firing. */}
      {onNavigate && (
        <>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--prev"
            onClick={(e) => { e.stopPropagation(); onNavigate(-1); }}
            aria-label="Previous character"
            title="Previous character"
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m15 6-6 6 6 6"/>
            </svg>
          </button>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--next"
            onClick={(e) => { e.stopPropagation(); onNavigate(1); }}
            aria-label="Next character"
            title="Next character"
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m9 6 6 6-6 6"/>
            </svg>
          </button>
        </>
      )}
      <div className={"char-modal glass" + (heroWide ? " char-modal--imgwide" : "")} ref={_swipeRef} onClick={(e) => e.stopPropagation()}>
        {/* v07zr — Edit button in the top-right corner, next to the
            close button. Opens an EditAssetModal so the user can
            edit name / role / notes without leaving the page. */}
        <button
          className="modal-edit-btn char-modal-edit"
          onClick={() => setEditingChar(true)}
          aria-label="Edit character"
          title="Edit"
        >
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 20h9"/>
            <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>
          </svg>
        </button>
        {/* v07zw — Archive button next to Edit. Soft-removes the
            character from the assets page; recoverable from
            Admin → Archived Assets. Uses the styled confirmation
            modal instead of the browser-default confirm(). */}
        <button
          className="modal-edit-btn char-modal-archive"
          onClick={() => character && setArchivingChar(true)}
          aria-label="Archive character"
          title="Archive"
        >
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M21 8H3v13h18V8z"/>
            <path d="M3 3h18v5H3z"/>
            <path d="M10 12h4"/>
          </svg>
        </button>
        {/* v07zz38 — Open in Generate. CharacterDetailModal is a SEPARATE
            component from AssetItemModal, which is why characters never got
            the button that animals/locations/props have. Mirror it here.
            v07zz281 — gated on generate_assets (admin-only) so non-generate
            roles don't see a Generate entry point (the page + /api/generate
            are gated on the same key). */}
        {character && (character.id || character.slug) && (!window.hasPerm || window.hasPerm("generate_assets")) && (
          <button
            className="md-generate-pill char-modal-generate"
            onClick={() => {
              const slug = character.id || character.slug || character.folder;
              if (!slug) return;
              window.__assetGenPersist = window.__assetGenPersist || {};
              window.__assetGenPersist.category = "characters";
              window.__assetGenPersist.selectedSlug = slug;
              window.__assetGenPersist.openRequest = { category: "characters", slug };  // v07zz281 — survives localStorage hydration (selectedSlug gets clobbered on first visit)
              window.__assetGenPersist.pendingOpen = true;   // v07zz39 — open the ASSETS flow, not shots
              // v07zz281 — fire an event so an ALREADY-MOUNTED Generate page jumps to
              // THIS asset (mutating __assetGenPersist alone is ignored once mounted,
              // which is why it always landed on the first asset).
              try { window.dispatchEvent(new CustomEvent("paradise-open-asset-gen", { detail: { category: "characters", slug } })); } catch (_) {}
              if (onClose) onClose();
              try { window.__nav && window.__nav.setView && window.__nav.setView("generate"); } catch (_) {}
            }}
            title="Open in Generate"
          >
            <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" stroke="none" aria-hidden="true" style={{ marginRight: 5, verticalAlign: "-1px" }}>
              <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>
            </svg>
            Generate
          </button>
        )}
        <button className="modal-close char-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        {editingChar && (
          <EditAssetModal
            kind="character"
            asset={character}
            onClose={() => setEditingChar(false)}
            onSaved={(updated) => {
              setCharacter(c => c ? { ...c, ...updated } : c);
              setEditingChar(false);
            }}
          />
        )}
        {archivingChar && character && (
          <ConfirmArchiveAssetModal
            kind="character"
            asset={character}
            busy={archiveBusy}
            onCancel={() => !archiveBusy && setArchivingChar(false)}
            onConfirm={async () => {
              setArchiveBusy(true);
              try {
                const fetcher = window.authFetch || fetch;
                const id = character.id || character.slug || character.folder;
                const r = await fetcher(`/api/assets/character/${encodeURIComponent(id)}/archive`, { method: "POST" });
                const b = await r.json().catch(() => ({}));
                if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
                try {
                  if (window.__appData && Array.isArray(window.__appData.assets.characters)) {
                    const arr = window.__appData.assets.characters;
                    const i = arr.findIndex(a => a.id === id || a.slug === id || a.folder === id);
                    if (i >= 0) arr[i] = { ...arr[i], archived: true, archived_at: new Date().toISOString() };
                  }
                  window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed"));
                } catch (_) {}
                setArchivingChar(false);
                onClose && onClose();
              } catch (e) { alert("Archive failed: " + e.message); }
              finally { setArchiveBusy(false); }
            }}
          />
        )}

        <div className="char-modal-grid">
          {/* LEFT: main image only — gallery moved below to span full width */}
          <div className="char-media">
            <div className={"char-main-img" + (activeImg && !isMobile ? " is-natural" : "")}
              style={activeImg
                ? (isMobile ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(activeImg, 800) : activeImg})` } : null)
                : { background: `linear-gradient(160deg, oklch(0.55 0.04 60), oklch(0.40 0.04 30))` }}>
              {/* v07zz369 — Hugo (desktop): hero takes the image's NATURAL aspect
                  ratio and fills the frame (was a fixed square with the image
                  letterboxed inside, leaving dead cream space). A real <img> sizes
                  itself. Mobile keeps its existing background-image hero. */}
              {activeImg && !isMobile && (
                <img className="char-main-img-el" src={window.thumbUrl ? window.thumbUrl(activeImg, 1200) : activeImg} alt={character.name} draggable={false}
                  onLoad={(e) => { const w = e.target.naturalWidth, h = e.target.naturalHeight; const wide = !!(w && h) && (w / h) >= 1.6; try { const k = character && (character.id || character.slug); if (k) _assetHeroAspect.set(k, wide); } catch (_) {} setHeroWide(wide); if (!firstHeroLoadRef.current) { firstHeroLoadRef.current = true; requestAnimationFrame(() => setModalAnim(true)); } }}/>
              )}
              {!activeImg && (
                <span className="character-thumb-initials">{character.name.split(" ").slice(0,2).map(n => n[0]).join("")}</span>
              )}
              {activeImg && (
                <div className="char-main-img-actions">
                  {/* v705 — the ARCHIVED button that used to sit here is GONE; archived
                      is now a bucket on the strip's HERO | WIP | ARCHIVED switch and
                      every per-image move lives on the thumbnails. This row is
                      view-only: full screen, refresh, reveal in folder. */}
                  {/* v07zz184 — Open the image full screen (same lightbox the
                      Generate "Generated Preview" uses). First action so it
                      lines up with the expand button Hugo asked to mirror. */}
                  <button type="button"
                    className="reveal-folder-btn char-img-expand-btn"
                    onClick={() => setZoomSrc(activeImg)}
                    title="Open full screen"
                    aria-label="Open full screen">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5"/><path d="M20 15v5h-5"/><path d="M9 20H4v-5"/><path d="M15 4h5v5"/></svg>
                  </button>
                  {/* v06p — Refresh button re-reads the
                      character from /api/assets/character/:id so
                      the image URL picks up a fresh mtime
                      cache-buster after Hugo overwrites the file
                      on disk. */}
                  <button type="button"
                    className="reveal-folder-btn char-img-refresh-btn"
                    onClick={() => refreshCharacter()}
                    disabled={refreshing}
                    title={refreshing ? "Refreshing…" : "Refresh image (re-read from disk)"}
                    aria-label="Refresh image">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
                      style={{ animation: refreshing ? "char-spin 0.8s linear infinite" : "none" }}>
                      <path d="M21 12a9 9 0 1 1-3.3-6.95"/>
                      <path d="M21 4v5h-5"/>
                    </svg>
                  </button>
                  {RevealBtn && (
                    <RevealBtn src={activeImg} label={activeFile ? `Open ${activeFile} in folder` : "Open in folder"}/>
                  )}
                  {/* v705 — "Send back to WIP" (◐) and "Delete/Archive" (🗄) moved onto
                      the thumbnails, per bucket. Nothing that MOVES an image is in
                      this row any more. */}
                </div>
              )}
            </div>
          </div>

          {/* RIGHT: details */}
          <div className="char-info">
            <div className="char-eyebrow">CHARACTER</div>
            <div className="char-name">{character.name}</div>
            <div className="char-role">{character.role}</div>

            <div className="char-stats-grid">
              <div className="char-stat">
                <div className="char-stat-num">{character.appearances}</div>
                <div className="char-stat-cap">SHOTS</div>
              </div>
              <div className="char-stat">
                <div className="char-stat-num">{character.ref_passes}</div>
                <div className="char-stat-cap">REF PASSES</div>
              </div>
              {/* v07zd — Hugo: LOCKED is now a toggleable gold star
                  button. Click to flip the consistency_locked flag
                  via POST /api/assets/character/:id/lock. The same
                  star treatment is used on the card thumbnail. */}
              <AssetLockStat
                kind="character"
                id={character.id}
                locked={!!character.consistency_locked}
                onChange={(next) => {
                  setCharacter(c => c ? { ...c, consistency_locked: next } : c);
                }}
              />
            </div>
            {/* v07zz240 — workflow status (First Pass → WIP → Retake → Approved). */}
            <AssetStatusControl
              kind="character"
              id={character.id}
              status={character.status}
              onChange={(next) => { setCharacter(c => c ? { ...c, status: next } : c); try { const arr = window.__appData && window.__appData.assets && window.__appData.assets.characters; if (arr) { const it = arr.find(x => x.id === character.id); if (it) it.status = next; } } catch (_) {} try { window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed")); } catch (_) {} }}
            />
            {/* v07zz369 — Notes affordance is ALWAYS visible. It used to live
                inside the voice-gated DIRECTION PRESETS block, so any character
                without a voice showed no notes button at all. Now it's its own
                row right under the status pills, independent of the voice switch. */}
            {/* v1001 — INLINE, not a drawer. Hugo: "it's opening the notes modal,
                but when i click on another image, the drawer leaves — get rid of the
                modal and put the notes dedicated to each image right on the notes
                section that has nothing there." The drawer sat over the right column
                and any click on the thumbnail strip fell on its scrim and closed it,
                so per-image notes were impossible to browse. AssetNotesSection already
                takes activeFile and carries its own This image | All notes switch — it
                is what mobile has always rendered inline. Desktop now does the same,
                so the list simply follows the gallery selection. */}
            {!isMobile && (
              <div className="char-section char-notes-section char-notes-inline">
                <AssetNotesSection kind="character" item={character} activeFile={activeFile}/>
              </div>
            )}
            {/* v07zz287 — mobile: lift ROLE IN STORY to the TOP (Hugo). The
                bottom copy below is rendered only on desktop. */}
            {isMobile && character.notes && (
              <div className="char-section char-role-top">
                <div className="char-section-head">ROLE IN STORY</div>
                <div className="char-role-text">{character.notes}</div>
              </div>
            )}
            {/* v07z — Notes pill moved to the DIRECTION PRESETS
                header (replacing the old position of Suggest with AI).
                The standalone char-tabs-row was removed. */}

            <div className="char-section">
              {/* v07zz — VOICE head carries an on/off switch. OFF collapses the
                  whole voice body + direction presets (clutter for the many
                  characters with no voice). */}
              <div className="char-section-head char-voice-head">
                <span>VOICE</span>
                <button type="button"
                  className={"char-voice-switch" + (voiceEnabled ? " is-on" : "")}
                  role="switch" aria-checked={voiceEnabled}
                  onClick={toggleVoiceEnabled} disabled={savingVoiceToggle}
                  title={voiceEnabled ? "Voice on — click to hide the voice section for this character" : "Voice off — click to enable"}>
                  <span className="char-voice-switch-track"><span className="char-voice-switch-knob"/></span>
                  <span className="char-voice-switch-txt">{voiceEnabled ? "On" : "Off"}</span>
                </button>
              </div>
              {!voiceEnabled && (
                <div className="char-voice-off-hint">No voice for this character. Turn the switch on to assign one.</div>
              )}
              {voiceEnabled && <>
              {/* v06p — Voice picker + preview. Replaces the faked
                  waveform player. Hugo assigns one ElevenLabs voice
                  per character; the Generate-page VO panel reads
                  this back and auto-selects the right voice when
                  generating dialogue for shots featuring this
                  character. */}
              <div className="char-voice-player">
                <button className={"char-voice-btn" + (voicePlaying ? " is-playing" : "")}
                  onClick={() => voicePlaying ? stopPreview() : playPreview()}
                  disabled={!assignedVoiceId || previewLoading}
                  aria-label={voicePlaying ? "Stop" : "Preview"}
                  title={!assignedVoiceId ? "Pick a voice first" : (voicePlaying ? "Stop preview" : "Play preview")}>
                  {previewLoading
                    ? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9" strokeDasharray="14 14" strokeLinecap="round"/></svg>
                    : voicePlaying
                    ? <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
                    : <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
                </button>
                {isAdmin ? (
                  <select className="char-voice-select"
                    value={assignedVoiceId}
                    onChange={e => saveVoice(e.target.value)}
                    disabled={saving || voices.length === 0}>
                    <option value="">— No voice assigned —</option>
                    {voices.map(v => (
                      <option key={v.voice_id} value={v.voice_id}>
                        {v.name}{v.category ? ` · ${v.category}` : ""}
                      </option>
                    ))}
                  </select>
                ) : (
                  <span className="char-voice-readonly">
                    {assignedVoiceName || (assignedVoiceId ? "Voice assigned" : "No voice assigned")}
                  </span>
                )}
              </div>
              <div className="char-voice-meta">
                {isAdmin
                  ? (voicesErr
                      ? <span style={{ color: "var(--danger, var(--red-24))" }}>Voices unavailable — {voicesErr}</span>
                      : voices.length === 0
                        ? "Loading voices from ElevenLabs…"
                        : assignedVoiceName
                          ? <>Assigned: <strong>{assignedVoiceName}</strong>{saving ? " · saving…" : ""}</>
                          : "Pick a voice from the dropdown to assign it to this character.")
                  : (assignedVoiceId
                      ? <>Voice: <strong>{assignedVoiceName || "assigned"}</strong> — press play to listen.</>
                      : "No voice assigned to this character yet.")}
                {isAdmin && saveErr && <div style={{ color: "var(--danger, var(--red-24))", marginTop: 4 }}>{saveErr}</div>}
                {previewErr && <div style={{ color: "var(--danger, var(--red-24))", marginTop: 4 }}>{previewErr}</div>}
              </div>
              </>}
            </div>

            {/* v06p — Direction presets manager. Lets Hugo save a
                handful of tone / delivery prompts for this
                character. The VO panel on the Generate page reads
                these and renders them as quick-pick chips above
                the Direction textarea. The "Suggest with AI"
                button asks Gemini Flash to propose 4 fresh
                presets based on the character's name / role /
                notes. */}
            {/* v07zz287 — mobile: drop the Direction-Presets manager entirely
                (Hugo: "get rid of Direction Presets on mobile"). The inline NOTES
                panel below replaces it. Desktop keeps it. */}
            {/* v07zz — direction presets are voice-delivery config; hide with
                the voice section when Voice is off. */}
            {!isMobile && voiceEnabled && (<div className="char-section">
              {/* v07z — Header row: title left, NOTES pill right.
                  Was: Suggest with AI on the right. Hugo moved the
                  Notes affordance into THIS slot for visibility. */}
              <div className="char-section-head char-direction-head">
                <span>DIRECTION PRESETS</span>
              </div>
              {/* v07z — Suggest with AI button moved here, under the
                  header, left-aligned. */}
              <div className="char-direction-suggest-row">
                <button type="button" className="char-direction-suggest-btn"
                  onClick={() => setShowSuggestPrompt(v => !v)}
                  disabled={suggesting || savingDir}>
                  {suggesting ? "Suggesting…" : "✨ Suggest with AI"}
                </button>
              </div>
              {/* v06p — Suggest-with-AI prompt popover. Click the
                  button → small panel slides in with a hint
                  textarea + count selector + Generate / Cancel.
                  Hint is optional; without it Gemini riffs purely
                  off the character's bio. */}
              {showSuggestPrompt && (
                <div className="char-direction-suggest-panel">
                  <label className="char-direction-suggest-label">What kind of delivery do you want?</label>
                  <textarea
                    className="char-direction-suggest-input"
                    rows={3}
                    placeholder={`e.g. "Weary and resigned, like an old man at the end of his life looking back." Leave blank to let AI riff freely from ${character.name}'s bio.`}
                    value={suggestHint}
                    onChange={e => setSuggestHint(e.target.value)}/>
                  <div className="char-direction-suggest-row">
                    <label className="char-direction-suggest-count">
                      <span>How many?</span>
                      <select value={suggestCount} onChange={e => setSuggestCount(parseInt(e.target.value, 10))}>
                        <option value={1}>1 preset</option>
                        <option value={2}>2 presets</option>
                        <option value={3}>3 presets</option>
                        <option value={4}>4 presets</option>
                        <option value={5}>5 presets</option>
                        <option value={6}>6 presets</option>
                      </select>
                    </label>
                    <div className="char-direction-suggest-actions">
                      <button type="button" className="char-direction-cancel-btn"
                        onClick={() => { setShowSuggestPrompt(false); setSuggestHint(""); }}
                        disabled={suggesting}>
                        Cancel
                      </button>
                      <button type="button" className="char-direction-save-btn"
                        onClick={suggestDirections} disabled={suggesting}>
                        {suggesting ? "Generating…" : "✨ Generate"}
                      </button>
                    </div>
                  </div>
                </div>
              )}
              {suggestErr && <div className="char-direction-err">{suggestErr}</div>}
              {savingDirErr && <div className="char-direction-err">{savingDirErr}</div>}
              {directions.length === 0 && !adding && !editingId && (
                <div className="char-direction-empty">
                  No presets yet. Hit <strong>Suggest with AI</strong> above to seed a starting set
                  from {character.name}'s bio, or add one manually.
                </div>
              )}
              <div className="char-direction-list">
                {directions.map(p => (
                  <div key={p.id} className={
                    "char-direction-row"
                    + (editingId === p.id ? " is-editing" : "")
                    + (confirmDeleteId === p.id ? " is-confirming-delete" : "")
                  }>
                    {editingId === p.id ? (
                      <div className="char-direction-edit">
                        <input className="char-direction-label-input"
                          value={draftLabel}
                          maxLength={80}
                          placeholder="Short label (e.g. Wry, observational)"
                          onChange={e => setDraftLabel(e.target.value)}/>
                        <textarea className="char-direction-body-input"
                          value={draftBody}
                          rows={3}
                          maxLength={2000}
                          placeholder="Tone, pace, pause behaviour. 1-2 sentences."
                          onChange={e => setDraftBody(e.target.value)}/>
                        <div className="char-direction-edit-actions">
                          <button type="button" className="char-direction-save-btn"
                            onClick={saveDraft} disabled={savingDir || !draftLabel.trim() || !draftBody.trim()}>
                            {savingDir ? "Saving…" : "Save"}
                          </button>
                          <button type="button" className="char-direction-cancel-btn"
                            onClick={cancelEdit} disabled={savingDir}>
                            Cancel
                          </button>
                        </div>
                      </div>
                    ) : confirmDeleteId === p.id ? (
                      <div className="char-direction-confirm">
                        <div className="char-direction-confirm-text">
                          Delete <strong>{p.label}</strong>?
                        </div>
                        <div className="char-direction-confirm-actions">
                          <button type="button" className="char-direction-cancel-btn"
                            onClick={cancelDelete} disabled={savingDir}>
                            Cancel
                          </button>
                          <button type="button" className="char-direction-delete-confirm-btn"
                            onClick={confirmDelete} disabled={savingDir}>
                            {savingDir ? "Deleting…" : "Delete"}
                          </button>
                        </div>
                      </div>
                    ) : (
                      <>
                        <div className="char-direction-body">
                          <div className="char-direction-label">{p.label}</div>
                          <div className="char-direction-text">{p.body}</div>
                        </div>
                        <div className="char-direction-actions">
                          <button type="button" className="char-direction-edit-btn"
                            onClick={() => startEdit(p)} title="Edit">✎</button>
                          <button type="button" className="char-direction-delete-btn"
                            onClick={() => requestDelete(p.id)} title="Delete">×</button>
                        </div>
                      </>
                    )}
                  </div>
                ))}
              </div>
              {/* v06p — Inline add form is on-demand. When no row is
                  being edited and the user hasn't clicked Add, the
                  bottom of the section shows a "+ Add manually"
                  trigger instead of the full form. */}
              {editingId === null && (adding ? (
                <div className="char-direction-row char-direction-row--add">
                  <div className="char-direction-edit">
                    <input className="char-direction-label-input"
                      value={draftLabel}
                      maxLength={80}
                      placeholder="Short label (e.g. Wry, observational)"
                      onChange={e => setDraftLabel(e.target.value)}/>
                    <textarea className="char-direction-body-input"
                      value={draftBody}
                      rows={3}
                      maxLength={2000}
                      placeholder="Tone, pace, pause behaviour. 1-2 sentences."
                      onChange={e => setDraftBody(e.target.value)}/>
                    <div className="char-direction-edit-actions">
                      <button type="button" className="char-direction-save-btn"
                        onClick={saveDraft} disabled={savingDir || !draftLabel.trim() || !draftBody.trim()}>
                        {savingDir ? "Saving…" : "Save preset"}
                      </button>
                      <button type="button" className="char-direction-cancel-btn"
                        onClick={cancelEdit} disabled={savingDir}>
                        Cancel
                      </button>
                    </div>
                  </div>
                </div>
              ) : (
                <button type="button" className="char-direction-add-trigger"
                  onClick={() => setAdding(true)}>
                  + Add preset manually
                </button>
              ))}
            </div>)}

            {/* v07zz287 — mobile: inline NOTES panel (scrollable list + composer,
                like the PDF deck) in the slot Direction Presets vacated. Desktop
                keeps the slide-in drawer (below). */}
            {isMobile && (
              <div className="char-section char-notes-inline">
                <div className="char-section-head">NOTES</div>
                <AssetNotesSection kind="character" item={character} activeFile={activeFile}/>
              </div>
            )}

            {!isMobile && (
            <div className="char-section">
              <div className="char-section-head">ROLE IN STORY</div>
              <div className="char-role-text">{character.notes}</div>
            </div>
            )}
            {/* v07y — Inline AssetNotesSection removed (was causing
                ugly scroll-in-place). The NOTES button at the top
                of char-info opens a drawer that slides in from the
                right edge of the modal instead. */}
          </div>
        </div>

        {/* v06k — gallery sits BELOW the grid (and inside the modal
            card) so it spans the full panel width instead of being
            constrained to the .char-media column. Hover-revealed
            edge arrows nudge the strip by ~3 thumbs when there's
            overflow. Drag-scroll works as before via galleryRef. */}
        <div className="char-gallery-wrap">
          {/* v705 — HERO | WIP | ARCHIVED switch, same .vs-pubfilter pills the shot
              modal's FRAMES / VIDEO strips use. */}
          <div className="char-gallery-funnel">
            <span className="vs-pubfilter" role="group" aria-label="Image filter">
              {[
                ["hero", "HERO", "Approved images — this character's promoted references"],
                ["wip", "WIP", "Un-promoted generations still in the WIP funnel"],
                ["archived", "ARCHIVED", "Discarded images"],
              ].map(([id, label, tip]) => (
                <button type="button" key={id} title={tip}
                  className={"vs-pf-btn" + (stripFunnel === id ? " is-on" : "")}
                  /* v870 - NO setHiddenNames here: this is the CHARACTER modal, which has no
                     such state (it is declared in AssetItemModal at ~4943). Calling it would throw
                     a ReferenceError on click - the same mistake that took out the Generate page. */
                  onClick={() => { setStripFunnel(id); setGalleryIdx(0); setFunnelErr(""); }}>
                  {label}
                  <span className="vs-pf-count">{_funnelCounts[id] != null ? ` (${_funnelCounts[id]})` : ""}</span>
                </button>
              ))}
            </span>
            {funnelErr && <span className="char-gallery-funnel-err" role="status">{funnelErr}</span>}
            {/* v945 — 🔄 refresh, mirror of AssetItemModal's (features go in BOTH
                modals). refreshCharacter re-pulls the hero refs; voiding the bucket
                states makes the v706 prefetch effect refill WIP + Archived. */}
            <button type="button" className="char-funnel-refresh"
              title="Refresh images — reload this character's pictures after overwriting a file in Photoshop"
              aria-label="Refresh images"
              onClick={() => {
                if (window.__bumpThumbCacheBust) window.__bumpThumbCacheBust();
                refreshCharacter({ silent: true });
                setWipRefs(null); setArchRefs(null);
              }}>
              <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
            </button>
          </div>
          <button type="button" className="char-gallery-arrow char-gallery-arrow--left"
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={() => galleryEl.current && galleryEl.current.scrollBy({ left: -320, behavior: "smooth" })}
                  aria-label="Scroll gallery left">‹</button>
          <div className="char-gallery-strip" ref={galleryRef}>
            {/* v705 — the 4 gradient placeholders are a HERO affordance; an empty
                WIP/ARCHIVED bucket gets the empty line below instead of fake tiles. */}
            {(gallery.length > 0 ? gallery : (stripFunnel === "hero" ? [null, null, null, null] : [])).map((g, i) => {
            const ref = oref[i];
            const canDragTile = stripFunnel === "hero";
            return (
              <button key={i}
                className={"char-gallery-thumb" + (galleryIdx === i ? " is-active" : "") + (g ? " char-gallery-thumb--img" : "") + (dropTarget && dropTarget.idx === i ? (dropTarget.side === "left" ? " drop-before" : " drop-after") : "")}
                onClick={() => setGalleryIdx(i)}
                title={galleryFilenames[i] || (g && canDragTile ? "Drag to reorder" : `Slot ${i + 1}`)}
                /* v07zz188 — drag to reorder. Only real images are draggable;
                   dropping onto another tile moves it there + persists. */
                draggable={!!(g && canDragTile)}
                onDragStart={(g && canDragTile) ? ((e) => { _dragFrom.current = i; _hideDragGhost(e); try { e.dataTransfer.effectAllowed = "move"; } catch (_) {} }) : undefined}
                onDragOver={(g && canDragTile) ? ((e) => { e.preventDefault(); try { e.dataTransfer.dropEffect = "move"; } catch (_) {} const r = e.currentTarget.getBoundingClientRect(); const side = (e.clientX - r.left) < r.width / 2 ? "left" : "right"; setDropTarget(prev => (prev && prev.idx === i && prev.side === side) ? prev : { idx: i, side }); }) : undefined}
                onDragEnd={() => { _dragFrom.current = null; setDropTarget(null); }}
                onDrop={(g && canDragTile) ? ((e) => { e.preventDefault(); const from = _dragFrom.current; _dragFrom.current = null; const dt = dropTarget; setDropTarget(null); if (from == null) return; let to = (dt && dt.side === "right") ? i + 1 : i; if (from < to) to -= 1; reorderRefsTo(from, to); }) : undefined}
                style={g ? { position: "relative" } : { position: "relative", background: `linear-gradient(160deg, oklch(0.50 0.04 ${60 + i*40}), oklch(0.35 0.04 ${30 + i*30}))` }}>
                {g && <img className="char-gallery-thumb-img" src={window.thumbUrl ? window.thumbUrl(g, 240) : g} alt="" draggable={false}/>}
                {!g && <span className="char-gallery-placeholder">{i + 1}</span>}
                {/* v705 — funnel actions ON THE THUMBNAIL (see AssetItemModal for the
                    full rationale). ★ = push up to HERO, ◐ = send to WIP, 🗄 = archive.
                    v706 — gated on the role that the SERVER actually accepts, so a
                    reviewer no longer sees buttons that silently 403. */}
                {g && ref && canMoveAssetRefs() && (
                  <span className={"vt-actions" + (funnelBusy ? " vt-actions--busy" : "")}>
                    {(stripFunnel === "wip" || stripFunnel === "archived") && (
                      <span className="vt-move-btn vt-move-hero" role="button" tabIndex={0}
                        onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                        onClick={(e) => { e.stopPropagation(); moveCharRef(ref, "hero"); }}
                        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveCharRef(ref, "hero"); } }}
                        title={stripFunnel === "archived" ? "Restore onto the character" : "Push to HERO — approve onto the character"}>
                        <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"><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>
                      </span>
                    )}
                    {stripFunnel === "hero" && ref.unpromotable && (
                      <span className="vt-move-btn vt-move-wip" role="button" tabIndex={0}
                        onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                        onClick={(e) => { e.stopPropagation(); moveCharRef(ref, "wip"); }}
                        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveCharRef(ref, "wip"); } }}
                        title="Send back to WIP (keeps the image, removes it from the asset folder)">
                        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill="currentColor" stroke="none"/></svg>
                      </span>
                    )}
                    {(stripFunnel === "hero" || stripFunnel === "wip") && (
                      <span className="vt-move-btn vt-move-arch" role="button" tabIndex={0}
                        onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                        onClick={(e) => { e.stopPropagation(); if (stripFunnel === "hero") { setGalleryIdx(i); setPendingDelete(ref); } else moveCharRef(ref, "archived"); }}
                        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (stripFunnel === "hero") { setGalleryIdx(i); setPendingDelete(ref); } else moveCharRef(ref, "archived"); } }}
                        title={stripFunnel === "hero" ? "Archive — remove from the character (recoverable from ARCHIVED)" : "Discard this generation"}>
                        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"/></svg>
                      </span>
                    )}
                  </span>
                )}
              </button>
            );
            })}
            {/* v705 — "+ FROM LIBRARY" tile, same as the shot modal's FRAMES strip and
                AssetItemModal's. Characters were the one asset type without it. */}
            {_charSlug && (
              <button type="button"
                className="char-gallery-thumb char-gallery-thumb--add"
                onClick={() => setAddLibOpen(true)}
                title="Add an image from anywhere in the project — shots, other assets, archived frames"
                aria-label="Add image from library"
                style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3,
                         border: "1.5px dashed var(--tile-add-border)", background: "var(--cream-27)", color: "var(--ink-6)", cursor: "pointer" }}>
                <span style={{ fontSize: "var(--fs-22)", lineHeight: 1, fontWeight: 300 }}>+</span>
                <span style={{ fontSize: "var(--fs-8-5)", letterSpacing: "var(--track-06)", fontWeight: "var(--fw-semi)", textAlign: "center", lineHeight: 1.15 }}>FROM<br/>LIBRARY</span>
              </button>
            )}
            {stripFunnel !== "hero" && gallery.length === 0 && (
              <div className="char-gallery-empty">
                {(stripFunnel === "wip" ? wipRefs : archRefs) === null
                  ? "Loading…"
                  : (stripFunnel === "wip" ? "No WIP generations" : "Nothing archived")}
              </div>
            )}
          </div>
          <button type="button" className="char-gallery-arrow char-gallery-arrow--right"
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={() => galleryEl.current && galleryEl.current.scrollBy({ left: 320, behavior: "smooth" })}
                  aria-label="Scroll gallery right">›</button>
        </div>
        {/* v07zz252/v705 — the separate "WIP — pick & approve" tray is GONE. WIP is a
            bucket on the strip switch above, so a second collapsed copy of the same
            images below it was the duplicate-panel mistake all over again. */}
        {/* v07ze — Outside-click scrim covering the rest of the modal
            while the notes drawer is open. Click closes the drawer
            same as the X button. Only present when the drawer is
            open so it doesn't intercept clicks otherwise. */}
        {/* v1001 — the notes drawer and its click-scrim are GONE. Notes render
            inline in the NOTES section of the right column instead, so clicking
            through the thumbnail strip no longer dismisses them. */}
      </div>
      {/* v07zz184 — full-screen lightbox for the main image. Inside the
          backdrop is safe: onBackdropClick only fires when the click target
          IS the backdrop, so clicking the lightbox won't close the modal. */}
      {zoomSrc && <Lightbox
        src={window.thumbUrl ? window.thumbUrl(zoomSrc, 1600) : zoomSrc}
        alt={character.name}
        onClose={() => setZoomSrc(null)}
        onPrev={gallery.length > 1 ? () => { const n = (galleryIdx - 1 + gallery.length) % gallery.length; setGalleryIdx(n); setZoomSrc(gallery[n]); } : null}
        onNext={gallery.length > 1 ? () => { const n = (galleryIdx + 1) % gallery.length; setGalleryIdx(n); setZoomSrc(gallery[n]); } : null}
      />}
      {pendingDelete && window.ConfirmDeleteImageModal && (
        <window.ConfirmDeleteImageModal
          name={character.name}
          busy={deleting}
          onCancel={() => setPendingDelete(null)}
          onConfirm={() => doDeleteRef(pendingDelete)}
        />
      )}
      {/* v705 — AssetArchivedPanel is gone: ARCHIVED is a bucket on the strip now.
          The "+ FROM LIBRARY" tile opens the shot modal's picker retargeted at this
          character; it COPIES, so the source shot/asset keeps its image. */}
      {addLibOpen && window.AddFromLibraryPicker && (
        <window.AddFromLibraryPicker
          assetTarget={{ cat: "characters", slug: _charSlug, name: character.name }}
          onClose={() => { setAddLibOpen(false); refreshCharacter({ silent: true }); }}
        />
      )}
    </div>
  ), portalRoot);
}

// v07w — Asset notes section. Same backed-by-/api/notes pattern as
// the shot modal's NotesPanel but compact and inline so it fits
// inside .char-info's narrow column. entity_type uses an
// `asset_${kind}` prefix so notes can't collide with shot notes.
// v07zd — Shared "LOCKED" stat for every asset modal (character +
// animal + location + prop + ref). Renders the same gold-star
// visual language used by the shot hero pill. Clicking flips
// consistency_locked on the asset record server-side. Admins and
// producers can toggle; everyone sees the state read-only.
function AssetLockStat({ kind, id, locked, onChange }) {
  const fetcher = window.authFetch || fetch;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const canToggle = role === "admin" || role === "producer";
  const [busy, setBusy] = React.useState(false);
  const toggle = () => {
    if (busy || !canToggle || !id) return;
    setBusy(true);
    const next = !locked;
    onChange && onChange(next);
    // v07zl — Hugo: card pill on the AssetsView wasn't updating
    // when the modal toggle flipped. Mutate window.__appData.assets
    // directly so the card's source-of-truth changes, then fan a
    // "paradise-asset-lock-changed" event so AssetsView re-renders.
    try {
      const PLURAL = {
        character: "characters", animal: "animals", location: "locations",
        prop: "props", ref: "refs",
      };
      const key = assetKindCategory(kind);
      if (key && window.__appData && Array.isArray(window.__appData.assets[key])) {
        const arr = window.__appData.assets[key];
        const i = arr.findIndex(a =>
          a.id === id || a.slug === id || a.folder === id || a.name === id,
        );
        if (i >= 0) arr[i] = { ...arr[i], consistency_locked: next };
      }
      window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed", {
        detail: { kind, id, locked: next },
      }));
    } catch (_) {}
    fetcher(`/api/assets/${kind}/${encodeURIComponent(id)}/lock`, {
      method: "POST",
      body: JSON.stringify({ locked: next }),
    })
      .then(r => r.ok ? r.json() : null)
      .catch(() => { onChange && onChange(!next); })  /* rollback */
      .finally(() => setBusy(false));
  };
  // v07ze — Hugo: match the OLD .char-stat layout exactly. The
  // wrapper is a <div> like the other stats; the only change is
  // the contents of .char-stat-num — a small star glyph in place
  // of the ✓/— text. The clickable surface is the wrapper itself
  // when the user has permission.
  return (
    <div
      className={"char-stat asset-lock-stat" + (locked ? " is-locked" : "") + (canToggle ? "" : " is-readonly")}
      role={canToggle ? "button" : undefined}
      tabIndex={canToggle ? 0 : undefined}
      onClick={toggle}
      onKeyDown={(e) => {
        if (canToggle && (e.key === "Enter" || e.key === " ")) {
          e.preventDefault();
          toggle();
        }
      }}
      title={
        !canToggle ? "Only admins/producers can lock assets" :
        locked ? "Click to unlock" : "Click to approve / lock"
      }
    >
      {/* v07zk — Hugo: hollow star when unlocked, full gold star
          when locked. Same .char-stat layout — no SVG wrappers
          changing the inner column alignment. */}
      <div className="char-stat-num">
        <span className={"asset-lock-glyph" + (locked ? " is-on" : "")}>
          {locked ? "★" : "☆"}
        </span>
      </div>
      <div className="char-stat-cap">LOCKED</div>
    </div>
  );
}

function AssetNotesSection({ kind, item, activeFile }) {
  // v07zb — Hugo: asset notes Resolve button should look + behave
  // EXACTLY like the shot-modal NotesPanel one — green pill, collapse
  // resolved notes behind a "Show N resolved" toggle, "resolved by"
  // label on each resolved entry, unresolved count in the header.
  // Solution: mirror the NotesPanel JSX structure and class names so
  // we reuse the same notes.css rules. The wrapping `.asset-notes`
  // div is kept for the outer spacing in the character/asset modal.
  // v07zz82 — Hugo: "I dont want to see that full name of the file in
  // the note section. it's noise polution." For archival/historical
  // batches (where the gallery filenames are scrape-y disk names like
  // "audubon-bird-of-america-plate-12.jpg") hide the filename from the
  // NOTES heading sub-line AND the textarea placeholder. The thumb's
  // title-tooltip already surfaces the filename on hover. Keep the
  // filename for character/animal/location/prop/ref where the per-file
  // context still matters (artist-curated names, not raw scrapes).
  const isArchivalLike = kind === "archival" || kind === "historical";
  const fetcher = window.authFetch || fetch;
  const entityId = item.slug || item.folder || item.id || item.name || "unknown";
  const entityType = `asset_${kind}`;
  const [notes, setNotes] = React.useState([]);
  const [draft, setDraft] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [showResolved, setShowResolved] = React.useState(false);
  // v962 — per-image scope switch, the same one the shot modal got in v944 (Hugo:
  // "in a props modal, i cant see the notes being linked to what image. it shows me
  // all the notes for all the images. i should have the same switch as the shots
  // modal"). "This image" = notes left on the picture on screen (the server also
  // returns un-tagged general notes, so nothing is ever hidden away); "All notes" =
  // every note on this asset, each row already carrying its own filename chip.
  const [noteScope, setNoteScope] = React.useState("version");
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const canResolve = role === "admin" || role === "producer";

  const load = React.useCallback(() => {
    // v962 — activeFile is a REAL dependency now: before this the panel fetched once
    // per asset and never re-ran when the gallery moved to another picture, which is
    // why every note showed against every image.
    const verQ = (noteScope === "version" && activeFile)
      ? `&version_label=${encodeURIComponent(activeFile)}`
      : "";
    fetcher(`/api/notes?entity_type=${encodeURIComponent(entityType)}&entity_id=${encodeURIComponent(entityId)}&include_resolved=true${verQ}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => setNotes((d && d.notes) || []))
      .catch(() => setNotes([]));
  }, [fetcher, entityType, entityId, noteScope, activeFile]);
  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (msg && (msg.type === "note_added" || msg.type === "note_resolved" || msg.type === "note_reply")) load();
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [load]);

  const submit = (e) => {
    e && e.preventDefault();
    if (!draft.trim() || busy) return;
    setBusy(true);
    fetcher("/api/notes", {
      method: "POST",
      body: JSON.stringify({
        entity_type: entityType,
        entity_id: entityId,
        body: draft.trim(),
        version_label: activeFile || null,
      }),
    })
      .then(() => {
        setDraft(""); load();
        // Fan paradise-sse so the dashboard footer's RecentActivityCard
        // refetches /api/logs and the new asset note appears there too.
        try {
          window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_added" } }));
        } catch (_) {}
      })
      .catch(() => {})
      .finally(() => setBusy(false));
  };
  const toggleResolve = React.useCallback((n) => {
    fetcher(`/api/notes/${n.id}/resolve`, {
      method: "PATCH",
      body: JSON.stringify({ resolved: !n.resolved }),
    }).then(() => {
      load();
      try {
        window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_resolved" } }));
      } catch (_) {}
    }).catch(() => {});
  }, [fetcher, load]);

  const fmt = (s) => {
    if (!s) return "";
    const d = new Date(String(s).replace(" ", "T") + "Z");
    if (isNaN(d.getTime())) return s;
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 60)        return `${Math.floor(diff)}s ago`;
    if (diff < 3600)      return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400)     return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
    return d.toLocaleDateString();
  };
  const initials = (name) => {
    if (!name) return "??";
    const parts = name.trim().split(/\s+/);
    return ((parts[0] || "")[0] || "") + ((parts[1] || "")[0] || "");
  };

  const unresolved = notes.filter(n => !n.resolved);
  const resolved   = notes.filter(n =>  n.resolved);

  return (
    <div className="char-section asset-notes">
      <section className="notes-panel">
        <div className="notes-head">
          <div className="notes-title">NOTES{unresolved.length ? ` · ${unresolved.length}` : ""}</div>
          {/* v962 — This image | All notes. Same classes as the shot modal so it
              reads identically (styles live in notes.css). */}
          {activeFile && (
            <div className="notes-scope" role="group" aria-label="Notes scope">
              <button type="button" className={"notes-scope-btn" + (noteScope === "version" ? " is-active" : "")}
                title="Only notes left on the picture you are looking at, plus general notes on this asset"
                onClick={() => setNoteScope("version")}>This image</button>
              <button type="button" className={"notes-scope-btn" + (noteScope === "all" ? " is-active" : "")}
                title="Every note on this asset — each row shows which image it was left on"
                onClick={() => setNoteScope("all")}>All notes</button>
            </div>
          )}
          {/* v07zz82 — Suppress the "on <filename>" sub-line for archival /
              historical batches; raw scrape names there are noise. */}
          {activeFile && !isArchivalLike && noteScope === "version" && <div className="notes-version">on {activeFile.slice(0, 36)}{activeFile.length > 36 ? "…" : ""}</div>}
        </div>
        {unresolved.length === 0 && resolved.length === 0 && (
          <div className="notes-empty">No notes yet — leave the first one below.</div>
        )}
        {unresolved.length > 0 && (
          <ul className="notes-list">
            {unresolved.map(n => (
              <li key={n.id} className="note-row">
                <span className="note-avatar">{initials(n.user_name).toUpperCase()}</span>
                <div className="note-body">
                  <div className="note-meta">
                    <span className="note-author">{n.user_name || "system"}</span>
                    {/* v07zz — Hugo: show which specific image EACH note was
                        left on. Notes can span different versions (leave one
                        on v003, then view v006), so the header's "active file"
                        line isn't enough. version_label already carries the
                        filename; just surface it per row. */}
                    {n.version_label && !isArchivalLike && (
                      <span className="note-version-tag" title={n.version_label}>{n.version_label}</span>
                    )}
                    <span className="note-time">{fmt(n.created_at)}</span>
                  </div>
                  <div className="note-text">{n.body}</div>
                </div>
                {canResolve && (
                  <button type="button" className="note-resolve" title="Mark as resolved" onClick={() => toggleResolve(n)}>
                    Resolve
                  </button>
                )}
              </li>
            ))}
          </ul>
        )}
        {resolved.length > 0 && (
          <>
            <button type="button" className="notes-toggle-resolved" onClick={() => setShowResolved(s => !s)}>
              {showResolved ? "Hide" : "Show"} {resolved.length} resolved
            </button>
            {showResolved && (
              <ul className="notes-list notes-list--resolved">
                {resolved.map(n => (
                  <li key={n.id} className="note-row note-row--resolved">
                    <span className="note-avatar">{initials(n.user_name).toUpperCase()}</span>
                    <div className="note-body">
                      <div className="note-meta">
                        <span className="note-author">{n.user_name || "system"}</span>
                        {n.version_label && !isArchivalLike && (
                          <span className="note-version-tag" title={n.version_label}>{n.version_label}</span>
                        )}
                        <span className="note-time">{fmt(n.created_at)}</span>
                        <span className="note-resolved-tag">Resolved{n.resolved_by_name ? ` by ${n.resolved_by_name}` : ""}</span>
                      </div>
                      <div className="note-text">{n.body}</div>
                    </div>
                    {canResolve && (
                      <button type="button" className="note-resolve note-resolve--undo" onClick={() => toggleResolve(n)} title="Re-open this note">
                        Re-open
                      </button>
                    )}
                  </li>
                ))}
              </ul>
            )}
          </>
        )}
        <form className="note-add-form" onSubmit={submit}>
          <textarea
            className="note-add-input"
            placeholder={isArchivalLike
              ? "Leave a note…"
              : (activeFile ? `Leave a note on ${activeFile.slice(0, 32)}…` : "Leave a note on this asset…")}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit(e);
            }}
            rows={2}
            maxLength={2000}
            disabled={busy}
          />
          <button type="submit" className="note-add-submit" disabled={busy || !draft.trim()}>
            {busy ? "Posting…" : "Post note"}
          </button>
        </form>
      </section>
    </div>
  );
}

// 24 Sep 2026 (G5) — Paradise Found's character VOICE + DIRECTION PRESETS for a character of a
// project other than Paradise Found (Trope's Trøpé, a series' gods ...), inside the generic asset
// modal. Same classes, same behaviour as CharacterDetailModal's blocks: the Voice on/off switch
// (voice_enabled through the fields patch), the voice picker (admins) + preview, and the presets
// manager (Suggest with AI, + Add preset manually, edit, delete with an inline confirm). Everything
// is stored on the character's own row in the project's database (server.js _projectCharacterVoice
// / _projectVoiceDirections). No hover tooltips outside Paradise Found.
function _projectCharPatch(item, kind, patch) {
  try { Object.assign(item, patch); } catch (_) {}
  try {
    const arr = window.__appData && window.__appData.assets && window.__appData.assets[kind];
    const it = Array.isArray(arr) && arr.find(x => x && (x.id === item.id || x.slug === item.id));
    if (it && it !== item) Object.assign(it, patch);
  } catch (_) {}
}
function ProjectCharacterVoice({ kind, item, isMobile }) {
  const fetcher = window.authFetch || fetch;
  const id = item && (item.id || item.slug);
  const isAdmin = (window.__effectiveRole || (window.__currentUser && window.__currentUser.role)) === "admin";
  const _voiceDefault = (c) => (c && c.voice_enabled != null) ? !!c.voice_enabled : !!(c && c.voice_id);
  const [voiceEnabled, setVoiceEnabled] = React.useState(() => _voiceDefault(item));
  const [savingToggle, setSavingToggle] = React.useState(false);
  const [voices, setVoices] = React.useState([]);
  const [voicesLoaded, setVoicesLoaded] = React.useState(false);
  const [voicesErr, setVoicesErr] = React.useState(null);
  const [assignedVoiceId, setAssignedVoiceId] = React.useState((item && item.voice_id) || "");
  const [assignedVoiceName, setAssignedVoiceName] = React.useState((item && item.voice_name) || "");
  const [saving, setSaving] = React.useState(false);
  const [saveErr, setSaveErr] = React.useState(null);
  const [voicePlaying, setVoicePlaying] = React.useState(false);
  const [previewLoading, setPreviewLoading] = React.useState(false);
  const [previewErr, setPreviewErr] = React.useState(null);
  const previewAudioRef = React.useRef(null);
  const [directions, setDirections] = React.useState(Array.isArray(item && item.voice_directions) ? item.voice_directions : []);
  const [draftLabel, setDraftLabel] = React.useState("");
  const [draftBody, setDraftBody] = React.useState("");
  const [editingId, setEditingId] = React.useState(null);
  const [adding, setAdding] = React.useState(false);
  const [savingDir, setSavingDir] = React.useState(false);
  const [savingDirErr, setSavingDirErr] = React.useState(null);
  const [suggesting, setSuggesting] = React.useState(false);
  const [suggestErr, setSuggestErr] = React.useState(null);
  const [confirmDeleteId, setConfirmDeleteId] = React.useState(null);
  const [showSuggestPrompt, setShowSuggestPrompt] = React.useState(false);
  const [suggestHint, setSuggestHint] = React.useState("");
  const [suggestCount, setSuggestCount] = React.useState(4);
  const role = (item && (item.role || item.description)) || "";

  React.useEffect(() => {
    if (!id) return;
    let dead = false;
    setVoicesErr(null); setVoicesLoaded(false);
    // 24 Sep 2026 (G5 review) — ask key-status first (as App.jsx does): a project with no
    // ElevenLabs key shows its one plain sentence straight away and never asks for the voice list
    // (that logged a 400 every time a character modal opened or moved on).
    const NO_KEY = "Add this project's ElevenLabs key in Admin > Integrations.";
    fetcher("/api/voiceover/key-status")
      .then(r => (r && r.ok ? r.json() : null))
      .catch(() => null)
      .then(ks => {
        if (dead) return null;
        if (ks && ks.has_key === false) throw new Error(NO_KEY);
        return fetcher("/api/voiceover/voices");
      })
      .then(async r => {
        if (!r) return null;
        const j = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(j && j.missing_key === "elevenlabs" ? "Add this project's ElevenLabs key in Admin > Integrations." : ((j && j.error) || `HTTP ${r.status}`));
        return j;
      })
      .then(d => { if (!dead && d) { setVoices(Array.isArray(d.voices) ? d.voices : []); setVoicesLoaded(true); } })
      .catch(e => { if (!dead) { setVoicesErr(String(e && e.message ? e.message : e)); setVoicesLoaded(true); } });
    return () => { dead = true; };
  }, [id]);   // eslint-disable-line react-hooks/exhaustive-deps
  React.useEffect(() => () => {
    if (previewAudioRef.current) { try { previewAudioRef.current.pause(); } catch (_) {} previewAudioRef.current = null; }
  }, []);

  const toggleVoiceEnabled = async () => {
    if (!id || savingToggle) return;
    const next = !voiceEnabled;
    setVoiceEnabled(next); setSavingToggle(true);
    _projectCharPatch(item, kind, { voice_enabled: next });
    try {
      await fetcher(`/api/assets/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/fields`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ fields: { voice_enabled: next } }),
      });
    } catch (_) { /* optimistic, like Paradise Found */ }
    finally { setSavingToggle(false); }
  };
  const saveVoice = async (newVoiceId) => {
    if (!id) return;
    setSaving(true); setSaveErr(null);
    try {
      const matched = voices.find(v => v.voice_id === newVoiceId);
      const r = await fetcher(`/api/assets/character/${encodeURIComponent(id)}/voice`, {
        method: "POST",
        body: JSON.stringify({ voice_id: newVoiceId || "", voice_name: matched ? matched.name : "", voice_preview_url: matched ? (matched.preview_url || "") : "" }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setAssignedVoiceId(newVoiceId || "");
      setAssignedVoiceName(matched ? matched.name : "");
      _projectCharPatch(item, kind, { voice_id: newVoiceId || "", voice_name: matched ? matched.name : "", voice_preview_url: matched ? (matched.preview_url || "") : "" });
    } catch (e) { setSaveErr(e.message || String(e)); }
    finally { setSaving(false); }
  };
  const _playSafely = (audio) => {
    const p = audio.play();
    if (p && typeof p.then === "function") {
      p.then(() => { if (previewAudioRef.current === audio) setVoicePlaying(true); })
       .catch(e => {
         if (e && (e.name === "AbortError" || /interrupted by a call to pause/i.test(String(e.message || "")))) return;
         if (previewAudioRef.current === audio) { setPreviewErr(`Preview playback failed: ${e.message}`); setVoicePlaying(false); }
       });
    } else if (previewAudioRef.current === audio) setVoicePlaying(true);
  };
  const stopPreview = () => {
    if (previewAudioRef.current) { try { previewAudioRef.current.pause(); } catch (_) {} previewAudioRef.current = null; }
    setVoicePlaying(false);
  };
  const playPreview = async () => {
    if (!assignedVoiceId) return;
    stopPreview();
    setPreviewErr(null);
    const matched = voices.find(v => v.voice_id === assignedVoiceId);
    const previewUrl = (matched && matched.preview_url) || (item && item.voice_preview_url) || null;
    if (previewUrl) {
      const audio = new Audio(previewUrl);
      previewAudioRef.current = audio;
      audio.addEventListener("ended", () => setVoicePlaying(false));
      _playSafely(audio);
      return;
    }
    setPreviewLoading(true);
    try {
      const r = await fetcher("/api/voiceover/preview", {
        method: "POST",
        body: JSON.stringify({ voice_id: assignedVoiceId, text: `Hi, I'm ${item.name}. This is a quick voice preview.` }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const url = URL.createObjectURL(await r.blob());
      const audio = new Audio(url);
      previewAudioRef.current = audio;
      audio.addEventListener("ended", () => { setVoicePlaying(false); URL.revokeObjectURL(url); });
      _playSafely(audio);
    } catch (e) { setPreviewErr(e.message || String(e)); }
    finally { setPreviewLoading(false); }
  };

  const persistDirections = async (next) => {
    if (!id) return;
    setSavingDir(true); setSavingDirErr(null);
    try {
      const r = await fetcher(`/api/assets/character/${encodeURIComponent(id)}/voice-directions`, { method: "PUT", body: JSON.stringify({ presets: next }) });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const j = await r.json();
      const out = Array.isArray(j.voice_directions) ? j.voice_directions : next;
      setDirections(out);
      _projectCharPatch(item, kind, { voice_directions: out });
    } catch (e) { setSavingDirErr(e.message || String(e)); }
    finally { setSavingDir(false); }
  };
  const startEdit = (p) => { setEditingId(p.id); setDraftLabel(p.label || ""); setDraftBody(p.body || ""); };
  const cancelEdit = () => { setEditingId(null); setDraftLabel(""); setDraftBody(""); setAdding(false); };
  const saveDraft = async () => {
    const label = draftLabel.trim(), body = draftBody.trim();
    if (!label || !body) return;
    const next = editingId
      ? directions.map(p => p.id === editingId ? { ...p, label, body } : p)
      : [...directions, { id: `vd-${Date.now().toString(36)}`, label, body }];
    await persistDirections(next);
    cancelEdit();
  };
  const confirmDelete = async () => {
    if (!confirmDeleteId) return;
    await persistDirections(directions.filter(p => p.id !== confirmDeleteId));
    setConfirmDeleteId(null);
  };
  const suggestDirections = async () => {
    setSuggesting(true); setSuggestErr(null);
    try {
      const r = await fetcher("/api/voiceover/suggest-directions", {
        method: "POST",
        body: JSON.stringify({
          character_id: id, character_name: item.name, role, notes: item.notes || "",
          hint: (suggestHint || "").trim(), count: Math.max(1, Math.min(8, parseInt(suggestCount, 10) || 4)),
        }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      const j = await r.json();
      const suggested = (j.presets || []).map((p, i) => ({ id: `vd-${Date.now().toString(36)}-${i}`, label: p.label, body: p.body }));
      if (!suggested.length) throw new Error("The AI returned no presets.");
      const merged = [...directions];
      for (const p of suggested) if (!merged.some(x => String(x.label).toLowerCase() === String(p.label).toLowerCase())) merged.push(p);
      await persistDirections(merged.slice(0, 12));
      setShowSuggestPrompt(false);
    } catch (e) { setSuggestErr(e.message || String(e)); }
    finally { setSuggesting(false); }
  };

  const editForm = (saveLabel) => (
    <div className="char-direction-edit">
      <input className="char-direction-label-input" value={draftLabel} maxLength={80}
        placeholder="Short label (e.g. Wry, observational)" onChange={e => setDraftLabel(e.target.value)}/>
      <textarea className="char-direction-body-input" value={draftBody} rows={3} maxLength={2000}
        placeholder="Tone, pace, pause behaviour. 1-2 sentences." onChange={e => setDraftBody(e.target.value)}/>
      <div className="char-direction-edit-actions">
        <button type="button" className="char-direction-save-btn" onClick={saveDraft}
          disabled={savingDir || !draftLabel.trim() || !draftBody.trim()}>{savingDir ? "Saving…" : saveLabel}</button>
        <button type="button" className="char-direction-cancel-btn" onClick={cancelEdit} disabled={savingDir}>Cancel</button>
      </div>
    </div>
  );

  return (
    <>
      <div className="char-section">
        <div className="char-section-head char-voice-head">
          <span>VOICE</span>
          <button type="button" className={"char-voice-switch" + (voiceEnabled ? " is-on" : "")}
            role="switch" aria-checked={voiceEnabled} aria-label="Voice on or off"
            onClick={toggleVoiceEnabled} disabled={savingToggle}>
            <span className="char-voice-switch-track"><span className="char-voice-switch-knob"/></span>
            <span className="char-voice-switch-txt">{voiceEnabled ? "On" : "Off"}</span>
          </button>
        </div>
        {!voiceEnabled && <div className="char-voice-off-hint">No voice for this character. Turn the switch on to assign one.</div>}
        {voiceEnabled && <>
          <div className="char-voice-player">
            <button className={"char-voice-btn" + (voicePlaying ? " is-playing" : "")}
              onClick={() => voicePlaying ? stopPreview() : playPreview()}
              disabled={!assignedVoiceId || previewLoading}
              aria-label={voicePlaying ? "Stop" : "Preview"}>
              {previewLoading
                ? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9" strokeDasharray="14 14" strokeLinecap="round"/></svg>
                : voicePlaying
                ? <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
                : <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
            </button>
            {isAdmin ? (
              <select className="char-voice-select" value={assignedVoiceId} onChange={e => saveVoice(e.target.value)}
                disabled={saving || voices.length === 0} aria-label="Voice">
                <option value="">— No voice assigned —</option>
                {voices.map(v => <option key={v.voice_id} value={v.voice_id}>{v.name}{v.category ? ` · ${v.category}` : ""}</option>)}
                {assignedVoiceId && !voices.some(v => v.voice_id === assignedVoiceId) && (
                  <option value={assignedVoiceId}>{assignedVoiceName || "Assigned voice"}</option>
                )}
              </select>
            ) : (
              <span className="char-voice-readonly">{assignedVoiceName || (assignedVoiceId ? "Voice assigned" : "No voice assigned")}</span>
            )}
          </div>
          <div className="char-voice-meta">
            {isAdmin
              ? (voicesErr
                  ? <span style={{ color: "var(--danger, var(--red-24))" }}>{voicesErr}</span>
                  : !voicesLoaded
                    ? "Loading voices from ElevenLabs…"
                    : voices.length === 0
                      ? "This ElevenLabs account has no voices yet."
                      : assignedVoiceName
                        ? <>Assigned: <strong>{assignedVoiceName}</strong>{saving ? " · saving…" : ""}</>
                        : "Pick a voice from the dropdown to assign it to this character.")
              : (assignedVoiceId
                  ? <>Voice: <strong>{assignedVoiceName || "assigned"}</strong> — press play to listen.</>
                  : "No voice assigned to this character yet.")}
            {isAdmin && saveErr && <div style={{ color: "var(--danger, var(--red-24))", marginTop: 4 }}>{saveErr}</div>}
            {previewErr && <div style={{ color: "var(--danger, var(--red-24))", marginTop: 4 }}>{previewErr}</div>}
          </div>
        </>}
      </div>
      {!isMobile && voiceEnabled && (
        <div className="char-section">
          <div className="char-section-head char-direction-head"><span>DIRECTION PRESETS</span></div>
          <div className="char-direction-suggest-row">
            <button type="button" className="char-direction-suggest-btn" onClick={() => setShowSuggestPrompt(v => !v)} disabled={suggesting || savingDir}>
              {suggesting ? "Suggesting…" : "✨ Suggest with AI"}
            </button>
          </div>
          {showSuggestPrompt && (
            <div className="char-direction-suggest-panel">
              <label className="char-direction-suggest-label">What kind of delivery do you want?</label>
              <textarea className="char-direction-suggest-input" rows={3}
                placeholder={`e.g. "Warm and close, like talking to one person." Leave blank to let AI riff freely from ${item.name}'s details.`}
                value={suggestHint} onChange={e => setSuggestHint(e.target.value)}/>
              <div className="char-direction-suggest-row">
                <label className="char-direction-suggest-count">
                  <span>How many?</span>
                  <select value={suggestCount} onChange={e => setSuggestCount(parseInt(e.target.value, 10))}>
                    {[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={n}>{n} preset{n > 1 ? "s" : ""}</option>)}
                  </select>
                </label>
                <div className="char-direction-suggest-actions">
                  <button type="button" className="char-direction-cancel-btn" onClick={() => { setShowSuggestPrompt(false); setSuggestHint(""); }} disabled={suggesting}>Cancel</button>
                  <button type="button" className="char-direction-save-btn" onClick={suggestDirections} disabled={suggesting}>{suggesting ? "Generating…" : "✨ Generate"}</button>
                </div>
              </div>
            </div>
          )}
          {suggestErr && <div className="char-direction-err">{suggestErr}</div>}
          {savingDirErr && <div className="char-direction-err">{savingDirErr}</div>}
          {directions.length === 0 && !adding && !editingId && (
            <div className="char-direction-empty">
              No presets yet. Hit <strong>Suggest with AI</strong> above to seed a starting set from {item.name}'s details, or add one manually.
            </div>
          )}
          <div className="char-direction-list">
            {directions.map(p => (
              <div key={p.id} className={"char-direction-row" + (editingId === p.id ? " is-editing" : "") + (confirmDeleteId === p.id ? " is-confirming-delete" : "")}>
                {editingId === p.id ? editForm("Save") : confirmDeleteId === p.id ? (
                  <div className="char-direction-confirm">
                    <div className="char-direction-confirm-text">Delete <strong>{p.label}</strong>?</div>
                    <div className="char-direction-confirm-actions">
                      <button type="button" className="char-direction-cancel-btn" onClick={() => setConfirmDeleteId(null)} disabled={savingDir}>Cancel</button>
                      <button type="button" className="char-direction-delete-confirm-btn" onClick={confirmDelete} disabled={savingDir}>{savingDir ? "Deleting…" : "Delete"}</button>
                    </div>
                  </div>
                ) : (
                  <>
                    <div className="char-direction-body">
                      <div className="char-direction-label">{p.label}</div>
                      <div className="char-direction-text">{p.body}</div>
                    </div>
                    <div className="char-direction-actions">
                      <button type="button" className="char-direction-edit-btn" onClick={() => startEdit(p)} aria-label="Edit preset">✎</button>
                      <button type="button" className="char-direction-delete-btn" onClick={() => setConfirmDeleteId(p.id)} aria-label="Delete preset">×</button>
                    </div>
                  </>
                )}
              </div>
            ))}
          </div>
          {editingId === null && (adding ? (
            <div className="char-direction-row char-direction-row--add">{editForm("Save preset")}</div>
          ) : (
            <button type="button" className="char-direction-add-trigger" onClick={() => setAdding(true)}>+ Add preset manually</button>
          ))}
        </div>
      )}
    </>
  );
}

// 24 Sep 2026 (G5) — a project asset's subtitle: the first short field its category declares
// (Trope's Trøpé → "Singer"), else its role / type. Never the long description.
function _projectAssetSubtitle(kind, item) {
  if (!item) return "";
  const cat = window.__projectCategories ? window.__projectCategories().find(c => c && c.id === kind) : null;
  const defs = cat && Array.isArray(cat.fields) ? cat.fields : null;
  if (defs) {
    for (const f of defs) {
      if (!f || f.type === "longtext" || f.key === "notes") continue;
      const v = item[f.key]; if (typeof v === "string" && v.trim()) return v.trim();
    }
  }
  return String(item.role || item.type || "").trim();
}
// The file name a picture URL ends with (for the notes' This image | All notes switch).
function _projectFileOf(url) {
  if (!url) return null;
  const last = String(url).split("?")[0].split("#")[0].split("/").pop();
  try { return decodeURIComponent(last) || null; } catch (_) { return last || null; }
}
function AssetItemModal({ kind, item, onClose, onNavigate, navDir }) {
  const [galleryIdx, setGalleryIdx] = React.useState(0);
  // v07zz287 — mobile swipe between assets (arrows hidden on phone).
  const _aimTier = window.useUiTier ? window.useUiTier() : "";
  const _swipeRef = window.useSwipeNav ? window.useSwipeNav({
    onPrev: () => onNavigate && onNavigate(-1),
    onNext: () => onNavigate && onNavigate(1),
    enabled: _aimTier === "s",
  }) : undefined;
  // v06o — mirror the CharacterDetailModal pattern for Animal /
  // Location / Prop / Ref / Archival modals so the experience is
  // consistent: drag-scrollable gallery with edge arrows, real
  // references rendered as thumbs (server pre-enriched), reveal-in-
  // folder action, and backdrop-close that only fires when the
  // mousedown AND mouseup both land on the backdrop (so dragging the
  // gallery past the modal edge doesn't dismiss the panel).
  // v07zz232 — wheel-scroll (was drag-scroll, which fought drag-to-reorder).
  const galleryEl = React.useRef(null);
  const galleryRef = React.useCallback((el) => _attachWheelScroll(el, galleryEl), []);
  // v07zi — Archival layout is now pure CSS — the modal sizes to
  // content (no fixed 92vh height), the right column is compact
  // enough to match the hero's height naturally, and the grid
  // stretches both columns to the same bottom. No JS height-sync,
  // no scrollbar in the right column.
  const backdropDownRef = React.useRef(false);
  // v07zr — Edit modal toggle.
  const [editing, setEditing] = React.useState(false);
  // v07zw — Archive confirmation modal toggle.
  const [archiving, setArchiving] = React.useState(false);
  const [archiveBusy, setArchiveBusy] = React.useState(false);
  // v07zz144 — Per-batch cover thumbnail. Stars show only on hover (and
  // gold on the current cover). Clicking sets that image as the batch's
  // card thumbnail via /api/archival/cover. Only meaningful for archival /
  // historical batches.
  const [hoverThumb, setHoverThumb] = React.useState(null);
  const [localCover, setLocalCover] = React.useState(null); // optimistic cover filename
  // v07zz148 — The drag-to-crop editor is collapsible. Starring opens it;
  // "Done" closes it; on the cover image with it closed, an "Adjust crop"
  // link reopens it — so it isn't permanently parked over the main image.
  const [showCropEditor, setShowCropEditor] = React.useState(false);
  const setCoverImage = async (ref) => {
    if (!ref || !ref.filename || !(kind === "archival" || kind === "historical")) return;
    setLocalCover(ref.filename);
    setShowCropEditor(true);
    const fetcher = window.authFetch || fetch;
    try {
      await fetcher("/api/archival/cover", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          category: kind === "historical" ? "historical" : "archival",
          episode_id: ref.episode_id, batch_folder: ref.batchFolder || null, filename: ref.filename,
        }),
      });
      try { window.dispatchEvent(new CustomEvent("paradise-archival-refresh")); } catch (_) {}
    } catch (_) { /* best-effort */ }
  };
  // v07zz147 — Cover crop focal point. When the active gallery image is the
  // batch cover, the user drags a card-shaped preview to choose where the
  // thumbnail crops. Stored as a background-position ("x% y%") via the same
  // /api/archival/cover endpoint (focus param). archival_stills is synced so
  // the chosen crop propagates to the other environment.
  const [coverFocus, setCoverFocus] = React.useState({ x: 50, y: 50 });
  const cropDragRef = React.useRef(null);
  const [zoomSrc, setZoomSrc] = React.useState(null); // v07zz184 — full-screen lightbox for the main asset image
  const [deleting, setDeleting] = React.useState(false); // v07zz188 — delete ANY reference image
  const [orderOverride, setOrderOverride] = React.useState(null); // v07zz188 — optimistic drag order (filenames)
  const [hiddenNames, setHiddenNames] = React.useState(() => new Set()); // v07zz188 — optimistically removed
  const [pendingDelete, setPendingDelete] = React.useState(null); // v07zz190 — styled delete-confirm target
  const _dragFrom = React.useRef(null);
  const [dropTarget, setDropTarget] = React.useState(null); // v07zz238 — gold drop-line {idx, side}
  const _assetKey = item && (item.id || item.slug || item.folder);
  // v07zz214 — Add Reference: per-category uploader for Historical / External
  // reference batches. Hugo: "I need an Add Reference button in each Historical
  // Reference category." Posts into the batch's REAL disk subfolder via
  // /api/refs/folder-upload, then appends optimistically + refreshes the grids.
  const _addRefInput = React.useRef(null);
  const [addingRef, setAddingRef] = React.useState(false);
  const [addedRefs, setAddedRefs] = React.useState([]);
  // v871 - THE REAL BUG. Hugo: "everytime i come back, all the images i sent to wip come
  // back." `item` is a FROZEN prop (v706) captured when the modal opened, and heroOverride
  // starts null - so _allItemRefs falls back to item.references, whatever App fetched
  // whenever it last refreshed. refreshHero() was only ever called AFTER a move or on an SSE
  // event, never on OPEN, so reopening the modal reverted the strip to a stale list and every
  // image he had sent to WIP appeared to come back.
  //
  // Verified against the live server while chasing this: /api/assets reports 11 hero refs for
  // santa-maria, matching the 11 files actually in the asset root - the SERVER was right the
  // whole time and the client was showing an old copy. So: clear the override and re-fetch on
  // every open / asset change. The hides reset here too, which is correct - a fresh list has
  // nothing to hide.
  React.useEffect(() => {
    setOrderOverride(null); setHiddenNames(new Set()); setAddedRefs([]);
    setHeroOverride(null);
    refreshHero();
    // refreshHero is deliberately NOT in the deps: it is declared ~180 lines BELOW this
    // effect, and babel-standalone treats const as var with no TDZ, so naming it here
    // evaluates to undefined during render. The effect BODY resolves it fine (effects run
    // after the whole component body has executed) - it is only the dependency array,
    // built mid-render, that would capture the undefined.
  }, [_assetKey]);   // eslint-disable-line react-hooks/exhaustive-deps
  const parseFocus = (s) => {
    const m = s && String(s).match(/(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)/);
    return m ? { x: +m[1], y: +m[2] } : { x: 50, y: 50 };
  };
  const saveCoverFocus = async (ref, fx, fy) => {
    if (!ref || !ref.filename) return;
    const focus = `${Math.round(fx)}% ${Math.round(fy)}%`;
    ref.cover_focus = focus; // keep the in-memory ref fresh for re-renders
    const fetcher = window.authFetch || fetch;
    try {
      await fetcher("/api/archival/cover", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          category: kind === "historical" ? "historical" : "archival",
          episode_id: ref.episode_id, batch_folder: ref.batchFolder || null,
          filename: ref.filename, focus,
        }),
      });
      try { window.dispatchEvent(new CustomEvent("paradise-archival-refresh")); } catch (_) {}
    } catch (_) { /* best-effort */ }
  };
  const onBackdropMouseDown = (e) => { backdropDownRef.current = (e.target === e.currentTarget); };
  const onBackdropClick = (e) => {
    if (backdropDownRef.current && e.target === e.currentTarget) onClose();
    backdropDownRef.current = false;
  };
  React.useEffect(() => {
    // v07zt — Arrow keys paginate when onNavigate is provided.
    const onKey = (e) => {
      if (e.key === "Escape") { onClose(); return; }
      if (onNavigate) {
        if (e.key === "ArrowLeft")  onNavigate(-1);
        if (e.key === "ArrowRight") onNavigate(1);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, onNavigate]);

  // v07zz80 — Hugo: "Historical Refs looks like it's using the same
  // card modal template as the locations, when it should be more the
  // same as the Archival footage. change that up." Treat "historical"
  // as an archival-like kind so it inherits every archival-specific
  // visual branch (compact modal, nested gallery, sepia gradient,
  // category glyph stat instead of the consistency-lock toggle).
  const isArchivalLike = kind === "archival" || kind === "historical";
  // v07zz380 — widescreen reshape, mirroring CharacterDetailModal. The animal /
  // location / prop / ref hero takes the image's natural aspect ratio; a wide
  // (>=1.6) image widens the whole modal to maximise image space. Seed from the
  // per-asset aspect CACHE (not the project aspect) so a portrait/square asset
  // opens at its normal width and never flashes wide-then-narrow, and a re-opened
  // asset opens at exactly the right shape. The <img> onLoad fills the cache.
  // Archival keeps its own 16:9 treatment.
  const _uiTier = window.useUiTier ? window.useUiTier() : "";
  const isMobile = _uiTier === "s";
  const [heroWide, setHeroWide] = React.useState(() => {
    try { const k = item && (item.id || item.slug || item.folder); return k ? !!_assetHeroAspect.get(k) : false; } catch (_) { return false; }
  });
  const [modalAnim, setModalAnim] = React.useState(false);
  const firstHeroLoadRef = React.useRef(false);
  const isLocOrRef = kind === "location" || kind === "ref" || isArchivalLike;
  const heroBg = isLocOrRef
    ? locGradient(item.hue || 100)
    : `linear-gradient(160deg, oklch(0.55 0.04 60), oklch(0.40 0.04 30))`;

  // v06o — use real references when the server-side /api/assets
  // enrichment populated them. Falls back to the single `image` if
  // present, otherwise renders gradient placeholders.
  // v07zz214 — merge any references the user just uploaded (optimistic) so the
  // gallery shows them immediately, before the parent grid re-fetches.
  // v07zz252 — for asset kinds (animal/location/prop), the main gallery shows only
  // approved references; raw gens + WIP candidates (under wip/, r.is_wip) are split
  // out (gens → Generate page, WIP → the AssetWipGallery below). Other kinds
  // (archival/historical/external refs) carry no is_wip flag, so they're unaffected.
  // v706 — HERO was a FROZEN PROP. `item` is captured at click time, and reloadAppData()
  // refetches /api/assets into a NEW array without re-resolving the open modal's item —
  // so the strip never changed after a move. heroOverride holds the freshly re-fetched
  // references (GET …/item). Declared HERE, above its first read: babel-standalone
  // compiles const to var with no TDZ, so a later declaration reads `undefined`.
  const [heroOverride, setHeroOverride] = React.useState(null);
  const _allItemRefs = heroOverride || (Array.isArray(item.references) ? item.references : []);
  const _approvedItemRefs = _allItemRefs.filter(r => !r.is_wip);
  // Hedge (see CharacterDetailModal): hide wip/ images only once approved refs
  // exist, so an un-promoted asset's gallery never goes surprise-empty.
  const baseRefs = _approvedItemRefs.length ? _approvedItemRefs : _allItemRefs;
  const refs = addedRefs.length
    ? [...baseRefs, ...addedRefs.filter(a => !baseRefs.some(b => b.filename === a.filename))]
    : baseRefs;
  // v07zz214 — Add Reference is supported for Historical (scope=historical) and
  // External (scope=external) batches — both map to /api/refs/folder-upload.
  // v07zz278 — also gated on upload_assets so low-perm users don't see an
  // Add button that would 403 on the upload.
  const canAddRef = (kind === "historical" || kind === "ref") && (!window.hasPerm || window.hasPerm("upload_assets"));
  const refScope = kind === "historical" ? "historical" : "external";
  const refSubdir = kind === "historical" ? "historical-refs" : "external-refs";
  // The batch card's `folder` field is the PARSED tag (e.g. PF_T0001); the real
  // on-disk subfolder lives on each item as `batchFolder`. Upload must target
  // the real folder or it splits the batch into a new one.
  const refDiskFolder = (baseRefs.find(r => r.batchFolder) || {}).batchFolder || item.folder || item.name || "General";
  const refEpisode = (baseRefs.find(r => r.episode_id) || {}).episode_id || item.episode_id || null;
  const onAddRefFiles = async (fileList) => {
    const files = Array.from(fileList || []).filter(Boolean);
    if (_addRefInput.current) _addRefInput.current.value = "";
    if (!files.length || addingRef) return;
    setAddingRef(true);
    const _galleryLenBefore = oref.length;
    try {
      const fd = new FormData();
      fd.append("scope", refScope);
      fd.append("folder", refDiskFolder);
      for (const f of files) fd.append("files", f);
      const fetcher = window.authFetch || fetch;
      const r = await fetcher("/api/refs/folder-upload", { method: "POST", body: fd });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      const saved = Array.isArray(j.saved) ? j.saved : [];
      // 24 Sep 2026 (G6) — another project's server sends each new file's URL (its libraries live
      // under work/references/, not an episode folder).
      if (Array.isArray(j.items) && j.items.length) {
        setAddedRefs(prev => [...prev, ...j.items]);
        setGalleryIdx(_galleryLenBefore);
      } else if (refEpisode && saved.length) {
        const adds = saved.map(fn => {
          const rel = `work/episodes/${refEpisode}/references/${refSubdir}/${refDiskFolder}/${fn}`;
          const isPdf = /\.pdf$/i.test(fn);
          return { filename: fn, url: `/local/${encodeURI(rel)}`, kind: isPdf ? "pdf" : "image", episode_id: refEpisode, batchFolder: refDiskFolder };
        });
        setAddedRefs(prev => [...prev, ...adds]);
        setGalleryIdx(_galleryLenBefore);
      }
      // Both AssetsView + MediaView listen for this to re-scan the batch lists.
      try { window.dispatchEvent(new CustomEvent("paradise-archival-refresh")); } catch (_) {}
    } catch (e) {
      console.warn("[add-reference]", e.message);
      alert("Add reference failed: " + e.message);
    } finally {
      setAddingRef(false);
    }
  };
  // v07zz188 — reorder/delete with optimistic local state (this modal has no
  // per-item refresh, so we apply the drag order + hide deleted names locally
  // and reloadAppData in the background). Only real asset kinds are editable.
  const _refCat = (typeof assetKindCategory === "function" && assetKindCategory(kind)) || (kind ? kind + "s" : null);
  // v706 — the category test says "this KIND has an editable gallery"; canMoveAssetRefs()
  // says "YOU may move images". Both must hold, or a reviewer sees buttons that 403.
  // 24 Sep 2026 — a project other than Paradise Found has its own categories (cover-art, logos,
  // instruments …) and now its own funnel (db/projectAssetFunnel.js), so its assets get the same
  // HERO | WIP | ARCHIVED strip. Paradise Found: __isDefaultProject() is true, the set is unchanged.
  const _isProjectCat = !!(_refCat && window.__isDefaultProject && !window.__isDefaultProject()
    && window.__projectCategories && window.__projectCategories().some(c => c && c.id === _refCat));
  const _canEditRefs = (["characters", "animals", "locations", "props"].includes(_refCat) || _isProjectCat) && canMoveAssetRefs();
  // 24 Sep 2026 (G5) — a project category that holds characters gets Paradise Found's character
  // blocks (VOICE, DIRECTION PRESETS, SHOTS / REF PASSES); button labels name ONE of the category.
  const _isProjectChar = _isProjectCat && !!(window.__isCharacterCategory && window.__isCharacterCategory(_refCat));
  const _kindWord = (_isProjectCat && window.__projectCategoryLabel) ? window.__projectCategoryLabel(kind, true).toLowerCase() : kind;
  // v695 — "add an image from anywhere" library picker for this asset.
  const [addLibOpen, setAddLibOpen] = React.useState(false);
  // v702/v705 — Send-back-to-WIP started as an icon-row button (v702, the first time
  // every asset kind got it — CharacterDetailModal's original was hardcoded to
  // /api/assets/characters/…). It's now the ◐ button on each HERO thumbnail, handled
  // by moveAssetRef below, so the standalone active-image version is gone.
  // v705 — HERO | WIP | ARCHIVED switch ON THE STRIP, exactly like the shot modal's
  // FRAMES / VIDEO strips: pills above the thumbnails, per-image actions ON the
  // thumbnails (hover), nothing in the icon row over the hero image.
  //   HERO     = item.references (approved / promoted, already in `refs`)
  //   WIP      = GET …/wip?stage=all  — the DB list, NOT item.wip_references, because
  //              only the DB rows carry the numeric `id` that /promote needs.
  //   ARCHIVED = GET …/archived      — same source AssetArchivedPanel used, so the
  //              two can never disagree.
  // Swapping `oref` IS the switch: gallery, filenames and activeRef all derive from
  // it, so the hero preview follows the selected bucket for free.
  const _assetBase = (_refCat && _assetKey)
    ? `/api/assets/${encodeURIComponent(_refCat)}/${encodeURIComponent(_assetKey)}`
    : null;
  const [stripFunnel, setStripFunnel] = React.useState("hero");
  const [wipRefs, setWipRefs] = React.useState(null);      // null = not fetched yet
  const [archRefs, setArchRefs] = React.useState(null);
  const [funnelBusy, setFunnelBusy] = React.useState(null); // filename/id currently moving
  const [funnelErr, setFunnelErr] = React.useState("");     // v706 — failures were console-only
  React.useEffect(() => {
    setStripFunnel("hero"); setWipRefs(null); setArchRefs(null);
    setHeroOverride(null); setFunnelErr("");
  }, [_assetKey]);
  const _loadBucket = React.useCallback((which) => {
    if (!_assetBase) return;
    const url = which === "wip" ? `${_assetBase}/wip?stage=all` : `${_assetBase}/archived`;
    const set = which === "wip" ? setWipRefs : setArchRefs;
    (window.authFetch || fetch)(url)
      .then(r => r.ok ? r.json() : null)
      // v706 — a gen that has been promoted is in HERO now; leaving it in WIP too made
      // ★ look like it did nothing (the tile stayed put and both counts held still).
      .then(d => set(((d && d.items) || []).filter(r => r && r.url && !(which === "wip" && r.promoted))))
      .catch(() => set([]));
  }, [_assetBase]);
  const refreshHero = React.useCallback(async () => {
    if (!_assetBase) return;
    try {
      const r = await (window.authFetch || fetch)(`${_assetBase}/item`);
      if (!r.ok) return;
      const j = await r.json();
      if (j && j.item && Array.isArray(j.item.references)) {
        // v870 - do NOT clear hiddenNames here. Hugo: "i send one to wip, it's gone but
        // then i send another one and the previous one comes back. it's a mess."
        // refreshHero runs on EVERY move, so wiping the set un-hid everything moved earlier
        // in the run - the server's hero list still lists them, so they popped back one by
        // one. The hides now accumulate for as long as he stays in this bucket, and clear
        // where it actually makes sense: switching bucket, or opening another asset.
        setHeroOverride(j.item.references); setOrderOverride(null);
      }
    } catch (_) {}
  }, [_assetBase]);
  // v706 — prefetch BOTH buckets on open, not on first click, so the counts are honest
  // before you touch anything (they used to come from a different source than the tiles).
  React.useEffect(() => {
    if (wipRefs === null) _loadBucket("wip");
    if (archRefs === null) _loadBucket("archived");
  }, [wipRefs, archRefs, _loadBucket]);
  // v818 — a picture dropped into THIS asset's folder on disk. The watcher
  // ingests it server-side and broadcasts; re-pull the bucket it landed in so
  // the strip updates with the modal still open. `item` is a frozen prop (v706),
  // so reloadAppData can't reach this modal — it has to refetch for itself.
  React.useEffect(() => {
    const fn = (e) => {
      const m = (e && e.detail) || {};
      if (m.type !== "asset_reference_added") return;
      if (!_assetKey || String(m.slug || "") !== String(_assetKey)) return;
      if (m.is_wip) _loadBucket("wip"); else refreshHero();
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [_assetKey, _loadBucket, refreshHero]);
  // Counts come from the SAME lists the tiles do — null until fetched, never a hint.
  const _funnelCounts = {
    hero: refs.length,
    wip: wipRefs ? wipRefs.length : null,
    archived: archRefs ? archRefs.length : null,
  };
  // One handler for every tile action. `to` is the destination bucket; the source is
  // whichever tab you're looking at, so the same three endpoints cover all six moves.
  const moveAssetRef = async (entry, to) => {
    if (!entry || !_assetBase || funnelBusy) return;
    if (!canMoveAssetRefs()) { setFunnelErr("You don't have permission to move asset images."); return; }
    const key = entry.id || entry.filename;
    setFunnelBusy(key); setFunnelErr("");
    const fetcher = window.authFetch || fetch;
    const post = (path, body) => fetcher(`${_assetBase}${path}`, {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
    });
    try {
      let r;
      if (stripFunnel === "wip" && to === "hero") r = await post("/promote", { reference_id: entry.id });
      else if (stripFunnel === "wip" && to === "archived") r = await fetcher(`${_assetBase}/wip/${encodeURIComponent(entry.id)}`, { method: "DELETE" });
      else if (stripFunnel === "archived" && to === "hero") r = await post("/restore-reference", { id: entry.id });
      else if (stripFunnel === "hero" && to === "wip") r = await post("/unpromote", { url: entry.url, filename: entry.filename });
      else if (stripFunnel === "hero" && to === "archived") r = await post("/delete-reference", { url: entry.url, filename: entry.filename });
      else return;
      const j = await r.json().catch(() => ({}));
      if (r && !r.ok) throw new Error((j && j.error) || `HTTP ${r.status}`);
      // v706 — refetch BOTH buckets (not set-to-null: the current list stays on screen
      // until fresh data lands, so no empty-strip flash) and re-resolve HERO.
      setGalleryIdx(0);
      _loadBucket("wip"); _loadBucket("archived");
      await refreshHero();
      // v867 — STAY WHERE HE IS. Hugo: "when I send an image to wip, dont bring me to the
      // archived or wip page, stay in hero, always stay in the category i am in."
      //
      // This deliberately REVERSES the earlier follow-the-image rule, which jumped the strip
      // to whichever bucket the server said the image landed in. That was added to make a
      // move feel self-evident, but it breaks the actual workflow: sorting a hero strip means
      // firing off several moves in a row, and being thrown into WIP after each one costs a
      // click back every single time. The tab is now HIS, never ours — the counts on the pills
      // already show where the image went, and the tile leaving the strip shows it moved.
      // (j.bucket is still returned by the server and still used by the callers below.)
      //
      // v869 — and MAKE THE MOVE VISIBLE. Hugo: "Send To wip in asset modal doesnt even
      // work at all" — it did work (verified against the live endpoint: the image really
      // does land in WIP), but the tile stayed sitting in the HERO strip, so from his seat
      // nothing happened. The old bucket-jump had been hiding that: it yanked him to WIP
      // where the image WAS visible, so the stale hero list never showed.
      //
      // refreshHero() re-fetches, but it also CLEARS hiddenNames, so an optimistic hide set
      // before it is wiped. Hide AFTER it resolves: if the refetch already dropped the tile
      // this is a no-op, and if the server still lists it the tile still leaves the strip.
      setHiddenNames(prev => { const n = new Set(prev); n.add(entry.filename); return n; });
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) {
      console.warn("[asset-funnel]", e.message);
      setFunnelErr(e.message || "That didn't work.");
    }
    finally { setFunnelBusy(null); }
  };
  const oref = (() => {
    // The bucket picks the list; everything downstream is unchanged.
    const src = stripFunnel === "wip" ? (wipRefs || [])
      : stripFunnel === "archived" ? (archRefs || [])
      : refs;
    let base = (hiddenNames && hiddenNames.size) ? src.filter(r => !hiddenNames.has(r.filename)) : src;
    if (stripFunnel !== "hero") return base;      // ordering only applies to the approved set
    if (!orderOverride || !base.length) return base;
    const byName = new Map(base.map(r => [r.filename, r]));
    const seen = new Set(); const out = [];
    for (const n of orderOverride) { const r = byName.get(n); if (r && !seen.has(n)) { out.push(r); seen.add(n); } }
    for (const r of base) if (!seen.has(r.filename)) out.push(r);
    return out;
  })();
  // v705 — the single-image fallback belongs to HERO only. Without this gate an
  // empty WIP/ARCHIVED bucket fell back to item.image and drew ONE phantom tile with
  // no ref behind it — no actions, no filename, and it looked like the bucket had
  // something in it. (Caught live on "On Deck of The Santa Maria ship".)
  const gallery = oref.length > 0
    ? oref.map(r => r.url)
    : ((stripFunnel === "hero" && item.image) ? [item.image] : []);
  const galleryFilenames = oref.length > 0 ? oref.map(r => r.filename) : [];
  const activeImg = gallery[galleryIdx] || null;
  const activeFile = galleryFilenames[galleryIdx] || null;
  const activeRef = (oref && oref[galleryIdx]) || null;
  const RevealBtn = window.FileActionBtns || window.RevealInFolderBtn;
  // v07zz190 — styled-confirm-driven delete (no native window.confirm).
  const doDeleteRef = async (ref) => {
    if (!ref || !_canEditRefs || deleting) return;
    setDeleting(true);
    setHiddenNames(prev => { const n = new Set(prev); n.add(ref.filename); return n; });
    setGalleryIdx(0);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher(`/api/assets/${_refCat}/${encodeURIComponent(_assetKey)}/delete-reference`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url: ref.url, filename: ref.filename }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setPendingDelete(null);
      setArchRefs(null); setWipRefs(null);   // v705 — the image just landed in ARCHIVED; refetch on next view
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) {
      // v706 — invariant #22: no native alert. Also un-hide the tile we optimistically
      // removed, or a failed delete leaves a hole until the modal is reopened.
      console.warn("[delete-reference]", e.message);
      setHiddenNames(prev => { const n = new Set(prev); n.delete(ref.filename); return n; });
      setFunnelErr("Couldn't remove that image: " + e.message);
    }
    finally { setDeleting(false); }
  };
  const reorderRefsTo = (from, to) => {
    if (!_canEditRefs || from == null || to == null || from === to) return;
    const names = oref.map(r => r.filename).filter(Boolean);
    if (names.length !== oref.length) return;
    const next = names.slice();
    const [m] = next.splice(from, 1);
    next.splice(to, 0, m);
    const prev = orderOverride; // v07zz238 — revert this optimistic order if the save fails
    setOrderOverride(next);
    setGalleryIdx(to);
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/assets/${_refCat}/${encodeURIComponent(_assetKey)}/reorder`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ order: next }),
    }).then(r => { if (!r.ok) { setOrderOverride(prev); throw new Error("reorder " + r.status); } if (typeof window.reloadAppData === "function") window.reloadAppData(); })
      .catch(e => { setOrderOverride(prev); console.warn("[reorder]", e.message); });
  };

  // v07zv — Preload every gallery image at modal-hero width on mount
  // so clicking any thumb paints from browser cache instead of
  // re-fetching from R2. The 800 px variant is pre-generated by the
  // mipmap warm-up so this is almost free.
  // v07zz79 — Skip PDFs in the warm-up (thumbUrl doesn't transform
  // PDFs and we don't want to inadvertently fetch the whole document).
  const _isPdfUrl = (u) => /\.pdf(?:$|\?)/i.test(String(u || ""));
  React.useEffect(() => {
    if (!window.thumbUrl) return;
    for (const src of gallery) {
      if (!src || _isPdfUrl(src)) continue;
      const img = new Image();
      img.src = window.thumbUrl(src, 800);
    }
  }, [gallery]);

  // v07zz147 — Is the currently-shown gallery image the batch cover? Only
  // then do we surface the drag-to-crop preview. localCover is the optimistic
  // just-starred filename; otherwise fall back to the persisted is_cover flag.
  // (activeRef is declared above, from the ordered gallery list `oref`.)
  const activeIsCover = isArchivalLike && !!activeRef && !!activeRef.filename
    && !_isPdfUrl(activeImg)
    && (localCover ? localCover === activeRef.filename : !!activeRef.is_cover);
  // Re-seed the focal point whenever the active image (or its saved crop) changes.
  React.useEffect(() => {
    setCoverFocus(parseFocus(activeRef && activeRef.cover_focus));
  }, [galleryIdx, activeRef && activeRef.filename, activeRef && activeRef.cover_focus]);
  const onCropDown = (e) => {
    e.preventDefault(); e.stopPropagation();
    const box = e.currentTarget;
    cropDragRef.current = {
      sx: e.clientX, sy: e.clientY,
      fx: coverFocus.x, fy: coverFocus.y,
      w: box.clientWidth || 1, h: box.clientHeight || 1,
      lastX: coverFocus.x, lastY: coverFocus.y,
      ref: activeRef,
    };
    try { box.setPointerCapture(e.pointerId); } catch (_) {}
  };
  const onCropMove = (e) => {
    const d = cropDragRef.current; if (!d) return;
    // Pan the image: dragging right reveals more of the left, so the focal
    // point moves the opposite way to the cursor. Full box width = 0→100%.
    const nx = Math.max(0, Math.min(100, d.fx - ((e.clientX - d.sx) / d.w) * 100));
    const ny = Math.max(0, Math.min(100, d.fy - ((e.clientY - d.sy) / d.h) * 100));
    d.lastX = nx; d.lastY = ny;
    setCoverFocus({ x: Math.round(nx), y: Math.round(ny) });
  };
  const onCropUp = () => {
    const d = cropDragRef.current; if (!d) return;
    cropDragRef.current = null;
    saveCoverFocus(d.ref, d.lastX, d.lastY);
  };

  // v04e — render via portal at #modal-root so the position:fixed
  // backdrop isn't trapped by .view-page's backdrop-filter containing
  // block. Whole UI darkens, not just the central panel.
  const portalRoot = document.getElementById("modal-root") || document.body;

  // v07n — Hugo: for Archival the gallery belongs IN the left column
  // directly under the hero, not stretched across the full modal width
  // beneath the two columns. Character/Animal/Location/Prop/Ref modals
  // still keep the full-width strip the design originally called for
  // (more thumbs, more breathing room). Extract the gallery JSX so we
  // can render it in either slot from a single source of truth — the
  // callback ref still fires exactly once because only one copy mounts.
  const galleryJsx = (
    <>
    <div className="char-gallery-wrap">
      {/* v705 — the switch itself, reusing the shot modal's own .vs-pubfilter pills so it
          reads identically to the FRAMES / VIDEO strips. */}
      {_canEditRefs && (
        <div className="char-gallery-funnel">
          <span className="vs-pubfilter" role="group" aria-label="Image filter">
            {[
              ["hero", "HERO", "Approved images — the asset's promoted references"],
              ["wip", "WIP", "Un-promoted generations still in the WIP funnel"],
              ["archived", "ARCHIVED", "Discarded images"],
            ].map(([id, label, tip]) => (
              <button type="button" key={id} title={tip}
                className={"vs-pf-btn" + (stripFunnel === id ? " is-on" : "")}
                onClick={() => { setStripFunnel(id); setGalleryIdx(0); setFunnelErr(""); setHiddenNames(new Set()); }}>
                {label}
                {/* v706 — reserved width so the pill doesn't jump as counts load (#20) */}
                <span className="vs-pf-count">{_funnelCounts[id] != null ? ` (${_funnelCounts[id]})` : ""}</span>
              </button>
            ))}
          </span>
          {/* v706 — failures used to be console.warn only (and delete threw a native
              alert, breaching invariant #22). Lives inside the fixed-height funnel row,
              so showing it moves nothing. */}
          {funnelErr && <span className="char-gallery-funnel-err" role="status">{funnelErr}</span>}
          {/* v945 — 🔄 refresh, same as the shot modal's v792 (Hugo: "i need the
              refresh on the asset modal as well on the images"). Bumps the global
              thumb cache-bust token so every image refetches, re-pulls the hero
              list, and voids both bucket caches (the v706 prefetch effect refills
              them). Fixed-height row — nothing shifts. */}
          <button type="button" className="char-funnel-refresh"
            title="Refresh images — reload this asset's pictures after overwriting a file in Photoshop"
            aria-label="Refresh images"
            onClick={() => {
              if (window.__bumpThumbCacheBust) window.__bumpThumbCacheBust();
              refreshHero();
              setWipRefs(null); setArchRefs(null);
            }}>
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
          </button>
        </div>
      )}
      <button type="button" className="char-gallery-arrow char-gallery-arrow--left"
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => galleryEl.current && galleryEl.current.scrollBy({ left: -320, behavior: "smooth" })}
              aria-label="Scroll gallery left">‹</button>
      <div className="char-gallery-strip" ref={galleryRef}>
        {/* v705 — the 4 gradient placeholder slots are a HERO-tab affordance ("this
            asset has no approved images yet"). An empty WIP/ARCHIVED bucket must not
            fake four tiles — it gets the empty line below instead. */}
        {(gallery.length > 0 ? gallery : (stripFunnel === "hero" ? [null, null, null, null] : [])).map((g, i) => {
          // v07zz79 — PDF tiles render a document icon + filename so
          // they're visually distinct from image references and
          // don't trigger a broken <img>. Clicking still selects the
          // tile (the hero panel handles the open-in-new-tab affordance).
          const isPdf = _isPdfUrl(g);
          // v07zz144 — Cover-thumbnail star (archival / historical only).
          // v705 — read from `oref` (the DISPLAYED list), not `refs`: with a funnel
          // bucket or a drag-reorder in play, refs[i] is a different image.
          const ref = oref[i];
          // v705 — reorder is a HERO-tab affordance; the WIP/ARCHIVED lists have no
          // persisted order to write back to.
          const canDragTile = stripFunnel === "hero";
          const canCover = isArchivalLike && ref && ref.filename && !isPdf;
          const isCover  = canCover && (localCover ? localCover === ref.filename : !!ref.is_cover);
          const showStar = canCover && (hoverThumb === i || isCover);
          const isImg = g && !isPdf;
          const baseStyle = isImg
            ? {}
            : isPdf
              ? { background: "linear-gradient(155deg, oklch(0.42 0.04 30), oklch(0.32 0.03 30))" }
              : (isLocOrRef
                  ? { background: locGradient((item.hue || 100) + i * 25) }
                  : { background: `linear-gradient(160deg, oklch(0.50 0.04 ${60 + i*40}), oklch(0.35 0.04 ${30 + i*30}))` });
          return (
            <button key={i}
              className={"char-gallery-thumb" + (galleryIdx === i ? " is-active" : "") + (isPdf ? " char-gallery-thumb--pdf" : "") + (isImg ? " char-gallery-thumb--img" : "") + (dropTarget && dropTarget.idx === i ? (dropTarget.side === "left" ? " drop-before" : " drop-after") : "")}
              onClick={() => setGalleryIdx(i)}
              onMouseEnter={() => setHoverThumb(i)}
              onMouseLeave={() => setHoverThumb(h => (h === i ? null : h))}
              title={(window.__isDefaultProject && !window.__isDefaultProject()) ? undefined : (galleryFilenames[i] || (isImg && _canEditRefs && canDragTile ? "Drag to reorder" : `Slot ${i + 1}`))}
              aria-label={galleryFilenames[i] || `Slot ${i + 1}`}
              /* v07zz188 — drag to reorder (real asset kinds only). */
              draggable={!!(isImg && _canEditRefs && canDragTile)}
              onDragStart={(isImg && _canEditRefs && canDragTile) ? ((e) => { _dragFrom.current = i; _hideDragGhost(e); try { e.dataTransfer.effectAllowed = "move"; } catch (_) {} }) : undefined}
              onDragOver={(isImg && _canEditRefs && canDragTile) ? ((e) => { e.preventDefault(); try { e.dataTransfer.dropEffect = "move"; } catch (_) {} const r = e.currentTarget.getBoundingClientRect(); const side = (e.clientX - r.left) < r.width / 2 ? "left" : "right"; setDropTarget(prev => (prev && prev.idx === i && prev.side === side) ? prev : { idx: i, side }); }) : undefined}
              onDragEnd={(isImg && _canEditRefs && canDragTile) ? (() => { _dragFrom.current = null; setDropTarget(null); }) : undefined}
              onDrop={(isImg && _canEditRefs && canDragTile) ? ((e) => { e.preventDefault(); const from = _dragFrom.current; _dragFrom.current = null; const dt = dropTarget; setDropTarget(null); if (from == null) return; let to = (dt && dt.side === "right") ? i + 1 : i; if (from < to) to -= 1; reorderRefsTo(from, to); }) : undefined}
              style={{ position: "relative", ...baseStyle, ...(isCover ? { boxShadow: "0 0 0 2px var(--gold-star) inset" } : {}) }}>
              {isImg && <img className="char-gallery-thumb-img" src={window.thumbUrl ? window.thumbUrl(g, 240) : g} alt="" draggable={false}/>}
              {!g && <span className="char-gallery-placeholder">{i + 1}</span>}
              {showStar && (
                <span role="button"
                  title={(window.__isDefaultProject && !window.__isDefaultProject()) ? undefined : (isCover ? "Cover thumbnail for this batch" : "Set as cover thumbnail")}
                  aria-label={isCover ? "Cover thumbnail for this batch" : "Set as cover thumbnail"}
                  onClick={(e) => { e.stopPropagation(); setGalleryIdx(i); setCoverImage(ref); }}
                  style={{
                    position: "absolute", top: 4, left: 4, width: 22, height: 22, borderRadius: "50%",
                    display: "flex", alignItems: "center", justifyContent: "center",
                    background: "color-mix(in srgb, var(--shade-8) 66%, transparent)", color: isCover ? "var(--gold-star)" : "var(--white)",
                    fontSize: "var(--fs-body)", lineHeight: 1, cursor: "pointer",
                    border: isCover ? "1px solid var(--gold-star)" : "1px solid color-mix(in srgb, var(--white) 45%, transparent)",
                  }}>★</span>
              )}
              {isPdf && (
                <span className="char-gallery-pdf-badge" aria-hidden="true">
                  <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
                    <polyline points="14 2 14 8 20 8"/>
                  </svg>
                  <span className="char-gallery-pdf-label">PDF</span>
                </span>
              )}
              {/* v705 — funnel actions ON THE THUMBNAIL, same cluster + same icons as
                  the shot modal's FRAMES tiles (.vt-actions / .vt-move-btn):
                    HERO     → [◐ Send to WIP] [🗄 Archive]
                    WIP      → [★ Push to HERO] [🗄 Discard]
                    ARCHIVED → [★ Restore]
                  ★ always means "push up to HERO". Archive from HERO routes through the
                  styled confirm (invariant #22); the WIP/ARCHIVED moves are reversible
                  from the other tabs, so they act immediately. */}
              {isImg && _canEditRefs && ref && !ref.from_disk && (
                <span className={"vt-actions" + (funnelBusy ? " vt-actions--busy" : "")}>
                  {(stripFunnel === "wip" || stripFunnel === "archived") && (
                    <span className="vt-move-btn vt-move-hero" role="button" tabIndex={0}
                      onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                      onClick={(e) => { e.stopPropagation(); moveAssetRef(ref, "hero"); }}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveAssetRef(ref, "hero"); } }}
                      title={stripFunnel === "archived" ? "Restore onto the asset" : "Push to HERO — approve onto the asset"}>
                      <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"><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>
                    </span>
                  )}
                  {stripFunnel === "hero" && ref.unpromotable && (
                    <span className="vt-move-btn vt-move-wip" role="button" tabIndex={0}
                      onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                      onClick={(e) => { e.stopPropagation(); moveAssetRef(ref, "wip"); }}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveAssetRef(ref, "wip"); } }}
                      title="Send back to WIP (keeps the image, removes it from the asset folder)">
                      <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill="currentColor" stroke="none"/></svg>
                    </span>
                  )}
                  {(stripFunnel === "hero" || stripFunnel === "wip") && (
                    <span className="vt-move-btn vt-move-arch" role="button" tabIndex={0}
                      onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                      onClick={(e) => { e.stopPropagation(); if (stripFunnel === "hero") { setGalleryIdx(i); setPendingDelete(ref); } else moveAssetRef(ref, "archived"); }}
                      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (stripFunnel === "hero") { setGalleryIdx(i); setPendingDelete(ref); } else moveAssetRef(ref, "archived"); } }}
                      title={stripFunnel === "hero" ? "Archive — remove from the asset (recoverable from ARCHIVED)" : "Discard this generation"}>
                      <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"/></svg>
                    </span>
                  )}
                </span>
              )}
            </button>
          );
        })}
        {/* v705 — an EMPTY bucket must still say so; without this the strip just
            collapses to the two add-tiles and reads like a broken page. */}
        {_canEditRefs && stripFunnel !== "hero" && gallery.length === 0 && (
          <div className="char-gallery-empty">
            {(stripFunnel === "wip" ? wipRefs : archRefs) === null
              ? "Loading…"
              : (stripFunnel === "wip" ? "No WIP generations" : "Nothing archived")}
          </div>
        )}
        {/* v07zz214 — Add Reference tile (Historical / External batches only).
            Scrolls with the strip like a "new slide" affordance; no layout
            shift. Opens a file picker that uploads into this batch's folder. */}
        {canAddRef && (
          <button type="button"
            className="char-gallery-thumb char-gallery-thumb--add"
            onClick={() => _addRefInput.current && _addRefInput.current.click()}
            title={(window.__isDefaultProject && !window.__isDefaultProject()) ? undefined : "Add an image or PDF to this reference category"}
            aria-label="Add reference"
            disabled={addingRef}
            style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3,
                     border: "1.5px dashed var(--tile-add-border)", background: "var(--cream-27)", color: "var(--ink-6)",
                     cursor: addingRef ? "default" : "pointer", opacity: addingRef ? 0.6 : 1 }}>
            <span style={{ fontSize: "var(--fs-22)", lineHeight: 1, fontWeight: 300 }}>{addingRef ? "…" : "+"}</span>
            <span style={{ fontSize: "var(--fs-9)", letterSpacing: "0.07em", fontWeight: "var(--fw-semi)" }}>{addingRef ? "ADDING" : "ADD"}</span>
          </button>
        )}
        {/* v701 — "+ FROM LIBRARY" tile, exactly like the shot modal's FRAMES strip.
            Hugo asked for this three times; I kept putting it in the icon row above the
            image instead, where it was a 6th anonymous circle. It belongs HERE, in the
            strip, next to the images it adds to. Same shape as the ADD tile beside it. */}
        {_canEditRefs && _assetKey && (   /* 24 Sep 2026 (G5) — every project: add-image copies into the asset's own _wip */
          <button type="button"
            className="char-gallery-thumb char-gallery-thumb--add"
            onClick={() => setAddLibOpen(true)}
            title={_isProjectCat ? undefined : "Add an image from anywhere in the project — shots, other assets, archived frames"}
            aria-label="Add image from library"
            style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3,
                     border: "1.5px dashed var(--tile-add-border)", background: "var(--cream-27)", color: "var(--ink-6)", cursor: "pointer" }}>
            <span style={{ fontSize: "var(--fs-22)", lineHeight: 1, fontWeight: 300 }}>+</span>
            <span style={{ fontSize: "var(--fs-8-5)", letterSpacing: "var(--track-06)", fontWeight: "var(--fw-semi)", textAlign: "center", lineHeight: 1.15 }}>FROM<br/>LIBRARY</span>
          </button>
        )}
      </div>
      {canAddRef && (
        <input ref={_addRefInput} type="file" accept="image/*,.pdf" multiple
               style={{ display: "none" }}
               onChange={(e) => onAddRefFiles(e.target.files)} />
      )}
      <button type="button" className="char-gallery-arrow char-gallery-arrow--right"
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => galleryEl.current && galleryEl.current.scrollBy({ left: 320, behavior: "smooth" })}
              aria-label="Scroll gallery right">›</button>
    </div>
    {/* v07zz252/v705 — the separate WIP tray is GONE here too; WIP is a bucket on the
        strip switch above (same reasoning as the character modal). */}
    </>
  );

  return ReactDOM.createPortal((
    <div className={"char-modal-backdrop" + (heroWide && !isArchivalLike ? " char-modal-backdrop--imgwide" : "")}
         data-nav-dir={navDir || undefined}
         onMouseDown={onBackdropMouseDown}
         onClick={onBackdropClick}>
      {/* v07zt — Prev/next arrows OUTSIDE the modal panel (same
          treatment as CharacterDetailModal) so the user can flip
          through every asset of the current kind without closing
          and re-opening cards. */}
      {onNavigate && (
        <>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--prev"
            onClick={(e) => { e.stopPropagation(); onNavigate(-1); }}
            aria-label={`Previous ${_kindWord}`}
            title={_isProjectCat ? undefined : `Previous ${kind}`}
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m15 6-6 6 6 6"/>
            </svg>
          </button>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--next"
            onClick={(e) => { e.stopPropagation(); onNavigate(1); }}
            aria-label={`Next ${_kindWord}`}
            title={_isProjectCat ? undefined : `Next ${kind}`}
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m9 6 6 6-6 6"/>
            </svg>
          </button>
        </>
      )}
      <div className={"char-modal glass" + (isArchivalLike ? " char-modal--archival" : "") + (heroWide && !isArchivalLike ? " char-modal--imgwide" : "")} ref={_swipeRef} onClick={(e) => e.stopPropagation()}>
        {/* v07zr — Edit button (skipped for archival, since archival
            batches are disk-discovered metadata, not user-curated).
            v07zz80 — Same for historical refs (also disk-curated). */}
        {!isArchivalLike && (
          <button
            className="modal-edit-btn char-modal-edit"
            onClick={() => setEditing(true)}
            aria-label={`Edit ${_kindWord}`}
            title={_isProjectCat ? undefined : "Edit"}
          >
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="M12 20h9"/>
              <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>
            </svg>
          </button>
        )}
        {/* v07zw — Archive button next to Edit (also skipped for
            archival batches since they aren't archivable assets).
            Uses the styled confirmation modal instead of the
            browser-default confirm(). v07zz80 — Skip for historical too. */}
        {!isArchivalLike && (
          <button
            className="modal-edit-btn char-modal-archive"
            onClick={() => item && setArchiving(true)}
            aria-label={`Archive ${_kindWord}`}
            title={_isProjectCat ? undefined : "Archive"}
          >
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="M21 8H3v13h18V8z"/>
              <path d="M3 3h18v5H3z"/>
              <path d="M10 12h4"/>
            </svg>
          </button>
        )}
        {/* v07zz182 — Open this asset straight in the Generate page (pre-selects
            it). Only for real generatable asset kinds.
            v07zz281 — gated on generate_assets (admin-only), matching the
            Generate page + /api/generate server gate. */}
        {["character", "animal", "location", "prop"].includes(assetKindSingular(kind)) && (item && (item.id || item.slug)) && (!window.hasPerm || window.hasPerm("generate_assets")) && (
          <button
            className="md-generate-pill char-modal-generate"
            onClick={() => {
              const plural = assetKindCategory(kind);
              const slug = item.id || item.slug || item.folder;
              if (!plural || !slug) return;
              window.__assetGenPersist = window.__assetGenPersist || {};
              window.__assetGenPersist.category = plural;
              window.__assetGenPersist.selectedSlug = slug;
              window.__assetGenPersist.openRequest = { category: plural, slug };  // v07zz281 — survives localStorage hydration
              window.__assetGenPersist.pendingOpen = true;   // v07zz39 — open the ASSETS flow, not shots
              // v07zz281 — fire an event so an ALREADY-MOUNTED Generate page jumps to
              // THIS asset (mutating __assetGenPersist alone is ignored once mounted).
              try { window.dispatchEvent(new CustomEvent("paradise-open-asset-gen", { detail: { category: plural, slug } })); } catch (_) {}
              if (onClose) onClose();
              try { window.__nav && window.__nav.setView && window.__nav.setView("generate"); } catch (_) {}
            }}
            title="Open in Generate"
          >
            <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" stroke="none" aria-hidden="true" style={{ marginRight: 5, verticalAlign: "-1px" }}>
              <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>
            </svg>
            Generate
          </button>
        )}
        <button className="modal-close char-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        {editing && (
          <EditAssetModal
            kind={kind}
            asset={item}
            onClose={() => setEditing(false)}
            onSaved={(updated) => {
              // Mutate item in place so the modal re-renders with new fields.
              Object.assign(item, updated);
              setEditing(false);
            }}
          />
        )}
        {archiving && item && (
          <ConfirmArchiveAssetModal
            kind={_kindWord}
            asset={item}
            busy={archiveBusy}
            onCancel={() => !archiveBusy && setArchiving(false)}
            onConfirm={async () => {
              setArchiveBusy(true);
              try {
                const fetcher = window.authFetch || fetch;
                const id = item.id || item.slug || item.folder;
                const r = await fetcher(`/api/assets/${kind}/${encodeURIComponent(id)}/archive`, { method: "POST" });
                const b = await r.json().catch(() => ({}));
                if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
                try {
                  const PLURAL = {
                    character: "characters", animal: "animals", location: "locations",
                    prop: "props", ref: "refs",
                  };
                  const k = assetKindCategory(kind);
                  if (k && window.__appData && Array.isArray(window.__appData.assets[k])) {
                    const arr = window.__appData.assets[k];
                    const i = arr.findIndex(a => a.id === id || a.slug === id || a.folder === id);
                    if (i >= 0) arr[i] = { ...arr[i], archived: true, archived_at: new Date().toISOString() };
                  }
                  window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed"));
                } catch (_) {}
                setArchiving(false);
                onClose && onClose();
              } catch (e) { alert("Archive failed: " + e.message); }
              finally { setArchiveBusy(false); }
            }}
          />
        )}

        <div className={"char-modal-grid" + (isArchivalLike ? " is-archival" : "")}>
          {/* LEFT: hero image; for archival the gallery strip nests
              underneath so the right info column stays uncrowded. */}
          <div className="char-media">
            {/* v07zz79 — When the active gallery item is a PDF, render
                an "Open PDF" affordance instead of trying to use the
                URL as an <img> background (which would just show a
                broken image). Click opens the PDF in a new tab via
                the same /local/ URL; the browser's native viewer
                handles rendering. */}
            <div className={"char-main-img" + (activeImg && !_isPdfUrl(activeImg) && !isMobile && !isArchivalLike ? " is-natural" : "")}
              style={activeImg && !_isPdfUrl(activeImg) && !isMobile && !isArchivalLike
                ? null
                : (activeImg && !_isPdfUrl(activeImg)
                    ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(activeImg, 800) : activeImg})` }
                    : (activeImg && _isPdfUrl(activeImg)
                        ? { background: "linear-gradient(155deg, oklch(0.40 0.04 28), oklch(0.30 0.03 28))", display: "grid", placeItems: "center" }
                        : { background: heroBg }))}>
              {activeImg && !_isPdfUrl(activeImg) && !isMobile && !isArchivalLike && (
                <img className="char-main-img-el" src={window.thumbUrl ? window.thumbUrl(activeImg, 1200) : activeImg} alt={(item && item.name) || kind} draggable={false}
                  onLoad={(e) => { const w = e.target.naturalWidth, h = e.target.naturalHeight; const wide = !!(w && h) && (w / h) >= 1.6; try { const k = item && (item.id || item.slug || item.folder); if (k) _assetHeroAspect.set(k, wide); } catch (_) {} setHeroWide(wide); if (!firstHeroLoadRef.current) { firstHeroLoadRef.current = true; requestAnimationFrame(() => setModalAnim(true)); } }}/>
              )}
              {!activeImg && kind === "prop" && <span className="character-thumb-initials">⬢</span>}
              {!activeImg && (kind === "location" || kind === "ref" || isArchivalLike) && (
                <span className="location-thumb-tag" style={{position: "absolute", left: 14, bottom: 14}}>{item.scenes || item.type}</span>
              )}
              {activeImg && _isPdfUrl(activeImg) && (
                <a className="char-pdf-open" href={activeImg} target="_blank" rel="noopener noreferrer"
                   onClick={(e) => e.stopPropagation()}>
                  <svg viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
                    <polyline points="14 2 14 8 20 8"/>
                  </svg>
                  <span className="char-pdf-open-text">
                    <span className="char-pdf-open-label">Open PDF</span>
                    <span className="char-pdf-open-file">{activeFile || "document"}</span>
                  </span>
                </a>
              )}
              {activeImg && !_isPdfUrl(activeImg) && (
                <div className="char-main-img-actions">
                  {/* v705 — the ARCHIVED button that used to sit here is GONE. Hugo:
                      "exactly like the shot modal, on the thumbnails, with a switch not
                      a button top right." Archived is now a bucket on the strip's
                      HERO | WIP | ARCHIVED switch, and every per-image action (send to
                      WIP, archive, restore, push to hero) lives on the thumbnails.
                      This row is view-only now: full screen + reveal in folder. */}
                  {/* v07zz184 — Open full screen (mirrors the character modal +
                      Generate preview). Shown for any viewable image. */}
                  <button type="button"
                    className="reveal-folder-btn char-img-expand-btn"
                    onClick={() => setZoomSrc(activeImg)}
                    title="Open full screen"
                    aria-label="Open full screen">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5"/><path d="M20 15v5h-5"/><path d="M9 20H4v-5"/><path d="M15 4h5v5"/></svg>
                  </button>
                  {RevealBtn && (
                    <RevealBtn src={activeImg} label={activeFile ? `Open ${activeFile} in folder` : "Open in folder"}/>
                  )}
                  {/* v695 — Add an image from ANYWHERE in the project (Hugo: "an image I
                      made in the Santa Maria asset put into the location asset"). Opens the
                      shot modal's library picker retargeted at this asset; it COPIES, so the
                      source asset/shot keeps its own image. Same _canEditRefs gate as delete
                      just below, which matches the endpoint's upload_assets permission. */}
                  {/* v701 — the Add-image control lives in the GALLERY STRIP as a
                      "+ FROM LIBRARY" tile (see below), matching the shot modal's FRAMES
                      strip. It was here as an icon-row button for v695-v700 and Hugo
                      couldn't find it in any of those forms — this row is for acting on
                      the CURRENT image (view / reveal / download / delete), not for
                      adding new ones. */}
                  {/* v702/v705 — "Send back to WIP" and "Delete" also moved onto the
                      thumbnails as ◐ and 🗄, per bucket. Nothing that MOVES an image
                      lives in this row any more. */}
                </div>
              )}
            </div>
            {/* v07zz147/148 — Cover crop: a card-shaped, draggable preview of
                how this image sits in the batch thumbnail. Collapsible — opens
                when you star, "Done" closes it, "Adjust crop" reopens it, so it
                doesn't stay parked over the main image. */}
            {activeIsCover && showCropEditor && (
              <div style={{ marginTop: 10 }}>
                <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6, fontSize: "var(--fs-sm)", color: "var(--ink-muted, var(--ink-grey-16))" }}>
                  <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                    <span style={{ color: "var(--gold-star)" }}>★</span> Card thumbnail — drag to reposition the crop
                  </span>
                  <span style={{ display: "inline-flex", gap: 6 }}>
                    <button type="button"
                      onClick={() => { setCoverFocus({ x: 50, y: 50 }); saveCoverFocus(activeRef, 50, 50); }}
                      style={{ background: "transparent", border: "1px solid color-mix(in srgb, var(--tan-4) 40%, transparent)", color: "inherit", borderRadius: "var(--r-xs)", padding: "2px 8px", fontSize: "var(--fs-xs)", cursor: "pointer" }}>
                      Reset
                    </button>
                    <button type="button"
                      onClick={() => setShowCropEditor(false)}
                      style={{ background: "color-mix(in srgb, var(--leaf) 16%, transparent)", border: "1px solid color-mix(in srgb, var(--leaf) 50%, transparent)", color: "inherit", borderRadius: "var(--r-xs)", padding: "2px 12px", fontSize: "var(--fs-xs)", fontWeight: "var(--fw-semi)", cursor: "pointer" }}>
                      Done
                    </button>
                  </span>
                </div>
                <div
                  onPointerDown={onCropDown}
                  onPointerMove={onCropMove}
                  onPointerUp={onCropUp}
                  onPointerCancel={onCropUp}
                  title="Drag to choose which part of the image the card thumbnail shows"
                  style={{
                    position: "relative", width: "100%",
                    aspectRatio: "var(--project-aspect)",
                    borderRadius: "var(--r-sm)", overflow: "hidden",
                    cursor: cropDragRef.current ? "grabbing" : "grab",
                    touchAction: "none", userSelect: "none",
                    border: "1px solid color-mix(in srgb, var(--gold-star) 50%, transparent)",
                    boxShadow: "0 0 0 2px color-mix(in srgb, var(--gold-star) 22%, transparent)",
                    backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(activeImg, 800) : activeImg})`,
                    backgroundSize: "cover",
                    backgroundPosition: `${coverFocus.x}% ${coverFocus.y}%`,
                    backgroundRepeat: "no-repeat",
                  }}>
                  <span aria-hidden="true" style={{
                    position: "absolute", left: "50%", top: "50%", transform: "translate(-50%,-50%)",
                    pointerEvents: "none", color: "color-mix(in srgb, var(--white) 90%, transparent)",
                    fontSize: "var(--fs-xs)", letterSpacing: 0.5, textTransform: "uppercase",
                    background: "color-mix(in srgb, var(--shade-8) 45%, transparent)", padding: "2px 9px", borderRadius: 99,
                  }}>↔ drag ↕</span>
                </div>
              </div>
            )}
            {activeIsCover && !showCropEditor && (
              <div style={{ marginTop: 8, textAlign: "center" }}>
                <button type="button" onClick={() => setShowCropEditor(true)}
                  style={{ background: "transparent", border: "1px solid color-mix(in srgb, var(--gold-star) 50%, transparent)", color: "var(--ink, inherit)", borderRadius: "var(--r-sm)", padding: "5px 12px", fontSize: "var(--fs-sm)", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <span style={{ color: "var(--gold-star)" }}>★</span> Adjust thumbnail crop
                </button>
              </div>
            )}
          </div>

          {/* RIGHT: details */}
          <div className="char-info">
            <div className="char-eyebrow">{kind === "historical" ? "HISTORICAL REF" : ((window.__isDefaultProject && !window.__isDefaultProject() && window.__projectCategoryLabel) ? window.__projectCategoryLabel(kind, true).toUpperCase() : kind.toUpperCase())}</div>
            <div className="char-name">{item.name}</div>
            <div className="char-role">{_isProjectCat ? (_projectAssetSubtitle(kind, item) || "—") : (item.type || item.scenes || "—")}</div>
            <div className="char-stats-grid">
              {_isProjectChar ? (<>
                {/* 24 Sep 2026 (G5) — Paradise Found's character stats: the shots that use this
                    character as a reference, and every picture made for it (asset + WIP). */}
                <div className="char-stat">
                  <div className="char-stat-num">{(() => {
                    const ids = new Set([item.id, item.slug].filter(Boolean));
                    const shots = (window.__appData && Array.isArray(window.__appData.shots)) ? window.__appData.shots : [];
                    const n = shots.filter(sh => sh && ((Array.isArray(sh.reference_assets) && sh.reference_assets.some(x => ids.has(x)))
                      || (Array.isArray(sh.locked_reference_assets) && sh.locked_reference_assets.some(x => ids.has(x))))).length;
                    return Math.max(n, Array.isArray(item.shots) ? item.shots.length : 0);
                  })()}</div>
                  <div className="char-stat-cap">SHOTS</div>
                </div>
                <div className="char-stat">
                  <div className="char-stat-num">{refs.length + (Array.isArray(item.wip_references) ? item.wip_references.length : 0)}</div>
                  <div className="char-stat-cap">REF PASSES</div>
                </div>
              </>) : (
              <div className="char-stat">
                {/* 24 Sep 2026 — a project asset shows its real count (0 included) */}
                <div className="char-stat-num">{_isProjectCat ? refs.length : (refs.length || item.ref_count || item.count || "—")}</div>
                <div className="char-stat-cap">REFS</div>
              </div>
              )}
              {/* v07zz82 — Hugo: "what is the deal with the FIRST SEEN
                  column?" For archival/historical it just echoed the
                  batch title (since scenes = batch name there) — pure
                  noise. For archival batches with a real tag show TAG
                  instead; for historical (or any tag-less batch) drop
                  the cell entirely so the grid renders REFS + CATEGORY
                  only. Non-archival kinds keep the original FIRST SEEN
                  derived from the scenes field. */}
              {isArchivalLike ? (
                item.tag ? (
                  <div className="char-stat">
                    <div className="char-stat-num">{item.tag}</div>
                    <div className="char-stat-cap">TAG</div>
                  </div>
                ) : null
              ) : ((_isProjectCat && !item.scenes) || _isProjectChar) ? null : (   /* 24 Sep 2026 — no sequences to be first seen in: no cell */
                <div className="char-stat">
                  <div className="char-stat-num">{item.scenes ? item.scenes.split(/[\s—,-]+/)[0] : "—"}</div>
                  <div className="char-stat-cap">FIRST SEEN</div>
                </div>
              )}
              {/* v07zd — Hugo: every asset type gets the same LOCKED
                  star toggle as characters. Archival is read-only
                  (no consistency_locked concept) — falls back to a
                  category glyph. v07zz80 — Historical refs are also
                  read-only and get the same glyph.
                  v07zz82 — Hugo: "make a better icon for those
                  categories icons, they are garbage." Swap the OS
                  emojis (which render as flat outlines on Windows)
                  for inline feather-style SVGs that match the modal's
                  stroke aesthetic. Archival → archive-box (lid + slot
                  + body). Historical → open-book (two pages + spine). */}
              {isArchivalLike ? (
                <div className="char-stat">
                  <div className="char-stat-num">
                    <span style={{ display: "inline-grid", placeItems: "center" }}>
                      {kind === "historical" ? (
                        <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <path d="M2 4h6a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/>
                          <path d="M22 4h-6a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h7z"/>
                        </svg>
                      ) : (
                        <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <rect x="3" y="3" width="18" height="4" rx="0.5"/>
                          <path d="M5 7v13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7"/>
                          <path d="M10 12h4"/>
                        </svg>
                      )}
                    </span>
                  </div>
                  <div className="char-stat-cap">CATEGORY</div>
                </div>
              ) : (
                <AssetLockStat
                  kind={kind}
                  id={item.slug || item.id || item.folder || item.name}
                  locked={!!item.consistency_locked}
                  onChange={(next) => { item.consistency_locked = next; }}
                />
              )}
            </div>
            {/* v07zz240 — workflow status (First Pass → WIP → Retake → Approved).
                Hidden for read-only archival/historical refs. */}
            {!isArchivalLike && (
              <AssetStatusControl
                kind={kind}
                id={item.slug || item.id || item.folder || item.name}
                status={item.status}
                onChange={(next) => {
                  if (item) item.status = next;
                  try {
                    const catKey = assetKindCategory(kind);
                    const arr = window.__appData && window.__appData.assets && window.__appData.assets[catKey];
                    if (arr) { const idv = item && (item.slug || item.id || item.folder || item.name); const it = idv && arr.find(x => (x.id === idv) || (x.slug === idv) || (x.name === idv)); if (it) it.status = next; }
                  } catch (_) {}
                  try { window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed")); } catch (_) {}
                }}
              />
            )}
            <div className="char-section">
              <div className="char-section-head">DETAILS</div>
              {/* 24 Sep 2026 — a project asset: its notes, else its description; where an imported
                  picture came from sits under it as a small muted line, only when there is one. */}
              <div className="char-role-text">{_isProjectCat
                ? (String(item.notes || "").trim() || String(item.description || "").trim() || "No details yet.")
                : (item.notes || `Linked to: ${item.scenes || "—"}.`)}</div>
              {_isProjectCat && String(item.imported_from || "").trim() && (
                <div className="char-imported-from" style={{ marginTop: 6, fontSize: "var(--fs-xs)", color: "var(--ink-muted)", overflowWrap: "anywhere" }}>Imported from {String(item.imported_from).trim()}</div>
              )}
            </div>
            <div className="char-section">
              <div className="char-section-head">USED IN</div>
              <div className="char-role-text">{item.scenes || "Not yet linked to any sequences."}</div>
            </div>
            {/* 24 Sep 2026 (G5) — a project's character: VOICE + DIRECTION PRESETS, as in Paradise Found. */}
            {_isProjectChar && item && <ProjectCharacterVoice key={_assetKey || "char"} kind={kind} item={item} isMobile={isMobile}/>}
            {/* v07w — Hugo: notes on every asset element. Same pattern as
                ShotDetailModal's NotesPanel but lives inline so the
                right-column layout doesn't break. entity_type =
                `asset_${kind}`; entity_id = the item's slug / folder /
                id so notes scope per-asset. version_label carries the
                active gallery image's filename so a note can be tied
                to a specific frame. */}
            <AssetNotesSection
              kind={kind}
              item={item}
              activeFile={activeFile || (_isProjectCat ? _projectFileOf(activeImg) : null)}
            />
          </div>
        </div>

        {/* v07ze — Gallery now ALWAYS renders as a footer row below
            the grid, regardless of kind. Previously archival nested
            the gallery inside char-media which left the right
            column dangling past the bottom of the left column.
            Matching the Locations layout (gallery spans both
            columns as a bottom row) keeps everything aligned. */}
        {galleryJsx}
      </div>
      {/* v07zz184 — full-screen lightbox for the main image (safe inside the
          backdrop — onBackdropClick only fires on a direct backdrop click). */}
      {zoomSrc && <Lightbox
        src={window.thumbUrl ? window.thumbUrl(zoomSrc, 1600) : zoomSrc}
        alt={item && item.name}
        onClose={() => setZoomSrc(null)}
        onPrev={gallery.length > 1 ? () => { const n = (galleryIdx - 1 + gallery.length) % gallery.length; setGalleryIdx(n); setZoomSrc(gallery[n]); } : null}
        onNext={gallery.length > 1 ? () => { const n = (galleryIdx + 1) % gallery.length; setGalleryIdx(n); setZoomSrc(gallery[n]); } : null}
      />}
      {pendingDelete && window.ConfirmDeleteImageModal && (
        <window.ConfirmDeleteImageModal
          name={item && item.name}
          busy={deleting}
          onCancel={() => setPendingDelete(null)}
          onConfirm={() => doDeleteRef(pendingDelete)}
        />
      )}
      {/* v695 — the shot modal's library picker, retargeted at this asset. Same tabs,
          search and drill-down; `assetTarget` swaps the commit from add-version to
          /api/assets/:cat/:slug/add-image, which COPIES so the source keeps its image. */}
      {addLibOpen && window.AddFromLibraryPicker && (
        <window.AddFromLibraryPicker
          assetTarget={{ cat: _refCat, slug: _assetKey, name: (item && item.name) || _assetKey }}
          onClose={() => setAddLibOpen(false)}
        />
      )}
      {/* v705 — AssetArchivedPanel is gone from this modal: ARCHIVED is a bucket on
          the strip's switch now, reading the same /archived endpoint. */}
    </div>
  ), portalRoot);
}

/* ─────────────────────────── CREW ─────────────────────────── */

function CrewView({ crew = [] }) {
  const [active, setActive] = React.useState(null);
  // v07zz135 — Permissions are an admin-only detail. Everyone else sees
  // names / roles / online status but not the permission chips. Uses the
  // EFFECTIVE role so an admin previewing via "See as <role>" also has
  // them hidden (i.e. sees exactly what that role would see).
  const _crewUserCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const crewIsAdmin = ((window.__effectiveRole || (_crewUserCtx && _crewUserCtx.user && _crewUserCtx.user.role)) === "admin");
  // v07zz25 — Live tick so the "X min ago" relative-time labels on
  // each card refresh without needing a refetch. 30 s cadence is
  // enough for human "last seen" semantics.
  const [, _bumpCrew] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => _bumpCrew(n => n + 1), 30_000);
    return () => clearInterval(id);
  }, []);
  // Relative time formatter shared by card + modal. Handles both
  // ISO and SQLite "YYYY-MM-DD HH:MM:SS" (the latter parsed as UTC
  // by appending Z) so we don't get the 10h-timezone-skew bug.
  const fmtRel = (val) => {
    if (!val) return "—";
    if (typeof val === "string" && !val.includes("T") && !val.includes("Z") && /^\d{4}-\d{2}-\d{2} /.test(val)) {
      val = val.replace(" ", "T") + "Z";
    }
    const t = new Date(val).getTime();
    if (!Number.isFinite(t)) return String(val);
    const sec = Math.floor((Date.now() - t) / 1000);
    if (sec < 60)         return `${Math.max(1, sec)}s ago`;
    if (sec < 3600)       return `${Math.floor(sec / 60)} min ago`;
    if (sec < 86400)      return `${Math.floor(sec / 3600)}h ago`;
    if (sec < 86400 * 2)  return "Yesterday";
    if (sec < 86400 * 7)  return `${Math.floor(sec / 86400)}d ago`;
    return new Date(t).toLocaleDateString();
  };
  // Online classification:
  //   is_online === true  → green dot
  //   seen < 5 min ago    → amber (recently active)
  //   anything older      → grey (offline)
  const onlineClassFor = (p) => {
    if (p.is_online) return "is-online";
    const ref = p.last_seen_iso || p.last_login;
    if (!ref) return "is-offline";
    let val = ref;
    if (typeof val === "string" && !val.includes("T") && /^\d{4}-\d{2}-\d{2} /.test(val)) {
      val = val.replace(" ", "T") + "Z";
    }
    const t = new Date(val).getTime();
    if (!Number.isFinite(t)) return "is-offline";
    return (Date.now() - t) < 5 * 60_000 ? "is-recent" : "is-offline";
  };
  return (
    <section className="view-page">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">CREW</div>
          <div className="vp-title">{crew.length} active members</div>
        </div>
      </div>

      <div className="crew-grid">
        {crew.map(p => {
          const onlineCls = onlineClassFor(p);
          const seenLabel = p.last_seen_iso ? fmtRel(p.last_seen_iso) : (p.last_login || "—");
          // v07zz29 — Resize avatar through the sharp proxy. Card
          // portrait is ~250 px wide at 1× / 500 px at 2×, so 400 is
          // the right width-class for the on-disk mipmap cache.
          // Without thumbUrl the browser pulled the full-res original
          // (often a 1–3 MB PNG straight from R2) on every card.
          const cardThumb = p.image && window.thumbUrl ? window.thumbUrl(p.image, 400) : p.image;
          const portraitStyle = p.image
            ? { backgroundImage: `url(${cardThumb})`, backgroundSize: "cover", backgroundPosition: "center" }
            : { background: `linear-gradient(160deg, ${p.color || "var(--leaf)"}, oklch(0.30 0.02 100))` };
          return (
            <article key={p.id} className="crew-card glass interactive-card" onClick={() => setActive(p.id)}>
              <div className="crew-portrait" style={portraitStyle}>
                {!p.image && <span className="crew-initials">{p.initials}</span>}
                <span className={"crew-status-dot " + onlineCls} title={p.is_online ? "Online now" : seenLabel}/>
              </div>
              <div className="crew-body">
                <div className="crew-name">{p.name}</div>
                <div className="crew-role">{p.role}</div>
                <div className="crew-company">{p.company}</div>
                {crewIsAdmin && (
                  <div className="crew-perms-preview">
                    {p.permissions && p.permissions.slice(0, 3).map(perm => (
                      <span key={perm} className="crew-perm-pill">{perm}</span>
                    ))}
                    {p.permissions && p.permissions.length > 3 && (
                      <span className="crew-perm-more">+{p.permissions.length - 3}</span>
                    )}
                  </div>
                )}
                <div className="crew-meta">
                  <span className="crew-meta-row">
                    <span className="crew-meta-label">Last seen</span>
                    <span>{p.is_online ? "Online now" : seenLabel}</span>
                  </span>
                </div>
              </div>
            </article>
          );
        })}
      </div>

      {active && (() => {
        // v04o — render via portal so the backdrop covers the full
        // viewport, not just the .view-page central panel.
        const portalRoot = document.getElementById("modal-root") || document.body;
        return ReactDOM.createPortal((
        <div className="crew-modal-backdrop" onClick={() => setActive(null)}>
          <div className="crew-modal glass" onClick={(e) => e.stopPropagation()}>
            {(() => {
              const p = crew.find(c => c.id === active);
              if (!p) return null;
              return (
                <>
                  <button className="modal-close crew-modal-close" onClick={() => setActive(null)} aria-label="Close">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
                  </button>
                  <div className="crew-modal-head">
                    <div className="crew-portrait crew-portrait--lg" style={p.image
                      ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(p.image, 800) : p.image})`, backgroundSize: "cover", backgroundPosition: "center" }
                      : { background: `linear-gradient(160deg, ${p.color || "var(--leaf)"}, oklch(0.30 0.02 100))` }
                    }>
                      {!p.image && <span className="crew-initials">{p.initials}</span>}
                    </div>
                    <div>
                      <div className="crew-modal-name">{p.name}</div>
                      <div className="crew-modal-role">{p.role}{p.company ? ` · ${p.company}` : ""}</div>
                      <div className="crew-modal-email">{p.email}</div>
                    </div>
                  </div>
                  <div className="crew-modal-bio">{p.bio || <em style={{ opacity: 0.6 }}>No bio yet.</em>}</div>
                  <div className="crew-modal-stats">
                    <div className="cms-stat"><div className="cms-stat-num">{p.notes_today || 0}</div><div className="cms-stat-cap">NOTES TODAY</div></div>
                    <div className="cms-stat"><div className="cms-stat-num">{p.tz ? p.tz.replace(/_/g, " ") : "—"}</div><div className="cms-stat-cap">TIMEZONE</div></div>
                  </div>
                  {crewIsAdmin && (
                    <div className="crew-modal-section">
                      <div className="cms-eyebrow">PERMISSIONS</div>
                      <div className="crew-perms-grid">
                        {(p.permissions || []).map(perm => (
                          <span key={perm} className="crew-perm-tag">{perm.replace(/-/g, " ")}</span>
                        ))}
                        {(!p.permissions || !p.permissions.length) && (
                          <span style={{ fontSize: "var(--fs-sm)", color: "var(--ink-muted)", fontStyle: "italic" }}>None assigned.</span>
                        )}
                      </div>
                    </div>
                  )}
                  <div className="crew-modal-section">
                    <div className="cms-eyebrow">LAST ACTIVITY · {p.is_online ? "Online now" : (p.last_seen_iso ? fmtRel(p.last_seen_iso) : (p.last_login || "—"))}</div>
                    <div className="cma-action">{p.last_action || ""}</div>
                  </div>
                </>
              );
            })()}
          </div>
        </div>
        ), portalRoot);
      })()}
    </section>
  );
}

/* ─────────────────────────── MEDIA ─────────────────────────── */

function MediaView({ shots = [] }) {
  // 15 Sep 2026 — PROJECT-AWARE. The Storyboard / Archival / Historical Refs /
  // Green Echo Trailer tabs (and their /api/media/* + /api/archival +
  // /api/historical-refs fetches) are Paradise Found's folder libraries: only
  // the default project shows them. A templated project gets Grids / Frames /
  // Videos / External Refs / Archived-* from its own asset_versions.
  const _isPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  // v03l — Media library reorganised. Tabs: Grids · Frames · Videos
  // (All / Audio / Images removed per spec). Backed by
  // /api/asset-versions global feed (v01v).
  // v06h — Hugo: clicking an item used to dive into the parent shot
  // popup, which was the wrong context — you wanted to inspect THAT
  // specific grid / frame / video on its own. Clicks now open
  // AssetReviewModal scoped to the clicked asset_id + kind (every
  // version of that grid, that frame, or that video — full filmstrip
  // + metadata) instead.
  const [filter, setFilter] = React.useState("grids");
  const [versions, setVersions] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  // v07zz469 — hover-to-enlarge floating preview on the Frames / Archived Frames
  // galleries (same box the Generate page uses). 17 Sep 2026 — eager global
  // (src/useHoverPreview.jsx): always called, so the hook order never changes.
  const _mvHov = useHoverPreview();
  // v07zz360 — "Show every image as its own card": when on, the per-shot grouping below is
  // skipped so each asset_version renders as its own tile (so each image can be favourited).
  const [expandAll, setExpandAll] = React.useState(false);
  // v811 — Hugo: "in Media Archived Grid, I need the button to show all the frames as well."
  // SHOW ALL was hard-gated to the two Frames tabs in three separate places (pill, flat
  // grid, grouped-view suppressor) — one const so they can never drift apart again. The
  // grid tabs need it MOST: they group by the shot RANGE in the filename, so every version
  // of a range collapses into one card and the rest are invisible. Both grid tabs get it,
  // matching how Frames and Archived Frames both have it.
  const canExpandAll = filter === "frames" || filter === "archived-frames"
                    || filter === "grids"  || filter === "archived-grids";
  // Leaving a tab that can't expand must not strand the flat view on a grouped tab.
  React.useEffect(() => { if (!canExpandAll && expandAll) setExpandAll(false); }, [canExpandAll, expandAll]);
  // 15 Sep 2026 — switching to a templated project while on a film-doc-only tab snaps back to Grids.
  // 24 Sep 2026 (G6) — Storyboard / Archival / Historical Refs are the project's own now; only the Green Echo trailer is Paradise Found's.
  React.useEffect(() => { if (!_isPF && filter === "trailer") setFilter("grids"); }, [_isPF, filter]);
  // v07zz59 — Promote-from-archived picker. Captures the
  // asset_version row + a list of shot candidates to retarget onto.
  // Hugo: proper modal, not native confirm; lets user choose the
  // target shot. Lives at the version level so each frame / slice
  // can be promoted independently.
  const [promotePicker, setPromotePicker] = React.useState(null);   // { version, defaultShotId }
  // v07zz313 — transient styled toast for one-click "send to shot" feedback (no native alert).
  const [sendToast, setSendToast] = React.useState(null);   // { ok, msg }
  React.useEffect(() => { if (!sendToast) return; const t = setTimeout(() => setSendToast(null), 4000); return () => clearTimeout(t); }, [sendToast]);
  // v06h — selected media item for the AssetReviewModal.
  const [mediaReview, setMediaReview] = React.useState(null);
  // v06i — separate state for the grid-specific modal (different panel
  // type, NOT the AssetReviewModal). Clicking a grid opens this; other
  // categories still use mediaReview.
  const [gridDetail, setGridDetail] = React.useState(null);
  // 17 Sep 2026 - the version list is loaded with ?lite=1 (no prompt text). The prompts the review
  // modal shows are fetched per row id when a tile opens and kept here; `seq` drops a stale open.
  const mediaPromptsRef = React.useRef({ byId: new Map(), seq: 0 });
  // v07m — Hugo wants the Media → Archival panel to match the cream
  // AssetItemModal style used on Assets → Archival, not the filmstrip
  // review modal. Same data, different UI; no notes column.
  // v07zz81 — Holds { kind, item } so historical batches open with the
  // correct kind (was hardcoded to "archival" before, which made the
  // eyebrow say ARCHIVAL on historical cards). Also enables onNavigate
  // to know which list to paginate through.
  const [archivalItem, setArchivalItem] = React.useState(null);
  const [archivalNavDir, setArchivalNavDir] = React.useState(null);
  // v07zz283 — Green Echo Trailer gallery. Folder-cards from /api/media/trailer
  // (manifest built by scripts/ingest-trailer.js → R2). Two-level browse:
  // folder cards → click a folder → its image grid → click an image → Lightbox.
  const [trailer, setTrailer] = React.useState([]);
  const [trailerLoaded, setTrailerLoaded] = React.useState(false);
  const [openTrailerFolder, setOpenTrailerFolder] = React.useState(null);
  const [trailerLightbox, setTrailerLightbox] = React.useState(-1); // index into openTrailerFolder.images, -1 = closed
  React.useEffect(() => {
    if (!_isPF) { setTrailer([]); setTrailerLoaded(true); return; }   // 15 Sep 2026 — Paradise Found's Green Echo folder
    const fetcher = window.authFetch || fetch;
    fetcher("/api/media/trailer")
      .then(r => r.ok ? r.json() : null)
      .then(d => setTrailer((d && d.folders) || []))
      .catch(() => setTrailer([]))
      .finally(() => setTrailerLoaded(true));
  }, []);
  // v07zz327 — Green Echo Trailer VIDEOS in the SAME tab. Recursive /api/media/trailer-videos,
  // grouped by subfolder into folder cards (like the images); click a folder → its video grid →
  // click a tile → play in a modal via the stream route. Posters are the same ffmpeg-frame route.
  const [trailerVids, setTrailerVids] = React.useState([]);
  const [openVidFolder, setOpenVidFolder] = React.useState(null);   // { name, videos:[...] }
  const [playVid, setPlayVid] = React.useState(null);               // a video object to play
  React.useEffect(() => {
    if (!_isPF) { setTrailerVids([]); return; }   // 15 Sep 2026 — Paradise Found only
    const fetcher = window.authFetch || fetch;
    fetcher("/api/media/trailer-videos")
      .then(r => r.ok ? r.json() : null)
      .then(d => setTrailerVids((d && d.videos) || []))
      .catch(() => setTrailerVids([]));
  }, []);
  const trailerVidFolders = React.useMemo(() => {
    const m = new Map();
    for (const v of trailerVids) { const g = v.group || "(root)"; if (!m.has(g)) m.set(g, []); m.get(g).push(v); }
    return Array.from(m.entries())
      .map(([name, videos]) => ({ name, videos, cover: (videos[0] && videos[0].poster) || null, count: videos.length }))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [trailerVids]);
  const trailerVidStream = (v) => "/api/media/trailer-video?rel=" + encodeURIComponent(v.rel) + (window.__authToken ? ("&token=" + encodeURIComponent(window.__authToken)) : "");
  // v07zz320 — Storyboard gallery (flat, one card per image from the read-only storyboard folder).
  const [storyboard, setStoryboard] = React.useState([]);
  const [storyboardLoaded, setStoryboardLoaded] = React.useState(false);
  // v07zz371 — Storyboard "highlight" star. Storyboard images are read-only files with no
  // asset_versions row (so no `favorite` column to write); persist the highlighted set in
  // localStorage keyed by image URL. Purely a curation marker — never touches a shot or Hero.
  const [sbFavs, setSbFavs] = React.useState(() => {
    try { return new Set(JSON.parse(localStorage.getItem("storyboard-favs") || "[]")); } catch (_) { return new Set(); }
  });
  const toggleSbFav = (url, e) => {
    if (e) e.stopPropagation();
    setSbFavs(prev => {
      const next = new Set(prev);
      if (next.has(url)) next.delete(url); else next.add(url);
      try { localStorage.setItem("storyboard-favs", JSON.stringify([...next])); } catch (_) {}
      return next;
    });
  };
  React.useEffect(() => {
    // 24 Sep 2026 (G6) — every project: another project's server lists its shots' storyboard versions.
    const fetcher = window.authFetch || fetch;
    fetcher("/api/media/storyboard")
      .then(r => r.ok ? r.json() : null)
      .then(d => setStoryboard((d && d.images) || []))
      .catch(() => setStoryboard([]))
      .finally(() => setStoryboardLoaded(true));
  }, []);
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    // v07zz58 — Pull BOTH live and archived in one go (include_archived=1)
    // so archived rows can route through the exact same tile renderer
    // as live ones. categoryOf below sends archived items into the
    // dedicated archived-frames / archived-videos buckets.
    // 17 Sep 2026 - ?lite=1: the same rows without prompt_text / reference_paths (19 of the 26 MB,
    // and up to 0.4 s of server time per visit). Only the review modal reads a prompt, and
    // openMediaItem fetches those for the tile being opened.
    fetcher("/api/asset-versions?include_archived=1&lite=1")
      .then(r => r.ok ? r.json() : null)
      .then(d => setVersions((d && d.versions) || []))
      .catch(() => setVersions([]))
      .finally(() => setLoading(false));
  }, []);

  // v03l — Map asset_versions.kind to one of the three tab categories.
  // grid → grids, frame/hero → frames, video/upscale → videos.
  // Extension fallback handles older rows missing kind.
  // v07zz51 — Hugo: reference images (kind=reference / asset_ref) were
  // leaking into the Frames tab. Return null so they're filtered out
  // entirely from grids/frames/videos. The External Refs tab below has
  // its own dedicated query (otherRefs).
  // v07zz58 — Archived rows route to "archived-frames" or
  // "archived-videos" so they share the same tile pipeline. Without
  // this Hugo got blank text-only cards via my earlier hand-rolled
  // implementation.
  const categoryOf = (v) => {
    const k = (v.kind || "").toLowerCase();
    // v07zz360 — references, ASSET AVATARS, and voiceover audio are not shot media:
    // keep them out of the Grids/Frames/Videos tabs entirely. (Avatars were leaking
    // into Frames via the default fall-through below — Hugo: "avatars should not appear here".)
    if (k === "reference" || k === "asset_ref" || k === "avatar" || k === "voiceover") return null;
    const ext = (v.file_path || v.cloud_url || "").split("?")[0].split(".").pop().toLowerCase();
    const isVideoExt = ["mp4","mov","webm","mkv","avi","m4v"].includes(ext);
    // v07zz301 — Route by STABLE old-shotlist membership (server `old_shotlist`,
    // from the pre-clean-slate snapshot), NOT the live `archived` flag. The
    // Railway bisync churns `archived` (it un-archived ~1200 old rows in one
    // pull), so old frames were leaking into the live tabs and the count was
    // erratic. Old-shotlist rows ALWAYS land in the Archived tabs; new-shotlist
    // versions that happen to be archived are hidden from Media entirely (they
    // still live in the shot's own version history).
    if (v.old_shotlist) {
      if (k === "video" || (k === "upscale" && isVideoExt)) return "archived-videos";
      if (k === "grid") return "archived-grids";
      return "archived-frames";   // frames, hero, image-upscales
    }
    if (v.archived) return null;   // new-shotlist archived version — not a Media tile
    // Live current rows stay in their normal buckets.
    if (k === "grid")                      return "grids";
    if (k === "frame" || k === "hero")     return "frames";
    if (k === "video")                     return "videos";
    if (k === "upscale")                   return isVideoExt ? "videos" : "frames";
    if (isVideoExt)                        return "videos";
    return "frames";
  };

  // v03l — Inject shot.video_path as a synthetic videos-tab item when
  // no asset_versions row covers it. Ensures SH0010 (which has
  // video_path set but possibly no asset_versions entry) appears in
  // the Videos tab regardless.
  const syntheticVideos = shots
    .filter(s => s.video_path)
    .filter(s => !versions.some(v => v.asset_id === s.id && (v.kind === "video" || v.kind === "upscale")))
    .map(s => ({
      id: `synthetic-${s.id}`,
      asset_id: s.id,
      shot_seq: s.seq,
      shot_frame_title: s.frame_title,
      version_label: "v?",
      kind: "video",
      file_path: s.video_path,
      created_at: null,
      model: null,
    }));

  const items = [...versions, ...syntheticVideos].map(v => ({
    ...v,
    // v07zz301 — categoryOf now routes by old_shotlist (stable snapshot) vs the
    // live archived flag, so the old/new split survives the Railway sync churn.
    cat: categoryOf(v),
    // v07zz298/301 — old-shotlist frames/grids/videos group by their ORIGINAL
    // (pre-reparse) sequence so Media → Archived Frames mirrors the old shotlist
    // folders, not the current shotlist. Live rows keep their current sequence.
    seq: (v.old_shotlist && v.archived_seq != null) ? v.archived_seq : (v.shot_seq || 0),
    archivedSeqName: v.archived_seq_name || null,
    is4k: v.kind === "upscale",
  })).filter(i => i.cat !== null);

  // v07zz76 — Hugo: "in media tab, things are taking one second to
  // load (thumbnails) on first cache on local, and 2-3 seconds on
  // railway. videos thumbs on local took quite a long time too. find
  // a way to speed this up". The video tiles were rendering a
  // <video src="...mp4" preload="metadata"> for every visible card —
  // each one fetched video file metadata + the first chunk just to
  // seek to 0.1s for a poster frame. On Railway that's an R2 round
  // trip per multi-MB video; locally it's still a full file open
  // per tile. Instead we look up the latest NON-archived frame/hero
  // for the same asset_id and use its already-pre-warmed mipmap as
  // a poster <img>. Falls back to the gradient when no frame exists.
  const videoPosterByAssetId = React.useMemo(() => {
    const m = new Map();
    // Sort frame-like rows once so we can take the first match.
    const ranked = versions
      .filter(v => {
        const k = (v.kind || "").toLowerCase();
        return (k === "hero" || k === "frame") && !v.archived && v.file_path;
      })
      .sort((a, b) => {
        // hero before frame, then by version number desc, then created_at desc.
        const ah = (a.kind === "hero") ? 0 : 1;
        const bh = (b.kind === "hero") ? 0 : 1;
        if (ah !== bh) return ah - bh;
        const an = a.version_number || 0, bn = b.version_number || 0;
        if (bn !== an) return bn - an;
        return String(b.created_at || "").localeCompare(String(a.created_at || ""));
      });
    for (const r of ranked) {
      if (!m.has(r.asset_id)) m.set(r.asset_id, r.file_path);
    }
    return m;
  }, [versions]);
  // v06o — Hugo: aggregate to ONE card per shot per kind. Versions
  // are still accessible inside the AssetReviewModal / GridDetailModal
  // (both fetch every version of the clicked asset_id+kind), so the
  // surface-level card list reads as a clean per-shot grid instead of
  // every version of every grid+frame+video appearing as its own tile.
  //
  // Strategy: group filtered items by `asset_id + cat`, then for each
  // group emit a single representative row — the LATEST version (by
  // version_number desc, then created_at desc). Carry the version
  // count + total alternate-version metadata on the representative so
  // the card can show a "v003 · 3 versions" sub-line.
  const filteredRaw = items.filter(i => i.cat === filter
    // v07zz386 — SEQ 00 in the Frames tab is all archive footage; hide it from the
    // live Frames view (it stays in the Archival tab). Hugo: "remove Sequence 00
    // from the Frames, that's all Archive." Count + grouping both follow from here.
    && !(filter === "frames" && Number(i.seq) === 0));
  const filtered = (() => {
    // v07zz360 — "Show every image as its own card" → bypass the per-shot/range grouping
    // entirely and emit one tile per asset_version, so every individual image is favouritable.
    if (expandAll) return filteredRaw.map(it => ({ ...it, _versionCount: 1 }));
    // v07zz64 — Hugo: "Archived Grids, now we have multiple cards of
    // the same shot, rather than one card per shot or group of
    // shots." Last attempt deduped by file BASENAME — but every
    // version of a grid (v001, v002, v003 …) has a unique basename
    // so the group key produced N cards per shot anyway. The real
    // grouping is by the SHOT RANGE encoded in the filename:
    //   "SH0010_grid_v003.png"           → range "SH0010"
    //   "SH0010-SH0030_grid_v001.png"    → range "SH0010-SH0030"
    // All versions sharing the same range collapse into ONE card
    // whose head is the latest version.
    const isGridTab = filter === "grids" || filter === "archived-grids";
    const rangeFromFile = (fp) => {
      const base = String(fp || "").split(/[\\/]/).pop() || "";
      // Filename pattern: <range>_grid_v<NNN>[_4k|_f#].png
      const m = base.match(/^(SH\d{2,5}(?:-SH\d{2,5})?)_grid_/i);
      return m ? m[1].toUpperCase() : null;
    };
    const groups = new Map();
    for (const it of filteredRaw) {
      let key;
      if (isGridTab) {
        const range = rangeFromFile(it.file_path) || it.asset_id || "?";
        key = `${range}|${it.cat}`;
      } else {
        key = `${it.asset_id || "?"}|${it.cat}`;
      }
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(it);
    }
    const out = [];
    for (const arr of groups.values()) {
      arr.sort((a, b) => {
        const an = a.version_number || 0, bn = b.version_number || 0;
        if (bn !== an) return bn - an;
        return String(b.created_at || "").localeCompare(String(a.created_at || ""));
      });
      const head = { ...arr[0], _versionCount: arr.length };
      if (isGridTab) {
        // Surface the shot range that the grid spans, e.g.
        // "SH0010-SH0030", by looking at every shot_id contributing
        // to this group.
        const shotIds = [...new Set(arr.map(r => r.asset_id).filter(Boolean))].sort();
        if (shotIds.length > 1) {
          head.asset_id = `${shotIds[0]}-${shotIds[shotIds.length - 1]}`;
        } else if (shotIds.length === 1) {
          head.asset_id = shotIds[0];
        }
        head._spanned_shot_ids = shotIds;
      }
      out.push(head);
    }
    return out;
  })();
  // v06m — collapsed-by-default sequences. Each sequence shows the
  // first 6 items; "Show more" reveals the rest in place without
  // pushing other sequences off the page.
  const [expandedSeqs, setExpandedSeqs] = React.useState(() => new Set());
  const isSeqExpanded = (seq) => expandedSeqs.has(seq);
  const toggleSeq = (seq) => setExpandedSeqs(prev => {
    const next = new Set(prev);
    if (next.has(seq)) next.delete(seq); else next.add(seq);
    return next;
  });
  const ITEMS_PER_ROW_DEFAULT = 6;

  // Group by sequence number → array of items
  const grouped = filtered.reduce((acc, it) => {
    (acc[it.seq] = acc[it.seq] || []).push(it);
    return acc;
  }, {});
  // v06l — sort items by asset_id (numeric) then by version_number
  // ASCENDING so multiple versions of the same grid land in v001 →
  // v002 → v003 order within a shot, instead of scrambling.
  Object.values(grouped).forEach(arr => arr.sort((a, b) => {
    const byShot = (a.asset_id || "").localeCompare(b.asset_id || "", undefined, { numeric: true });
    if (byShot !== 0) return byShot;
    return (a.version_number || 0) - (b.version_number || 0);
  }));
  const seqsSorted = Object.keys(grouped).map(Number).sort((a, b) => a - b);

  const openShot = (id) => { window.__nav && window.__nav.openShot && window.__nav.openShot(id); };

  // v07zz360 — toggle the per-image Favourite bookmark. Optimistic: flips the row in `versions`
  // immediately (so the star + every derived view update), then PATCHes; reverts on failure.
  // Purely a bookmark — never touches hero/approve. Synthetic video rows (string id) are skipped.
  const toggleFavorite = async (it, e) => {
    if (e) { e.stopPropagation(); e.preventDefault(); }
    const id = it && it.id;
    if (!Number.isInteger(id)) return;
    const next = !it.favorite;
    setVersions(prev => prev.map(v => v.id === id ? { ...v, favorite: next } : v));
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher(`/api/asset-versions/${id}/favorite`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ favorite: next }),
      });
      if (!r.ok) throw new Error("favorite failed");
    } catch (_) {
      setVersions(prev => prev.map(v => v.id === id ? { ...v, favorite: !next } : v));
    }
  };

  // v06i — Grids get a bespoke GridDetailModal (shot-popup-styled,
  // shows the grid + its 4 slices + prompt + versions). Frames and
  // videos still use the existing AssetReviewModal.
  // 17 Sep 2026 - the rows with their prompt_text filled in, from the cache or from
  // /api/asset-versions/prompts (500 ids per request). A failed request, or one slower than 4 s,
  // opens the modal without those prompts instead of blocking it.
  const withMediaPrompts = async (rows) => {
    const byId = mediaPromptsRef.current.byId;
    const missing = [...new Set(rows.map((v) => v && v.id).filter((id) => Number.isInteger(id) && !byId.has(id)))];
    if (missing.length) {
      const fetcher = window.authFetch || fetch;
      const load = async () => {
        for (let i = 0; i < missing.length; i += 500) {
          const ids = missing.slice(i, i + 500);
          const r = await fetcher("/api/asset-versions/prompts?ids=" + ids.join(","));
          if (!r.ok) return;
          const d = await r.json();
          const got = (d && d.prompts) || {};
          for (const id of ids) byId.set(id, Object.prototype.hasOwnProperty.call(got, id) ? got[id] : null);
        }
      };
      try {
        await Promise.race([load(), new Promise((resolve) => setTimeout(resolve, 4000))]);
      } catch (_) { /* network blip: the modal opens without those prompts */ }
    }
    return rows.map((v) => (v && byId.has(v.id) ? { ...v, prompt_text: byId.get(v.id) } : v));
  };
  const openMediaItem = async (clicked) => {
    if (clicked.cat === "grids" && window.GridDetailModal) {
      mediaPromptsRef.current.seq++;   // a frame tile still fetching its prompts must not open over this one
      setGridDetail(clicked);
      return;
    }
    // v07zz62 — Multi-shot grids surface as one card whose asset_id
    // was synthesized to a range (e.g. "SH0010-SH0030"). To collect
    // the full version history of THIS grid file, match by the
    // spanned shot id list when present + filter by file basename.
    const spannedSet = Array.isArray(clicked._spanned_shot_ids) && clicked._spanned_shot_ids.length > 0
      ? new Set(clicked._spanned_shot_ids)
      : null;
    const clickedFileBase = String(clicked.file_path || "").split(/[\\/]/).pop() || "";
    const rawSameAsset = (items.filter(v => {
      if (v.cat !== clicked.cat) return false;
      if (spannedSet) {
        // For a spanned grid: every row with the same file basename,
        // restricted to the shot ids that span this grid.
        const base = String(v.file_path || "").split(/[\\/]/).pop() || "";
        return spannedSet.has(v.asset_id) && (clicked.cat === "archived-grids" || clicked.cat === "grids" ? true : base === clickedFileBase);
      }
      return v.asset_id === clicked.asset_id;
    }));
    // v07zj — Hugo: filmstrip was showing each version twice — once
    // for the kind="frame" row and once for the kind="hero" row of
    // the SAME version_label (when a frame had been promoted). Dedupe
    // by version_label, preferring the "hero" variant so the active
    // hero shows the correct indicator.
    const byLabel = new Map();
    for (const v of rawSameAsset) {
      const lbl = v.version_label || "";
      const existing = byLabel.get(lbl);
      if (!existing || (v.kind === "hero" && existing.kind !== "hero")) {
        byLabel.set(lbl, v);
      }
    }
    const sameAsset = Array.from(byLabel.values());
    // v07zz39 — For FRAME categories (not grids or videos): collapse
    // _fN slice rows to ONE entry per base version, using the preferred
    // slice's image (kind="hero" wins, else slice_prefs, else f1, else
    // first). Without this, the strip shows v005_f1, v005_f2, v005_f3,
    // v005_f4 as 4 separate "V005" tiles and the modal opens at
    // whichever id is newest (usually F4) — even when slice_prefs says
    // F3 is the chosen hero. Hugo's complaint: "the grid modal shows
    // F3 but the filmstrip keeps showing F4". Picker mirrors the one
    // in ShotDetailModal so both views agree.
    const baseLabelOf = (lbl) => String(lbl || "").split("_")[0];
    const looksLikeSlice = (lbl) => /^v\d+_f[1-4]$/i.test(String(lbl || ""));
    let collapsed = sameAsset;
    if (clicked.cat === "frames") {
      // Build slice_prefs lookup for this shot.
      const shotPrefs = (() => {
        try {
          const data = (window.__appData && window.__appData.shots) || [];
          const s2 = data.find(sh => sh.id === clicked.asset_id);
          return (s2 && s2.slice_prefs) || {};
        } catch (_) { return {}; }
      })();
      const baseGroups = new Map();
      for (const v of sameAsset) {
        const base = baseLabelOf(v.version_label) || v.version_label || `v${v.version_number || "?"}`;
        if (!baseGroups.has(base)) baseGroups.set(base, []);
        baseGroups.get(base).push(v);
      }
      const pickForBase = (rows, base) => {
        // Same priority order as pickPreferredFrameRow in ShotDetailModal.
        // 1. upscale "<base>_4k" or "<base>"
        let r = rows.find(v => v.kind === "upscale" && (v.version_label === base + "_4k" || v.version_label === base));
        if (r) return r;
        // 2. preferred slice from slice_prefs (e.g. {"v005":3} → v005_f3)
        const prefN = Number(shotPrefs[base]);
        if (Number.isFinite(prefN) && prefN >= 1 && prefN <= 4) {
          r = rows.find(v => v.version_label === `${base}_f${prefN}`);
          if (r) return r;
        }
        // 3. ANY kind="hero" matching <base>_fN
        r = rows.find(v => v.kind === "hero" && looksLikeSlice(v.version_label) && baseLabelOf(v.version_label) === base);
        if (r) return r;
        // 4. <base>_f1 (canonical first slice)
        r = rows.find(v => v.version_label === base + "_f1");
        if (r) return r;
        // 5. bare-label row
        r = rows.find(v => v.version_label === base);
        if (r) return r;
        // 6. first available
        return rows[0];
      };
      collapsed = [];
      for (const [base, rows] of baseGroups.entries()) {
        // v07zz469 — the CLICKED row wins its own base group, so clicking e.g. the
        // v005_f3 card opens the modal ON that exact image instead of the base's
        // preferred slice (slice_prefs / _f1) silently replacing it.
        const pick = rows.find(v => v.id === clicked.id) || pickForBase(rows, base);
        // Use the BASE label so the tile reads "v005" not "v005_f4".
        collapsed.push({ ...pick, version_label: base, _resolvedFrom: pick.version_label });
      }
    }
    // v07zz62 — Hugo: "film strip is shownig versions from right to
    // left rather than left to right". Sort ascending v001 → vN so
    // the strip reads chronologically: oldest on the left, newest
    // on the right (matches how filmstrips normally display).
    collapsed.sort((a, b) => {
      const an = a.version_number || 0, bn = b.version_number || 0;
      if (an !== bn) return an - bn;
      const at = a.created_at || "", bt = b.created_at || "";
      return at.localeCompare(bt);
    });
    const openSeq = ++mediaPromptsRef.current.seq;
    collapsed = await withMediaPrompts(collapsed);
    if (openSeq !== mediaPromptsRef.current.seq) return;   // another tile was opened meanwhile
    const reviewVersions = collapsed.map(v => ({
      label: v.version_label || `v${v.version_number || "?"}`,
      // v07zz64 — Hugo: "Archived videos STILL dont load anything."
      // Bug: the cat check was `=== "videos"` only, so archived-videos
      // rows fell through to the image branch (file_path set as image,
      // video stayed null). AssetReviewModal then tried to render
      // them as images. Now any video-class cat → video src.
      image: (v.cat === "videos" || v.cat === "archived-videos") ? null : v.file_path,
      video: (v.cat === "videos" || v.cat === "archived-videos") ? v.file_path : null,
      time:  v.created_at,
      model: v.model,
      prompt: v.prompt_text || null,
      row_kind: v.kind,
      // v07zz59 — Pass through identity so AssetReviewModal can wire
      // a per-version Promote pill for archived rows. The modal renders
      // the pill near the version's filmstrip thumb.
      _id: v.id,
      archived: !!v.archived,
      asset_id: v.asset_id,
    }));
    // v07zz469 — open the filmstrip ON the clicked image. Match by row id first
    // (exact image), then exact/resolved label, then the base version. The old
    // base-only match broke on ARCHIVED frames (no collapse there, so entries keep
    // full labels like "v003_f1" while clickedBase was stripped to "v003" →
    // findIndex missed → the modal always opened at index 0, the first image).
    const clickedBase = baseLabelOf(clicked.version_label) || clicked.version_label;
    let initialIndex = collapsed.findIndex(v => v.id === clicked.id);
    if (initialIndex < 0) initialIndex = collapsed.findIndex(v =>
      v.version_label === clicked.version_label || v._resolvedFrom === clicked.version_label);
    if (initialIndex < 0) initialIndex = collapsed.findIndex(v =>
      baseLabelOf(v.version_label) === clickedBase);
    initialIndex = Math.max(0, initialIndex);
    setMediaReview({
      entityType: clicked.cat === "grids" ? "grid" : clicked.cat === "frames" ? "frame" : "video",
      entityId: `${clicked.asset_id} · ${clicked.kind}`,
      title: `${clicked.asset_id} — ${clicked.shot_frame_title || clicked.kind}`,
      subtitle: `SEQ ${String(clicked.seq).padStart(2, "0")} · ${clicked.kind}${clicked.is4k ? " · 4K" : ""}`,
      versions: reviewVersions,
      initialIndex,
    });
  };

  const fmtTime = (s) => {
    if (!s) return "";
    const d = new Date(s);
    if (Number.isNaN(d.getTime())) return s;
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 3600)      return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400)     return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
    return d.toLocaleDateString();
  };

  // v06s — Archival tab now pulls real archival stills from the
  // /api/archival endpoint (which scans <WATCH>/work/episodes/*/
  // references/archival-stills/** and groups by PF_T<NNNN> prefix).
  // Each batch becomes one card with all its frames inside.
  const [archivalBatches, setArchivalBatches] = React.useState([]);
  // v07zz59 — Historical Refs batches in Media too.
  const [historicalBatches, setHistoricalBatches] = React.useState([]);
  const refreshBatches = React.useCallback(() => {
    // 24 Sep 2026 (G6) — every project: the server scans the active project's own libraries.
    const fetcher = window.authFetch || fetch;
    fetcher("/api/archival")
      .then(r => r.ok ? r.json() : null)
      .then(d => setArchivalBatches((d && d.batches) || []))
      .catch(() => setArchivalBatches([]));
    fetcher("/api/historical-refs")
      .then(r => r.ok ? r.json() : null)
      .then(d => setHistoricalBatches((d && d.batches) || []))
      .catch(() => setHistoricalBatches([]));
  }, []);
  React.useEffect(() => { refreshBatches(); }, [refreshBatches]);
  // v07zz144 — Re-fetch batches when a cover thumbnail is changed so the
  // batch card updates immediately.
  React.useEffect(() => {
    const fn = () => refreshBatches();
    window.addEventListener("paradise-archival-refresh", fn);
    return () => window.removeEventListener("paradise-archival-refresh", fn);
  }, [refreshBatches]);
  // v07zz12 — External Refs now matches ANY "general/*" asset_id
  // (visual_style, vibe_mood, other, …) so refs uploaded from the
  // Generate page external-refs tab AND the Overview media uploader
  // both surface here. Was previously hard-coded to exactly
  // "general/other" which only matched the Media Import "Other"
  // category — leaving everything Hugo dropped from the Generate
  // page invisible in this view.
  const otherRefs = (versions || []).filter(v =>
    (v.kind || "").toLowerCase() === "reference"
    && String(v.asset_id || "").toLowerCase().startsWith("general/")
  );

  return (
    /* v07zz61 — media-view class so the shared .vp-tab-fade +
       transparent .vp-tabs CSS in views-pages.css applies here too. */
    <section className="view-page media-view">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">MEDIA LIBRARY</div>
          <div className="vp-title">{loading ? "Loading…" : `${filtered.length} ${filter} across ${seqsSorted.length} sequences`}</div>
        </div>
        <div className="vp-tabs">
          {/* v07zz61 — Tab order with Archived Grids added (Hugo's
              request: "Grids should be in a different Archived Grid
              section actually. implemented the same way it used to
              be in Grids"). */}
          {(_isPF
            ? ["grids","frames","videos","storyboard","archival","historical","refs","archived-grids","archived-frames","archived-videos","trailer"]
            // 24 Sep 2026 (G6) — another project: the same tabs from its own folders; the Green Echo trailer stays Paradise Found's
            : ["grids","frames","videos","storyboard","archival","historical","refs","archived-grids","archived-frames","archived-videos"]).map(t => (
            <button key={t} className={"vp-tab" + (filter === t ? " is-active" : "")} onClick={() => setFilter(t)}>
              {t === "refs" ? "External Refs"
                : t === "storyboard" ? "Storyboard"
                : t === "trailer" ? "Green Echo Trailer"
                : t === "archived-grids" ? "Archived Grids"
                : t === "archived-frames" ? "Archived Frames"
                : t === "archived-videos" ? "Archived Videos"
                : t === "historical" ? "Historical Refs"
                : t.charAt(0).toUpperCase() + t.slice(1)}
            </button>
          ))}
          {/* v07zz371 — SHOW ALL: far-right of the tab row. OFF = one card per shot / shot
              range (grouped). ON = flat Storyboard-style grid of every image.
              v811 — the grid tabs get it too (see canExpandAll). */}
          {canExpandAll && (
            <div className="vp-tabs-actions">
              <button type="button" className={"media-expand-pill" + (expandAll ? " is-on" : "")}
                title={expandAll
                  ? (filter === "grids" || filter === "archived-grids" ? "Group back into one card per shot range" : "Group back into one card per shot")
                  : (filter === "grids" || filter === "archived-grids" ? "Show every grid version as its own card" : "Show every frame as its own card")}
                aria-pressed={expandAll}
                onClick={() => setExpandAll(v => !v)}>
                <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
                <span>SHOW ALL</span>
              </button>
            </div>
          )}
        </div>
      </div>
      {/* v07zz62 — Scroll wrapper with mask-fade so cards dissolve
          under the tabs row. Tabs sit outside, never get scrolled
          or masked. */}
      <div className="vp-scroll">
      {!loading && filter !== "archival" && filter !== "refs" && filter !== "historical" && filter !== "trailer" && filter !== "storyboard" && filtered.length === 0 && (
        <div className="archive-empty glass">No {filter} yet — once the folder watcher / webhook ingests asset_versions, they appear here.</div>
      )}
      {/* v07zz371 — SHOW ALL (Frames + Archived Frames): flat grid of every individual frame,
          rendered exactly like the Storyboard cards (image + shot # + Send-to-shot + Star). */}
      {_mvHov ? _mvHov.portal : null}
      {expandAll && canExpandAll && (
        <div className="media-grid">
          {[...filtered]
            .sort((a, b) => (a.seq - b.seq)
              || String(a.asset_id || "").localeCompare(String(b.asset_id || ""))
              || ((b.version_number || 0) - (a.version_number || 0)))
            .map(it => {
              const tileSrc = window.thumbUrl ? window.thumbUrl(it.file_path, 560) : it.file_path;
              return (
                <article key={it.id} className="media-card-v glass interactive-card">
                  <div className="media-card-thumb"
                    style={{ aspectRatio: "var(--project-aspect)", background: locGradient(SEQ_HUE_VIEW[it.seq] || 100), cursor: "zoom-in" }}
                    {...(_mvHov ? _mvHov.hoverBind(it.file_path) : {})}
                    onClick={() => openMediaItem(it)}>
                    {Number.isInteger(it.id) && (
                      <button type="button" className={"media-fav-star" + (it.favorite ? " is-on" : "")}
                        title={it.favorite ? "Remove from Favourites" : "Add to Favourites"}
                        aria-label={it.favorite ? "Remove from Favourites" : "Add to Favourites"}
                        aria-pressed={!!it.favorite}
                        onClick={(e) => toggleFavorite(it, e)}>
                        <svg viewBox="0 0 24 24" width="15" height="15" fill={it.favorite ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round">
                          <path d="M12 3.6l2.6 5.28 5.82.85-4.21 4.1.99 5.79L12 16.88l-5.2 2.73.99-5.79-4.21-4.1 5.82-.85z"/>
                        </svg>
                      </button>
                    )}
                    <img src={tileSrc} alt={it.asset_id || ""} loading="lazy"
                      onError={(e) => { e.currentTarget.style.display = "none"; }} />
                  </div>
                  <div className="media-card-meta">
                    <span className="media-card-name" title={it.asset_id}>{it.asset_id}{it.version_label ? ` · ${it.version_label}` : ""}</span>
                    {/* v811 — Send-to-shot is a FRAME action. A grid is a 2x2/3x3 sheet, so
                        copying the whole sheet into a shot as its frame is never what's
                        wanted — open it instead and slice from the grid modal. */}
                    {!(filter === "grids" || filter === "archived-grids") && (
                      <button type="button" className="media-card-send"
                        onClick={(e) => { e.stopPropagation(); setPromotePicker({ version: it, defaultShotId: it.asset_id, copyMode: true }); }}
                        title="Send this frame to a shot (copies it in)">
                        ⤵ Send to shot
                      </button>
                    )}
                  </div>
                </article>
              );
            })}
        </div>
      )}
      {!(expandAll && canExpandAll)
        && (filter === "grids" || filter === "frames" || filter === "videos"
        || filter === "archived-grids"
        || filter === "archived-frames" || filter === "archived-videos") && seqsSorted.map(seqNum => {
        const seqItems = grouped[seqNum];
        // v07zz303 — Archived tabs show EVERY item per sequence (Hugo: no "Show more"
        // truncation in Archived Frames). Live tabs keep the per-row collapse.
        // v07zz366 — Frames shows ALL items per sequence (no "Show more" collapse), like the archived tabs.
        const isArchivedTab = filter === "archived-grids" || filter === "archived-frames" || filter === "archived-videos" || filter === "frames";
        const expanded = isSeqExpanded(seqNum);
        const visible = (isArchivedTab || expanded) ? seqItems : seqItems.slice(0, ITEMS_PER_ROW_DEFAULT);
        const hiddenCount = seqItems.length - visible.length;
        return (
        <div key={seqNum} className="media-seq-group">
          <div className="media-seq-head">
            SEQ {String(seqNum).padStart(2, "0")}{(seqItems[0] && seqItems[0].archivedSeqName) ? ` · ${seqItems[0].archivedSeqName}` : ""} · {seqItems.length} item{seqItems.length === 1 ? "" : "s"}
            {hiddenCount > 0 && !expanded && (
              <button type="button" className="media-seq-expand" onClick={() => toggleSeq(seqNum)}>
                Show {hiddenCount} more →
              </button>
            )}
            {expanded && seqItems.length > ITEMS_PER_ROW_DEFAULT && (
              <button type="button" className="media-seq-expand" onClick={() => toggleSeq(seqNum)}>
                Show less ←
              </button>
            )}
          </div>
          <div className="media-grid">
            {visible.map(it => {
              const isVideo = it.cat === "videos" || it.cat === "archived-videos";
              const isArchived = it.cat === "archived-frames" || it.cat === "archived-videos";
              // v07zz59 — Promote moved OFF the tile and into the
              // AssetReviewModal filmstrip (so each version + slice
              // can be promoted individually, not the whole shot).
              // The tile click opens the modal; the Promote pill is
              // rendered there instead.
              // v05e — request a 560px-wide thumbnail for Media tiles.
              // 280-320px is the typical rendered width on screen, so
              // 560 gives us a Retina-quality 2× factor without
              // shipping 4K originals.
              const tileSrc = window.thumbUrl ? window.thumbUrl(it.file_path, 560) : it.file_path;
              // v07o — background-image is fetched eagerly (CSS images
              // start downloading the moment the parent enters the DOM,
              // regardless of viewport), so the Media tab was firing
              // ~50 large grid downloads in parallel on every open.
              // Switch to a native <img loading="lazy"> on top of the
              // gradient fallback — the browser now defers off-screen
              // tiles until they scroll into view.
              const thumbStyle = {
                aspectRatio: "var(--project-aspect)",
                background: locGradient(SEQ_HUE_VIEW[it.seq] || 100),
              };
              // v07zl — Hover prefetch for media tiles. When user
              // hovers, preload the larger variant that the modal
              // (or grid/asset detail) will request. By click time
              // the larger image is in browser cache → modal hero
              // paints synchronously.
              const onHoverPrefetch = () => {
                if (!it.file_path || !window.thumbUrl) return;
                const img = new Image();
                img.src = window.thumbUrl(it.file_path, 800);
              };
              return (
                <article
                  key={it.id}
                  className="media-card-v glass interactive-card"
                  onClick={() => openMediaItem(it)}
                  onMouseEnter={onHoverPrefetch}
                >
                  <div className="media-thumb-v" style={thumbStyle}
                    {...(!isVideo && _mvHov && (it.cat === "frames" || it.cat === "archived-frames") ? _mvHov.hoverBind(it.file_path) : {})}>
                    {/* v07zz360 — Favourite star (per-image bookmark). Separate from Hero/approve:
                        toggling it never promotes anything, it just curates the Favourites set that
                        feeds the Add-a-Frame Favourites tab. Visible on hover, stays lit when on. */}
                    {Number.isInteger(it.id) && (
                      <button
                        type="button"
                        className={"media-fav-star" + (it.favorite ? " is-on" : "")}
                        title={it.favorite ? "Remove from Favourites" : "Add to Favourites"}
                        aria-label={it.favorite ? "Remove from Favourites" : "Add to Favourites"}
                        aria-pressed={!!it.favorite}
                        onClick={(e) => toggleFavorite(it, e)}
                      >
                        <svg viewBox="0 0 24 24" width="15" height="15" fill={it.favorite ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round">
                          <path d="M12 3.6l2.6 5.28 5.82.85-4.21 4.1.99 5.79L12 16.88l-5.2 2.73.99-5.79-4.21-4.1 5.82-.85z"/>
                        </svg>
                      </button>
                    )}
                    {tileSrc && !isVideo && (
                      <img
                        src={tileSrc}
                        alt=""
                        loading="lazy"
                        decoding="async"
                        draggable={false}
                        ref={(el) => {
                          // v07zl — Hugo: cards that have been loaded
                          // before should NOT replay the gradient→image
                          // fade after a tab switch. If the browser
                          // already has the image decoded (img.complete
                          // === true at mount), add the loaded class
                          // synchronously and skip the keyframe.
                          if (el && el.complete && el.naturalWidth > 0) {
                            el.classList.add("media-thumb-loaded");
                            el.classList.add("is-cached");
                          }
                        }}
                        onLoad={(e) => e.currentTarget.classList.add("media-thumb-loaded")}
                        onError={(e) => {
                          // v07zz297 — the /local file is gone (archived frame whose
                          // on-disk copy was cleaned after it was backed up to R2).
                          // Fall back to the R2 cloud_url ONCE before giving up to the
                          // gradient, so archived tiles still show their image.
                          const el = e.currentTarget;
                          if (it.cloud_url && el.dataset.cloudTried !== "1") {
                            el.dataset.cloudTried = "1";
                            el.src = window.thumbUrl ? window.thumbUrl(it.cloud_url, 560) : it.cloud_url;
                            return;
                          }
                          el.classList.add("media-thumb-loaded");
                        }}
                        style={{
                          position: "absolute",
                          inset: 0,
                          width: "100%",
                          height: "100%",
                          objectFit: "cover",
                          display: "block",
                        }}
                      />
                    )}
                    {/* v07zz76 — Hugo: video tile thumbs were slow on
                        local + 2-3s on Railway. Root cause: each tile
                        rendered <video src="...mp4" preload="metadata">
                        which fetched the video file's header + first
                        chunk just to seek to 0.1s. With 5-20 visible
                        video tiles that was 5-20 parallel multi-MB
                        partial-file downloads on every tab open.
                        Replaced with an <img> poster pointing at the
                        latest non-archived frame/hero for the same
                        asset_id — a tiny pre-warmed WebP mipmap that
                        comes from the same cache the Frames tab uses.
                        Falls through to the gradient when no frame
                        exists. Actual playback still happens in the
                        AssetReviewModal where the user explicitly
                        opened the tile. */}
                    {isVideo && (() => {
                      const posterSrc = videoPosterByAssetId.get(it.asset_id);
                      if (!posterSrc) return null;
                      const posterUrl = window.thumbUrl ? window.thumbUrl(posterSrc, 560) : posterSrc;
                      return (
                        <img
                          src={posterUrl}
                          alt=""
                          loading="lazy"
                          decoding="async"
                          draggable={false}
                          ref={(el) => {
                            if (el && el.complete && el.naturalWidth > 0) {
                              el.classList.add("media-thumb-loaded");
                              el.classList.add("is-cached");
                            }
                          }}
                          onLoad={(e) => e.currentTarget.classList.add("media-thumb-loaded")}
                          onError={(e) => e.currentTarget.classList.add("media-thumb-loaded")}
                          style={{
                            position: "absolute",
                            inset: 0,
                            width: "100%",
                            height: "100%",
                            objectFit: "cover",
                            display: "block",
                            pointerEvents: "none",
                          }}
                        />
                      );
                    })()}
                    {isVideo && (
                      <span style={{position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "var(--ink-cream)", fontSize: "var(--fs-32)", textShadow: "0 2px 6px rgba(0,0,0,0.85)", pointerEvents: "none"}}>▶</span>
                    )}
                    <span className="media-type-pill">{it.kind || it.cat}</span>
                    <span className="media-shot-pill">{it.asset_id}</span>
                    {it.is4k && <span className="media-4k-badge">4K</span>}
                    {/* v07zz59 — Promote pill removed from the tile;
                        now lives inside the AssetReviewModal so each
                        version + slice can be promoted individually. */}
                  </div>
                  <div className="media-body-v">
                    {/* v06l — Hugo: tile heading should ALWAYS show the
                        shot id (or grid range like SH0630-SH0640) and
                        keep the frame title as the muted sub-line so
                        the eye can scan by shot number. */}
                    <div className="media-title-v">{it.asset_id}{it.shot_frame_title ? ` · ${it.shot_frame_title}` : ""}</div>
                    <div className="media-meta-v">{it.version_label || `v${it.version_number}`}{it._versionCount > 1 ? ` · ${it._versionCount} versions` : ""} · {it.model || "—"}{it.created_at ? ` · ${fmtTime(it.created_at)}` : ""}{isArchived ? " · archived" : ""}</div>
                  </div>
                </article>
              );
            })}
          </div>
        </div>
        );
      })}
      {/* v06s — Archival Footage tab: real stills from the
          /api/archival endpoint, grouped by PF_T<NNNN> filename tag.
          One card per batch; clicking opens the batch in AssetReviewModal
          where every frame in the group is browsable. */}
      {filter === "archival" && (
        archivalBatches.length === 0 ? (
          _isPF ? (
          <div className="archive-empty glass">
            No archival stills found. Files should live under
            <code style={{margin: "0 4px"}}>&lt;WATCH_PATH&gt;/work/episodes/&lt;ep&gt;/references/archival-stills/</code>
            with filenames like <code>PF_T0166_Sharks of Hawaii_001743_0010926575.jpg</code> —
            files sharing the same <code>PF_T&lt;NNNN&gt;</code> tag get grouped automatically.
          </div>
          ) : (
          <div className="archive-empty glass">
            No archival stills found. Drop files under
            <code style={{margin: "0 4px"}}>work/references/archival-stills/</code>
            in this project's folder — each sub-folder becomes one batch.
          </div>
          )
        ) : (
          <div className="media-grid">
            {archivalBatches.map(b => (
              <article key={b.id} className="media-card-v glass interactive-card"
                onMouseEnter={() => {
                  // v07zl — Preload EVERY thumbnail in the batch when
                  // the user hovers the card. Archival batches have
                  // up to ~10 frames; warming them on hover means the
                  // gallery strip in the modal paints instantly.
                  if (!window.thumbUrl) return;
                  // Hero (cover) at modal size (1200 for retina).
                  if (b.cover) {
                    const img = new Image();
                    img.src = window.thumbUrl(b.cover, 800);
                  }
                  // Each gallery thumb at 240 (matches gallery-thumb size).
                  for (const it of (b.items || [])) {
                    if (!it.url) continue;
                    const im = new Image();
                    im.src = window.thumbUrl(it.url, 240);
                  }
                }}
                onClick={() => setArchivalItem({
                  // v07zz81 — Wrap in { kind, item } so the modal knows
                  // which list to paginate. Was a bare item before; the
                  // navigation handler couldn't disambiguate archival
                  // vs historical without it.
                  kind: "archival",
                  item: {
                    // Mirror the shape AssetsView builds for its
                    // archival cards (see Views.jsx ~line 643) so the
                    // exact same AssetItemModal renders here.
                    ...b,
                    type: `${b.count} frames`,
                    scenes: b.name,
                    notes: `Archive batch: ${b.name}. ${b.count} frames pulled from ${b.folder}.`,
                    references: (b.items || []).map(it => ({ ...it })),
                    image: b.cover,
                  },
                })}>
                <div className="media-thumb-v"
                  style={b.cover
                    ? { aspectRatio: "var(--project-aspect)", backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(b.cover, 560) : b.cover})`, backgroundSize: "cover", backgroundPosition: b.coverFocus || "center", filter: "grayscale(0.20)" }
                    : { aspectRatio: "var(--project-aspect)", background: "linear-gradient(155deg, var(--umber-6), var(--umber-7))", display: "grid", placeItems: "center", color: "var(--gold-21)", fontSize: "var(--fs-36)" }}>
                  {!b.cover && "🎞"}
                  <span className="media-type-pill">archival</span>
                  <span className="media-shot-pill">{b.folder}</span>
                </div>
                <div className="media-body-v">
                  <div className="media-title-v">{b.name}</div>
                  <div className="media-meta-v">{b.count} frame{b.count === 1 ? "" : "s"} · {b.folder}</div>
                </div>
              </article>
            ))}
          </div>
        )
      )}
      {/* v07zz59 — Historical Refs in Media. Same card layout as
          Archival, fed by /api/historical-refs. */}
      {filter === "historical" && (
        historicalBatches.length === 0 ? (
          _isPF ? (
          <div className="archive-empty glass">
            No historical reference imagery yet. Drop files (organised by
            sub-folder for batch grouping) under
            <code style={{margin: "0 4px"}}>&lt;WATCH_PATH&gt;/work/episodes/&lt;ep&gt;/references/historical-refs/</code>.
          </div>
          ) : (
          <div className="archive-empty glass">
            No historical reference imagery yet. Drop files under
            <code style={{margin: "0 4px"}}>work/references/historical/</code>
            in this project's folder — each sub-folder becomes one batch.
          </div>
          )
        ) : (
          <div className="media-grid">
            {historicalBatches.map(b => (
              <article key={b.id} className="media-card-v glass interactive-card"
                onClick={() => setArchivalItem({
                  // v07zz81 — Tag the kind so the AssetItemModal eyebrow
                  // shows HISTORICAL REF (was ARCHIVAL before because
                  // the mount hardcoded kind="archival") AND so the
                  // pagination handler routes through historicalBatches.
                  kind: "historical",
                  item: {
                    ...b,
                    type: `${b.count} references`,
                    scenes: b.name,
                    notes: `Historical batch: ${b.name}. ${b.count} files pulled from ${b.folder}.`,
                    references: (b.items || []).map(it => ({ ...it })),
                    image: b.cover,
                  },
                })}>
                <div className="media-thumb-v"
                  style={b.cover
                    ? { aspectRatio: "var(--project-aspect)", backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(b.cover, 560) : b.cover})`, backgroundSize: "cover", backgroundPosition: b.coverFocus || "center", filter: "sepia(0.30)" }
                    : { aspectRatio: "var(--project-aspect)", background: "linear-gradient(155deg, var(--umber-7), var(--tan-deep-3))", display: "grid", placeItems: "center", color: "var(--ink-cream)", fontSize: "var(--fs-36)" }}>
                  {!b.cover && "🏛"}
                  <span className="media-type-pill">historical</span>
                  <span className="media-shot-pill">{b.folder}</span>
                </div>
                <div className="media-body-v">
                  <div className="media-title-v">{b.name}</div>
                  <div className="media-meta-v">{b.count} reference{b.count === 1 ? "" : "s"} · {b.folder}</div>
                </div>
              </article>
            ))}
          </div>
        )
      )}
      {/* v06o — External References tab: only Media-Import refs tagged
          "Other" appear here. One card per uploaded file. */}
      {filter === "refs" && (
        otherRefs.length === 0 ? (
          <div className="archive-empty glass">
            No external references uploaded yet — drop files via Media Uploads
            with the "Other" reference category to see them here.
          </div>
        ) : (
          <div className="media-grid">
            {otherRefs.map(r => {
              const tileSrc = window.thumbUrl ? window.thumbUrl(r.file_path, 560) : r.file_path;
              const fileBase = String(r.file_path || "").split("/").pop() || "reference";
              return (
                <article key={r.id} className="media-card-v glass interactive-card" onClick={() => setMediaReview({
                  entityType: "ref",
                  entityId: `${r.asset_id} · ${r.version_label || ""}`,
                  title: fileBase,
                  subtitle: `External reference · ${r.version_label || `v${r.version_number || "?"}`}`,
                  versions: [{
                    label: r.version_label || `v${r.version_number || "?"}`,
                    image: r.file_path,
                    video: null,
                    time: r.created_at,
                    model: null,
                  }],
                  initialIndex: 0,
                })}>
                  <div className="media-thumb-v" style={{aspectRatio: "var(--project-aspect)", background: tileSrc ? `center / cover no-repeat url(${tileSrc})` : locGradient(140)}}>
                    <span className="media-type-pill">{(String(r.asset_id || "").split("/")[1] || "other").replace(/_/g, " ")}</span>
                  </div>
                  <div className="media-body-v">
                    <div className="media-title-v">{fileBase}</div>
                    <div className="media-meta-v">{r.version_label || `v${r.version_number || "?"}`}{r.created_at ? ` · ${fmtTime(r.created_at)}` : ""}</div>
                  </div>
                </article>
              );
            })}
          </div>
        )
      )}
      {/* v07zz320 — Storyboard: flat grid, one card per image from the read-only
          storyboard folder. Each card opens the Lightbox; the ⤵ pill sends a COPY
          into a chosen shot (via add-version) — the storyboard folder is never written. */}
      {filter === "storyboard" && (
        storyboard.length === 0 ? (
          <div className="archive-empty glass">
            {storyboardLoaded
              ? (_isPF
                ? "No storyboard images found in work/outgoings/forEditor/storyboard."
                : "No storyboard frames yet. This tab lists the shot frames moved to Storyboard (the storyboards/ folder of each shot).")
              : "Loading storyboard…"}
          </div>
        ) : (
          <div className="media-grid">
            {storyboard.map((im, i) => {
              const tileSrc = window.thumbUrl ? window.thumbUrl(im.url, 560) : im.url;
              return (
                <article key={im.url + "_" + i} className="media-card-v glass interactive-card">
                  <div className="media-card-thumb"
                    style={{ aspectRatio: "var(--project-aspect)", background: locGradient((i * 37) % 360), cursor: "zoom-in" }}
                    onClick={() => { setOpenTrailerFolder({ name: "Storyboard", images: storyboard }); setTrailerLightbox(i); }}>
                    {/* v07zz371 — highlight star (same control as the Frames SHOW ALL grid). */}
                    <button type="button" className={"media-fav-star" + (sbFavs.has(im.url) ? " is-on" : "")}
                      title={!_isPF ? undefined : (sbFavs.has(im.url) ? "Remove highlight" : "Highlight this frame")}
                      aria-label={sbFavs.has(im.url) ? "Remove highlight" : "Highlight this frame"}
                      aria-pressed={sbFavs.has(im.url)}
                      onClick={(e) => toggleSbFav(im.url, e)}>
                      <svg viewBox="0 0 24 24" width="15" height="15" fill={sbFavs.has(im.url) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round">
                        <path d="M12 3.6l2.6 5.28 5.82.85-4.21 4.1.99 5.79L12 16.88l-5.2 2.73.99-5.79-4.21-4.1 5.82-.85z"/>
                      </svg>
                    </button>
                    <img src={tileSrc} alt={im.filename} loading="lazy"
                      onError={(e) => { e.currentTarget.style.display = "none"; }} />
                  </div>
                  <div className="media-card-meta">
                    <span className="media-card-name" title={!_isPF ? undefined : im.filename}>{im.filename}</span>
                    <button type="button" className="media-card-send"
                      onClick={(e) => { e.stopPropagation(); setPromotePicker({ storyboardUrl: im.url, label: im.filename }); }}
                      title={!_isPF ? undefined : "Send this storyboard image to a shot (copies it in as a new frame)"}>
                      ⤵ Send to shot
                    </button>
                  </div>
                </article>
              );
            })}
          </div>
        )
      )}
      {/* v07zz283 — Green Echo Trailer: folder cards → click a folder → its
          image grid → click an image → Lightbox. Same .media-card-v layout as
          Archival/Historical; cloud_url thumbs route through /api/r2-thumb. */}
      {filter === "trailer" && !openTrailerFolder && !openVidFolder && (
        (trailer.length === 0 && trailerVidFolders.length === 0) ? (
          <div className="archive-empty glass">
            {trailerLoaded
              ? "No trailer images yet. Run scripts/ingest-trailer.js to upload + index them."
              : "Loading trailer gallery…"}
          </div>
        ) : trailer.length === 0 ? null : (
          /* v07zz371 — folder covers use a real <img> (same as Storyboard) so previews render
             reliably; the old CSS background-image approach was leaving blank cards. */
          <div className="media-grid">
            {trailer.map(f => {
              const cover = window.thumbUrl ? window.thumbUrl(f.cover, 560) : f.cover;
              return (
                <article key={f.name} className="media-card-v glass interactive-card"
                  onClick={() => { setOpenTrailerFolder(f); setTrailerLightbox(-1); }}>
                  <div className="media-card-thumb"
                    style={{ aspectRatio: "var(--project-aspect)", background: locGradient(120), cursor: "pointer" }}>
                    {cover
                      ? <img src={cover} alt={f.name} loading="lazy" onError={(e) => { e.currentTarget.style.display = "none"; }} />
                      : <span style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "var(--ink-cream)", fontSize: "var(--fs-36)" }}>🎬</span>}
                    <span className="media-type-pill">folder</span>
                    <span className="media-shot-pill">{f.count} image{f.count === 1 ? "" : "s"}</span>
                  </div>
                  <div className="media-body-v">
                    <div className="media-title-v">{f.name}</div>
                    <div className="media-meta-v">{f.count} image{f.count === 1 ? "" : "s"}</div>
                  </div>
                </article>
              );
            })}
          </div>
        )
      )}
      {/* v07zz327 — Green Echo Trailer VIDEOS: folder cards (grouped by subfolder) → video grid → play. */}
      {filter === "trailer" && !openTrailerFolder && !openVidFolder && trailerVidFolders.length > 0 && (
        <div className="media-seq-group" style={{ marginTop: 20 }}>
          <div className="media-seq-head">
            <span style={{ fontWeight: "var(--fw-semi)" }}>Videos</span>
            <span style={{ marginLeft: 8, opacity: 0.6 }}>· {trailerVids.length} clip{trailerVids.length === 1 ? "" : "s"} across {trailerVidFolders.length} folder{trailerVidFolders.length === 1 ? "" : "s"}</span>
          </div>
          <div className="media-grid">
            {trailerVidFolders.map(f => (
              <article key={"vid_" + f.name} className="media-card-v glass interactive-card"
                onClick={() => setOpenVidFolder(f)}>
                <div className="media-thumb-v"
                  style={f.cover
                    ? { aspectRatio: "var(--project-aspect)", backgroundColor: "var(--shade-56)", backgroundImage: `url(${f.cover})`, backgroundSize: "cover", backgroundPosition: "center" }
                    : { aspectRatio: "var(--project-aspect)", background: locGradient(120), display: "grid", placeItems: "center", color: "var(--ink-cream)", fontSize: "var(--fs-36)" }}>
                  {!f.cover && "🎬"}
                  <span className="media-type-pill">videos</span>
                  <span className="media-shot-pill">{f.count} clip{f.count === 1 ? "" : "s"}</span>
                </div>
                <div className="media-body-v">
                  <div className="media-title-v">{f.name}</div>
                  <div className="media-meta-v">{f.count} clip{f.count === 1 ? "" : "s"}</div>
                </div>
              </article>
            ))}
          </div>
        </div>
      )}
      {filter === "trailer" && openVidFolder && (
        <div className="media-seq-group">
          <div className="media-seq-head">
            <button type="button" className="media-seq-expand" onClick={() => setOpenVidFolder(null)}>← All folders</button>
            <span style={{ marginLeft: 12, fontWeight: "var(--fw-semi)" }}>{openVidFolder.name}</span>
            <span style={{ marginLeft: 8, opacity: 0.6 }}>· {openVidFolder.videos.length} clip{openVidFolder.videos.length === 1 ? "" : "s"}</span>
          </div>
          <div className="media-grid">
            {openVidFolder.videos.map((v, i) => {
              const fname = (v.rel || "").split("/").pop();
              return (
                <article key={v.rel || i} className="media-card-v glass interactive-card" onClick={() => setPlayVid(v)}>
                  <div className="media-thumb-v"
                    style={{ aspectRatio: "var(--project-aspect)", backgroundColor: "var(--shade-56)", backgroundImage: v.poster ? `url(${v.poster})` : undefined, backgroundSize: "cover", backgroundPosition: "center", display: "grid", placeItems: "center" }}>
                    <span style={{ color: "color-mix(in srgb, var(--white) 92%, transparent)", fontSize: "var(--fs-30)", textShadow: "0 1px 8px rgba(0,0,0,0.75)" }}>▶</span>
                  </div>
                  <div className="media-body-v">
                    <div className="media-meta-v" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{fname}</div>
                  </div>
                </article>
              );
            })}
          </div>
        </div>
      )}
      {filter === "trailer" && openTrailerFolder && (
        <div className="media-seq-group">
          <div className="media-seq-head">
            <button type="button" className="media-seq-expand" onClick={() => { setOpenTrailerFolder(null); setTrailerLightbox(-1); }}>← All folders</button>
            <span style={{ marginLeft: 12, fontWeight: "var(--fw-semi)" }}>{openTrailerFolder.name}</span>
            <span style={{ marginLeft: 8, opacity: 0.6 }}>· {openTrailerFolder.images.length} image{openTrailerFolder.images.length === 1 ? "" : "s"}</span>
          </div>
          <div className="media-grid">
            {openTrailerFolder.images.map((im, i) => {
              const t = window.thumbUrl ? window.thumbUrl(im.url, 560) : im.url;
              return (
                <article key={im.rel || i} className="media-card-v glass interactive-card" onClick={() => setTrailerLightbox(i)}>
                  <div className="media-card-thumb"
                    style={{ aspectRatio: "var(--project-aspect)", background: locGradient(120), cursor: "zoom-in" }}>
                    <img src={t} alt={im.filename} loading="lazy" onError={(e) => { e.currentTarget.style.display = "none"; }} />
                  </div>
                  <div className="media-card-meta">
                    <span className="media-card-name" title={im.filename}>{im.filename}</span>
                  </div>
                </article>
              );
            })}
          </div>
        </div>
      )}
      </div>{/* /.vp-scroll — modals live outside the scroll/mask */}
      {/* v07zz327 — Green Echo trailer video player (streamed; outside WATCH so no /local). */}
      {playVid && ReactDOM.createPortal(
        <div onClick={() => setPlayVid(null)}
          style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.86)", zIndex: "var(--z-lightbox)", display: "grid", placeItems: "center", padding: 24 }}>
          <div onClick={e => e.stopPropagation()} style={{ maxWidth: "92vw" }}>
            <video src={trailerVidStream(playVid)} controls autoPlay playsInline
              style={{ maxWidth: "92vw", maxHeight: "82vh", borderRadius: "var(--r-panel)", background: "var(--black)", display: "block" }}/>
            <div style={{ color: "var(--ink-cream)", marginTop: 10, fontSize: "var(--fs-body)", opacity: 0.85, textAlign: "center", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "92vw" }}>{playVid.name}</div>
          </div>
          <button onClick={() => setPlayVid(null)} aria-label="Close"
            style={{ position: "fixed", top: 18, right: 24, background: "none", border: "none", color: "var(--white)", fontSize: "var(--fs-30)", lineHeight: 1, cursor: "pointer" }}>×</button>
        </div>,
        document.getElementById("modal-root") || document.body
      )}
      {/* v06h — full-screen media asset review. Filmstrip shows the
          clicked asset's own version history, not the parent shot. */}
      {mediaReview && window.AssetReviewModal && (
        <window.AssetReviewModal
          open={true}
          onClose={() => setMediaReview(null)}
          entityType={mediaReview.entityType}
          entityId={mediaReview.entityId}
          title={mediaReview.title}
          subtitle={mediaReview.subtitle}
          versions={mediaReview.versions}
          initialIndex={mediaReview.initialIndex}
          onPromote={(version) => {
            // v07zz319 — Hugo: EVERY frame in Media (archived OR live) uses the SAME
            // "Send to shot" picker — pick the target shot, the frame is copied + renamed
            // into that shot's folder. Dropped the old one-click archived "Promote" path
            // so there's only one action everywhere.
            if (!version) return;
            setPromotePicker({ version, defaultShotId: version.asset_id, copyMode: true });
          }}
        />
      )}
      {/* v07zz59 — Promote-from-archived picker modal. Lists every
          active shot, lets Hugo pick the target. The default
          highlighted row is the original shot the version was on. */}
      {promotePicker && (() => {
        const versionsArr = ((window.__appData && window.__appData.shots) || [])
          .filter(s => !s.is_archive)
          .sort((a, b) => (a.id || "").localeCompare(b.id || ""));
        const v = promotePicker.version || {};
        // v07zz311 — copyMode (Media → Frames "Send to shot"): copy + RENAME the actual
        // image file into the chosen shot's frames\ folder as its selected frame, instead
        // of just re-pointing the DB row (which leaves the file in the slice cache).
        const copyMode = !!promotePicker.copyMode;
        // v07zz320 — storyboard mode: the "image" is a plain file from the read-only Storyboard
        // folder (no asset_versions row), so route through add-version (copies it INTO the shot).
        const sbUrl = promotePicker.storyboardUrl || null;
        const onConfirm = (shotId) => {
          if (sbUrl) {
            (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shotId)}/add-version`, {
              method: "POST", headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ url: sbUrl, source: "storyboard" }),
            })
              .then(async r => { 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); } return r.json(); })
              .then(() => { setPromotePicker(null); if (window.reloadAppData) window.reloadAppData(); })
              .catch(err => setPromotePicker(p => p ? { ...p, err: err.message } : p));
            return;
          }
          const endpoint = copyMode
            ? `/api/asset-versions/${v._id}/copy-to-shot`
            : `/api/asset-versions/${v._id}/promote`;
          const payload = copyMode ? { target_shot_id: shotId } : { hero: true, target_shot_id: shotId };
          (window.authFetch || fetch)(endpoint, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(payload),
          })
            .then(async r => {
              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); }
              return r.json();
            })
            .then(() => {
              setPromotePicker(null);
              setMediaReview(null);
              if (window.reloadAppData) window.reloadAppData();
            })
            // v07zz311 — styled inline error, never a native alert (invariant #22).
            .catch(err => setPromotePicker(p => p ? { ...p, err: err.message } : p));
        };
        // v07zz61 — Hugo: "Promote button within film strip doesnt
        // do anything at all". The picker was getting trapped under
        // .view-page's backdrop-filter containing block (any
        // backdrop-filter on an ancestor makes position:fixed
        // descendants positioned relative to THAT ancestor, not
        // the viewport, so the modal landed inside the scrollable
        // area and could be clipped or sit behind the
        // AssetReviewModal portal). Render via portal at
        // #modal-root, same trick AssetReviewModal uses.
        const portalRoot = document.getElementById("modal-root") || document.body;
        return ReactDOM.createPortal((
          /* v07zz62 — Promote picker MUST sit above the
             AssetReviewModal (its arm-backdrop is z-index: 5000),
             so the picker has to be ≥ 6000 to render on top. */
          <div className="modal-backdrop" onClick={() => setPromotePicker(null)} style={{ zIndex: "var(--z-popover)" }}>
            <div className="promote-picker glass" onClick={(e) => e.stopPropagation()}>
              <button type="button"
                className="modal-close-btn"
                aria-label="Close"
                onClick={() => setPromotePicker(null)}>
                <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
              </button>
              <div className="promote-picker-head">
                <div className="promote-picker-eyebrow">{(sbUrl || copyMode) ? "SEND TO SHOT" : "PROMOTE"}</div>
                <div className="promote-picker-title">{sbUrl ? (promotePicker.label || "Storyboard image") : `${v.label} · ${v.row_kind || "frame"}`}</div>
                <div className="promote-picker-sub">{sbUrl
                  ? "Pick the shot to add this storyboard image to — it's copied into that shot's frames folder as a new frame. The storyboard folder stays untouched."
                  : copyMode
                  ? "Pick the target shot. This copies + renames the frame into that shot's frames folder as its selected frame — the original stays where it is."
                  : "Pick the shot to promote this version onto. The version is un-archived AND set as the shot's hero in one step."}</div>
                {promotePicker.err && <div className="promote-picker-sub" style={{ color: "var(--danger-2)", fontWeight: "var(--fw-semi)" }}>Failed: {promotePicker.err}</div>}
              </div>
              <div className="promote-picker-list">
                {versionsArr.map(s => (
                  <button key={s.id}
                    type="button"
                    className={"promote-picker-row" + (s.id === promotePicker.defaultShotId ? " is-default" : "")}
                    onClick={() => onConfirm(s.id)}>
                    <span className="promote-picker-id">{s.id}</span>
                    <span className="promote-picker-name">{s.frame_title || ""}</span>
                    {s.id === promotePicker.defaultShotId && <span className="promote-picker-default">original</span>}
                  </button>
                ))}
              </div>
            </div>
          </div>
        ), portalRoot);
      })()}
      {/* v07zz313 — one-click "send to shot" toast (styled, auto-dismiss; no native alert). */}
      {sendToast && ReactDOM.createPortal(
        <div onClick={() => setSendToast(null)}
          style={{ position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", zIndex: "var(--z-toast)",
            background: sendToast.ok ? "color-mix(in srgb, var(--toast-ok) 97%, transparent)" : "color-mix(in srgb, var(--red-28) 97%, transparent)", color: "var(--white)",
            padding: "10px 18px", borderRadius: "var(--r-9)", fontSize: "var(--fs-body)", fontWeight: "var(--fw-semi)", letterSpacing: "var(--track-01)",
            boxShadow: "0 8px 28px rgba(0,0,0,0.45)", cursor: "pointer", maxWidth: "80vw" }}>
          {sendToast.ok ? "✓ " : "✕ "}{sendToast.msg}
        </div>,
        document.getElementById("modal-root") || document.body
      )}
      {/* v06i — bespoke grid popup, modelled on the shot popup. */}
      {gridDetail && window.GridDetailModal && (
        <window.GridDetailModal
          gridVersion={gridDetail}
          onClose={() => setGridDetail(null)}
        />
      )}
      {/* v07m — Archival batches open in the cream AssetItemModal
          (same as Assets → Archival), not the filmstrip review modal.
          v07zz81 — Wire prev/next arrows: archival paginates
          archivalBatches, historical paginates historicalBatches.
          Both lists are batch objects with the same {id, name, folder,
          count, items, cover} shape, so the navigation logic mirrors
          AssetsView's onNavigate (Views.jsx ~line 2165). */}
      {archivalItem && (
        <AssetItemModal
          key={(archivalItem.item && (archivalItem.item.id || archivalItem.item.folder || archivalItem.item.name)) || "no-item"}
          kind={archivalItem.kind}
          item={archivalItem.item}
          onClose={() => { setArchivalItem(null); setArchivalNavDir(null); }}
          navDir={archivalNavDir}
          onNavigate={(dir) => {
            setArchivalNavDir(dir > 0 ? "next" : "prev");
            setArchivalItem(curr => {
              if (!curr || !curr.item) return curr;
              const list = curr.kind === "historical" ? historicalBatches : archivalBatches;
              if (!Array.isArray(list) || list.length < 2) return curr;
              const cid = curr.item.id, cfd = curr.item.folder, cnm = curr.item.name;
              const idx = list.findIndex(b =>
                (cid && b.id === cid) ||
                (cfd && b.folder === cfd) ||
                (cnm && b.name === cnm)
              );
              if (idx < 0) return curr;
              const nextIdx = (idx + dir + list.length) % list.length;
              const b = list[nextIdx];
              if (curr.kind === "historical") {
                return {
                  kind: "historical",
                  item: {
                    ...b,
                    type: `${b.count} references`,
                    scenes: b.name,
                    notes: `Historical batch: ${b.name}. ${b.count} files pulled from ${b.folder}.`,
                    references: (b.items || []).map(it => ({ ...it })),
                    image: b.cover,
                  },
                };
              }
              return {
                kind: "archival",
                item: {
                  ...b,
                  type: `${b.count} frames`,
                  scenes: b.name,
                  notes: `Archive batch: ${b.name}. ${b.count} frames pulled from ${b.folder}.`,
                  references: (b.items || []).map(it => ({ ...it })),
                  image: b.cover,
                },
              };
            });
          }}
        />
      )}
      {/* v07zz283 — full-size trailer image viewer (reuses the shared Lightbox
          with prev/next across the open folder's images). v07zz320 — also serves the
          Storyboard tab (its cards set the same openTrailerFolder/trailerLightbox state). */}
      {(filter === "trailer" || filter === "storyboard") && openTrailerFolder && trailerLightbox >= 0 && window.Lightbox && (() => {
        const imgs = openTrailerFolder.images || [];
        const im = imgs[trailerLightbox];
        if (!im) return null;
        return (
          <window.Lightbox
            src={im.url}
            alt={im.filename}
            caption={`${openTrailerFolder.name} · ${im.filename}`}
            onClose={() => setTrailerLightbox(-1)}
            onPrev={trailerLightbox > 0 ? () => setTrailerLightbox(trailerLightbox - 1) : undefined}
            onNext={trailerLightbox < imgs.length - 1 ? () => setTrailerLightbox(trailerLightbox + 1) : undefined}
          />
        );
      })()}
    </section>
  );
}

/* ─────────────────────────── ARCHIVE ─────────────────────────── */

function ArchiveView({ shots = [] }) {
  const archived = shots.filter(s => s.is_archive);
  return (
    <section className="view-page">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">ARCHIVE</div>
          <div className="vp-title">{archived.length} archive shots · scrapped or replaced takes preserved here</div>
        </div>
      </div>
      <div className="archive-help glass">
        <div className="archive-help-title">What lives in the archive?</div>
        <div className="archive-help-text">
          Shots that were generated and processed but did not make it into the final cut. Per the contract, all archive shots
          still count toward the contracted hour commitment. Useful for reference, future re-edits, or as raw stock for
          downstream episodes.
        </div>
      </div>
      <div className="archive-grid">
        {archived.length === 0 && <div className="archive-empty glass">No archive shots yet.</div>}
        {archived.map(s => (
          <article key={s.id} className="archive-card glass">
            <div className="archive-thumb" style={{background: seqGradient(s.seq)}}>
              <span className="archive-id">{s.id}</span>
              <span className="archive-tag">ARCHIVED</span>
            </div>
            <div className="archive-body">
              <div className="archive-title">{s.frame_title}</div>
              <div className="archive-meta">SEQ {String(s.seq).padStart(2,"0")} · {s.shot_type || "—"}</div>
            </div>
          </article>
        ))}
      </div>
    </section>
  );
}

/* ─────────────────────────── REPORTS ─────────────────────────── */

// ============================================================================
// Reports kit (v07zz225) — shared primitives + normalized data model used by
// all 5 switchable Reports layouts. Layouts are pure functions of `R` (the kit
// + data), so each composes the SAME visual primitives in a different grid.
// ============================================================================
// 15 Sep 2026 — chart colours come from tokens (styles/tokens.css --chart-*) so a preset recolours
// the Reports charts. _tok() reads the CURRENT computed value — call it inside render / memo, never
// at module load, so a preset switch is picked up on the next render. Fallbacks = the forest values.
const _tok = (name, fallback) => { try { const v = getComputedStyle(document.body).getPropertyValue(name).trim(); return v || fallback; } catch (_) { return fallback; } };
const RV_PALETTE_FOREST = ["#6E9E54", "#5E8FB0", "#C9A84C", "#9C7BB0", "#D08A4A", "#6FB2A6", "#B0654E"];
const rvPalette = () => RV_PALETTE_FOREST.map((c, i) => _tok("--chart-" + (i + 1), c));
// v07zz240 — semantic report colours, ONE source of truth. Every layout pulls
// from here (exposed as R.C) so the same metric is the same colour everywhere —
// fixes the "failed" donut that was a different red in each of the 5 layouts
// (#C98B6A / #C0573E / #C0584C) and the green/gold scattered as literals.
const RV_C_FOREST = { api: "#6E9E54", labor: "#C9A84C", xtra: "#A8794A", ok: "#6E9E54", fail: "#C0573E", render: "#6E9E54", prompt: "#C9A84C" };
const RV_C_TOKEN  = { api: "--chart-1", labor: "--chart-labor", xtra: "--chart-xtra", ok: "--chart-ok", fail: "--chart-fail", render: "--chart-1", prompt: "--chart-prompt" };
// RV_C.<key> is a getter: every read resolves the token at that moment (RV_C.fail inside a layout render is live).
const RV_C = {};
Object.keys(RV_C_FOREST).forEach((k) => Object.defineProperty(RV_C, k, { enumerable: true, get: () => _tok(RV_C_TOKEN[k], RV_C_FOREST[k]) }));
const _rvUsd  = (n) => "$" + (Math.round((Number(n) || 0) * 100) / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const _rvUsd0 = (n) => "$" + Math.round(Number(n) || 0).toLocaleString();
const _rvUsdK = (n) => { const v = Number(n) || 0; return v >= 1000 ? "$" + Math.round(v / 1000) + "K" : "$" + Math.round(v); };
const _rvNum  = (n) => (Number(n) || 0).toLocaleString();
const _rvPct  = (n) => (n == null ? "—" : (Math.round((Number(n) || 0) * 10) / 10) + "%");
const _rvNav  = (v) => { try { (window.__nav && window.__nav.setView) ? window.__nav.setView(v) : (window.__navigate && window.__navigate(v)); } catch (_) {} };
const _rvThumb = (u, w) => (window.thumbUrl ? window.thumbUrl(u, w) : u);
const _rvAgo = (iso) => { try { const s = (Date.now() - new Date(iso).getTime()) / 1000; if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago"; if (s < 86400) return Math.floor(s / 3600) + "h ago"; if (s < 86400 * 30) return Math.floor(s / 86400) + "d ago"; return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } catch (_) { return ""; } };
const _rvShotThumb = (shotId) => {
  try {
    const ad = window.__appData || {};
    const s = (ad.shots || []).find(x => x.id === shotId);
    const src = s && (s.video_poster || (s.image_paths && (s.image_paths.selected || s.image_paths.first_pass)) || s.image || s.hero);
    return src || null;
  } catch (_) { return null; }
};

const RvI = (() => {
  const S = ({ d }) => <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{d}</svg>;
  return {
    clock: <S d={<><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2.5"/></>}/>,
    cost: <S d={<><circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.2a2.4 2.4 0 0 1 2.5-1.7c1.4 0 2.4.8 2.4 1.9 0 2.4-4.8 1.4-4.8 3.7 0 1.1 1 1.9 2.4 1.9a2.4 2.4 0 0 0 2.5-1.7"/></>}/>,
    shots: <S d={<><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 9h18M8 5v14"/></>}/>,
    active: <S d={<path d="m12 3 2.6 5.3 5.9.9-4.3 4.1 1 5.9L12 16.8 6.8 19.2l1-5.9L3.5 9.2l5.9-.9z"/>}/>,
    archive: <S d={<><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11h14V8M10 12h4"/></>}/>,
    note: <S d={<><path d="M4 4h12l4 4v12H4z"/><path d="M8 12h8M8 16h5"/></>}/>,
    comment: <S d={<path d="M21 12a8 8 0 0 1-8 8H7l-4 3V6a3 3 0 0 1 3-3h7a8 8 0 0 1 8 8z"/>}/>,
    assets: <S d={<><circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0M16 11a3 3 0 1 0-1-5.8M21 20a6 6 0 0 0-5-5.9"/></>}/>,
    image: <S d={<><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="m21 16-5-5L5 21"/></>}/>,
    video: <S d={<><rect x="2" y="5" width="14" height="14" rx="2"/><path d="m22 8-6 4 6 4z"/></>}/>,
    voice: <S d={<><path d="M4 10v4M8 6v12M12 3v18M16 6v12M20 10v4"/></>}/>,
    sparkle: <S d={<path d="m12 3 2.1 5.4 5.4 2.1-5.4 2.1L12 18l-2.1-5.4L4.5 10.5 9.9 8.4z"/>}/>,
    copy: <S d={<><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></>}/>,
    chars: <S d={<><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>}/>,
    animal: <S d={<path d="M4 13c0-3 2-5 4-5 1 0 2 1 4 1s3-1 4-1c2 0 4 2 4 5 0 4-3 7-8 7s-8-3-8-7zM7 11v-3M17 11v-3"/>}/>,
    location: <S d={<><path d="M12 21s-7-6-7-11a7 7 0 0 1 14 0c0 5-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></>}/>,
    prop: <S d={<><rect x="4" y="7" width="16" height="13" rx="2"/><path d="M9 7V5a3 3 0 0 1 6 0v2"/></>}/>,
  };
})();

// ── Donut: ring + (optional) legend. legend = "right" | "below" | "none". ──
function RvDonut({ segments = [], centerTop, centerBig, size = 170, thickness = 13, legend = "right", track = true }) {
  const t = segments.reduce((a, d) => a + (d.value || 0), 0) || 1;
  const r = 50 - thickness / 2, c = 2 * Math.PI * r;
  let acc = 0;
  const ring = (
    <div className="rvk-ring" style={{ width: size, height: size, flex: "0 0 " + size + "px" }}>
      <svg viewBox="0 0 100 100">
        {track && <circle cx="50" cy="50" r={r} fill="none" style={{ stroke: "color-mix(in srgb, var(--ink-muted) 14%, transparent)" }} strokeWidth={thickness}/>}
        {segments.map((d, i) => { const p = ((d.value || 0) / t) * c, off = c - acc; acc += p;
          return <circle key={i} cx="50" cy="50" r={r} fill="none" style={{ stroke: d.color }} strokeWidth={thickness} strokeDasharray={p.toFixed(3) + " " + (c - p).toFixed(3)} strokeDashoffset={off.toFixed(3)} transform="rotate(-90 50 50)"/>; })}
      </svg>
      {(centerTop != null || centerBig != null) && <div className="rvk-ring-center"><div className="rvk-ring-top">{centerTop}</div><div className="rvk-ring-big">{centerBig}</div></div>}
    </div>
  );
  if (legend === "none") return <div className="rvk-donut rvk-donut--solo">{ring}</div>;
  return (
    <div className={"rvk-donut rvk-donut--" + legend}>
      {ring}
      <div className="rvk-legend">
        {segments.map((d, i) => (
          <div key={i} className="rvk-legrow">
            <span className="rvk-dot" style={{ background: d.color }}/>
            <span className="rvk-leg-label">{d.label}</span>
            <span className="rvk-leg-val">{d.valDisplay != null ? d.valDisplay : _rvNum(d.value)}{d.pct != null ? "  " + d.pct + "%" : ""}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Horizontal bars. rows: [{label, value, display, pct, color}]. axis: optional tick labels. ──
function RvHBars({ rows = [], max, axis, empty }) {
  const m = max || Math.max(0.0001, ...rows.map(r => r.value || 0));
  if (!rows.length) return <div className="rvk-empty">{empty || "Nothing yet."}</div>;
  return (
    <div className="rvk-hbars">
      {rows.map((r, i) => (
        <div key={i} className="rvk-hbar">
          <span className="rvk-hbar-label">{r.label}</span>
          <div className="rvk-hbar-track"><div className="rvk-hbar-fill" style={{ width: Math.max(2, (r.value / m) * 100) + "%", background: r.color || _tok("--chart-1", "#6E9E54") }}/></div>
          <span className="rvk-hbar-val">{r.display != null ? r.display : _rvNum(r.value)}{r.pct != null ? <i className="rvk-hbar-pct">{r.pct}%</i> : null}</span>
        </div>
      ))}
      {axis && axis.length > 0 && <div className="rvk-hbar-axis"><span/>{axis.map((a, i) => <span key={i}>{a}</span>)}</div>}
    </div>
  );
}

// ── Vertical stacked/grouped bars. groups: [{label, parts:[{value,color}]}]. legend: [{label,color}]. ──
function RvStacked({ groups = [], legend, ymax, valueLabels = false }) {
  const totals = groups.map(g => (g.parts || []).reduce((a, p) => a + (p.value || 0), 0));
  const m = ymax || Math.max(1, ...totals);
  return (
    <div className="rvk-vbars">
      <div className="rvk-vbars-plot">
        {groups.map((g, i) => (
          <div key={i} className="rvk-vbar-col">
            {valueLabels && <span className="rvk-vbar-num">{_rvNum(totals[i])}</span>}
            <div className="rvk-vbar-stack" style={{ height: ((totals[i] / m) * 100) + "%" }}>
              {(g.parts || []).map((p, j) => <div key={j} className="rvk-vbar-seg" style={{ flex: (p.value || 0), background: p.color }}/>)}
            </div>
            <span className="rvk-vbar-label">{g.label}</span>
          </div>
        ))}
      </div>
      {legend && legend.length > 0 && <div className="rvk-vbars-legend">{legend.map((l, i) => <span key={i}><i style={{ background: l.color }}/>{l.label}</span>)}</div>}
    </div>
  );
}

// ── Line / area chart. points: [{label, value}]. ──
function RvLine({ points = [], color = _tok("--chart-line", "#5E8C46"), area = true, markLast = false, height = 96 }) {
  const max = Math.max(1, ...points.map(p => p.value || 0)); const W = 320, H = height, n = points.length;
  const xy = points.map((p, i) => [n > 1 ? (i / (n - 1)) * W : W / 2, H - ((p.value || 0) / max) * (H - 10) - 4]);
  const line = xy.map(p => p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
  return (
    <div className="rvk-line">
      <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none">
        <defs><linearGradient id="rvkArea" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" style={{ stopColor: color }} stopOpacity="0.34"/><stop offset="100%" style={{ stopColor: color }} stopOpacity="0.02"/></linearGradient></defs>
        {area && n > 1 && <polygon points={"0," + H + " " + line + " " + W + "," + H} fill="url(#rvkArea)"/>}
        {n > 1 && <polyline points={line} fill="none" style={{ stroke: color }} strokeWidth="2" strokeLinejoin="round"/>}
        {markLast && n > 0 && <circle cx={xy[n - 1][0]} cy={xy[n - 1][1]} r="3.4" style={{ fill: color }}/>}
      </svg>
      {points.some(p => p.label) && <div className="rvk-line-axis">{points.map((p, i) => <span key={i}>{p.label}</span>)}</div>}
    </div>
  );
}

// ── Multi-series trends chart. series: [{label, color, points:[{label,value}], area?}]. ──
// The first series area-fills by default; both draw as smooth lines + a legend.
function RvTrends({ series = [], height = 132 }) {
  const all = series.flatMap(s => s.points || []);
  const max = Math.max(1, ...all.map(p => p.value || 0));
  const W = 340, H = height;
  const n = Math.max(0, ...series.map(s => (s.points || []).length));
  const xLabels = (series.find(s => (s.points || []).length === n) || {}).points || [];
  const xyFor = (pts) => pts.map((p, i) => [(n > 1 ? (i / (n - 1)) : 0.5) * W, H - ((p.value || 0) / max) * (H - 18) - 9]);
  const grad0 = (series[0] && series[0].color) || _tok("--chart-1", "#6E9E54");
  return (
    <div className="rvk-trends">
      <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none">
        <defs><linearGradient id="rvkTrendArea" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" style={{ stopColor: grad0 }} stopOpacity="0.24"/><stop offset="100%" style={{ stopColor: grad0 }} stopOpacity="0.02"/></linearGradient></defs>
        {series.map((s, si) => {
          const xy = xyFor(s.points || []);
          if (xy.length < 2) return null;
          const line = xy.map(p => p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
          const wantArea = s.area != null ? s.area : si === 0;
          return (
            <g key={si}>
              {wantArea && <polygon points={"0," + H + " " + line + " " + W + "," + H} fill="url(#rvkTrendArea)"/>}
              <polyline points={line} fill="none" style={{ stroke: s.color }} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
            </g>
          );
        })}
      </svg>
      {xLabels.some(p => p.label) && <div className="rvk-line-axis">{xLabels.map((p, i) => <span key={i}>{p.label}</span>)}</div>}
      {series.length > 0 && <div className="rvk-trends-legend">{series.map((s, i) => <span key={i}><i style={{ background: s.color }}/>{s.label}</span>)}</div>}
    </div>
  );
}

// ── Single gauge ring (for split %). ──
function RvRing({ pct = 0, size = 58, color = _tok("--chart-1", "#6E9E54"), label }) {
  const r = 42, c = 2 * Math.PI * r, p = Math.max(0, Math.min(100, pct)) / 100 * c;
  return (
    <div className="rvk-gauge" style={{ width: size, height: size }}>
      <svg viewBox="0 0 100 100">
        <circle cx="50" cy="50" r={r} fill="none" style={{ stroke: "color-mix(in srgb, var(--ink-muted) 16%, transparent)" }} strokeWidth="11"/>
        <circle cx="50" cy="50" r={r} fill="none" style={{ stroke: color }} strokeWidth="11" strokeDasharray={p.toFixed(2) + " " + (c - p).toFixed(2)} strokeLinecap="round" transform="rotate(-90 50 50)"/>
      </svg>
      <span className="rvk-gauge-lbl">{label != null ? label : Math.round(pct) + "%"}</span>
    </div>
  );
}

// ── Compact metric tile. ──
function RvStat({ icon, label, value, sub, accent }) {
  return (
    <div className="rvk-stat">
      {icon && <span className="rvk-stat-ico" style={accent ? { color: accent, background: accent + "22" } : undefined}>{icon}</span>}
      <div className="rvk-stat-body">
        <div className="rvk-stat-label">{label}</div>
        <div className="rvk-stat-num">{value}</div>
        {sub && <div className="rvk-stat-sub">{sub}</div>}
      </div>
    </div>
  );
}

// ── Avatar: raw URL first (tiny image, no proxy), initials fallback on error. ──
function RvAvatar({ url, name, size = 28 }) {
  const initials = (name || "?").split(/\s+/).map(x => x[0]).slice(0, 2).join("").toUpperCase();
  const [failed, setFailed] = React.useState(false);
  return (
    <span className="rvk-av" style={{ width: size, height: size, flex: "0 0 " + size + "px", fontSize: Math.round(size * 0.38) }}>
      {url && !failed
        ? <img src={url} alt="" loading="lazy" onError={() => setFailed(true)}/>
        : initials}
    </span>
  );
}

// ── Tool subscriptions table. columns: [{key,label,align}]. rows carry those keys. ──
function RvSubsTable({ rows = [], columns, total, empty }) {
  const cols = columns || [{ key: "tool", label: "Tool", align: "left" }, { key: "monthly", label: "Monthly cost", align: "right" }, { key: "toDate", label: "Cost to date", align: "right" }];
  if (!rows.length) return <div className="rvk-empty">{empty || "No subscriptions configured."}</div>;
  // v07zz228 — grid is dynamic from the column count (was hardcoded to 3 in CSS,
  // so a 4-column table e.g. Finance layout's +Status wrapped + misaligned). First
  // column flexes wider; the rest share evenly.
  const gridStyle = { gridTemplateColumns: "1.6fr " + Array(Math.max(1, cols.length - 1)).fill("1fr").join(" ") };
  return (
    <div className="rvk-subs">
      <div className="rvk-subs-head" style={gridStyle}>{cols.map((c, i) => <span key={i} className={"ta-" + (c.align || "left")}>{i === 0 ? "" : c.label}</span>)}</div>
      {rows.map((r, i) => (
        <div key={i} className="rvk-subs-row" style={gridStyle}>
          {cols.map((c, j) => (
            <span key={j} className={"ta-" + (c.align || "left") + (j === 0 ? " rvk-subs-tool" : "")}>
              {j === 0 && r.color && <i className="rvk-subs-dot" style={{ background: r.color }}/>}
              {r[c.key] != null ? r[c.key] : ""}
            </span>
          ))}
        </div>
      ))}
      {total && <div className="rvk-subs-row rvk-subs-total" style={gridStyle}>{cols.map((c, j) => <span key={j} className={"ta-" + (c.align || "left")}>{total[c.key] != null ? total[c.key] : (j === 0 ? "Total" : "")}</span>)}</div>}
    </div>
  );
}

// ── Top contributors ranked list. rows: [{name, avatar, value}]. ──
function RvContribList({ rows = [], max, rank = false, showBar = true, footValue }) {
  const m = max || Math.max(1, ...rows.map(r => r.value || 0));
  if (!rows.length) return <div className="rvk-empty">No activity logged.</div>;
  return (
    <div className="rvk-contribs">
      {rows.map((r, i) => (
        <div key={i} className="rvk-contrib">
          {rank && <span className="rvk-contrib-rank">{i + 1}</span>}
          <RvAvatar url={r.avatar} name={r.name} size={24}/>
          <span className="rvk-contrib-name">{r.name}</span>
          {showBar && <span className="rvk-contrib-bar"><i style={{ width: Math.max(4, (r.value / m) * 100) + "%", background: r.color || _tok("--chart-3", "#C9A84C") }}/></span>}
          <span className="rvk-contrib-val">{_rvNum(r.value)}{r.unit ? " " + r.unit : ""}</span>
        </div>
      ))}
    </div>
  );
}

// ── Open-notes inbox list. rows: [{body, author, when, target}]. ──
function RvNoteList({ rows = [], limit = 4, onOpen, empty }) {
  if (!rows.length) return <div className="rvk-empty">{empty || "No open notes."}</div>;
  return (
    <div className="rvk-notes">
      {rows.slice(0, limit).map((n, i) => (
        <div key={n.id || i} className="rvk-note" onClick={onOpen ? () => onOpen(n) : undefined} style={onOpen ? { cursor: "pointer" } : undefined}>
          <span className="rvk-note-dot"/>
          <div className="rvk-note-body">
            <div className="rvk-note-text">{n.body}</div>
            <div className="rvk-note-meta">{[n.author, n.target, _rvAgo(n.when)].filter(Boolean).join(" · ")}</div>
          </div>
        </div>
      ))}
    </div>
  );
}

// ── Unresolved review-comment list with a shot thumbnail. rows from data.recentComments. ──
function RvCommentList({ rows = [], limit = 4, thumbs = true, onOpen, empty }) {
  if (!rows.length) return <div className="rvk-empty">{empty || "No unresolved comments."}</div>;
  return (
    <div className="rvk-cmts">
      {rows.slice(0, limit).map((c, i) => {
        const th = thumbs ? _rvShotThumb(c.shot) : null;
        return (
          <div key={c.id || i} className="rvk-cmt" onClick={onOpen ? () => onOpen(c) : undefined} style={onOpen ? { cursor: "pointer" } : undefined}>
            {thumbs && <span className="rvk-cmt-thumb" style={th ? { backgroundImage: "url(" + _rvThumb(th, 120) + ")" } : undefined}>{!th && <span className="rvk-cmt-thumb-ph"/>}</span>}
            <div className="rvk-cmt-body">
              <div className="rvk-cmt-title">{c.title}{c.shot ? " · " + c.shot : ""}</div>
              <div className="rvk-cmt-text">{c.body}</div>
              <div className="rvk-cmt-meta">{[c.author, _rvAgo(c.when)].filter(Boolean).join(" · ")}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Panel wrapper (reuses .rv-panel chrome). actions render top-right; foot bottom. ──
function RvPanel({ title, sub, actions, foot, className, tone, children }) {
  return (
    <div className={"rv-panel rvk-panel" + (tone === "light" ? " rvk-panel--light" : "") + (className ? " " + className : "")}>
      {(title || actions) && (
        <div className="rvk-panel-head">
          <div className="rvk-panel-hel">{title && <div className="rv-panel-title">{title}</div>}{sub && <div className="rv-panel-sub">{sub}</div>}</div>
          {actions && <div className="rvk-panel-act">{actions}</div>}
        </div>
      )}
      <div className="rv-panel-body rvk-panel-body">{children}</div>
      {foot && <div className="rv-panel-foot rvk-panel-foot">{foot}</div>}
    </div>
  );
}
const RvLink = ({ to, children }) => <button type="button" className="rv-link" onClick={() => _rvNav(to)}>{children} →</button>;

// ── Normalize /api/reports + live shots into one flat model every layout reads. ──
// v1055 — "how much more than quoted", formatted once and used by every layout.
// Returns null when there is no quote to compare against (or when money is
// stripped for a non-budget role), so each call site keeps its old wording.
function _rvQuoteMoneyLine(c) {
  if (!c || c.budgetPct == null) return null;
  const q = _rvUsd0(c.quoteBudget);
  if (c.budgetOver == null) return { over: false, text: c.budgetPct + "% of " + q + " quote budget" };
  const over = c.budgetOver > 0;
  return { over, text: _rvUsd0(Math.abs(c.budgetOver)) + (over ? " over" : " under") + " the " + q + " quote \u00b7 " + c.budgetPct + "%" };
}
// `short` is for the cards that only have room to append to an existing line.
function _rvQuoteHoursLine(c, short) {
  if (!c || !c.quoteHours || c.hoursOver == null) return null;
  const over = c.hoursOver > 0;
  const n = Math.abs(c.hoursOver).toFixed(1);
  return {
    over,
    text: short
      ? (over ? "+" : "\u2212") + n + "h vs quote"
      : (over ? "+" : "\u2212") + n + " h " + (over ? "over" : "under") + " the " + c.quoteHours + " h quoted \u00b7 " + c.hoursPct + "%",
  };
}

// 24 Sep 2026 (G6 fix) — Reports for another project: the server sends counts.asset_categories
// ({design: 3, wardrobe: 2, instruments: 1}) in the project's declared order. Returns
// { cats: [{id,label,count}], total } or null (Paradise Found / no field = the four fixed keys).
function _reportAssetsFromCategories(counts) {
  const ac = counts && counts.asset_categories;
  if (!ac || typeof ac !== "object" || Array.isArray(ac)) return null;
  const cats = Object.keys(ac).map((id) => ({
    id,
    label: window.__projectCategoryLabel ? window.__projectCategoryLabel(id) : id,
    count: Number(ac[id]) || 0,
  }));
  return { cats, total: cats.reduce((s, c) => s + c.count, 0) };
}
const _REPORT_CAT_ICON_KEY = { characters: "chars", animals: "animal", locations: "location", props: "prop" };

function buildReportsData(rd, shots) {
  rd = rd || {}; shots = shots || [];
  const counts = rd.counts || {}, gen = rd.generation || {}, cost = rd.cost || {}, hoursD = rd.hours || null;
  const rendersByModel = rd.renders_by_model || [], apiByModel = (cost.api_by_model) || [], subsRaw = (cost.subscriptions) || [];
  const PAL = rvPalette();

  const genTotal = rendersByModel.reduce((s, m) => s + (m.count || 0), 0);
  const genByModel = rendersByModel.map((m, i) => ({ label: m.model, value: m.count || 0, pct: genTotal ? Math.round((m.count || 0) / genTotal * 1000) / 10 : 0, color: PAL[i % PAL.length] }));
  const apiTot = apiByModel.reduce((s, m) => s + (m.usd || 0), 0);
  const apiBy = apiByModel.map((m, i) => ({ label: m.model, usd: m.usd || 0, pct: apiTot ? Math.round((m.usd || 0) / apiTot * 1000) / 10 : 0, color: PAL[i % PAL.length] }));

  const stageOf = (s) => window.getCurrentStage ? window.getCurrentStage(s) : "PENDING";
  const TINTS = window.STAGE_TINTS || {};
  const STAGE_ORDER = ["FIRST-PASS", "CONCEPT-WIP", "CONCEPT-APPROVED", "VIDEO-WIP", "VIDEO-APPROVED", "UPSCALED", "PENDING"];
  let pipeline = STAGE_ORDER.map((stage, i) => ({ label: (TINTS[stage] && TINTS[stage].label) || stage, color: (TINTS[stage] && TINTS[stage].dot) || PAL[i % PAL.length], value: shots.filter(s => !s.is_archive && stageOf(s) === stage).length })).filter(p => p.value > 0);
  const pipelineTotal = pipeline.reduce((s, p) => s + p.value, 0) || 0;
  pipeline = pipeline.map(p => ({ ...p, pct: pipelineTotal ? Math.round(p.value / pipelineTotal * 1000) / 10 : 0 }));

  const shotsTotal = counts.shots_total != null ? counts.shots_total : shots.length;
  const archived = counts.shots_archived != null ? counts.shots_archived : shots.filter(s => s.is_archive).length;
  const active = counts.shots_active != null ? counts.shots_active : Math.max(0, shotsTotal - archived);
  const shotLives = [
    { label: "In Production (Active)", value: active, color: PAL[0] },
    { label: "Archived", value: archived, color: PAL[2] },
  ].filter(d => d.value > 0).map(d => ({ ...d, pct: shotsTotal ? Math.round(d.value / shotsTotal * 1000) / 10 : 0 }));

  const subs = subsRaw.map((s, i) => ({ tool: s.tool, monthly: s.monthly_usd || 0, toDate: s.to_date_usd || 0, project: s.project_usd || 0, color: PAL[i % PAL.length], category: s.category || "" }));
  const subMonthlyTotal = cost.subscription_monthly != null ? cost.subscription_monthly : subs.reduce((a, s) => a + s.monthly, 0);
  const subToDateTotal = cost.subscription_to_date != null ? cost.subscription_to_date : subs.reduce((a, s) => a + s.toDate, 0);

  const contributors = (rd.top_contributors || []).map(c => ({ id: c.id, name: c.name || c.email || ("User " + c.id), avatar: c.avatar_square_url || c.avatar_portrait_url || null, value: c.actions || 0 }));
  const contribMax = Math.max(1, ...contributors.map(c => c.value));

  const activity = rd.activity_timeline || [], promptsTl = rd.prompt_timeline || [], hoursTl = rd.hours_timeline || [];
  const rendersPerWeek = activity.map((w, i) => ({ label: w.label || ("Wk" + (i + 1)), value: w.count || 0 }));
  const promptsPerWeek = promptsTl.map((w, i) => ({ label: w.label || ("Wk" + (i + 1)), value: w.count || 0 }));
  // v07zz590 — when Rize has no weekly data, show FLAT ZEROS, not render counts.
  // The old fallback silently plotted renders-per-week on the "Hours over time"
  // chart — fake hours. Zeros read honestly as "no hours recorded".
  const hoursTimeline = (hoursTl.length ? hoursTl.map((w, i) => ({ label: "W" + (i + 1), value: w.value })) : rendersPerWeek.map((w, i) => ({ label: "W" + (i + 1), value: 0 })));

  const hoursTotal = hoursD && Number.isFinite(hoursD.hoursTotal) ? hoursD.hoursTotal : (cost.hours_total != null ? cost.hours_total : null);
  const hoursWeek = hoursD && Number.isFinite(hoursD.hoursThisWeek) ? hoursD.hoursThisWeek : null;

  // v07zz229 — prefer the effective API spend (REAL OpenAI billing + estimated
  // non-OpenAI providers) over the pure per-render estimate.
  const apiSpend = (cost.api_effective != null) ? cost.api_effective : (cost.api_spend_total || 0);
  const labor = cost.labor_cost != null ? cost.labor_cost : ((hoursTotal || 0) * (cost.labor_rate || 0));
  const projectCost = cost.project_cost != null ? cost.project_cost : (apiSpend + labor);
  const quoteBudget = cost.quote_budget || 0;
  const budgetPct = cost.budget_pct != null ? cost.budget_pct : (quoteBudget ? Math.round(projectCost / quoteBudget * 1000) / 10 : null);
  const costApiPct = projectCost ? Math.round(apiSpend / projectCost * 1000) / 10 : 0;
  const costLaborPct = projectCost ? Math.round(labor / projectCost * 1000) / 10 : 0;
  // v947 — the hand-entered standalone purchases ("extra cost" rows) have ALWAYS been
  // inside project_cost (server: api_effective + labour + purchases_standalone), but no
  // breakdown line showed them — API% + Labour% never reached 100 and Hugo read the
  // extras as "not being added". Third slice, same treatment as the other two.
  const costXtra = cost.purchases_standalone || 0;
  const costXtraPct = projectCost ? Math.round(costXtra / projectCost * 1000) / 10 : 0;

  const now = new Date();
  const asOf = now.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) + " · " + now.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });

  return {
    asOf,
    hours: { total: hoursTotal, week: hoursWeek, configured: !!(hoursD && hoursD.configured), source: hoursD && hoursD.source, timeline: hoursTimeline },
    cost: {
      api: apiSpend, labor, laborRate: cost.labor_rate || 0, hoursTotal: hoursTotal || 0,
      projectCost, quoteBudget, budgetPct, apiPct: costApiPct, laborPct: costLaborPct,
      // v1055 — overrun vs the original quote, computed server-side (the quoted
      // hours live in schedule.json and must not reach the browser raw).
      quoteHours: cost.quote_hours || 0, hoursOver: cost.hours_over != null ? cost.hours_over : null,
      hoursPct: cost.hours_pct != null ? cost.hours_pct : null,
      budgetOver: cost.budget_over != null ? cost.budget_over : null,
      overrunLaborCost: cost.overrun_labor_cost != null ? cost.overrun_labor_cost : null,
      xtra: costXtra, xtraPct: costXtraPct,   // v947 — standalone "extra cost" purchases slice
      subscriptionTotal: cost.subscription_total || 0, subMonthlyTotal, subToDateTotal,
      totalProjectCost: cost.total_project_cost || (apiSpend + (cost.subscription_total || 0)),
      // v07zz229 — real OpenAI billing (null until OPENAI_ADMIN_KEY is set)
      openaiActual: cost.openai_actual != null ? cost.openai_actual : null,
      apiSource: cost.api_source || "estimate",
      openaiConfigured: !!cost.openai_configured, openaiError: cost.openai_error || null,
      openaiPending: !!cost.openai_pending,          // v07zz382 — live billing fetch in flight
      estimateNote: cost.estimate_note || null,      // v07zz382 — plain-English source/caveat
      // v812 — hand-entered real spend (credit top-ups, tool buys)
      purchases: cost.purchases || [],
      purchasesTotal: cost.purchases_total || 0,
      purchasesStandalone: cost.purchases_standalone || 0,
    },
    shots: { total: shotsTotal, active, archived, activePct: shotsTotal ? Math.round(active / shotsTotal * 1000) / 10 : 0, archivedPct: shotsTotal ? Math.round(archived / shotsTotal * 1000) / 10 : 0 },
    // 24 Sep 2026 (G6 fix) — another project: one row per DECLARED category (design, wardrobe,
    // instruments ...) from counts.asset_categories, total = their sum. Paradise Found: unchanged.
    assets: _reportAssetsFromCategories(counts) || { characters: counts.characters || 0, animals: counts.animals || 0, locations: counts.locations || 0, props: counts.props || 0, total: (counts.characters || 0) + (counts.animals || 0) + (counts.locations || 0) + (counts.props || 0) },
    generated: { images: counts.images || 0, videos: counts.videos || 0, voiceovers: counts.voiceovers || counts.vo_takes || 0 },
    prompts: { launched: gen.prompts_total || 0, copied: gen.prompts_video_copied || 0 },
    genByModel, genTotal, apiByModel: apiBy, apiTotal: apiSpend,
    subscriptions: subs, subMonthlyTotal, subToDateTotal,
    pipeline, pipelineTotal,
    success: { pct: gen.success_rate, completed: gen.prompts_completed || 0, failed: gen.prompts_failed || 0, total: (gen.prompts_completed || 0) + (gen.prompts_failed || 0) },
    shotLives,
    contributors, contribMax,
    notes: { open: counts.notes_open || 0, total: counts.notes_total || 0 },
    reviews: { open: counts.review_comments_open || 0, total: counts.review_comments_total || 0 },
    recentNotes: (rd.recent_notes || []).map(n => ({ id: n.id, body: n.body, author: n.author, when: n.created_at, target: n.entity_id, entityType: n.entity_type })),
    recentComments: (rd.recent_review_comments || []).map(c => ({ id: c.id, body: c.body, author: c.author, when: c.created_at, title: c.review_title || ("Review #" + c.video_review_id), shot: c.shot_id, timecode: c.timecode_seconds, reviewId: c.video_review_id })),
    rendersPerWeek, promptsPerWeek,
  };
}

const REPORT_LAYOUTS = [
  { id: 1, name: "Overview" },
  { id: 2, name: "Summary" },
  { id: 3, name: "Compact" },
  { id: 4, name: "Editorial" },
  { id: 5, name: "Finance" },
  // 16 Sep 2026 — src/FootprintReport.jsx. It shows no money, so roles without view_budget open it too.
  { id: 6, name: "AI footprint" },
];

// v07zz590 — last-known reports payload survives page switches, so returning to
// Reports paints instantly from the previous data while the refetch runs behind
// (same pattern as __recentActivityCache). Session-scoped, module-level.
let _reportsLastPayload = null;
function ReportsView({ shots = [], schedule }) {
  // v07zz225 — 5 switchable layouts of the same real data. The chosen layout
  // persists in localStorage; a segmented control switches between them.
  const [reportData, setReportData] = React.useState(() => _reportsLastPayload);
  const [layout, setLayout] = React.useState(() => { try { return Number(localStorage.getItem("reports-layout")) || 1; } catch (_) { return 1; } });
  // v812 — hoisted so the Costs panel can refresh the whole report after a save or a
  // delete (the totals it feeds live in the same payload). Returns the promise so the
  // panel can await it and only then drop its busy state.
  const reloadReports = React.useCallback(() => {
    const fetcher = window.authFetch || fetch;
    return fetcher("/api/reports").then(r => r.ok ? r.json() : null)
      .then(d => { if (d) { _reportsLastPayload = d; setReportData(d); } }).catch(() => {});
  }, []);
  React.useEffect(() => {
    const load = () => reloadReports();
    load();
    // v07zz228 — refetch when the user returns to the tab so freshly-logged Rize
    // hours (90s server cache) show up without a manual reload.
    const onFocus = () => load();
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
  }, []);
  React.useEffect(() => { try { localStorage.setItem("reports-layout", String(layout)); } catch (_) {} }, [layout]);

  const data = React.useMemo(() => buildReportsData(reportData, shots), [reportData, shots]);
  const R = {
    data, usd: _rvUsd, usd0: _rvUsd0, usdK: _rvUsdK, num: _rvNum, pct: _rvPct, nav: _rvNav, thumb: _rvThumb, ago: _rvAgo,
    PALETTE: rvPalette(), C: RV_C, I: RvI,
    Donut: RvDonut, HBars: RvHBars, Stacked: RvStacked, Line: RvLine, Trends: RvTrends, Ring: RvRing,
    Stat: RvStat, Avatar: RvAvatar, SubsTable: RvSubsTable, ContribList: RvContribList,
    NoteList: RvNoteList, CommentList: RvCommentList,
    Panel: RvPanel, Link: RvLink,
  };
  const LAYOUTS = { 1: ReportsLayout1, 2: ReportsLayout2, 3: ReportsLayout3, 4: ReportsLayout4, 5: ReportsLayout5 };
  const Chosen = LAYOUTS[layout] || ReportsLayout1;

  // 🔒 v07zz277 — SECURITY: the Reports page is the cost/budget dashboard (every
  // layout leads with Total Project Cost, API spend $, subscriptions). Gate the
  // whole page on view_budget. This makes "View as <role>" accurate (the admin's
  // browser may hold cost data, but a role without view_budget sees nothing) and
  // never renders/​crashes on the now-money-stripped /api/reports payload. Real
  // non-budget roles are ALSO protected server-side (cost fields stripped).
  // v07zz340 — Hide-money toggle suppresses the whole cost/budget page too (even for budget roles).
  const _budgetPerm = window.hasPerm ? window.hasPerm("view_budget") : false;
  const canBudget = _budgetPerm && !window.__hideMoney;
  // 16 Sep 2026 — tab 6 (AI footprint) shows no money, so it opens for every role that has Reports.
  const isFootprint = layout === 6;
  if (!canBudget && !isFootprint) {
    const _hiddenByToggle = _budgetPerm && window.__hideMoney;
    return (
      <section className="view-page view-page--scroll reports-view reports-vx reports-layout-1">
        <div className="rvx-loading">{_hiddenByToggle
          ? <>Money figures are hidden. Turn off <strong>Hide&nbsp;all&nbsp;money&nbsp;figures</strong> in Settings to view budget &amp; cost reports.</>
          : <>Budget &amp; cost reports are restricted to roles with the <strong>View&nbsp;Budget</strong> permission.</>}
          <div className="fp-open-row"><button type="button" className="rvx-costs-add" onClick={() => setLayout(6)}>Open the AI footprint</button></div>
        </div>
      </section>
    );
  }

  return (
    <section className={"view-page view-page--scroll reports-view reports-vx reports-layout-" + layout}>
      <div className="rvx-switcher">
        <span className="rvx-switcher-label">Layout</span>
        {REPORT_LAYOUTS.filter(l => canBudget || l.id === 6).map(l => (
          <button key={l.id} type="button" className={"rvx-tab" + (layout === l.id ? " is-active" : "")} onClick={() => setLayout(l.id)}>
            <b>{l.id}</b><span>{l.name}</span>
          </button>
        ))}
      </div>
      {isFootprint
        ? (window.FootprintReport
          ? <ReportsErrorBoundary key="footprint">{React.createElement(window.FootprintReport)}</ReportsErrorBoundary>
          : <div className="rvx-loading">Loading the AI footprint…</div>)
        : <>
          {/* v812 — Costs I've paid: sits ABOVE the layout so it's there whichever of the
              five is chosen, instead of being copy-pasted into all five panels. */}
          <CostEntriesPanel data={data} onChanged={reloadReports}/>
          {!reportData
            ? <div className="rvx-loading">Loading reports…</div>
            : <ReportsErrorBoundary key={layout}><Chosen R={R}/></ReportsErrorBoundary>}
        </>}
    </section>
  );
}

// ─── v812 — COSTS I'VE PAID ──────────────────────────────────────────────────
// Hugo: "I need to be able to add costs. For example I bought more credits on
// Seedance and I need to add that in." Everything else on this page is INFERRED
// (generations × a per-render rate); Kling/Seedance/Freepik have no billing API, so
// a credit top-up was invisible until now. Tagging a row with a model makes it
// REPLACE that model's estimate in API Spend per Model; leaving the model blank
// makes it a standalone line added to the project cost.
const _COST_CATS = [
  { key: "credits",      label: "Credits / top-up" },
  { key: "subscription", label: "Subscription" },
  { key: "software",     label: "Software" },
  { key: "hardware",     label: "Hardware" },
  { key: "service",      label: "Service" },
  { key: "other",        label: "Other" },
];
function CostEntriesPanel({ data, onChanged }) {
  const [open, setOpen] = React.useState(false);
  // v948 — collapsible (Hugo: "i need to be able to collapse those costs i've paid
  // panel"). Remembered per browser so it stays folded across reloads. Collapsed
  // hides the table + form; the header keeps the total so nothing is lost.
  const [collapsed, setCollapsed] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.reports.costsCollapsed") === "1"; } catch (_) { return false; }
  });
  const toggleCollapsed = () => setCollapsed(c => {
    const next = !c;
    try { localStorage.setItem("filmtracker.reports.costsCollapsed", next ? "1" : "0"); } catch (_) {}
    return next;
  });
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [confirmDel, setConfirmDel] = React.useState(null);
  // en-CA is YYYY-MM-DD in LOCAL time — toISOString() would be UTC and date it
  // yesterday all evening (Hugo is AEST+10).
  const today = () => new Date().toLocaleDateString("en-CA");
  const BLANK = { label: "", vendor: "", model: "", category: "credits", amount_usd: "", spent_on: today(), note: "" };
  const [form, setForm] = React.useState(BLANK);
  const rows = (data && data.cost && data.cost.purchases) || [];
  const total = (data && data.cost && data.cost.purchasesTotal) || 0;
  // The model dropdown offers exactly the models the report already knows about, so a
  // linked purchase always lands on a real row instead of creating a near-duplicate.
  const modelOptions = ((data && data.apiByModel) || []).map(m => m.label).filter(Boolean);
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const submit = async () => {
    if (busy) return;
    setErr("");
    if (!String(form.label).trim()) { setErr("Give the cost a name."); return; }
    if (String(form.amount_usd).trim() === "" || !Number.isFinite(Number(form.amount_usd))) { setErr("Enter an amount."); return; }
    setBusy(true);
    try {
      const f = window.authFetch || fetch;
      const r = await f("/api/costs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setErr(j.error || "Couldn't save that."); setBusy(false); return; }
      setForm({ ...BLANK, spent_on: form.spent_on });   // keep the date — receipts arrive in batches
      setOpen(false);
      if (onChanged) await onChanged();
    } catch (_) { setErr("Couldn't save that."); }
    setBusy(false);
  };
  const doDelete = async (row) => {
    setBusy(true);
    try {
      const f = window.authFetch || fetch;
      await f("/api/costs/" + row.id, { method: "DELETE" });
      setConfirmDel(null);
      if (onChanged) await onChanged();
    } catch (_) {}
    setBusy(false);
  };

  // v815b — no `glass` on the panel: that class is for the app's DARK surfaces and was
  // what made this panel fight the light Reports page. (A {/* */} comment cannot live
  // directly inside `return (` — it parses as an object literal, which is what took the
  // whole Reports view down.)
  return (
    <div className="rvx-costs">
      <div className="rvx-costs-head">
        {/* v948 — fold toggle; the title is clickable too. The header (with the
            total) always stays, so collapsing loses nothing. */}
        <button type="button" className="rvx-costs-fold" aria-expanded={!collapsed}
          title={collapsed ? "Show the cost entries" : "Collapse this panel"}
          onClick={toggleCollapsed}>{collapsed ? "▸" : "▾"}</button>
        <span className="rvx-costs-title" role="button" style={{ cursor: "pointer" }}
          onClick={toggleCollapsed}>COSTS I'VE PAID</span>
        <span className="rvx-costs-total">{_rvUsd(total)}</span>
        <button type="button" className="rvx-costs-add"
          onClick={() => { setErr(""); if (collapsed) { toggleCollapsed(); setOpen(true); } else setOpen(o => !o); }}>
          {open && !collapsed ? "Cancel" : "+ Add cost"}
        </button>
      </div>
      {!collapsed && open && (
        <div className="rvx-costs-form">
          <label><span>What</span>
            <input type="text" value={form.label} placeholder="Seedance credits" autoFocus
              onChange={e => set("label", e.target.value)}
              onKeyDown={e => { if (e.key === "Enter") submit(); }}/></label>
          <label><span>Amount (USD)</span>
            <input type="number" min="0" step="0.01" value={form.amount_usd} placeholder="150"
              onChange={e => set("amount_usd", e.target.value)}
              onKeyDown={e => { if (e.key === "Enter") submit(); }}/></label>
          <label><span>Date</span>
            <input type="date" value={form.spent_on} onChange={e => set("spent_on", e.target.value)}/></label>
          <label><span>Kind</span>
            <select value={form.category} onChange={e => set("category", e.target.value)}>
              {_COST_CATS.map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
            </select></label>
          <label><span>Counts as</span>
            <select value={form.model} onChange={e => set("model", e.target.value)}>
              <option value="">Extra cost (adds on top)</option>
              {modelOptions.map(m => <option key={m} value={m}>{m} — replaces its estimate</option>)}
            </select></label>
          <label className="rvx-costs-note"><span>Note (optional)</span>
            <input type="text" value={form.note} placeholder="1000 credits" onChange={e => set("note", e.target.value)}/></label>
          <div className="rvx-costs-actions">
            {err && <span className="rvx-costs-err">{err}</span>}
            <button type="button" className="rvx-costs-save" onClick={submit} disabled={busy}>{busy ? "Saving…" : "Save cost"}</button>
          </div>
        </div>
      )}
      {!collapsed && (rows.length === 0
        ? <div className="rvx-costs-empty">Nothing added yet. Bought credits or paid for a tool? Add it here and it counts in the project cost.</div>
        : (
          <table className="rvx-costs-table">
            <thead><tr><th>Date</th><th>What</th><th>Kind</th><th>Counts as</th><th className="rvx-num">Amount</th><th aria-label="Remove"></th></tr></thead>
            <tbody>
              {rows.map(p => (
                <tr key={p.id}>
                  <td>{p.spent_on || "—"}</td>
                  <td>{p.label}{p.note ? <em className="rvx-costs-sub"> · {p.note}</em> : null}</td>
                  <td>{(_COST_CATS.find(c => c.key === p.category) || {}).label || p.category}</td>
                  <td>{p.model ? p.model : <span className="rvx-costs-sub">extra cost</span>}</td>
                  <td className="rvx-num">{_rvUsd(p.amount_usd)}</td>
                  <td><button type="button" className="rvx-costs-del" title="Remove this cost" onClick={() => setConfirmDel(p)}>×</button></td>
                </tr>
              ))}
            </tbody>
          </table>
        ))}
      {/* Styled confirm, never window.confirm (invariant #22). */}
      {confirmDel && ReactDOM.createPortal((
        <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-6)" }} onClick={() => !busy && setConfirmDel(null)}>
          <div className="confirm-delete-modal glass" onClick={e => e.stopPropagation()}>
            <div className="confirm-delete-eyebrow" style={{ color: "var(--warn-deep)" }}>REMOVE COST</div>
            <div className="confirm-delete-title">Remove <strong>{confirmDel.label}</strong>?</div>
            <div className="confirm-delete-body"><p>{_rvUsd(confirmDel.amount_usd)} will stop counting towards the project cost. You can add it again at any time.</p></div>
            <div className="confirm-delete-actions">
              <button type="button" className="admin-suspend-btn" onClick={() => setConfirmDel(null)} disabled={busy}>Cancel</button>
              <button type="button" className="confirm-delete-btn" onClick={() => doDelete(confirmDel)} disabled={busy}>{busy ? "Removing…" : "Remove"}</button>
            </div>
          </div>
        </div>
      ), document.getElementById("modal-root") || document.body)}
    </div>
  );
}

// v07zz225 — keep one misbehaving layout from white-screening the whole page;
// keyed by layout in ReportsView so switching layouts clears a prior error.
class ReportsErrorBoundary extends React.Component {
  constructor(p) { super(p); this.state = { err: null }; }
  static getDerivedStateFromError(e) { return { err: e }; }
  componentDidCatch(e, info) { try { console.error("[reports-layout] crashed:", e, info); } catch (_) {} }
  render() {
    if (this.state.err) return <div className="rvx-loading">This layout hit an error — pick another layout above. <span style={{ opacity: 0.6 }}>({String((this.state.err && this.state.err.message) || this.state.err).slice(0, 140)})</span></div>;
    return this.props.children;
  }
}

// ============================================================================
// LAYOUT 1 — "Overview" (matches reference screenshot 1). Golden reference: the
// other layouts compose the same kit primitives in different arrangements.
// ============================================================================
function ReportsLayout1({ R }) {
  const { data, usd, usd0, num, nav, Panel, Donut, HBars, Stacked, Line, Trends, Stat, SubsTable, ContribList, Link, I, C } = R;
  const d = data;
  return (
    <div className="rl1">
      {/* top row: time & usage | cost | shots */}
      <div style={{ display: "grid", gridTemplateColumns: "1.55fr 1.05fr 0.85fr", gap: 14, alignItems: "stretch" }}>
        <Panel className="rl1-time" title="Time & Usage">
          <div className="rl1-time-grid">
            <div className="rl1-mini"><div className="rl1-mini-lbl">Hours worked</div><div className="rl1-mini-num">{d.hours.total != null ? d.hours.total.toFixed(1) : "—"}</div><div className={"rl1-mini-sub" + (_rvQuoteHoursLine(d.cost) && _rvQuoteHoursLine(d.cost).over ? " is-over" : "")}
              title={d.cost.overrunLaborCost ? "Worth " + usd0(Math.abs(d.cost.overrunLaborCost)) + " at the contracted rate" : undefined}>
              {(_rvQuoteHoursLine(d.cost) || { text: "Total project hours" }).text}</div></div>
            <div className="rl1-mini"><div className="rl1-mini-lbl">This week</div><div className="rl1-mini-num">{d.hours.week != null ? d.hours.week.toFixed(1) : "—"}</div><div className="rl1-mini-sub">{d.hours.configured ? (d.hours.source === "manual" ? "manual entry" : "via Rize") : ((window.__isDefaultProject && !window.__isDefaultProject()) ? "not tracked" : "connect Rize")}</div></div>
            <div className="rl1-time-chart"><div className="rl1-cap">Hours over time (10 weeks)</div><Line points={d.hours.timeline}/></div>
          </div>
        </Panel>
        <Panel title="Total Project Cost">
          <div className="rl1-cost-big">{usd0(d.cost.projectCost)}</div>
          <div className={"rl1-cost-sub" + (_rvQuoteMoneyLine(d.cost) && _rvQuoteMoneyLine(d.cost).over ? " is-over" : "")}>
            {(_rvQuoteMoneyLine(d.cost) || { text: "API + labour + extras to date" }).text}</div>
          <div className="rl1-cost-split">
            <div className="rl1-cost-card"><span className="rvk-dot" style={{ background: C.api }}/><div><div className="rl1-cc-lbl">API / Generation</div><div className="rl1-cc-num">{usd0(d.cost.api)} <i>{d.cost.apiPct}%</i></div></div></div>
            <div className="rl1-cost-card"><span className="rvk-dot" style={{ background: C.labor }}/><div><div className="rl1-cc-lbl">Labour ({num(Math.round(d.cost.hoursTotal))}h × ${d.cost.laborRate})</div><div className="rl1-cc-num">{usd0(d.cost.labor)} <i>{d.cost.laborPct}%</i></div></div></div>
            {/* v947 — the "extra cost" purchases were always inside the total but had no line */}
            <div className="rl1-cost-card"><span className="rvk-dot" style={{ background: C.xtra }}/><div><div className="rl1-cc-lbl">Extra costs (credits / tools)</div><div className="rl1-cc-num">{usd0(d.cost.xtra)} <i>{d.cost.xtraPct}%</i></div></div></div>
          </div>
        </Panel>
        <Panel className="rl1-shots3">
          <div className="rl1-s3"><span className="rl1-s3-ico">{I.shots}</span><div className="rl1-s3-main"><div className="rl1-s3-lbl">Total shots</div><div className="rl1-s3-num">{num(d.shots.total)}</div></div><Link to="shots">View shots</Link></div>
          <div className="rl1-s3"><span className="rl1-s3-ico">{I.active}</span><div className="rl1-s3-main"><div className="rl1-s3-lbl">Active shots</div><div className="rl1-s3-num">{num(d.shots.active)}</div></div><Link to="shots">View active</Link></div>
          <div className="rl1-s3"><span className="rl1-s3-ico">{I.archive}</span><div className="rl1-s3-main"><div className="rl1-s3-lbl">Archived shots</div><div className="rl1-s3-num">{num(d.shots.archived)}</div></div><Link to="shots">View archived</Link></div>
        </Panel>
      </div>

      {/* gen donut | api bars | subs */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel title="Generations by Model" foot={<Link to="generate">View all models</Link>}>
          <Donut segments={d.genByModel} centerTop="Total generations" centerBig={num(d.genTotal)} legend="right"/>
        </Panel>
        <Panel title="API Spend per Model" foot={<div><div className="rvk-total"><span>Total API Spend</span><b>{usd(d.cost.api)}</b></div>{d.cost.estimateNote ? <div style={{ fontSize: "var(--fs-2xs)", color: "var(--ink-muted)", fontStyle: "italic", marginTop: 5, lineHeight: 1.35 }}>{(d.cost.openaiActual != null ? "✓ " : "ⓘ ") + d.cost.estimateNote}</div> : null}</div>}>
          <HBars rows={d.apiByModel.map(m => ({ label: m.label, value: m.usd, display: usd(m.usd), pct: m.pct, color: m.color }))}/>
        </Panel>
        <Panel title="Tool Subscriptions">
          <SubsTable rows={d.subscriptions.map(s => ({ tool: s.tool, monthly: usd(s.monthly), toDate: usd(s.toDate) }))} total={{ tool: "Total", monthly: usd(d.subMonthlyTotal), toDate: usd(d.subToDateTotal) }}/>
        </Panel>
      </div>

      {/* strip of 7 */}
      <Panel className="rl1-strip">
        <div className="rl1-strip-grid">
          <div className="rl1-cell"><div className="rl1-cell-lbl">Real assets</div><div className="rl1-cell-num">{num(d.assets.total)}</div>
            {d.assets.cats
              ? <div className="rl1-assets">{d.assets.cats.map((c) => <div key={c.id}><span>{c.label}</span><b>{num(c.count)}</b></div>)}</div>
              : <div className="rl1-assets"><div><span>Characters</span><b>{num(d.assets.characters)}</b></div><div><span>Animals</span><b>{num(d.assets.animals)}</b></div><div><span>Locations</span><b>{num(d.assets.locations)}</b></div><div><span>Props</span><b>{num(d.assets.props)}</b></div></div>}
            <Link to="characters">View all assets</Link>
          </div>
          <div className="rl1-cell"><div className="rl1-cell-lbl">Images generated</div><span className="rl1-cell-ico">{I.image}</span><div className="rl1-cell-num">{num(d.generated.images)}</div></div>
          <div className="rl1-cell"><div className="rl1-cell-lbl">Videos generated</div><span className="rl1-cell-ico">{I.video}</span><div className="rl1-cell-num">{num(d.generated.videos)}</div></div>
          <div className="rl1-cell"><div className="rl1-cell-lbl">Voiceover sections</div><span className="rl1-cell-ico">{I.voice}</span><div className="rl1-cell-num">{num(d.generated.voiceovers)}</div></div>
          <div className="rl1-cell"><div className="rl1-cell-lbl">Prompts launched</div><span className="rl1-cell-ico">{I.sparkle}</span><div className="rl1-cell-num">{num(d.prompts.launched)}</div></div>
          <div className="rl1-cell"><div className="rl1-cell-lbl">Prompts copied (video)</div><span className="rl1-cell-ico">{I.copy}</span><div className="rl1-cell-num">{num(d.prompts.copied)}</div></div>
          <div className="rl1-cell rl1-cell--success"><div className="rl1-cell-lbl">Generation success rate</div>
            <Donut segments={[{ label: "Completed", value: d.success.completed, color: C.ok, pct: d.success.pct }, { label: "Failed", value: d.success.failed, color: C.fail, pct: d.success.pct != null ? Math.round((100 - d.success.pct) * 10) / 10 : null }]} centerBig={d.success.pct != null ? d.success.pct + "%" : "—"} size={92} thickness={12} legend="below"/>
          </div>
        </div>
      </Panel>

      {/* row of 4: pipeline | where lives | trends combo | top contributors */}
      <div style={{ display: "grid", gridTemplateColumns: "1.25fr 1fr 1.25fr 1.1fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Pipeline Stage Distribution" foot={<div className="rvk-foot-note">Total Shots {num(d.pipelineTotal)}</div>}>
          <HBars rows={d.pipeline.map(p => ({ label: p.label, value: p.value, display: p.value, pct: p.pct, color: p.color }))}/>
        </Panel>
        <Panel title="Where Every Shot Lives" foot={<div className="rvk-foot-note">Total Shots {num(d.shots.total)}</div>}>
          <Donut segments={d.shotLives} centerTop="Total shots" centerBig={num(d.shots.total)} size={120} legend="below"/>
        </Panel>
        <Panel title="Trends" sub="last 10 weeks">
          <Trends series={[
            { label: "Renders", color: C.render, points: d.rendersPerWeek, area: true },
            { label: "Prompts", color: C.prompt, points: d.promptsPerWeek, area: false },
          ]}/>
        </Panel>
        <Panel title="Top Contributors" sub="by edits" foot={<Link to="crew">View full leaderboard</Link>}>
          <ContribList rows={d.contributors.slice(0, 5)} max={d.contribMax} rank/>
        </Panel>
      </div>

      {/* bottom row: open notes | unresolved review comments */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Open Notes" foot={<Link to="todo">View all notes</Link>}>
          <div className="rl1-bigcount"><span className="rl1-bigcount-ico">{I.note}</span><span className="rl1-bigcount-num">{num(d.notes.open)}</span><span className="rl1-bigcount-sub">unresolved notes across shots &amp; assets</span></div>
        </Panel>
        <Panel title="Unresolved Review Comments" foot={<Link to="todo">View all comments</Link>}>
          <div className="rl1-bigcount"><span className="rl1-bigcount-ico">{I.comment}</span><span className="rl1-bigcount-num">{num(d.reviews.open)}</span><span className="rl1-bigcount-sub">comments awaiting resolution</span></div>
        </Panel>
      </div>

      <div className="rl1-asof">All data is current as of {d.asOf}</div>
    </div>
  );
}

// LAYOUT STUBS (replaced by workflow output) ---------------------------------
function ReportsLayout2({ R }) {
  const { data, usd, usd0, num, Panel, Donut, HBars, Stacked, Line, Ring, Stat, SubsTable, ContribList, Link, I, C } = R;
  const d = data;
  const labelRow = (label, value) => (
    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "7px 0", borderBottom: "1px solid color-mix(in srgb, var(--black) 7%, transparent)" }}>
      <span style={{ fontSize: "var(--fs-body)", opacity: 0.7 }}>{label}</span>
      <b style={{ fontSize: "var(--fs-15)" }}>{value}</b>
    </div>
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div style={{ display: "grid", gridTemplateColumns: "1.6fr 1fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Hours Worked" sub="project">
          <div style={{ display: "grid", gridTemplateColumns: "0.7fr 0.7fr 1.6fr", gap: 14, alignItems: "center" }}>
            <div>
              <div style={{ fontSize: "var(--fs-sm)", opacity: 0.6 }}>Hours worked</div>
              <div style={{ fontSize: "var(--fs-30)", fontWeight: "var(--fw-bold)", lineHeight: 1.1 }}>{d.hours.total != null ? d.hours.total.toFixed(1) : "—"}</div>
              <div style={{ fontSize: "var(--fs-sm)", opacity: 0.55, color: (_rvQuoteHoursLine(d.cost) || {}).over ? RV_C.fail : undefined }}>
                {(_rvQuoteHoursLine(d.cost) || { text: "Total hours" }).text}</div>
            </div>
            <div>
              <div style={{ fontSize: "var(--fs-sm)", opacity: 0.6 }}>This week</div>
              <div style={{ fontSize: "var(--fs-30)", fontWeight: "var(--fw-bold)", lineHeight: 1.1 }}>{d.hours.week != null ? d.hours.week.toFixed(1) : "—"}</div>
              <div style={{ fontSize: "var(--fs-sm)", opacity: 0.55 }}>Hours</div>
            </div>
            <div>
              <div style={{ fontSize: "var(--fs-xs)", opacity: 0.55, marginBottom: 4 }}>Hours over time (last 10 weeks)</div>
              <Line points={d.hours.timeline} />
            </div>
          </div>
        </Panel>
        <Panel title="Total Project Cost">
          <div style={{ fontSize: "var(--fs-30)", fontWeight: "var(--fw-bold)", lineHeight: 1.1 }}>{usd0(d.cost.projectCost)}</div>
          <div style={{ fontSize: "var(--fs-sm)", opacity: 0.55, marginBottom: 2 }}>To date</div>
          <div style={{ fontSize: "var(--fs-sm)", opacity: 0.7, marginBottom: 12, color: (_rvQuoteMoneyLine(d.cost) || {}).over ? RV_C.fail : undefined }}>
            {(_rvQuoteMoneyLine(d.cost) || { text: "API + labour + extras to date" }).text}</div>
          <div style={{ display: "flex", alignItems: "center", gap: 18 }}>
            <Ring pct={d.cost.apiPct} size={58} color={C.api} />
            <Ring pct={d.cost.laborPct} size={58} color={C.labor} />
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ width: 10, height: 10, borderRadius: 3, background: C.api, display: "inline-block" }} />
                <div>
                  <div style={{ fontSize: "var(--fs-sm)", opacity: 0.7 }}>API / Generation Spend</div>
                  <b style={{ fontSize: "var(--fs-14)" }}>{usd0(d.cost.api)}</b>
                </div>
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ width: 10, height: 10, borderRadius: 3, background: C.labor, display: "inline-block" }} />
                <div>
                  <div style={{ fontSize: "var(--fs-sm)", opacity: 0.7 }}>Labour</div>
                  <b style={{ fontSize: "var(--fs-14)" }}>{usd0(d.cost.labor)}</b>
                </div>
              </div>
              {/* v947 — the standalone "extra cost" purchases slice */}
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ width: 10, height: 10, borderRadius: 3, background: C.xtra, display: "inline-block" }} />
                <div>
                  <div style={{ fontSize: "var(--fs-sm)", opacity: 0.7 }}>Extra costs</div>
                  <b style={{ fontSize: "var(--fs-14)" }}>{usd0(d.cost.xtra)}</b>
                </div>
              </div>
            </div>
          </div>
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel title="Shots">
          {labelRow("Total", num(d.shots.total) + " (100%)")}
          {labelRow("Active", num(d.shots.active) + " (" + d.shots.activePct + "%)")}
          {labelRow("Archived", num(d.shots.archived) + " (" + d.shots.archivedPct + "%)")}
        </Panel>
        <Panel title="Real Assets">
          {d.assets.cats ? (
          <div style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 10 }}>
            {d.assets.cats.map((c) => <Stat key={c.id} icon={I[_REPORT_CAT_ICON_KEY[c.id]] || I.assets} label={c.label} value={num(c.count)} />)}
          </div>
          ) : (
          <div style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 10 }}>
            <Stat icon={I.chars} label="Characters" value={num(d.assets.characters)} />
            <Stat icon={I.animal} label="Animals" value={num(d.assets.animals)} />
            <Stat icon={I.location} label="Locations" value={num(d.assets.locations)} />
            <Stat icon={I.prop} label="Props" value={num(d.assets.props)} />
          </div>
          )}
        </Panel>
        <Panel title="Generated Content">
          {labelRow("Images Generated", num(d.generated.images))}
          {labelRow("Videos Generated", num(d.generated.videos))}
          {labelRow("Voiceover Sections", num(d.generated.voiceovers))}
        </Panel>
        <Panel title="Prompts">
          {labelRow("Launched", num(d.prompts.launched))}
          {labelRow("Copied (Video)", num(d.prompts.copied))}
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel title="Generations by Model" foot={<Link to="generate">View detailed report</Link>}>
          <Donut segments={d.genByModel} centerTop="Total Generations" centerBig={num(d.genTotal)} legend="right" />
        </Panel>
        <Panel title="API Spend per Model" foot={<div><div className="rvk-total"><span>Total API Spend</span><b>{usd(d.cost.api)}</b></div>{d.cost.estimateNote ? <div style={{ fontSize: "var(--fs-2xs)", color: "var(--ink-muted)", fontStyle: "italic", marginTop: 5, lineHeight: 1.35 }}>{(d.cost.openaiActual != null ? "✓ " : "ⓘ ") + d.cost.estimateNote}</div> : null}</div>}>
          <HBars rows={d.apiByModel.map(m => ({ label: m.label, value: m.usd, display: usd(m.usd), pct: m.pct, color: m.color }))} axis={["$0", "$6K", "$12K", "$18K", "$24K"]} />
        </Panel>
        <Panel title="Tool Subscriptions">
          <SubsTable rows={d.subscriptions.map(s => ({ tool: s.tool, monthly: usd(s.monthly), toDate: usd(s.toDate) }))} total={{ tool: "Total", monthly: usd(d.subMonthlyTotal), toDate: usd(d.subToDateTotal) }} />
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 0.9fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Generation Success Rate" foot={<Link to="generate">View failure analysis</Link>}>
          <Donut
            segments={[
              { label: "Completed", value: d.success.completed, pct: d.success.pct, color: C.ok },
              { label: "Failed", value: d.success.failed, pct: d.success.pct != null ? Math.round((100 - d.success.pct) * 10) / 10 : null, color: C.fail }
            ]}
            centerTop="Success"
            centerBig={(d.success.pct != null ? d.success.pct : "—") + "%"}
            size={120}
            legend="below"
          />
        </Panel>
        <Panel title="Pipeline Stage Distribution" foot={<Link to="shots">View pipeline</Link>}>
          <HBars rows={d.pipeline.map(p => ({ label: p.label, value: p.value, display: p.value, pct: p.pct, color: p.color }))} />
        </Panel>
        <Panel title="Where Every Shot Lives" foot={<Link to="shots">View shots</Link>}>
          <Donut segments={d.shotLives} centerTop="Total shots" centerBig={num(d.shots.total)} size={120} legend="below" />
        </Panel>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Panel><Stat label="Open Notes" value={num(d.notes.open)} sub="Requires attention" /></Panel>
          <Panel><Stat label="Unresolved Comments" value={num(d.reviews.open)} sub="Across reviews" /></Panel>
        </div>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel title="Renders per Week" sub="last 10 weeks">
          <Stacked groups={d.rendersPerWeek.map(w => ({ label: w.label, parts: [{ value: w.value, color: C.render }] }))} valueLabels />
        </Panel>
        <Panel title="Prompts per Week" sub="last 10 weeks">
          <Stacked groups={d.promptsPerWeek.map(w => ({ label: w.label, parts: [{ value: w.value, color: C.prompt }] }))} valueLabels />
        </Panel>
        <Panel title="Top Contributors" sub="by edits" foot={<Link to="crew">View all contributors</Link>}>
          <ContribList rows={d.contributors.slice(0, 5).map(c => ({ name: c.name, avatar: c.avatar, value: c.value, unit: "edits" }))} max={d.contribMax} showBar />
        </Panel>
        <Panel title="Quick Snapshot">
          {labelRow("Total Shots", num(d.shots.total))}
          {labelRow("Active Shots", num(d.shots.active) + " (" + d.shots.activePct + "%)")}
          {labelRow("Total Generations", num(d.genTotal))}
          {labelRow("Success Rate", (d.success.pct != null ? d.success.pct : "—") + "%")}
          {labelRow("Total Spend", usd0(d.cost.projectCost))}
          {labelRow("Hours Worked", (d.hours.total != null ? d.hours.total.toFixed(1) : "\u2014")
            + (_rvQuoteHoursLine(d.cost, true) ? "  \u00b7  " + _rvQuoteHoursLine(d.cost, true).text : ""))}
        </Panel>
      </div>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", paddingTop: 4 }}>
        <span style={{ fontSize: "var(--fs-sm)", opacity: 0.55 }}>Reports generated {d.asOf}</span>
        <button className="rv-export-btn" onClick={() => window.print()} style={{ background: C.api, border: "none", borderRadius: "var(--r-sm)", padding: "9px 18px", fontSize: "var(--fs-body)", fontWeight: "var(--fw-semi)", cursor: "pointer" }}>Export Report</button>
      </div>
    </div>
  );
}
function ReportsLayout3({ R }) {
  const { data, usd, usd0, num, Panel, Donut, HBars, Stacked, Line, Stat, SubsTable, ContribList, Link, I, C } = R;
  const d = data;
  const tile = { display: "flex", flexDirection: "column", gap: 4 };
  const tileHead = { display: "flex", alignItems: "center", gap: 6, fontSize: "var(--fs-xs)", fontWeight: "var(--fw-bold)", letterSpacing: "var(--track-06)", textTransform: "uppercase", opacity: 0.72 };
  const tileBig = { fontSize: "var(--fs-26)", fontWeight: "var(--fw-heavy)", lineHeight: 1.1 };
  const tileSub = { fontSize: "var(--fs-11-5)", opacity: 0.65 };
  const tinyLine = { fontSize: "var(--fs-xs)", opacity: 0.7, display: "flex", justifyContent: "space-between", gap: 8 };
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(6,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel>
          <div style={tile}>
            <div style={tileHead}>{I.clock} Hours Worked</div>
            <div style={tileBig}>{d.hours.total != null ? d.hours.total.toFixed(1) + "h" : "—"}</div>
            <div style={tileSub}>This week {d.hours.week != null ? d.hours.week.toFixed(1) + "h" : "\u2014"}
              {_rvQuoteHoursLine(d.cost, true) ? "  \u00b7  " + _rvQuoteHoursLine(d.cost, true).text : ""}</div>
            <div style={{ marginTop: 4 }}><Line points={d.hours.timeline} height={34} area /></div>
          </div>
        </Panel>
        <Panel>
          <div style={tile}>
            <div style={tileHead}>{I.cost} Total Project Cost</div>
            <div style={tileBig}>{usd0(d.cost.projectCost)}</div>
            <div style={{ display: "flex", height: 6, borderRadius: 4, overflow: "hidden", margin: "4px 0" }}>
              <div style={{ flex: d.cost.apiPct, background: C.api }} />
              <div style={{ flex: d.cost.laborPct, background: C.labor }} />
            </div>
            <div style={tinyLine}><span>API / Generation</span><b>{usd0(d.cost.api)} ({d.cost.apiPct}%)</b></div>
            <div style={tinyLine}><span>Labour</span><b>{usd0(d.cost.labor)} ({d.cost.laborPct}%)</b></div>
            <div style={tinyLine}><span>Extra costs</span><b>{usd0(d.cost.xtra)} ({d.cost.xtraPct}%)</b></div>
            <div style={{ ...tileSub, color: (_rvQuoteMoneyLine(d.cost) || {}).over ? RV_C.fail : undefined }}>
              {(_rvQuoteMoneyLine(d.cost) || { text: "API + labour + extras to date" }).text}</div>
          </div>
        </Panel>
        <Panel foot={<Link to="shots">View shots</Link>}>
          <div style={tile}>
            <div style={tileHead}>{I.shots} Shots</div>
            <div style={tileBig}>{num(d.shots.total)}</div>
            <div style={tileSub}>Active {num(d.shots.active)}, Archived {num(d.shots.archived)}</div>
          </div>
        </Panel>
        <Panel foot={<Link to="characters">View assets</Link>}>
          <div style={tile}>
            <div style={tileHead}>{I.assets} Real Assets</div>
            <div style={tileBig}>{num(d.assets.total)}</div>
            {d.assets.cats
              ? <div style={tileSub}>{d.assets.cats.map((c) => num(c.count) + " " + String(c.label).toLowerCase()).join(", ")}</div>
              : <div style={tileSub}>{num(d.assets.characters)} characters, {num(d.assets.animals)} animals, {num(d.assets.locations)} locations, {num(d.assets.props)} props</div>}
          </div>
        </Panel>
        <Panel>
          <div style={tile}>
            <div style={tileHead}>{I.image} Generated Content</div>
            <div style={tileBig}>{num(d.generated.images + d.generated.videos + d.generated.voiceovers)}</div>
            <div style={tileSub}>{num(d.generated.images)} Images, {num(d.generated.videos)} Videos, {num(d.generated.voiceovers)} Voiceover</div>
          </div>
        </Panel>
        <Panel>
          <div style={tile}>
            <div style={tileHead}>{I.sparkle} Video Prompts</div>
            <div style={tileBig}>{num(d.prompts.launched)}</div>
            <div style={tileSub}>Launched {num(d.prompts.launched)}, Copied {num(d.prompts.copied)}</div>
          </div>
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel title="Generations by Model" foot={<Link to="generate">View all models</Link>}>
          <Donut segments={d.genByModel} centerTop="Total Generations" centerBig={num(d.genTotal)} legend="right" />
        </Panel>
        <Panel title="API Spend per Model" foot={<Link to="generate">View spend details</Link>}>
          <Donut segments={d.apiByModel.map(m => ({ label: m.label, value: m.usd, pct: m.pct, color: m.color, valDisplay: usd(m.usd) }))} centerTop="Total API Spend" centerBig={usd0(d.cost.api)} legend="right" />
        </Panel>
        <Panel title="Tool Subscriptions">
          <SubsTable rows={d.subscriptions.map(s => ({ tool: s.tool, monthly: usd(s.monthly), toDate: usd(s.toDate), color: s.color }))} total={{ tool: "Total", monthly: usd(d.subMonthlyTotal), toDate: usd(d.subToDateTotal) }} />
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "1.1fr 1fr 0.9fr", gap: 14, alignItems: "start" }}>
        <Panel title="Pipeline Stage Distribution" foot={<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><Link to="shots">View pipeline</Link><span style={{ fontSize: "var(--fs-sm)", opacity: 0.65 }}>Total Shots {num(d.pipelineTotal)}</span></div>}>
          <HBars rows={d.pipeline.map(p => ({ label: p.label, value: p.value, display: p.value, pct: p.pct, color: p.color }))} />
        </Panel>
        <Panel title="Generation Success Rate" foot={<Link to="generate">View details</Link>}>
          <div style={{ display: "flex", alignItems: "center", gap: 18 }}>
            <Donut segments={d.success.total ? [{ label: "Completed", value: d.success.completed, color: C.ok }, { label: "Failed", value: d.success.failed, color: C.fail }] : []} centerBig={d.success.pct != null ? d.success.pct + "%" : "—"} size={150} legend="none" />
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--fs-body)" }}><span style={{ color: C.ok, fontWeight: "var(--fw-heavy)" }}>✓</span><b>{num(d.success.completed)}</b> Completed</div>
              <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--fs-body)" }}><span style={{ color: C.fail, fontWeight: "var(--fw-heavy)" }}>✕</span><b>{num(d.success.failed)}</b> Failed</div>
            </div>
          </div>
        </Panel>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Panel title="Shot Snapshot" foot={<Link to="shots">View all shots</Link>}>
            <Donut segments={d.shotLives} centerTop="Total shots" centerBig={num(d.shots.total)} size={110} legend="below" />
          </Panel>
          <Panel title="Open Notes" foot={<Link to="todo">View all notes</Link>}>
            <Stat icon={I.note} label="Open Notes" value={num(d.notes.open)} sub="need attention" />
          </Panel>
          <Panel title="Unresolved Review Comments" foot={<Link to="review">View all comments</Link>}>
            <Stat icon={I.comment} label="Unresolved Review Comments" value={num(d.reviews.open)} sub="Across reviews" />
          </Panel>
          <Panel title="Top Contributors" foot={<Link to="crew">View all contributors</Link>}>
            <ContribList rows={d.contributors.slice(0, 5)} max={d.contribMax} showBar />
          </Panel>
        </div>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Renders per Week" sub="last 10 weeks">
          <Stacked groups={d.rendersPerWeek.map(w => ({ label: w.label, parts: [{ value: w.value, color: C.render }] }))} legend={[{ label: "Renders", color: C.render }]} valueLabels />
        </Panel>
        <Panel title="Prompts per Week" sub="last 10 weeks">
          <Stacked groups={d.promptsPerWeek.map(w => ({ label: w.label, parts: [{ value: w.value, color: C.prompt }] }))} legend={[{ label: "Prompts", color: C.prompt }]} valueLabels />
        </Panel>
      </div>
    </div>
  );
}
function ReportsLayout4({ R }) {
  const { data, usd, usd0, num, Panel, Donut, HBars, Stacked, Line, ContribList, SubsTable, NoteList, CommentList, Link, I, C } = R;
  const d = data;
  const cellLbl = { fontSize: "var(--fs-xs)", textTransform: "uppercase", letterSpacing: "var(--track-06)", color: "var(--ink-muted)", fontWeight: "var(--fw-bold)", marginBottom: 6 };
  const bigNum = { fontFamily: "'Bebas Neue', sans-serif", fontSize: "var(--fs-34)", lineHeight: 1, color: "var(--ink)" };
  const subTxt = { fontSize: "var(--fs-sm)", color: "var(--ink-muted)", marginTop: 6 };
  const lineRow = { fontSize: "var(--fs-12-5)", color: "var(--ink-muted)", display: "flex", justifyContent: "space-between", marginTop: 4 };
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <Panel tone="light">
        <div style={{ display: "grid", gridTemplateColumns: "1.3fr 1.1fr repeat(4, 0.8fr)", gap: 18, alignItems: "stretch" }}>
          <div>
            <div style={cellLbl}>Hours Worked</div>
            <div style={bigNum}>{d.hours.total != null ? d.hours.total.toFixed(1) : "—"} hrs</div>
            <div style={subTxt}>This week {d.hours.week != null ? d.hours.week.toFixed(1) : "\u2014"} hrs
              {_rvQuoteHoursLine(d.cost, true) ? "  \u00b7  " + _rvQuoteHoursLine(d.cost, true).text : ""}</div>
            <div style={{ marginTop: 8 }}><Line points={d.hours.timeline} height={48} /></div>
          </div>
          <div>
            <div style={cellLbl}>Total Project Cost</div>
            <div style={bigNum}>{usd0(d.cost.projectCost)}</div>
            <div style={{ ...subTxt, color: (_rvQuoteMoneyLine(d.cost) || {}).over ? RV_C.fail : undefined }}>
              {(_rvQuoteMoneyLine(d.cost) || { text: "API + labour + extras to date" }).text}</div>
            <div style={lineRow}><span>API / Generation</span><b style={{ color: "var(--ink)" }}>{usd0(d.cost.api)} <i style={{ color: "var(--ink-muted)", fontStyle: "normal" }}>{d.cost.apiPct}%</i></b></div>
            <div style={lineRow}><span>Labour ({num(Math.round(d.cost.hoursTotal))}h × ${d.cost.laborRate})</span><b style={{ color: "var(--ink)" }}>{usd0(d.cost.labor)} <i style={{ color: "var(--ink-muted)", fontStyle: "normal" }}>{d.cost.laborPct}%</i></b></div>
            <div style={lineRow}><span>Extra costs</span><b style={{ color: "var(--ink)" }}>{usd0(d.cost.xtra)} <i style={{ color: "var(--ink-muted)", fontStyle: "normal" }}>{d.cost.xtraPct}%</i></b></div>
          </div>
          <div>
            <div style={cellLbl}>Shots</div>
            <div style={bigNum}>{num(d.shots.total)}</div>
            <div style={subTxt}>Total</div>
            <div style={lineRow}><span>Active</span><b style={{ color: "var(--ink)" }}>{num(d.shots.active)}</b></div>
            <div style={lineRow}><span>Archived</span><b style={{ color: "var(--ink)" }}>{num(d.shots.archived)}</b></div>
          </div>
          <div>
            <div style={cellLbl}>Assets</div>
            <div style={bigNum}>{num(d.assets.total)}</div>
            {d.assets.cats ? d.assets.cats.map((c) => <div key={c.id} style={lineRow}><span>{c.label}</span><b style={{ color: "var(--ink)" }}>{num(c.count)}</b></div>) : (<>
            <div style={lineRow}><span>Characters</span><b style={{ color: "var(--ink)" }}>{num(d.assets.characters)}</b></div>
            <div style={lineRow}><span>Locations</span><b style={{ color: "var(--ink)" }}>{num(d.assets.locations)}</b></div>
            <div style={lineRow}><span>Animals</span><b style={{ color: "var(--ink)" }}>{num(d.assets.animals)}</b></div>
            <div style={lineRow}><span>Props</span><b style={{ color: "var(--ink)" }}>{num(d.assets.props)}</b></div>
            </>)}
          </div>
          <div>
            <div style={cellLbl}>Generated Content</div>
            <div style={bigNum}>{num(d.generated.images)}</div>
            <div style={subTxt}>Images</div>
            <div style={lineRow}><span>Videos</span><b style={{ color: "var(--ink)" }}>{num(d.generated.videos)}</b></div>
            <div style={lineRow}><span>Voiceover</span><b style={{ color: "var(--ink)" }}>{num(d.generated.voiceovers)}</b></div>
          </div>
          <div>
            <div style={cellLbl}>Prompts</div>
            <div style={bigNum}>{num(d.prompts.launched)}</div>
            <div style={subTxt}>Launched</div>
            <div style={lineRow}><span>Copied</span><b style={{ color: "var(--ink)" }}>{num(d.prompts.copied)}</b></div>
          </div>
        </div>
      </Panel>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel tone="light" title="Generations by Model" foot={<Link to="generate">View all models</Link>}>
          <Donut segments={d.genByModel} centerTop="Generations" centerBig={num(d.genTotal)} legend="right" />
        </Panel>
        <Panel tone="light" title="API Spend per Model" foot={<div><div className="rvk-total"><span>Total API Spend</span><b>{usd(d.cost.api)}</b></div>{d.cost.estimateNote ? <div style={{ fontSize: "var(--fs-2xs)", color: "var(--ink-muted)", fontStyle: "italic", marginTop: 5, lineHeight: 1.35 }}>{(d.cost.openaiActual != null ? "✓ " : "ⓘ ") + d.cost.estimateNote}</div> : null}</div>}>
          <HBars rows={d.apiByModel.map(m => ({ label: m.label, value: m.usd, display: usd(m.usd), pct: m.pct, color: m.color }))} />
        </Panel>
        <Panel tone="light" title="Pipeline & Success">
          <div>
            <div style={{ fontSize: "var(--fs-xs)", textTransform: "uppercase", letterSpacing: "var(--track-06)", color: "var(--ink-muted)", fontWeight: "var(--fw-bold)", marginBottom: 8 }}>Pipeline Stage Distribution</div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-10-5)", color: "var(--ink-muted)", marginBottom: 4 }}>
              {d.pipeline.map((p, i) => <span key={i} style={{ flex: p.value, textAlign: "center", minWidth: 0, overflow: "hidden" }}>{p.pct}%</span>)}
            </div>
            <div style={{ display: "flex", height: 16, borderRadius: "var(--r-xs)", overflow: "hidden" }}>
              {d.pipeline.map((p, i) => <div key={i} style={{ flex: p.value, background: p.color }} />)}
            </div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", marginTop: 8 }}>
              {d.pipeline.map((p, i) => (
                <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: "var(--fs-11-5)", color: "var(--ink-muted)" }}>
                  <span style={{ width: 9, height: 9, borderRadius: "50%", background: p.color, display: "inline-block" }} />{p.label}
                </span>
              ))}
            </div>
          </div>
          <div style={{ height: 1, background: "color-mix(in srgb, var(--taupe-6) 18%, transparent)", margin: "14px 0" }} />
          <div>
            <div style={{ fontSize: "var(--fs-xs)", textTransform: "uppercase", letterSpacing: "var(--track-06)", color: "var(--ink-muted)", fontWeight: "var(--fw-bold)", marginBottom: 8 }}>Generation Success Rate</div>
            <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
              <Donut segments={[{ label: "Completed", value: d.success.completed, color: C.ok }, { label: "Failed", value: d.success.failed, color: C.fail }]} centerBig={(d.success.pct != null ? d.success.pct : 0) + "%"} legend="none" size={90} />
              <div style={{ flex: 1 }}>
                <div style={lineRow}><span>Completed</span><b style={{ color: "var(--ink)" }}>{num(d.success.completed)}</b></div>
                <div style={lineRow}><span>Failed</span><b style={{ color: "var(--ink)" }}>{num(d.success.failed)}</b></div>
                <div style={lineRow}><span>Total</span><b style={{ color: "var(--ink)" }}>{num(d.success.total)}</b></div>
              </div>
            </div>
          </div>
        </Panel>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 14, alignItems: "stretch" }}>
        <Panel tone="light" title="Tool Subscriptions">
          <SubsTable rows={d.subscriptions.map(s => ({ tool: s.tool, monthly: usd(s.monthly), toDate: usd(s.toDate) }))} total={{ tool: "Total", monthly: usd(d.subMonthlyTotal), toDate: usd(d.subToDateTotal) }} />
        </Panel>
        <Panel tone="light" title="Shot Snapshot">
          <Donut segments={d.shotLives} centerTop="Total shots" centerBig={num(d.shots.total)} size={110} legend="below" />
        </Panel>
        <Panel tone="light" title="Renders per Week" sub="last 10 weeks">
          <Stacked groups={d.rendersPerWeek.map(w => ({ label: w.label, parts: [{ value: w.value, color: C.render }] }))} valueLabels />
        </Panel>
        <Panel tone="light" title="Prompts per Week" sub="last 10 weeks">
          <Line points={d.promptsPerWeek} color={C.prompt} area markLast />
        </Panel>
        <Panel tone="light" title="Top Contributors" sub="by edits" foot={<div className="rvk-foot-note">Total Edits {num(d.contributors.reduce((a, c) => a + c.value, 0))}</div>}>
          <ContribList rows={d.contributors.slice(0, 5)} max={d.contribMax} unit="edits" rank />
        </Panel>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1.4fr", gap: 14, alignItems: "stretch" }}>
        <Panel tone="light" title="Notes" actions={<Link to="todo">New Note</Link>}>
          <NoteList rows={d.recentNotes} limit={3} />
        </Panel>
        <Panel tone="light" title="Unresolved Review Comments">
          <div style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 18, alignItems: "start" }}>
            <CommentList rows={d.recentComments} limit={3} thumbs />
            <div style={{ minWidth: 130 }}>
              <div style={lineRow}><span>Total comments</span><b style={{ color: "var(--ink)" }}>{num(d.reviews.total)}</b></div>
              <div style={lineRow}><span>Open</span><b style={{ color: "var(--ink)" }}>{num(d.reviews.open)}</b></div>
              <div style={lineRow}><span>Resolved</span><b style={{ color: "var(--ink)" }}>{num(Math.max(0, d.reviews.total - d.reviews.open))}</b></div>
            </div>
          </div>
        </Panel>
      </div>
    </div>
  );
}
function ReportsLayout5({ R }) {
  const { data, usd, usd0, num, Panel, Donut, HBars, Line, Stat, SubsTable, ContribList, NoteList, CommentList, Link, I, C } = R;
  const d = data;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr 1.1fr", gap: 14, alignItems: "stretch" }}>
        <Panel title="Shots">
          <Stat icon={I.shots} label="Total shots" value={num(d.shots.total)} />
          <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Active</span><b>{num(d.shots.active)}</b></div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Archived</span><b>{num(d.shots.archived)}</b></div>
          </div>
        </Panel>
        <Panel title="Assets">
          <Stat icon={I.assets} label="Total assets" value={num(d.assets.total)} />
          {d.assets.cats ? (
          <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
            {d.assets.cats.map((c) => <div key={c.id} style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>{c.label}</span><b>{num(c.count)}</b></div>)}
          </div>
          ) : (
          <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Characters</span><b>{num(d.assets.characters)}</b></div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Animals</span><b>{num(d.assets.animals)}</b></div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Locations</span><b>{num(d.assets.locations)}</b></div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Props</span><b>{num(d.assets.props)}</b></div>
          </div>
          )}
        </Panel>
        <Panel title="Generated Content">
          <Stat icon={I.image} label="Images" value={num(d.generated.images) + " Images"} />
          <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Videos</span><b>{num(d.generated.videos)}</b></div>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Voiceover</span><b>{num(d.generated.voiceovers)}</b></div>
          </div>
        </Panel>
        <Panel title="Prompts">
          <Stat icon={I.sparkle} label="Launched" value={num(d.prompts.launched) + " Launched"} />
          <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--fs-body)" }}><span style={{ opacity: 0.7 }}>Copied</span><b>{num(d.prompts.copied)}</b></div>
          </div>
        </Panel>
        <Panel title="Open Notes" sub={num(d.notes.open) + " open"} foot={<Link to="todo">View all notes</Link>}>
          <NoteList rows={d.recentNotes} limit={3} />
        </Panel>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1.5fr 0.95fr", gap: 14, alignItems: "start" }}>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Panel title="Project Finances">
            <div style={{ fontSize: "var(--fs-34)", fontWeight: "var(--fw-bold)", letterSpacing: "-0.5px" }}>{usd0(d.cost.projectCost)}</div>
            <div style={{ fontSize: "var(--fs-body)", opacity: 0.7, marginTop: 2 }}>Total Project Cost (to date)</div>
            <div style={{ fontSize: "var(--fs-sm)", opacity: 0.6, marginTop: 4, color: (_rvQuoteMoneyLine(d.cost) || {}).over ? RV_C.fail : undefined }}>
              {(_rvQuoteMoneyLine(d.cost) || { text: "API + labour + extras to date" }).text}</div>
            <div style={{ display: "flex", height: 10, borderRadius: "var(--r-xs)", overflow: "hidden", marginTop: 14, background: "color-mix(in srgb, var(--black) 6%, transparent)" }}>
              <div style={{ flex: d.cost.apiPct, background: C.api }} />
              <div style={{ flex: d.cost.laborPct, background: C.labor }} />
            </div>
            <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 14 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ width: 9, height: 9, borderRadius: "50%", background: C.api, flex: "0 0 auto" }} />
                <span style={{ fontSize: "var(--fs-body)", opacity: 0.75, flex: 1 }}>API / Generation</span>
                <b style={{ fontSize: "var(--fs-body)" }}>{usd(d.cost.api)} <i style={{ fontWeight: 400, opacity: 0.6, fontStyle: "normal" }}>({d.cost.apiPct}%)</i></b>
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ width: 9, height: 9, borderRadius: "50%", background: C.labor, flex: "0 0 auto" }} />
                <span style={{ fontSize: "var(--fs-body)", opacity: 0.75, flex: 1 }}>Labour</span>
                <b style={{ fontSize: "var(--fs-body)" }}>{usd(d.cost.labor)} <i style={{ fontWeight: 400, opacity: 0.6, fontStyle: "normal" }}>({d.cost.laborPct}%)</i></b>
              </div>
            </div>
          </Panel>
          <Panel title="Cost Breakdown">
            <Donut segments={[{ label: "API / Generation", value: d.cost.api, color: C.api, pct: d.cost.apiPct, valDisplay: usd(d.cost.api) }, { label: "Labour", value: d.cost.labor, color: C.labor, pct: d.cost.laborPct, valDisplay: usd(d.cost.labor) }]} centerTop="Total" centerBig={usd0(d.cost.projectCost)} legend="below" />
          </Panel>
          <Panel title="Tool Subscriptions">
            <SubsTable columns={[{ key: "tool", label: "Tool", align: "left" }, { key: "monthly", label: "Monthly cost", align: "right" }, { key: "toDate", label: "Cost to date", align: "right" }, { key: "status", label: "Status", align: "right" }]} rows={d.subscriptions.map(s => ({ tool: s.tool, monthly: usd(s.monthly), toDate: usd(s.toDate), status: "Active", color: s.color }))} total={{ tool: "Total", monthly: usd(d.subMonthlyTotal), toDate: usd(d.subToDateTotal), status: "" }} />
          </Panel>
          <Panel title="API Spend per Model" foot={<div><div className="rvk-total"><span>Total API Spend</span><b>{usd(d.cost.api)}</b></div>{d.cost.estimateNote ? <div style={{ fontSize: "var(--fs-2xs)", color: "var(--ink-muted)", fontStyle: "italic", marginTop: 5, lineHeight: 1.35 }}>{(d.cost.openaiActual != null ? "✓ " : "ⓘ ") + d.cost.estimateNote}</div> : null}</div>}>
            <HBars rows={d.apiByModel.map(m => ({ label: m.label, value: m.usd, display: usd(m.usd), pct: m.pct, color: m.color }))} axis={["$0", "$5K", "$10K", "$15K", "$20K"]} />
          </Panel>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Panel title="Generations by Model" sub="all time" foot={<Link to="generate">View model usage details</Link>}>
            <Donut segments={d.genByModel} centerTop="Total Generations" centerBig={num(d.genTotal)} legend="right" />
          </Panel>
          <Panel title="Pipeline Stage Distribution" foot={<div className="rvk-foot-note">Total Shots {num(d.pipelineTotal)}</div>}>
            <HBars rows={d.pipeline.map(p => ({ label: p.label, value: p.value, display: p.value, pct: p.pct, color: p.color }))} />
          </Panel>
          <Panel title="Shot Snapshot" sub="where every shot lives" foot={<Link to="shots">View shot list</Link>}>
            <Donut segments={d.shotLives} centerTop="Total shots" centerBig={num(d.shots.total)} legend="below" />
          </Panel>
          <Panel title="Renders per Week" sub="last 10 weeks">
            <Line points={d.rendersPerWeek} color={C.render} area markLast />
          </Panel>
          <Panel title="Prompts per Week" sub="last 10 weeks">
            <Line points={d.promptsPerWeek} color={C.prompt} area markLast />
          </Panel>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Panel title="Unresolved Review Comments" sub={num(d.reviews.open) + " unresolved"} foot={<Link to="review">View all comments</Link>}>
            <CommentList rows={d.recentComments} limit={4} thumbs />
          </Panel>
          <Panel title="Top Contributors" sub="by edits" foot={<Link to="crew">View all contributors</Link>}>
            <ContribList rows={d.contributors.slice(0, 5)} max={d.contribMax} showBar />
          </Panel>
        </div>
      </div>
    </div>
  );
}

function ReportsSortableGrid({ items = [] }) {
  // v07zz210 — items declare a `span` (3/4/6/12 of a 12-col grid) instead of the
  // old wide boolean, and `stat: true` for compact stat tiles. The default order
  // re-derives from the items so adding/removing panels updates the persisted set.
  const defaults = items.map(it => it.id);
  const itemMap = Object.fromEntries(items.map(it => [it.id, it]));
  const { order, getProps } = window.useSortableZone("reports-v2", defaults, { axis: "xy" });
  return (
    <div className="report-charts-grid sortable-zone-reports">
      {order.map(id => {
        const item = itemMap[id];
        if (!item) return null;
        const span = item.span || 6;
        return (
          <window.SortablePanel key={id} {...getProps(id)} className={"report-chart-card-wrap rcc-span-" + span}>
            <article className={"report-chart-card glass" + (item.stat ? " report-chart-card--stat" : "")}>
              {item.body}
            </article>
          </window.SortablePanel>
        );
      })}
    </div>
  );
}

function ReportDonut({ data = [] }) {
  const total = data.reduce((a, d) => a + d.count, 0) || 1;
  let acc = 0;
  const r = 38, c = 2 * Math.PI * r;
  return (
    <div className="rcc-donut-wrap">
      <svg viewBox="0 0 100 100" width="120" height="120">
        <circle cx="50" cy="50" r={r} fill="none" stroke="color-mix(in srgb, var(--ink-muted) 18%, transparent)" strokeWidth="11"/>
        {data.map((d, i) => {
          const portion = (d.count / total) * c;
          const offset = c - acc;
          acc += portion;
          return (
            <circle key={i} cx="50" cy="50" r={r} fill="none"
              stroke={d.color} strokeWidth="11"
              strokeDasharray={`${portion} ${c - portion}`}
              strokeDashoffset={offset}
              transform="rotate(-90 50 50)"/>
          );
        })}
      </svg>
      <div className="rcc-donut-legend">
        {data.map((d, i) => (
          <div key={i} className="rcc-donut-row">
            <span className="rcc-dot" style={{background: d.color}}/>
            <span className="rcc-donut-label">{d.label}</span>
            <span className="rcc-donut-count">{d.count}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ─────────────────────────── DOCUMENTS ─────────────────────────── */

const DOC_PREVIEWS = {
  "quote-v3": [
    // 🔒 v07zz278 — NO dollar figures in client source (this file is served as
    // public static). Budget figures live only in the gated document file +
    // /api/schedule, shown to roles with view_budget.
    "Project quote for a 15-min documentary pilot — 110 AI-generated shots over a 10-week schedule. Budget figures are restricted — open the document with the View Budget permission.",
    "Phase 1 — Asset Design (2 weeks): Mark Twain consistency, historical figures, vessels.",
    "Phase 2 — Shot Concepting (2 weeks): 110 final concept frames + lighting keys.",
    "Phase 2.5 — Video Generation (5 weeks): 110 AI shots, deep iteration on 25 D5 shots.",
    "Phase 3 — 4K Upscaling (1 week): DaVinci Neural Engine.",
    "Payment schedule: three milestones — deposit, concept-gate approval, and final delivery."
  ],
  "msa": [
    "Master Services Agreement between Salter Creek PTE. LTD and Terra Mater. Covers the full pilot production scope.",
    "Scope changes: any sequence finalised then discarded constitutes a Change in Scope and bills as extra hours beyond the contracted hour guarantee.",
    "Delivery: 110 mastered ProRes 4444, 25fps clips by end of June 2026."
  ],
  "brief": [
    "Editorial brief — Episode 00 (Pilot). Narrator: Mark Twain (recreated). Three story arcs:",
    "1. Past America montage — bison, sky-darkening flocks, abundance baseline.",
    "2. Columbus 1492 — Atlantic crossing, curlews, San Salvador landing, Caribbean turtles.",
    "3. Calusa 1513 — Ponce de Leon, Fontaneda's journals, Black Water culture.",
    "Tone: blue-chip nature documentary. Photorealistic, 21:9, 2K, intercut-ready with BBC archive."
  ],
  "schedule": [
    "10-week build, 2026-04-20 → 2026-06-29. Three payment milestones, weekly Tuesday check-ins throughout.",
    "Concept gate (Phase 1.5 approval) lands May 17 — triggers Milestone 2.",
    "Full master delivery June 28."
  ],
  "tech-specs": [
    "Master spec: ProRes 4444, 25fps, 16-bit, Rec.709 colour space, 48kHz 24-bit audio.",
    "Naming: SH####_v###_master.mov. Slates required on all final masters."
  ],
  "nda-artists": ["Restricts asset and ref-pack distribution to artist team. Standard 3-year confidentiality, mutual."],
  "vo-script": ["Full VO master combining Twain narration, Columbus journal readings, and Fontaneda's captive accounts. Includes stage directions [pause], [breath], [wry chuckle] and timing markers throughout."],
  "invoice-m1": ["Milestone 1 deposit invoice — paid 2026-04-21. Covers project commencement and initial technology / R&D overhead. Amount restricted — open the document with the View Budget permission."],
};

// v06p — Document detail modal. Matches the standard character-modal
// shell (cream card, subtle dark backdrop with 2px blur, round
// cream X button) instead of the heavy green tint it had before.
// Adds two action buttons:
//   - "Show in folder" → reveals the file's location on disk (if
//     the document has a file_path; otherwise hidden)
//   - "Open file"      → opens the file via the system handler
//                        (same path as Show in folder when available)
// And an editable access section: admins toggle which roles can
// see this document. Changes persist via POST /api/documents/:id.
// v07zz68 — Document upload modal. Producer+ can upload a file
// (PDF / DOCX / XLSX / etc) plus metadata. The server slots the
// file under <WATCH>/documents/<type>/<name>.<ext> (creating the
// folder if needed) and appends the record to documents.json.
function DocumentUploadModal({ initial, allRoles = [], onClose, onUploaded }) {
  const fetcher = window.authFetch || fetch;
  const [name, setName] = React.useState(initial.name || "");
  const [type, setType] = React.useState(initial.type || "Contract");
  const [summary, setSummary] = React.useState(initial.summary || "");
  const [access, setAccess] = React.useState(initial.access || ["Creative Director", "Producer"]);
  const [file, setFile] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const inputRef = React.useRef(null);

  const TYPES = ["Contract", "Brief", "Quote", "Invoice", "Schedule", "Script", "Technical", "Document"];

  const toggleRole = (r) => {
    setAccess(prev => prev.includes(r) ? prev.filter(x => x !== r) : [...prev, r]);
  };

  const pick = () => inputRef.current && inputRef.current.click();

  const onSelect = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    setFile(f);
    if (!name.trim()) {
      // Default the display name from the file stem so the user
      // gets a sensible starting point.
      const stem = f.name.replace(/\.[^.]+$/, "");
      setName(stem);
    }
  };

  const onSubmit = async () => {
    if (!file) { setErr("Pick a file first."); return; }
    if (!name.trim()) { setErr("Document name is required."); return; }
    setBusy(true); setErr(null);
    const fd = new FormData();
    fd.append("file", file);
    fd.append("name", name.trim());
    fd.append("type", type);
    fd.append("summary", summary.trim());
    fd.append("access", JSON.stringify(access));
    try {
      const r = await fetcher("/api/documents/upload", { method: "POST", body: fd });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      onUploaded && onUploaded(j.document);
    } catch (e) {
      setErr(e.message || String(e));
    } finally {
      setBusy(false);
    }
  };

  return (
    <div className="modal-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}>
      <div className="modal-card glass" onClick={(e) => e.stopPropagation()} style={{maxWidth: 560}}>
        <button className="modal-close-btn" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div style={{display: "flex", flexDirection: "column", gap: 12, padding: "4px 4px 0"}}>
          <div style={{fontFamily: "var(--font-display)", fontSize: "var(--fs-22)", color: "var(--ink)"}}>Upload document</div>
          <div style={{fontSize: "var(--fs-sm)", color: "var(--ink-muted)", lineHeight: 1.5}}>
            Stored securely and shared only with the roles you pick below (you can change this later).
          </div>

          <input ref={inputRef} type="file" style={{display: "none"}} onChange={onSelect}/>
          <button type="button" onClick={pick}
            style={{
              border: "1px dashed color-mix(in srgb, var(--tan-deep-2) 45%, transparent)",
              background: file ? "color-mix(in srgb, var(--leaf) 12%, transparent)" : "color-mix(in srgb, var(--cream-11) 50%, transparent)",
              padding: "18px 16px",
              borderRadius: "var(--r-panel)",
              fontFamily: "var(--font-body)",
              fontSize: "var(--fs-body)",
              color: file ? "var(--ink-olive-12)" : "var(--ink-muted)",
              cursor: "pointer",
              textAlign: "center",
            }}>
            {file ? `${file.name} — ${(file.size/1024 >= 1024 ? (file.size/1024/1024).toFixed(1)+" MB" : Math.round(file.size/1024)+" KB")}` : "Click to pick a file (PDF / DOCX / XLSX / etc)"}
          </button>

          <label style={{display: "flex", flexDirection: "column", gap: 4, fontSize: "var(--fs-xs)", color: "var(--ink-muted)", letterSpacing: "var(--track-10)", fontWeight: "var(--fw-semi)", textTransform: "uppercase"}}>
            Display name
            <input type="text" value={name} onChange={e => setName(e.target.value)}
              style={{font: "inherit", fontSize: "var(--fs-body)", padding: "8px 10px", borderRadius: "var(--r-xs)", border: "1px solid color-mix(in srgb, var(--tan-deep-2) 35%, transparent)", background: "color-mix(in srgb, var(--cream-11) 85%, transparent)"}}/>
          </label>

          <label style={{display: "flex", flexDirection: "column", gap: 4, fontSize: "var(--fs-xs)", color: "var(--ink-muted)", letterSpacing: "var(--track-10)", fontWeight: "var(--fw-semi)", textTransform: "uppercase"}}>
            Type
            <select value={type} onChange={e => setType(e.target.value)}
              style={{font: "inherit", fontSize: "var(--fs-body)", padding: "8px 10px", borderRadius: "var(--r-xs)", border: "1px solid color-mix(in srgb, var(--tan-deep-2) 35%, transparent)", background: "color-mix(in srgb, var(--cream-11) 85%, transparent)"}}>
              {TYPES.map(t => <option key={t} value={t}>{t}</option>)}
            </select>
          </label>

          <label style={{display: "flex", flexDirection: "column", gap: 4, fontSize: "var(--fs-xs)", color: "var(--ink-muted)", letterSpacing: "var(--track-10)", fontWeight: "var(--fw-semi)", textTransform: "uppercase"}}>
            Summary (optional)
            <textarea value={summary} onChange={e => setSummary(e.target.value)} rows={2}
              style={{font: "inherit", fontSize: "var(--fs-body)", padding: "8px 10px", borderRadius: "var(--r-xs)", border: "1px solid color-mix(in srgb, var(--tan-deep-2) 35%, transparent)", background: "color-mix(in srgb, var(--cream-11) 85%, transparent)", resize: "vertical"}}/>
          </label>

          <div style={{display: "flex", flexDirection: "column", gap: 6}}>
            <div style={{fontSize: "var(--fs-xs)", color: "var(--ink-muted)", letterSpacing: "var(--track-10)", fontWeight: "var(--fw-semi)", textTransform: "uppercase"}}>Visible to</div>
            <div style={{display: "flex", flexWrap: "wrap", gap: 6}}>
              {(allRoles && allRoles.length ? allRoles : ["Creative Director", "Director", "Producer", "Artist"]).map(r => (
                <button key={r} type="button" onClick={() => toggleRole(r)}
                  style={{
                    fontSize: "var(--fs-sm)",
                    fontWeight: "var(--fw-semi)",
                    padding: "5px 10px",
                    borderRadius: "var(--r-round)",
                    border: "1px solid " + (access.includes(r) ? "color-mix(in srgb, var(--olive-2) 85%, transparent)" : "color-mix(in srgb, var(--tan-deep-2) 30%, transparent)"),
                    background: access.includes(r) ? "color-mix(in srgb, var(--leaf) 20%, transparent)" : "color-mix(in srgb, var(--cream-11) 50%, transparent)",
                    color: access.includes(r) ? "var(--ink-forest-5)" : "var(--ink-muted)",
                    cursor: "pointer",
                  }}>{r}</button>
              ))}
            </div>
          </div>

          {err && <div style={{color: "var(--danger)", fontSize: "var(--fs-sm)", padding: "6px 10px", background: "color-mix(in srgb, var(--danger-soft) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--danger-soft) 30%, transparent)", borderRadius: "var(--r-xs)"}}>{err}</div>}

          <div style={{display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 6}}>
            <button type="button" onClick={onClose} disabled={busy}
              style={{font: "inherit", fontSize: "var(--fs-sm)", padding: "8px 16px", borderRadius: "var(--r-xs)", background: "transparent", border: "1px solid color-mix(in srgb, var(--tan-deep-2) 35%, transparent)", color: "var(--ink)", cursor: "pointer"}}>Cancel</button>
            <button type="button" onClick={onSubmit} disabled={busy || !file || !name.trim()}
              style={{font: "inherit", fontSize: "var(--fs-sm)", fontWeight: "var(--fw-semi)", padding: "8px 18px", borderRadius: "var(--r-xs)", background: "color-mix(in srgb, var(--leaf) 85%, transparent)", border: "1px solid color-mix(in srgb, var(--olive-2) 85%, transparent)", color: "var(--white)", cursor: busy ? "not-allowed" : "pointer", opacity: (busy || !file || !name.trim()) ? 0.5 : 1}}>
              {busy ? "Uploading…" : "Upload"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function DocumentModal({ d, lines, allRoles = [], onClose, canEdit, isOwner }) {
  const fetcher = window.authFetch || fetch;
  const RevealBtn = window.FileActionBtns || window.RevealInFolderBtn;
  // v07zz132 — The visibility/access section shows only to an admin or
  // the document's owner. Everyone else just reads the doc — the access
  // chips are an admin/owner management affordance, not public info.
  const canManage = !!(canEdit || isOwner);
  // Local mirror so toggles update the UI instantly while the
  // server save happens in the background.
  const [access, setAccess] = React.useState(Array.isArray(d.access) ? d.access : []);
  const [editing, setEditing] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [saveErr, setSaveErr] = React.useState(null);

  // Backdrop close — mousedown + mouseup must both land on the
  // backdrop, so dragging text out of the modal doesn't dismiss
  // it. Same pattern as CharacterDetailModal.
  const backdropDownRef = React.useRef(false);
  const onBackdropMouseDown = (e) => { backdropDownRef.current = (e.target === e.currentTarget); };
  const onBackdropClick = (e) => {
    if (backdropDownRef.current && e.target === e.currentTarget) onClose();
    backdropDownRef.current = false;
  };
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  const toggleRole = (role) => {
    const next = access.includes(role) ? access.filter(r => r !== role) : [...access, role];
    setAccess(next);
    setSaving(true);
    setSaveErr(null);
    fetcher(`/api/documents/${encodeURIComponent(d.id)}/access`, {
      method: "POST",
      body: JSON.stringify({ access: next }),
    })
      .then(async r => {
        if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
        // Patch in-memory data so re-opening reflects the change.
        if (d) d.access = next;
      })
      .catch(e => setSaveErr(e.message))
      .finally(() => setSaving(false));
  };

  return (
    <div className="doc-modal-backdrop"
         onMouseDown={onBackdropMouseDown}
         onClick={onBackdropClick}>
      <div className="doc-modal glass" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close doc-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div className="doc-modal-head">
          <div className="doc-modal-icon">{d.icon === "lock" ? "🔒" : d.icon === "cal" ? "📅" : d.icon === "wrench" ? "🔧" : "📄"}</div>
          <div>
            <div className="doc-modal-eyebrow">{(d.type || "").toUpperCase()}</div>
            <div className="doc-modal-name">{d.name}</div>
            <div className="doc-modal-meta">{d.size} · modified {d.modified} · by {d.owner}</div>
          </div>
        </div>
        <div className="doc-modal-summary">{d.summary}</div>
        {(d.file_path || d.cloud_url) && (
          <div style={{display: "flex", gap: 10, alignItems: "center", padding: "10px 14px", background: "color-mix(in srgb, var(--leaf) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--leaf) 30%, transparent)", borderRadius: "var(--r-sm)", marginBottom: 10}}>
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="var(--olive-12)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>
            </svg>
            <span style={{fontSize: "var(--fs-sm)", color: "var(--ink)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{String(d.file_path || d.cloud_url || "").split(/[\\/?#]/).pop()}</span>
            {/* v1039 — show the real file in Windows Explorer.
                TWO steps on purpose. doc.file_path cannot be handed straight to
                /api/reveal: a relative path there resolves against the REPO,
                while /api/documents/:id/file resolves it against WATCH_PATH — so
                the same string meant two different files and reveal 404'd. And 8
                of the 9 documents carry no file_path at all; the server finds
                those by type. So ask the server for the absolute path (one
                resolver, shared with the streaming route), then reveal THAT.
                Hidden only for a cloud-only document — nothing on this disk. */}
            {!(d.cloud_url && !d.file_path) && (
              <button type="button"
                title="Show this file in Explorer"
                onClick={(e) => {
                  e.stopPropagation();
                  const f = window.authFetch || fetch;
                  f(`/api/documents/${encodeURIComponent(d.id)}/path`)
                    .then(r => r.json().then(j => r.ok && j.ok ? j : Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
                    .then(j => f("/api/reveal", {
                      method: "POST",
                      headers: { "Content-Type": "application/json" },
                      body: JSON.stringify({ path: j.path }),
                    }).then(r => r.ok ? null : r.json().then(x => Promise.reject(new Error(x.error || "reveal failed")))))
                    // Say so rather than failing silently — a button that does
                    // nothing is worse than one that explains why.
                    .catch(err => {
                      console.warn("[doc reveal]", err.message);
                      if (window.__toast) window.__toast("Couldn't open in Explorer — " + err.message, false);
                    });
                }}
                style={{font: "inherit", fontSize: "var(--fs-sm)", fontWeight: "var(--fw-semi)", padding: "5px 12px", borderRadius: "var(--r-xs)", background: "color-mix(in srgb, var(--leaf) 85%, transparent)", color: "var(--white)", border: "none", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6}}>
                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M3 6.5a1.5 1.5 0 0 1 1.5-1.5h4l1.7 2H19.5a1.5 1.5 0 0 1 1.5 1.5v9.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18z"/>
                  <path d="M11 14l4-4M11 10h4v4"/>
                </svg>
                Explorer
              </button>
            )}
            {/* Browser view stays: it is the ONLY way to see a cloud-only doc,
                and the only one that works from Railway. Demoted to an outline so
                Explorer reads as the primary action Hugo asked for. */}
            <a href={`/api/documents/${encodeURIComponent(d.id)}/file`} target="_blank" rel="noreferrer noopener"
              title="Open this file in a browser tab"
              style={{font: "inherit", fontSize: "var(--fs-sm)", fontWeight: "var(--fw-semi)", padding: "5px 12px", borderRadius: "var(--r-xs)", background: "transparent", color: "var(--ink-olive-12)", border: "1px solid color-mix(in srgb, var(--leaf) 55%, transparent)", textDecoration: "none"}}>
              Browser ↗
            </a>
          </div>
        )}
        <div className="doc-modal-body">
          {lines.map((ln, i) => (<p key={i}>{ln}</p>))}
        </div>

        {/* v07zz132 — Access/visibility — shown only to an admin or the
            document's owner; editable by both. Hidden from other viewers. */}
        {canManage && (
        <div className="doc-modal-access" style={{ flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10, width: "100%" }}>
            <span className="doc-access-label">{isOwner && !canEdit ? "WHO CAN SEE THIS" : "ACCESS"}</span>
            <button type="button" className="doc-access-edit-btn"
              style={{
                marginLeft: "auto", font: "inherit", fontSize: "var(--fs-10-5)", fontWeight: "var(--fw-semi)",
                letterSpacing: "var(--track-06)", textTransform: "uppercase",
                padding: "3px 10px", borderRadius: "var(--r-xs)",
                border: "1px solid var(--card-border, color-mix(in srgb, var(--card-border) 55%, transparent))",
                background: editing ? "color-mix(in srgb, var(--leaf) 18%, transparent)" : "transparent",
                color: editing ? "var(--ink-forest-5)" : "var(--ink-muted)", cursor: "pointer",
              }}
              onClick={() => setEditing(v => !v)}>
              {editing ? "Done" : "Edit access"}
            </button>
            {saving && <span style={{ fontSize: "var(--fs-xs)", color: "var(--ink-muted)", fontStyle: "italic" }}>saving…</span>}
          </div>
          <div className="doc-modal-access-edit">
            {(editing ? allRoles : access).map((r) => {
              const allowed = access.includes(r);
              return editing ? (
                <button key={r} type="button"
                  className={"doc-access-toggle" + (allowed ? " is-allowed" : "")}
                  onClick={() => toggleRole(r)}>
                  {allowed ? "✓ " : ""}{r}
                </button>
              ) : (
                <span key={r} className="doc-access-pill">{r}</span>
              );
            })}
          </div>
          {saveErr && <div style={{ fontSize: "var(--fs-xs)", color: "var(--danger, var(--red-24))" }}>{saveErr}</div>}
        </div>
        )}

        {/* Actions — Show in folder + Open. Only render the
            reveal button when the document has a file_path. */}
        <div className="doc-modal-actions">
          {d.file_path && RevealBtn && (
            <button type="button" className="doc-modal-action"
              onClick={() => RevealBtn && document.dispatchEvent(new CustomEvent("reveal-doc", { detail: d.file_path }))}>
              📂 Show in folder
            </button>
          )}
          {(d.file_path || d.cloud_url) && (
            <a className="doc-modal-action doc-modal-action--primary"
              href={d.cloud_url ? d.cloud_url : (d.file_path.startsWith("/local/") || /^https?:\/\//i.test(d.file_path) ? d.file_path : `/local/${d.file_path.replace(/^\/+/, "")}`)}
              target="_blank" rel="noreferrer" style={{ textDecoration: "none" }}>
              ↗ Open file
            </a>
          )}
          {!d.file_path && !d.cloud_url && (
            <span style={{ fontSize: "var(--fs-11-5)", color: "var(--ink-muted)", fontStyle: "italic" }}>
              No file attached. Text-only entry.
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

// Wire the embedded reveal-doc event to the standard /api/reveal
// endpoint so the "Show in folder" button on the document modal
// works the same way as elsewhere.
if (typeof document !== "undefined" && !window.__revealDocWired) {
  window.__revealDocWired = true;
  document.addEventListener("reveal-doc", (e) => {
    const src = e && e.detail;
    if (!src) return;
    (window.authFetch || fetch)("/api/reveal", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: src }),
    }).catch(() => {});
  });
}

function DocumentsView({ documents = [], roles = [], currentRole = "Creative Director" }) {
  // v01x — VIEW AS toggle is admin-only. Non-admins see documents
  // filtered by their actual role (mapped from auth role to the
  // display-role string the documents.access arrays use).
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const authRole = (userCtx && userCtx.user && userCtx.user.role) || null;
  const isAdmin = authRole === "admin";
  const ROLE_MAP = {
    admin:    "Creative Director",
    producer: "Producer",
    director: "Director",
    artist:   "Artist",
    reviewer: "Reviewer",
  };
  const effectiveRole = ROLE_MAP[authRole] || currentRole;

  const [role, setRole] = React.useState(isAdmin ? currentRole : effectiveRole);
  const [type, setType] = React.useState("all");
  const [openDoc, setOpenDoc] = React.useState(null);
  // v07zz68 — Upload modal state. Producer+ can upload; the server
  // routes the file under <WATCH>/documents/<type>/, renames it,
  // and appends a record to documents.json.
  const [uploading, setUploading] = React.useState(null); // null | { name, type, summary, file }
  // v07zz132 — Any signed-in user can add their own documents.
  const canUpload = !!(userCtx && userCtx.user);
  const myName = (userCtx && userCtx.user && userCtx.user.name) || null;
  // Lock non-admin role to their actual auth role even if state drifts.
  const liveRole = isAdmin ? role : effectiveRole;
  // v07zz132 — A user can see a document if: they're admin (everything),
  // OR their (live) role is in the doc's access list, OR they own it.
  const canSee = (d) => {
    if (isAdmin) return true;
    const acc = Array.isArray(d.access) ? d.access : [];
    if (acc.includes(liveRole)) return true;
    if (myName && d.owner && String(d.owner).toLowerCase() === String(myName).toLowerCase()) return true;
    return false;
  };
  const myDocs = documents.filter(canSee);
  // v07zz132 — Category chips reflect ONLY the categories the current
  // user can actually see (so a user with no Invoices never sees an
  // "Invoice" tab). Admin still sees every category via canSee.
  // 24 Sep 2026 (G7) — another project offers Paradise Found's categories from the start (the money
  // ones only to people who may see money), plus any other type its own documents carry.
  const _docsPF = !(typeof window !== "undefined" && typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _docsMoney = isAdmin || !!(window.hasPerm && window.hasPerm("view_budget"));
  const types = _docsPF
    ? ["all", ...Array.from(new Set(myDocs.map(d => d.type)))]
    : ["all", ...(() => {
        // 24 Sep 2026 (G7 review) - agent and seed documents carry lowercase / snake_case types
        // ('brief', 'posting_plan'); the fixed Title-case chips and those types are ONE list,
        // matched in lowercase, so 'Brief' and 'brief' are one chip that shows both.
        const seen = new Set(); const out = [];
        ["Quote", "Contract", "Brief", "Schedule", "Technical", "Script", "Invoice"]
          .filter(t => _docsMoney || !["Quote", "Contract", "Invoice"].includes(t))
          .concat(myDocs.map(d => d.type))
          .forEach(t => { if (!t) return; const k = String(t).toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(String(t)); } });
        return out;
      })()];
  const _docsLabel = (t) => { const s = String(t || "").replace(/_+/g, " ").trim(); return s.charAt(0).toUpperCase() + s.slice(1); };
  const visible = _docsPF
    ? myDocs.filter(d => type === "all" || d.type === type)
    : myDocs.filter(d => type === "all" || String(d.type || "").toLowerCase() === String(type).toLowerCase());
  return (
    <section className="view-page">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">DOCUMENTS</div>
          <div className="vp-title">Contracts, briefs, schedules — role-based access</div>
        </div>
        {isAdmin && (
          <div className="vp-tabs">
            <span className="docs-role-label">VIEW AS</span>
            {roles.map(r => (
              <button key={r}
                className={"vp-tab" + (role === r ? " is-active" : "")}
                onClick={() => setRole(r)}>{r}</button>
            ))}
          </div>
        )}
      </div>
      <div className="docs-filter">
        {types.map(t => (
          <button key={t} className={"docs-type-pill" + (type === t ? " is-active" : "")} onClick={() => setType(t)}>
            {_docsPF ? t.charAt(0).toUpperCase() + t.slice(1) : _docsLabel(t)}
          </button>
        ))}
        {canUpload && (
          <button
            className="docs-type-pill docs-upload-pill"
            style={{ marginLeft: "auto", background: "color-mix(in srgb, var(--leaf) 85%, transparent)", borderColor: "color-mix(in srgb, var(--olive-2) 85%, transparent)" }}
            onClick={() => setUploading({ name: "", type: (!_docsPF && type !== "all") ? type : "Contract", summary: "", access: ["Creative Director", "Producer"], file: null })}>
            ＋ Upload document
          </button>
        )}
      </div>
      <div className="docs-grid-v2">
        {visible.length === 0 && (
          <div className="docs-empty glass">No documents visible to <strong>{liveRole}</strong> in this category.</div>
        )}
        {visible.map(d => (
          <article key={d.id} className="doc-card-v2 glass interactive-card" onClick={() => setOpenDoc(d.id)}>
            <div className="doc-card-v2-head">
              <div className="doc-icon">{d.icon === "lock" ? "🔒" : d.icon === "cal" ? "📅" : d.icon === "wrench" ? "🔧" : "📄"}</div>
              <div className="doc-card-v2-type">{_docsPF ? d.type : _docsLabel(d.type)}</div>
            </div>
            <div className="doc-card-v2-name">{d.name}</div>
            <div className="doc-card-v2-summary">{d.summary}</div>
            {/* v07zz132 — Visibility (access) tags are an admin-only
                affordance. Other users just see the documents they're
                allowed to; they don't need the role-routing chips. */}
            {isAdmin && (
              <div className="doc-card-v2-access">
                {(d.access || []).map((a, i) => <span key={i} className="doc-access-pill">{a.split(" ")[0]}</span>)}
              </div>
            )}
          </article>
        ))}
      </div>

      {openDoc && (() => {
        const d = visible.find(x => x.id === openDoc) || documents.find(x => x.id === openDoc);
        if (!d) return null;
        const lines = DOC_PREVIEWS[d.id] || [d.summary];
        const portalRoot = document.getElementById("modal-root") || document.body;
        const isOwner = !!(myName && d.owner && String(d.owner).toLowerCase() === String(myName).toLowerCase());
        return ReactDOM.createPortal((
          <DocumentModal d={d} lines={lines} allRoles={roles} onClose={() => setOpenDoc(null)} canEdit={isAdmin} isOwner={isOwner}/>
        ), portalRoot);
      })()}

      {uploading && (() => {
        const portalRoot = document.getElementById("modal-root") || document.body;
        return ReactDOM.createPortal((
          <DocumentUploadModal
            initial={uploading}
            allRoles={roles}
            onClose={() => setUploading(null)}
            onUploaded={(doc) => {
              // Splice into the current data (rendered from prop).
              // Then trigger an app-wide reload so other consumers
              // see the new doc.
              try {
                if (window.__appData && Array.isArray(window.__appData.documents)) {
                  window.__appData.documents.push(doc);
                }
              } catch (_) {}
              setUploading(null);
              if (window.reloadAppData) window.reloadAppData();
            }}/>
        ), portalRoot);
      })()}

      {/* 🔒 v07zz278 — removed a dead `{false && ...}` docs-grid block that
          hardcoded the full quote/invoice/billing breakdown (total, phase
          amounts, milestone amounts) in client source. Even though it never
          rendered, the strings shipped in this public static file. Money now
          lives ONLY in the gated document file + /api/schedule. */}
    </section>
  );
}

/* ─────────────────────────── SETTINGS — dual theme system ─────────────────────────── */

// 16 Sep 2026 — every skin is offered on EVERY project. Hugo, on the bench project: "in the
// settings i cannot see the preset for the Neon skin, and i cannot see any of the other ones
// either... We need the Paradise Found one, the Neon Synth Wave one and the couple of other ones
// i gave you from the trope project." The presets used to be tagged with the project they were
// designed for, so a project that matched none (any project created after them) showed an EMPTY
// palette card. A skin is paint, so there is no reason it cannot be worn by any project.
// Swatches are page, panel, accent, second accent.
//
// 16 Sep 2026 - THE SKIN REGISTRY. Hugo: "let's delete every other template which is not
// Paradise found one (the forest template) and the synth wave." Only these two exist now; the
// other ten (trope, mono, slate, crimson, ember, ice, ocean, sunset, midnight, dark-cinematic)
// were removed from styles/themes.css as well as from here. This list is the single answer to
// "does this skin exist?" - window.__knownSkin below reads it, App.jsx uses that to ignore any
// saved id that no longer exists, and test/skinRegistry.test.js fails if this list and the
// body[data-theme] blocks in themes.css ever disagree.
// 23 Sep 2026 - Monochrome Teal, Midnight Lavender and Marble & Gold ship a photo too (Hugo: "it's
// not bringing the right images when i switch styles on a new project, except synth wave"). Their
// photos lived only in Paradise Found's own folder (_tracker/backdrop-<skin>.jpg, picked by Hugo on
// 18 Sep), so a new project got none. The files are those exact photos.
const PALETTE_THEMES = [
  { id: "forest",   name: "Paradise Found", desc: "The original — green and warm cream.", swatches: ["#2D3D2A", "#E0D9C7", "#7FA85E", "#D4A574"] },
  { id: "neon",     name: "Neon Synth Wave", family: "glass", photo: "/assets/skins/neon/backdrop.jpg?v=1", desc: "Night city. Near-black panels, magenta and cyan light.", swatches: ["#060912", "#18202C", "#FF2ED1", "#00E5FF"] },
  { id: "mono-teal", name: "Monochrome Teal", family: "glass", photo: "/assets/skins/mono-teal/backdrop.jpg?v=1", desc: "Near-black glass, pale list rows, teal light, red accents.", swatches: ["#000000", "#DFE5EB", "#A84754", "#04E1DA"] },
  { id: "midnight-lavender", name: "Midnight Lavender", family: "glass", photo: "/assets/skins/midnight-lavender/backdrop.jpg?v=1", desc: "Night-blue glass, pale lavender stats band, periwinkle light.", swatches: ["#090D12", "#DFE3EA", "#6986FA", "#B9A3F1"] },
  { id: "marble-gold", name: "Marble & Gold", family: "paper", photo: "/assets/skins/marble-gold/backdrop.jpg?v=1", desc: "White marble panels on a near-black ground, bronze and champagne gold, an aqua ring.", swatches: ["#14130E", "#ECE8E0", "#886B42", "#78A5A6"] },
  { id: "forest-dark", name: "Paradise Found Dark", family: "glass", photo: "/assets/skins/forest-dark/backdrop.jpg?v=1", desc: "Deep forest glass. Near-black green panels, moss and lime light, a teal ring, amber accents.", swatches: ["#0E1311", "#1C2521", "#7FBD59", "#A7E26A"] },
  { id: "greek-dark-01", name: "Greek Dark 01", family: "glass", photo: "/assets/skins/athenian-marble/backdrop.jpg?v=1", desc: "Smoky charcoal, muted gold and olive, laurel details and a recessed ring.", swatches: ["#1B1E20", "#343B3E", "#D9B777", "#9FC08B"] },
  { id: "greek-dark-light-mix", name: "Greek Dark Light Mix", family: "glass", defaultContentTone: "light", photo: "/assets/skins/athenian-marble/backdrop.jpg?v=1", desc: "Dark marble surrounds, cream centre, gold and olive details.", swatches: ["#1C1C1A", "#F8F5EC", "#B9A573", "#83916C"] },
  { id: "athenian-marble", name: "Greek Light", family: "paper", photo: "/assets/skins/athenian-marble/backdrop.jpg?v=1", desc: "Cream marble, bronze and olive, laurel details and a recessed ring.", swatches: ["#EFE9E0", "#F2EDE3", "#CEB48C", "#5F7050"] },
  { id: "maya-dark-01", name: "Maya Dark 01", family: "glass", photo: "/assets/skins/maya-temple/backdrop.jpg?v=1", desc: "Charcoal stone, pale carved details, coral and sage, a cream recessed ring centre.", swatches: ["#292928", "#494A46", "#D3C8BA", "#A85F4E"] },
  { id: "maya-light-01", name: "Maya Light 01", family: "paper", photo: "/assets/skins/maya-temple/backdrop.jpg?v=1", desc: "Pale limestone panels, a charcoal rail, coral details and a recessed sage and terracotta ring.", swatches: ["#333231", "#EEEBE7", "#A85F4E", "#6E8670"] },
  { id: "maya-temple", name: "Maya Light 02", family: "paper", photo: "/assets/skins/maya-temple/backdrop.jpg?v=1", desc: "Oxblood rail, limestone panels, carved glyphs and a recessed red and jade ring.", swatches: ["#31150F", "#D9CDBD", "#86392B", "#5B7561"] },
  { id: "rain-at-dusk", name: "Rain At Dusk", family: "glass", photo: "/assets/skins/rain-at-dusk/backdrop.jpg?v=3", desc: "Night-blue slate glass over rain at sunset, amber light and a rainbow-to-orange bar.", swatches: ["#111C26", "#1F2E3B", "#E8913A", "#4F86AE"] },
];

// v01s — Trim visual styles down to Glass + Minimalist only. The
// other 4 (Editorial / Neon / Studio / Broadcast) were aspirational
// and never had real CSS, so they confused users.
const STYLE_THEMES = [
  { id: "glass",      name: "Glass",      desc: "Frosted glass with bevel and gold-foil edges. (current)" },
  { id: "minimal",    name: "Minimalist", desc: "Flat 2D — no blur, no shadow, hairline borders." },
];

// 15 Sep 2026 — presets belong to a project: Hugo makes a set of mockups per project and they
// get matched as presets (the five original looks are Paradise Found's, the six new ones are
// Trøpé's). A preset with no `projects` shows everywhere. Hugo: "remove the ones that were
// before as well for that specific project".
function _presetsForProject(pid) {
  // Kept as the single entry point (the builder calls it too), but it no longer filters: a skin
  // is paint, so every project may wear any of them. Filtering by project is what left a newly
  // created project with an empty palette card and no way to pick a skin at all.
  return PALETTE_THEMES;
}
window.THEME_PRESETS = PALETTE_THEMES;   // the full list; the picker and the builder filter by project
// true only for a skin that still exists. A project, an album or a browser can carry an id saved
// before the cull ("crimson" on Trope's Sin City album, a stale localStorage key); those must
// fall through to the next layer, never render a body[data-theme] that matches nothing.
window.__knownSkin = (id) => !!id && PALETTE_THEMES.some((t) => t.id === id);
// the skin FAMILY decides which treatment file applies (styles/skin-<family>.css, scoped by
// body[data-skin-family]). A skin is only values; its family supplies every rule. forest has none.
window.__skinFamily = (id) => { const t = PALETTE_THEMES.find((x) => x.id === id); return (t && t.family) || null; };
window.__skinContentTone = (id) => { const t = PALETTE_THEMES.find((x) => x.id === id); return (t && t.defaultContentTone) || null; };
window.__themePresetsFor = _presetsForProject;
// 17 Sep 2026 - a skin may ship a backdrop PHOTO that travels with it (Hugo: "the background image
// isnt coming through when i change the style, it should travel with it"). A repo file served
// statically, so Railway has it too. App.jsx shows it exactly as it is: nothing filters or tints it.
window.__skinPhoto = (id) => { const t = PALETTE_THEMES.find((x) => x.id === id); return (t && t.photo) || null; };
// 17 Sep 2026 - the saved thumbnail size applies from the first paint. It used to be applied only
// once the Settings page had been opened, so a saved small/large size was ignored after a reload
// and the shot rows changed shape the moment Settings was visited.
try {
  const _savedThumbsize = localStorage.getItem("filmtracker.thumbsize");
  if (_savedThumbsize && document.body) document.body.setAttribute("data-thumbsize", _savedThumbsize);
} catch (e) {}
function SettingsView({ theme = "forest", onTheme = () => {}, style = "glass", onStyle = () => {}, hideMoney = false, onHideMoney = () => {} }) {
  // v07zz121 — admin-only controls (Frame aspect ratio). Hugo.
  const isAdmin = (window.__effectiveRole || (window.__currentUser && window.__currentUser.role)) === "admin";
  const [confirmed, setConfirmed] = React.useState(false);
  const [hint, setHint] = React.useState(false);
  // 23 Sep 2026 — the "Email notifications" switch was removed with the email feature.
  // v01s — preference toggles persist to localStorage so reloads don't
  // reset them, and the body classes that drive their CSS effects are
  // applied via useEffect.
  const [autosave, setAutosave] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.autosave") !== "0"; } catch (e) { return true; }
  });
  React.useEffect(() => {
    try { localStorage.setItem("filmtracker.autosave", autosave ? "1" : "0"); } catch (e) {}
  }, [autosave]);
  const [reduced, setReduced] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.reduced-motion") === "1"; } catch (e) { return false; }
  });
  React.useEffect(() => {
    document.body.classList.toggle("reduced-motion", !!reduced);
    try { localStorage.setItem("filmtracker.reduced-motion", reduced ? "1" : "0"); } catch (e) {}
  }, [reduced]);
  // v07zz14 — Notification fanfare: gold-outline + shine pulse on
  // the bell + toast card slide-in when a new notification arrives.
  // On by default; toggling off mutes the visual fanfare (toasts
  // and bell-dot still render — this just disables the
  // attention-grabbing animation Hugo asked for).
  const [notifFanfare, setNotifFanfare] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.notif-fanfare") !== "0"; } catch (e) { return true; }
  });
  React.useEffect(() => {
    try { localStorage.setItem("filmtracker.notif-fanfare", notifFanfare ? "1" : "0"); } catch (e) {}
  }, [notifFanfare]);
  // v07zz156 — Generated Preview slide-in animation toggle (Generate → Assets).
  const [previewSlide, setPreviewSlide] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.assetPreviewSlide") !== "0"; } catch (e) { return true; }
  });
  React.useEffect(() => {
    try { localStorage.setItem("filmtracker.assetPreviewSlide", previewSlide ? "1" : "0"); } catch (e) {}
  }, [previewSlide]);
  // v1081 — FLOATING BUTTONS: the round List / Chat / Notes buttons on the right edge (Hugo:
  // "a settings to turn on or off the pills for list, chat and notes"). Per browser, like the
  // preferences below; App.jsx mounts each bubble only while it is on. Lists and notes stay
  // saved when a button is off.
  // 23 Sep 2026 — only the Chat bubble is left (List + Notes were removed).
  const [bubbles, setBubblesState] = React.useState(() => {
    try { const j = JSON.parse(localStorage.getItem("filmtracker.bubbles") || "{}") || {}; return { chat: j.chat !== false }; }
    catch (e) { return { chat: true }; }
  });
  const setBubble = (k, on) => {
    const next = { ...bubbles, [k]: on };
    setBubblesState(next);
    try { localStorage.setItem("filmtracker.bubbles", JSON.stringify(next)); } catch (e) {}
    try { window.dispatchEvent(new CustomEvent("filmtracker:bubbles")); } catch (e) {}
  };
  const canChatBubble = !window.hasPerm || window.hasPerm("use_assistant");
  // v07zz280 — App scale: whole-UI zoom in increments, or "auto" (picks a
  // scale from the window width so laptops get the desktop density). The
  // actual zoom is applied by window.__applyUiScale (inline script in
  // index.html, runs before first paint); this state just drives the UI
  // and persists the choice. Per-device by design (localStorage).
  const [uiScale, setUiScale] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.uiScale") || "auto"; } catch (e) { return "auto"; }
  });
  React.useEffect(() => {
    if (window.__applyUiScale) window.__applyUiScale(uiScale);
  }, [uiScale]);
  // Re-render when auto mode re-applies on resize so the "currently N%"
  // hint stays truthful.
  const [, bumpScaleHint] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const fn = () => bumpScaleHint();
    window.addEventListener("paradise-ui-scale", fn);
    return () => window.removeEventListener("paradise-ui-scale", fn);
  }, []);
  const uiScaleLabel = uiScale === "auto"
    ? `App scale — Auto (currently ${Math.round((window.__uiScaleEffective || 1) * 100)}%)`
    : "App scale";
  const [thumbsize, setThumbsize] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.thumbsize") || "medium"; } catch (e) { return "medium"; }
  });
  React.useEffect(() => {
    document.body.setAttribute("data-thumbsize", thumbsize);
    try { localStorage.setItem("filmtracker.thumbsize", thumbsize); } catch (e) {}
  }, [thumbsize]);
  // Auto-play behaviour for shot-row video thumbnails:
  //   "all"   — every row plays its video continuously (chaos mode)
  //   "hover" — row plays its video while the row is hovered (default)
  //   "none"  — no playback; thumbnails are static first-frames only
  // ShotsPanel listens for the "filmtracker:thumb-autoplay" custom
  // event so changes here propagate to every visible row without
  // requiring a page reload.
  const [thumbAutoplay, setThumbAutoplay] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.thumb-autoplay") || "hover"; }
    catch (e) { return "hover"; }
  });
  React.useEffect(() => {
    document.body.setAttribute("data-thumb-autoplay", thumbAutoplay);
    try { localStorage.setItem("filmtracker.thumb-autoplay", thumbAutoplay); } catch (e) {}
    try {
      window.dispatchEvent(new CustomEvent("filmtracker:thumb-autoplay", { detail: thumbAutoplay }));
    } catch (e) {}
  }, [thumbAutoplay]);
  // v06q — project aspect override. Lets the user pick a different
  // aspect mid-project (e.g. switch from 21:9 cinema to 16:9 broadcast
  // for a re-cut) without changing the canonical episode.aspect_ratio
  // in the DB. "project" = use whatever /api/data returns. The setter
  // writes to localStorage.aspect-override and pokes the :root tokens
  // (--thumb-ratio + --project-aspect) so every aspect-ratio cascade
  // updates immediately.
  const ASPECT_OPTIONS = [
    { id: "project", label: "Project default" },
    { id: "21:9",    label: "21:9" },
    { id: "2.39:1",  label: "2.39:1" },
    { id: "16:9",    label: "16:9" },
    { id: "4:3",     label: "4:3" },
    { id: "1:1",     label: "1:1" },
    { id: "9:16",    label: "9:16" },
  ];
  const [aspectOverride, setAspectOverride] = React.useState(() => {
    try { return localStorage.getItem("frameflow.aspect-override") || "project"; }
    catch (e) { return "project"; }
  });
  React.useEffect(() => {
    try {
      if (aspectOverride && aspectOverride !== "project") {
        localStorage.setItem("frameflow.aspect-override", aspectOverride);
      } else {
        localStorage.removeItem("frameflow.aspect-override");
      }
    } catch (e) {}
    // Re-derive the :root tokens from either the override or the
    // project's episode.aspect_ratio. Falls back to 21:9.
    const fallback = (window.__projectAspectRatio || "21:9");
    const ar = (aspectOverride && aspectOverride !== "project")
      ? aspectOverride
      : fallback;
    // "2.39:1" → [2.39, 1]; "21:9" → [21, 9]
    const [w, h] = String(ar).split(":").map(Number);
    if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
      const pct = ((h / w) * 100).toFixed(4) + "%";
      document.documentElement.style.setProperty("--thumb-ratio", pct);
      document.documentElement.style.setProperty("--project-aspect", `${w} / ${h}`);
    }
  }, [aspectOverride]);
  // v07zz31 — Background image picker. Three modes:
  //   "forest"  — the default forest-bg.png ships with the repo
  //   "none"    — kill the photo; the green veil layer becomes the bg
  //   "custom"  — user-uploaded image; URL stored in localStorage so
  //               the swap survives reloads without a server hit
  // We deliberately keep this client-side (localStorage, not user
  // prefs in the DB) so the setting is per-device — Hugo on a 4K
  // monitor vs. on his laptop will likely want different choices.
  const BG_PRESETS = [
    { id: "forest", label: "Forest (default)", url: "styles/assets/forest-bg.png" },
    { id: "none",   label: "Plain dark",       url: null },
  ];
  const [bgMode, setBgMode] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.bg-mode") || "forest"; } catch (_) { return "forest"; }
  });
  const [bgCustomUrl, setBgCustomUrl] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.bg-custom-url") || ""; } catch (_) { return ""; }
  });
  const [bgUploading, setBgUploading] = React.useState(false);
  const [bgError, setBgError] = React.useState(null);
  const bgFileRef = React.useRef(null);
  // Apply the chosen background to <body>. CSS specificity: an inline
  // style on <body> beats the styles/backdrop.css declaration without
  // having to edit the CSS file.
  // v327 — this effect used to write the page photo inline on <body> on every Settings visit,
  // which silently beat the style builder's per-project backdrop. The builder owns it now.
  React.useEffect(() => {}, [bgMode, bgCustomUrl]);
  const onBgUpload = async (file) => {
    if (!file) return;
    setBgUploading(true);
    setBgError(null);
    const fetcher = window.authFetch || fetch;
    try {
      const fd = new FormData();
      fd.append("file", file);
      fd.append("kind", "reference");
      fd.append("asset_category", "visual_style");
      const r = await fetcher("/api/upload", { method: "POST", body: fd });
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        throw new Error(j.error || `HTTP ${r.status}`);
      }
      const j = await r.json();
      // The upload returns either local_url (W:/refs/...) or cloud_url
      // (R2). The browser can fetch /local/... directly so either is
      // fine — pick whichever the server preferred.
      const url = j.cloud_url || j.local_url || (j.asset_version && (j.asset_version.cloud_url || j.asset_version.file_path));
      if (!url) throw new Error("Upload returned no URL");
      const finalUrl = url.startsWith("/") || url.startsWith("http") ? url : `/local/${url}`;
      setBgCustomUrl(finalUrl);
      setBgMode("custom");
      try { localStorage.setItem("filmtracker.bg-custom-url", finalUrl); } catch (_) {}
    } catch (e) {
      setBgError(e.message);
    } finally {
      setBgUploading(false);
      if (bgFileRef.current) bgFileRef.current.value = "";
    }
  };

  // t11 — current user (admin/producer/director/reviewer). Used to gate
  // the User Management section below so non-admins don't see it.
  // v07zz125 — `isAdmin` is ALREADY declared near the top of SettingsView
  // (the effectiveRole-aware version). Declaring it a second time here is
  // a duplicate-const parse error that breaks the ENTIRE Views.jsx bundle
  // — every component in the file (CrewView, MediaView, AssetsView, …)
  // becomes undefined and the page renders blank. Keep only userCtx here.
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));

  const onReset = () => {
    if (!confirmed) {
      setConfirmed(true);
      setHint(true);
      setTimeout(() => setHint(false), 2400);
      return;
    }
    window.resetAllPanelLayouts && window.resetAllPanelLayouts();
    setConfirmed(false);
    setHint(true);
    setTimeout(() => setHint(false), 2400);
  };

  return (
    <section className="view-page settings-view">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">SETTINGS</div>
          <div className="vp-title">Workspace preferences</div>
        </div>
      </div>

      {/* v06p — Pair Color Palette + Visual Style side-by-side
          to save vertical space. Both panels share the same
          "theme-grid" inner structure so they read consistently. */}
      <div className="settings-row">
        <div className="settings-section glass">
          <div className="settings-section-head">APPEARANCE · SKIN</div>
          <div className="settings-section-sub">The whole look — page, panels, accents, glows. Saved on this project.</div>
          <div className="theme-grid">
            {_presetsForProject().map(t => (
              <button key={t.id}
                className={"theme-card" + (theme === t.id ? " is-active" : "")}
                onClick={() => onTheme(t.id)}>
                <div className="theme-swatches">
                  {t.swatches.map((s, i) => <span key={i} className="theme-swatch" style={{background: s}}/>)}
                </div>
                <div className="theme-name">{t.name}</div>
                <div className="theme-desc">{t.desc}</div>
                {theme === t.id && <span className="theme-active-tag">ACTIVE</span>}
              </button>
            ))}
          </div>
        </div>

        {/* 16 Sep 2026 — the VISUAL STYLE card (Glass / Minimalist) is gone. Hugo: "Remove the
            visual style top right, make that whole bar the presets for the Skin." With one child
            left, the settings-row grid gives the skin bar the full width on its own. The
            style/onStyle props stay wired so data-style keeps working; only the picker is gone. */}
      </div>

      {/* v1087 — Settings tidy-up (Hugo: "too many settings cards that are taking a whole row").
          Small cards share a row: three across (settings-row--3) or two across (settings-row).
          Preferences keeps the full width: switches in three columns, choices in two. */}
      <div className="settings-section glass">
        <div className="settings-section-head">PREFERENCES</div>
        <div className="settings-list settings-list--prefs">
          <SettingToggle label="Auto-save shot edits"
            sub="Save changes immediately. Off = explicit save button."
            value={autosave} onChange={setAutosave}/>
          <SettingToggle label="Reduced motion"
            sub="Disable transitions and panel animations."
            value={reduced} onChange={setReduced}/>
          <SettingToggle label="Notification fanfare"
            sub="Gold-outline shine on the bell + a toast card when a new alert arrives."
            value={notifFanfare} onChange={setNotifFanfare}/>
          <SettingToggle label="Generated Preview slide-in"
            sub="Slide the Generated Preview panel in from the right when you open the Assets generator."
            value={previewSlide} onChange={setPreviewSlide}/>
        </div>
        <div className="settings-list settings-list--selects">
          <SettingSelect label={uiScaleLabel}
            value={uiScale} onChange={setUiScale}
            options={[
              { id: "auto", label: "Auto" },
              { id: "0.7",  label: "70%" },
              { id: "0.8",  label: "80%" },
              { id: "0.85", label: "85%" },
              { id: "0.9",  label: "90%" },
              { id: "1",    label: "100%" },
              { id: "1.1",  label: "110%" },
            ]}/>
          <SettingSelect label="Thumbnail size in shot list"
            value={thumbsize} onChange={setThumbsize}
            options={[{ id: "small", label: "Small" }, { id: "medium", label: "Medium" }, { id: "large", label: "Large" }]}/>
          <SettingSelect label="Auto-play thumbnail video"
            value={thumbAutoplay} onChange={setThumbAutoplay}
            options={[{ id: "all", label: "All" }, { id: "hover", label: "Hover" }, { id: "none", label: "None" }]}/>
          {isAdmin && (
            <SettingSelect label="Frame aspect ratio"
              value={aspectOverride} onChange={setAspectOverride}
              options={ASPECT_OPTIONS}/>
          )}
        </div>
      </div>

      {/* v1090 — which pages the left menu shows, per stage (see MenuPresetsSection). Full
          width: its page grid uses the width. */}
      <MenuPresetsSection/>

      {/* v1087 — one row of three, Hugo's pick: the floating buttons, your calendar, and where
          dropped files land. */}
      <div className="settings-row settings-row--3">
        {/* 23 Sep 2026 — HIDDEN TOOLS (Hugo's post-mortem decisions) + the chat button. The
            List and Notes bubbles were removed; the three tools below are hidden by default (Multi-dispatch was removed on 23 Sep)
            because the Agent does their job now, and each switch brings one back. */}
        <HiddenToolsSection canChatBubble={canChatBubble} chatOn={bubbles.chat} onChat={(on) => setBubble("chat", on)}/>
        {/* v07zz121 — also here, so people without the Admin page can connect their calendar. */}
        <ProjectCalendarSection/>
        <ProjectDatesSection/>
      </div>

      {/* 15 Sep 2026 — three project cards: the look (wordmark + backdrop photo), where dropped
          files land (v1067, project-wide), and the progress weights (v1081, project-wide). */}
      <div className="settings-row settings-row--3">
        <ProjectLookSection theme={theme}/>
        <DropRoutingSection/>
        {/* 24 Sep 2026 (G7) — every project, always (it was missing in a project with no container yet):
            with no container the weights are the project's own defaults. */}
        <StageWeightsSection/>
      </div>

      {/* 24 Sep 2026 — music projects: the Google Drive folders the songs are mirrored from
          (src/LibrarySourcesSection.jsx). Full width: the Drive paths are long. Renders nothing
          for a project without a song library. */}
      {window.LibrarySourcesSection ? <window.LibrarySourcesSection key={window.__activeProjectId || ""}/> : null}

      {/* v1087 — the three smallest cards in one row: hide money, reset the panel layout,
          notifications. */}
      <div className="settings-row settings-row--3">
        {/* v07zz340 — Hide all money figures. Per-user toggle for screen-sharing / presenting:
            suppresses every budget, cost, and payment amount across the app (Schedule amounts,
            Reports + cost charts) regardless of the View Budget permission. Persisted in the
            user's preferences (follows them across devices). */}
        <div className="settings-section glass">
          <div className="settings-section-head">PRIVACY · MONEY</div>
          <div className="settings-section-sub">Hide every budget, cost, and payment figure across the app — handy when screen-sharing or presenting. Applies to your account only; turn it back off any time.</div>
          <button type="button"
            className={"char-voice-switch" + (hideMoney ? " is-on" : "")}
            role="switch" aria-checked={hideMoney}
            onClick={() => onHideMoney(!hideMoney)}>
            <span className="char-voice-switch-track"><span className="char-voice-switch-knob"/></span>
            <span className="char-voice-switch-txt">{hideMoney ? "Money figures hidden" : "Money figures visible"}</span>
          </button>
        </div>
        <div className="settings-section glass">
          <div className="settings-section-head">PANEL LAYOUT</div>
          <div className="settings-section-sub">Restore the default order of sidebar, right-column, and footer panels.</div>
          <div className="layout-reset-card">
            <div className="lr-text">
              <div className="lr-title">Reset layout</div>
              <div className="lr-sub">
                {hint
                  ? (confirmed ? "Click again to confirm reset." : "Layout restored to defaults.")
                  : "Drag-and-drop ordering will be reset to defaults."}
              </div>
            </div>
            <button className={"layout-reset-btn" + (confirmed ? " confirmed" : "")} onClick={onReset}>
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M3 12a9 9 0 1 0 3-6.7"/>
                <path d="M3 4v5h5"/>
              </svg>
              {confirmed ? "Click to confirm" : "Reset layout"}
            </button>
          </div>
        </div>
        <NotificationPrefsSection compact/>
      </div>

      {/* NotificationPrefsSection moved above into the row with Panel Layout.
          v07zw — UserManagement / FolderSync / CalendarSync sections
          were removed from Settings since they now live in the Admin
          page exclusively. Settings is for per-user preferences only;
          anything that affects the whole deployment lives in Admin. */}

    </section>
  );
}

// t08b — per-user email notification preferences.
const NOTIFICATION_LABELS = {
  new_note:           "New note",
  note_resolved:      "Note resolved",
  note_reply:         "Note reply",
  shot_status_change: "Shot status change",
  new_asset_version:  "New asset version",
  phase_started:      "Phase started",
  new_episode:        "New episode created",
};
function NotificationPrefsSection({ compact }) {
  const fetcher = window.authFetch || fetch;
  const [detailed, setDetailed] = React.useState({});
  const [eventTypes, setEventTypes] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [showModal, setShowModal] = React.useState(false);
  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    fetcher("/api/users/me/notifications")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!d) return;
        setEventTypes(d.event_types || []);
        setDetailed(d.detailed || {});
        setLoading(false);
      })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);

  // v06p — Per-channel toggle. `channel` is "email" or "in_app".
  const toggleChannel = (event_type, channel) => {
    const current = (detailed[event_type] && detailed[event_type][channel]) ?? true;
    const next = !current;
    setDetailed(prev => ({
      ...prev,
      [event_type]: { ...(prev[event_type] || { email: true, in_app: true }), [channel]: next },
    }));
    fetcher("/api/users/me/notifications", {
      method: "PATCH",
      body: JSON.stringify({ event_type, channel, enabled: next }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
      .catch(err => { setError(err.message); load(); });
  };

  // Summary numbers for the compact card. 23 Sep 2026 — email was removed; in-app only.
  const inAppOn  = eventTypes.filter(t => (detailed[t]?.in_app ?? true)).length;

  if (compact) {
    return (
      <>
        <div className="settings-section glass">
          <div className="settings-section-head">NOTIFICATIONS</div>
          <div className="settings-section-sub">
            In-app alerts (the bell) for events you care about. Click Configure for the full per-event list.
          </div>
          {error && <div className="users-error">{error}</div>}
          {loading ? (
            <div className="users-empty">Loading…</div>
          ) : (
            <div className="notif-compact-summary">
              <div className="notif-compact-stat">
                <span className="notif-compact-stat-num">{inAppOn}<span className="notif-compact-stat-of">/{eventTypes.length}</span></span>
                <span className="notif-compact-stat-cap">IN-APP</span>
              </div>
              <button type="button" className="notif-compact-configure"
                onClick={() => setShowModal(true)}>
                Configure
              </button>
            </div>
          )}
        </div>
        {showModal && (
          <NotificationPrefsModal
            eventTypes={eventTypes}
            detailed={detailed}
            onToggle={toggleChannel}
            onClose={() => setShowModal(false)}/>
        )}
      </>
    );
  }

  // Full inline render (legacy callers).
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">NOTIFICATIONS</div>
      <div className="settings-section-sub">
        Toggle which events alert you in the app (the bell).
      </div>
      {error && <div className="users-error">{error}</div>}
      {loading && <div className="users-empty">Loading…</div>}
      {!loading && (
        <NotificationMatrix
          eventTypes={eventTypes}
          detailed={detailed}
          onToggle={toggleChannel}/>
      )}
    </div>
  );
}

// v06p — Reusable matrix that renders an Event × Channel grid
// with toggles. Used both inline (legacy) and inside the
// granular modal.
function NotificationMatrix({ eventTypes, detailed, onToggle }) {
  return (
    <div className="notif-matrix">
      <div className="notif-matrix-head">
        <span/>
        <span>IN-APP</span>
      </div>
      {eventTypes.map(t => (
        <div key={t} className="notif-matrix-row">
          <div className="notif-matrix-label">
            <div className="notif-matrix-name">{NOTIFICATION_LABELS[t] || t}</div>
            <div className="notif-matrix-id">{t}</div>
          </div>
          <label className="notif-toggle">
            <input type="checkbox"
              checked={detailed[t]?.in_app ?? true}
              onChange={() => onToggle(t, "in_app")}/>
            <span className="notif-toggle-slider"/>
          </label>
        </div>
      ))}
    </div>
  );
}

// v06p — Granular notification settings modal. Click Configure on
// the compact notifications card to open it.
function NotificationPrefsModal({ eventTypes, detailed, onToggle, onClose }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="doc-modal-backdrop" onClick={onClose}>
      <div className="doc-modal glass" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 600 }}>
        <button className="modal-close doc-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div className="doc-modal-head" style={{ gridTemplateColumns: "1fr" }}>
          <div>
            <div className="doc-modal-eyebrow">NOTIFICATION PREFERENCES</div>
            <div className="doc-modal-name">Per-event toggles</div>
            <div className="doc-modal-meta">
              Toggle each event independently for the <strong>in-app</strong> bell.
              Disabled in-app events are still recorded server-side; you just won't see them in the bell.
            </div>
          </div>
        </div>
        <NotificationMatrix
          eventTypes={eventTypes}
          detailed={detailed}
          onToggle={onToggle}/>
        <div className="doc-modal-actions" style={{ justifyContent: "flex-end" }}>
          <button type="button" className="doc-modal-action doc-modal-action--primary" onClick={onClose}>
            Done
          </button>
        </div>
      </div>
    </div>
  ), portalRoot);
}

// t07b — Calendar Sync settings card. Admin-only. Shows the connected
// status of the Google Calendar integration + a "Sync now" button. The
// standalone Calendar page was removed from the sidebar in t07b;
// milestones still surface via the existing MilestonesCard widget on
// the overview and the Schedule page's calendar view.
// 15 Sep 2026 — the style builder that lived here is gone (Hugo: "i dont actually need this
// style builder anymore. i just want the cards to switch pallettes!"). The palette cards above
// are the one way to pick a look; App.jsx setThemeForProject saves it on the project.

// 15 Sep 2026 — PROJECT · LOOK: the wordmark shown at the top instead of the project name, and
// the photo behind the panels. Hugo: "where is the background image slot gone?" / "put the trope
// logo at the top instead of the TROPE text". The palette cards above pick the colours.
function ProjectLookSection({ theme }) {
  const fetcher = window.authFetch || fetch;
  const pid = window.__activeProjectId || null;
  const row = ((window.__projects && window.__projects.projects) || []).find((p) => p.id === pid) || null;
  const [logoV, setLogoV] = React.useState(row ? (row.logo_v || 0) : 0);
  const [logoUrl, setLogoUrl] = React.useState(null);
  const [mode, setMode] = React.useState(() => ((window.__projectStyle || {}).backdrop || {}).mode || "default");
  const _defaultContentTone = (style) => {
    const skin = (style && style.base) || theme || document.body.getAttribute("data-theme") || "forest";
    const presetTone = window.__skinContentTone && window.__skinContentTone(skin);
    if (presetTone) return presetTone;
    return window.__skinFamily && window.__skinFamily(skin) === "glass" ? "dark" : "light";
  };
  const [contentTone, setContentTone] = React.useState(() => {
    const st = window.__projectStyle || {};
    return st.content_tone === "dark" || st.content_tone === "light" ? st.content_tone : _defaultContentTone(st);
  });
  const [backdropFade, setBackdropFade] = React.useState(() => {
    const n = Number(((window.__projectStyle || {}).backdrop || {}).fade);
    return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
  });
  const [status, setStatus] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [confirmRemove, setConfirmRemove] = React.useState(false);
  const logoRef = React.useRef(null);
  const photoRef = React.useRef(null);
  React.useEffect(() => {
    const st = window.__projectStyle || {};
    setMode((st.backdrop || {}).mode || "default");
    setContentTone(st.content_tone === "dark" || st.content_tone === "light" ? st.content_tone : _defaultContentTone(st));
    const n = Number((st.backdrop || {}).fade);
    setBackdropFade(Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0);
  }, [pid, theme]);
  React.useEffect(() => {
    if (!pid || !logoV) { setLogoUrl(null); return; }
    let dead = false, url = null;
    fetcher("/api/projects/" + encodeURIComponent(pid) + "/logo?v=" + logoV)
      .then((r) => (r.ok ? r.blob() : null))
      .then((b) => { if (dead) return; url = b ? URL.createObjectURL(b) : null; setLogoUrl(url); })
      .catch(() => {});
    return () => { dead = true; if (url) { try { URL.revokeObjectURL(url); } catch (_) {} } };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pid, logoV]);
  const setBackdropMode = (m) => {
    setMode(m);
    const st = window.__projectStyle || {};
    if (window.__saveProjectStyle) window.__saveProjectStyle({ ...st, backdrop: { ...(st.backdrop || {}), mode: m } });
  };
  const setOverviewTone = (tone) => {
    setContentTone(tone);
    const st = window.__projectStyle || {};
    if (window.__saveProjectStyle) window.__saveProjectStyle({ ...st, content_tone: tone });
  };
  const previewBackdropFade = (value) => {
    const fade = Math.max(0, Math.min(100, Number(value) || 0));
    setBackdropFade(fade);
    const st = window.__projectStyle || {};
    if (window.__applyProjectStyle) window.__applyProjectStyle({ ...st, backdrop: { ...(st.backdrop || {}), fade } });
  };
  const saveBackdropFade = (value) => {
    const fade = Math.max(0, Math.min(100, Number(value) || 0));
    const st = window.__projectStyle || {};
    if (window.__saveProjectStyle) window.__saveProjectStyle({ ...st, backdrop: { ...(st.backdrop || {}), fade } });
  };
  const upload = (kind, file) => {
    if (!file || !pid) return;
    const fd = new FormData(); fd.append("file", file, file.name);
    setBusy(true); setStatus("Uploading…"); setConfirmRemove(false);
    // 17 Sep 2026 - a photo belongs to the skin it was chosen for (the project keeps one per skin,
    // and the one it replaces is renamed, not overwritten)
    const skin = (window.__projectStyle && window.__projectStyle.base) || document.body.getAttribute("data-theme") || "forest";
    const q = kind === "backdrop" ? "?skin=" + encodeURIComponent(skin) : "";
    fetcher("/api/projects/" + encodeURIComponent(pid) + "/" + kind + q, { method: "POST", body: fd })
      .then((r) => r.json().catch(() => ({})).then((j) => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error((j && j.error) || "Upload failed.");
        if (kind === "logo") {
          setLogoV((j && j.logo_v) || Date.now());
          if (window.__reloadProjects) window.__reloadProjects();
          setStatus("Wordmark saved. It shows at the top now.");
        } else {
          // fetch the new photo first, then switch to it, so the page never shows the old one.
          // (17 Sep 2026 review) only while this project is still the active one: a switch during the
          // upload must not save "use the photo" on the project switched to
          Promise.resolve(window.__reloadProjectBackdrop ? window.__reloadProjectBackdrop(skin, pid) : null)
            .then(() => { if (window.__activeProjectId === pid) setBackdropMode("image"); });
          setStatus("Photo saved.");
        }
      })
      .catch((e) => setStatus(e.message || "Upload failed."))
      .finally(() => setBusy(false));
  };
  const removeLogo = () => {
    if (!pid) return;
    setBusy(true); setConfirmRemove(false);
    fetcher("/api/projects/" + encodeURIComponent(pid) + "/logo", { method: "DELETE" })
      .then((r) => { if (!r.ok) throw new Error("Could not remove."); setLogoV(0); if (window.__reloadProjects) window.__reloadProjects(); setStatus("Wordmark removed. The project name shows again."); })
      .catch((e) => setStatus(e.message || "Could not remove."))
      .finally(() => setBusy(false));
  };
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">PROJECT · LOOK</div>
      <div className="settings-section-sub">The wordmark shown at the top instead of the project name, and the photo behind the panels. Colours come from the palette cards above.</div>
      <div className="pl-label">WORDMARK</div>
      <div className="pl-logo-box">
        {logoUrl ? <img className="pl-logo" src={logoUrl} alt=""/> : <span className="pl-logo-empty">No wordmark yet — the project name shows.</span>}
      </div>
      <div className="pl-actions">
        {!confirmRemove ? (
          <React.Fragment>
            <button type="button" className="user-add-submit" disabled={busy} onClick={() => logoRef.current && logoRef.current.click()}>Choose image…</button>
            <button type="button" className="user-add-submit" disabled={busy || !logoV} onClick={() => setConfirmRemove(true)}>Remove</button>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <span className="pl-confirm">Remove the wordmark?</span>
            <button type="button" className="user-add-submit" disabled={busy} onClick={removeLogo}>Yes, remove</button>
            <button type="button" className="user-add-submit" disabled={busy} onClick={() => setConfirmRemove(false)}>Keep it</button>
          </React.Fragment>
        )}
        <input ref={logoRef} type="file" accept="image/png,image/jpeg,image/webp" style={{ display: "none" }}
          onChange={(e) => { upload("logo", e.target.files && e.target.files[0]); e.target.value = ""; }}/>
      </div>
      <SettingSelect label="Backdrop" value={mode} onChange={setBackdropMode}
        options={[{ id: "default", label: "The skin's photo" }, { id: "image", label: "This project's photo" }, { id: "none", label: "Plain colour" }]}/>
      <SettingSelect label="Overview middle panel" value={contentTone} onChange={setOverviewTone}
        options={[{ id: "light", label: "Light" }, { id: "dark", label: "Dark" }]}/>
      <div className="pl-range-row">
        <div className="pl-range-head">
          <span>BACKGROUND IMAGE FADE</span>
          <strong>{backdropFade}%</strong>
        </div>
        <input className="pl-range" type="range" min="0" max="100" step="1" value={backdropFade}
          aria-label="Background image fade"
          onChange={(e) => previewBackdropFade(e.target.value)}
          onPointerUp={(e) => saveBackdropFade(e.currentTarget.value)}
          onKeyUp={(e) => saveBackdropFade(e.currentTarget.value)}
          onBlur={(e) => saveBackdropFade(e.currentTarget.value)}/>
        <div className="pl-range-scale"><span>Clear photo</span><span>Fully faded</span></div>
      </div>
      <div className="pl-actions">
        <button type="button" className="user-add-submit" disabled={busy} onClick={() => photoRef.current && photoRef.current.click()}>Choose photo…</button>
        <input ref={photoRef} type="file" accept="image/png,image/jpeg,image/webp" style={{ display: "none" }}
          onChange={(e) => { upload("backdrop", e.target.files && e.target.files[0]); e.target.value = ""; }}/>
      </div>
      <div className="pl-status">{status || "\u00a0"}</div>
    </div>
  );
}

// 15 Sep 2026 — the PROJECT's calendar (Hugo: "calendar seems to be app wide, should be
// project wide"): one iCal URL per project, saved on the project row. Paradise Found keeps
// the feed it always had; any other project shows only its own.
function ProjectCalendarSection() {
  const fetcher = window.authFetch || fetch;
  const pid = window.__activeProjectId || null;
  const [url, setUrl] = React.useState("");
  const [saved, setSaved] = React.useState("");
  const [status, setStatus] = React.useState("");
  const [feed, setFeed] = React.useState(null);   // { configured, source, count }
  const checkFeed = React.useCallback(() => {
    const d = new Date(); const s = d.toISOString().slice(0, 10);
    d.setDate(d.getDate() + 30); const e = d.toISOString().slice(0, 10);
    fetcher("/api/calendar/events?start=" + s + "&end=" + e).then((r) => r.json())
      .then((j) => setFeed({ configured: !!(j && j.configured), source: (j && j.source) || "none", count: (j && j.events || []).length }))
      .catch(() => setFeed(null));
  }, [fetcher]);
  React.useEffect(() => {
    if (!pid) return;
    fetcher("/api/projects").then((r) => r.json()).then((j) => {
      const p = ((j && j.projects) || []).find((x) => x.id === pid);
      const u = (p && p.calendar_ics_url) || "";
      setUrl(u); setSaved(u);
    }).catch(() => {});
    checkFeed();
  }, [pid, checkFeed]);
  const save = (value) => {
    if (!pid) return;
    setStatus("Saving…");
    fetcher("/api/projects/" + encodeURIComponent(pid), { method: "PATCH", body: JSON.stringify({ calendar_ics_url: value }) })
      .then(async (r) => { if (!r.ok) { const b = await r.json().catch(() => ({})); throw new Error(b.error || "HTTP " + r.status); } })
      .then(() => { setSaved(value); setUrl(value); setStatus(value ? "Saved. This project's events show on its overview now." : "Removed."); checkFeed(); })
      .catch((e) => setStatus(e.message));
  };
  const valid = !url.trim() || /^https?:\/\//i.test(url.trim());
  const feedLine = !feed ? " "
    : feed.source === "ics" && saved ? `${feed.count} events in the next 30 days, from this project's address.`
    : feed.configured ? `${feed.count} events in the next 30 days, from the server's default feed. Paste an address above to use this project's own.`
    : "No calendar yet. Paste an address above.";
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">CALENDAR · THIS PROJECT</div>
      <div className="settings-section-sub">One calendar per project. Paste its iCal address (Google Calendar → Settings → "Secret address in iCal format"). Its events show on this project only.</div>
      <div className="npi-note npi-note--flat" style={{ minHeight: "1.45em" }}>{feedLine}</div>
      <div className="npi-customrow">
        <input className="np-input npi-input" type="text" value={url} placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
          onChange={(e) => { setUrl(e.target.value); setStatus(""); }}
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (valid) save(url.trim()); } }}/>
        <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={() => save(url.trim())} disabled={!valid || url.trim() === saved}>Save</button>
        {saved ? <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={() => save("")}>Remove</button> : null}
      </div>
      <div className="npi-note npi-note--flat" style={{ minHeight: "1.45em" }}>{!valid ? "An iCal address starts with http(s)://" : (status || " ")}</div>
    </div>
  );
}

// 15 Sep 2026 — the project's own start and deadline, both optional (Hugo: "sometimes we dont
// have deadlines"). Shown for every project whose dates are editable here (all but Paradise
// Found, whose dates live in the Schedule page). Saving refreshes the top bar.
function ProjectDatesSection() {
  const fetcher = window.authFetch || fetch;
  const [editable, setEditable] = React.useState(false);
  const [start, setStart] = React.useState("");
  const [end, setEnd] = React.useState("");
  const [saved, setSaved] = React.useState({ start: "", end: "" });
  const [status, setStatus] = React.useState("");
  React.useEffect(() => {
    fetcher("/api/schedule").then((r) => r.json()).then((j) => {
      if (!j || !j.editable_dates) { setEditable(false); return; }
      setEditable(true);
      const s = j.project_start || "", e = j.project_end || "";
      setStart(s); setEnd(e); setSaved({ start: s, end: e });
    }).catch(() => setEditable(false));
  }, []);
  if (!editable) return null;
  const dirty = start !== saved.start || end !== saved.end;
  const save = () => {
    setStatus("Saving…");
    fetcher("/api/schedule/dates", { method: "PATCH", body: JSON.stringify({ project_start: start || null, project_end: end || null }) })
      .then(async (r) => { const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || "HTTP " + r.status); return b; })
      .then((b) => {
        const s = b.project_start || "", e = b.project_end || "";
        setStart(s); setEnd(e); setSaved({ start: s, end: e });
        setStatus(s || e ? "Saved. The bar now follows these dates." : "Saved. No deadline: the bar shows work progress.");
        if (typeof window.reloadAppData === "function") window.reloadAppData();
      })
      .catch((e) => setStatus(e.message));
  };
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">PROJECT DATES</div>
      <div className="settings-section-sub">Both optional. With a start and a deadline the top bar shows time elapsed and the health. Without them it shows work progress only.</div>
      <div className="np-fieldset-row npi-fieldrow">
        <div className="np-fieldset">
          <label className="np-label">Start</label>
          <input className="np-input npi-input" type="date" value={start} onChange={(e) => { setStart(e.target.value); setStatus(""); }}/>
        </div>
        <div className="np-fieldset">
          <label className="np-label">Deadline</label>
          <input className="np-input npi-input" type="date" value={end} onChange={(e) => { setEnd(e.target.value); setStatus(""); }}/>
        </div>
      </div>
      <div className="npi-customrow">
        <button type="button" className="np-btn np-btn-create npi-addbtn" onClick={save} disabled={!dirty}>Save dates</button>
        <span className="npi-note npi-note--flat" style={{ minHeight: "1.45em" }}>{status || " "}</span>
      </div>
    </div>
  );
}

function CalendarSyncSection() {
  // v07zz185 — PER-USER calendar. Every user connects their OWN Google Calendar
  // (no admin/permission needed); the dashboard + Schedule show their events.
  // A "Connect" button opens a modal with step-by-step instructions + paste box.
  const fetcher = window.authFetch || fetch;
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [icsUrl, setIcsUrl] = React.useState("");
  const [saving, setSaving] = React.useState(false);
  const [showModal, setShowModal] = React.useState(false);
  const load = React.useCallback(() => {
    fetcher("/api/calendar/milestones")
      .then(r => r.ok ? r.json() : null)
      .then(d => setData(d))
      .catch(err => setError(err.message));
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);
  const connect = () => {
    const u = icsUrl.trim();
    if (!/^https?:\/\//i.test(u)) { setError("Enter a valid http(s) iCal (.ics) URL — your Google Calendar 'Secret address in iCal format'."); return; }
    setSaving(true); setError(null);
    fetcher("/api/users/me/calendar", { method: "POST", body: JSON.stringify({ ics_url: u }) })
      .then(async r => { const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; })
      .then(() => { setShowModal(false); load(); })
      .catch(err => setError(err.message))
      .finally(() => setSaving(false));
  };
  const disconnect = () => {
    setSaving(true); setError(null);
    fetcher("/api/users/me/calendar", { method: "POST", body: JSON.stringify({ clear: true }) })
      .then(async r => { const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; })
      .then(() => { setIcsUrl(""); load(); })
      .catch(err => setError(err.message))
      .finally(() => setSaving(false));
  };
  const hasPersonal = !!(data && data.personal_ics);
  const milestones = (data && data.milestones) || [];
  const modalRoot = (typeof document !== "undefined" && document.getElementById("modal-root")) || (typeof document !== "undefined" ? document.body : null);
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">MY CALENDAR</div>
      <div className="settings-section-sub">
        Connect your own Google Calendar to see your events in the dashboard widget and Schedule view.
        Everyone links their own — it only affects your view, no admin needed.
      </div>
      {error && !showModal && <div className="users-error">{error}</div>}

      <div className="folder-sync-stats" style={{ marginTop: 12 }}>
        <span><strong>{hasPersonal ? "Connected" : "Not connected"}</strong></span>
        <span><strong>{milestones.length}</strong> events</span>
      </div>

      <div className="folder-sync-actions" style={{ display: "flex", gap: 8, marginTop: 10 }}>
        <button type="button" className="user-add-submit"
          onClick={() => { setError(null); setIcsUrl((data && data.personal_ics) || ""); setShowModal(true); }}>
          {hasPersonal ? "Update calendar" : "Connect Google Calendar"}
        </button>
        {hasPersonal && (
          <button type="button" className="user-add-submit" onClick={disconnect} disabled={saving}
            style={{ background: "transparent", border: "1px solid color-mix(in srgb, var(--danger-soft) 50%, transparent)", color: "var(--danger)" }}>
            Disconnect
          </button>
        )}
      </div>

      {showModal && modalRoot && ReactDOM.createPortal(
        <div onClick={() => setShowModal(false)}
          style={{ position: "fixed", inset: 0, background: "color-mix(in srgb, var(--shade-13) 55%, transparent)", backdropFilter: "blur(var(--blur-scrim-3))", display: "grid", placeItems: "center", zIndex: 9999 }}>
          <div className="glass" onClick={(e) => e.stopPropagation()}
            style={{ width: "min(560px, 92vw)", background: "var(--panel-cream)", borderRadius: "var(--r-card)", padding: "22px 24px", border: "1px solid var(--card-border, color-mix(in srgb, var(--card-border) 55%, transparent))", boxShadow: "0 18px 60px color-mix(in srgb, var(--shade-31) 45%, transparent)" }}>
            <div style={{ fontFamily: "var(--font-display, 'Bebas Neue'), sans-serif", fontSize: "var(--fs-23)", letterSpacing: "var(--track-01)", color: "var(--ink)" }}>Connect your Google Calendar</div>
            <ol style={{ fontSize: "var(--fs-12-5)", color: "var(--ink-muted)", lineHeight: 1.6, margin: "10px 0 14px", paddingLeft: 18 }}>
              <li>Open <strong>Google Calendar</strong> on the web.</li>
              <li>Hover your calendar in the left sidebar → click <strong>⋮</strong> → <strong>Settings and sharing</strong>.</li>
              <li>Scroll to <strong>Integrate calendar</strong>.</li>
              <li>Copy the <strong>“Secret address in iCal format”</strong> (it ends in <code>basic.ics</code>).</li>
              <li>Paste it below and hit Connect.</li>
            </ol>
            {error && <div className="users-error" style={{ marginBottom: 8 }}>{error}</div>}
            <input type="url" autoFocus value={icsUrl}
              onChange={(e) => setIcsUrl(e.target.value)}
              onKeyDown={(e) => { if (e.key === "Enter") connect(); if (e.key === "Escape") setShowModal(false); }}
              placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
              style={{ width: "100%", boxSizing: "border-box", font: "inherit", fontSize: "var(--fs-body)", padding: "10px 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--card-border, color-mix(in srgb, var(--card-border) 55%, transparent))", background: "color-mix(in srgb, var(--cream) 95%, transparent)", color: "var(--ink)" }}/>
            <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 14 }}>
              <button type="button" className="user-add-submit" onClick={() => setShowModal(false)}
                style={{ background: "transparent", border: "1px solid var(--card-border, color-mix(in srgb, var(--card-border) 55%, transparent))", color: "var(--ink-muted)" }}>Cancel</button>
              <button type="button" className="user-add-submit" onClick={connect} disabled={saving || !icsUrl.trim()}>
                {saving ? "Saving…" : "Connect"}
              </button>
            </div>
          </div>
        </div>, modalRoot)}
    </div>
  );
}

// t16 — Folder ↔ DB reconciliation. Admin-only. Read-only by default
// (server enforces): clicking "Sync now" calls GET /api/folder/diff and
// shows the lists of shots that exist only on disk, only in DB, or in
// both. Resolution buttons POST /api/folder/sync — currently gated
// server-side (503) until the watcher is enabled.
function FolderSyncSection() {
  const [diff, setDiff] = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);
  const fetcher = window.authFetch || fetch;

  const scan = () => {
    setLoading(true); setError(null);
    fetcher("/api/folder/diff")
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(d => { setDiff(d); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  };

  return (
    <div className="settings-section glass">
      <div className="settings-section-head">FOLDER SYNC · ADMIN ONLY</div>
      <div className="settings-section-sub">
        Compare the shot folders under <code>WATCH_PATH</code> against the
        shots in the database. Read-only — filesystem writes are gated
        until the folder watcher is enabled in <code>db/folderWatcher.js</code>.
      </div>

      <div className="folder-sync-actions">
        <button type="button" className="user-add-submit" onClick={scan} disabled={loading}>
          {loading ? "Scanning…" : "Sync now"}
        </button>
      </div>

      {error && <div className="users-error">{error}</div>}

      {diff && !diff.active && (
        <div className="folder-sync-empty">
          <strong>WATCH_PATH not configured.</strong> Set it in <code>.env</code> and restart the server.
        </div>
      )}

      {diff && diff.active && (
        <div className="folder-sync-results">
          <div className="folder-sync-stats">
            <span><strong>{diff.bothPresent.length}</strong> matched</span>
            <span><strong>{diff.onlyOnDisk.length}</strong> only on disk</span>
            <span><strong>{diff.onlyInDb.length}</strong> only in DB</span>
          </div>

          {diff.onlyOnDisk.length > 0 && (
            <details className="folder-sync-group" open>
              <summary>Folders without a DB record ({diff.onlyOnDisk.length})</summary>
              <ul className="folder-sync-list">
                {diff.onlyOnDisk.map(r => (
                  <li key={r.shotId}>
                    <span className="fs-shotid">{r.shotId}</span>
                    <span className="fs-folder">{r.folderPath}</span>
                    <span className="fs-files">{r.fileCount} files</span>
                  </li>
                ))}
              </ul>
            </details>
          )}
          {diff.onlyInDb.length > 0 && (
            <details className="folder-sync-group">
              <summary>Shots without a folder ({diff.onlyInDb.length})</summary>
              <ul className="folder-sync-list">
                {diff.onlyInDb.slice(0, 50).map(r => (
                  <li key={r.shotId}>
                    <span className="fs-shotid">{r.shotId}</span>
                    <span className="fs-title">{r.frame_title || ""}</span>
                    <span className="fs-files">SEQ {String(r.sequence).padStart(2,"0")}{r.is_archive ? " · archived" : ""}</span>
                  </li>
                ))}
                {diff.onlyInDb.length > 50 && (
                  <li className="fs-more">+ {diff.onlyInDb.length - 50} more…</li>
                )}
              </ul>
            </details>
          )}
        </div>
      )}
    </div>
  );
}

// t11 — admin-only user management. Lists every user, adds new ones,
// changes roles, deletes (refuses to delete the last admin server-side).
// v05c — central role taxonomy. The order here matches the back-end
// ROLE_RANKS hierarchy (highest authority last). Display labels are
// title-cased; badge colours use distinct accents not already taken by
// the existing 4 roles so the new roles are visually identifiable.
const ROLE_OPTIONS = [
  { id: "reviewer",   label: "Reviewer",   badge: "reviewer"   },
  { id: "artist",     label: "Artist",     badge: "artist"     },
  // v07zz515 — Editor: video-editor profile (Review/Media/Presentations focus).
  { id: "editor",     label: "Editor",     badge: "editor"     },
  { id: "lead",       label: "Lead",       badge: "lead"       },
  { id: "director",   label: "Director",   badge: "director"   },
  { id: "supervisor", label: "Supervisor", badge: "supervisor" },
  // v06p — Temporary "Tester" role. Sits between supervisor and
  // producer; near-full access for invited testers without the
  // admin-only powers (manage users, edit integration secrets).
  { id: "tester",     label: "Tester",     badge: "tester"     },
  { id: "producer",   label: "Producer",   badge: "producer"   },
  { id: "admin",      label: "Admin",      badge: "admin"      },
];
const ROLE_LABEL = (id) => (ROLE_OPTIONS.find(r => r.id === id) || {}).label || id;
// v05i — surface the role taxonomy on window so AdminPage.jsx (a
// separate <script> tag and therefore a separate top-level scope) can
// read it without having to redeclare the list.
window.ROLE_OPTIONS = ROLE_OPTIONS;
window.ROLE_LABEL   = ROLE_LABEL;

function UserManagementSection() {
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [draft, setDraft] = React.useState({ email: "", name: "", role: "reviewer", password: "" });
  const [submitting, setSubmitting] = React.useState(false);
  const fetcher = window.authFetch || fetch;

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    fetcher("/api/users")
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(({ users: rows }) => { setUsers(Array.isArray(rows) ? rows : []); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);

  const submit = (e) => {
    e && e.preventDefault();
    if (submitting) return;
    setError(null); setSubmitting(true);
    fetcher("/api/users", { method: "POST", body: JSON.stringify(draft) })
      .then(async r => {
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
        return body;
      })
      .then(() => { setDraft({ email: "", name: "", role: "reviewer", password: "" }); load(); })
      .catch(err => setError(err.message))
      .finally(() => setSubmitting(false));
  };
  const updateRole = (id, role) => {
    fetcher(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify({ role }) })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => load())
      .catch(err => setError(err.message));
  };
  const remove = (id, email) => {
    if (!confirm(`Remove ${email}? This cannot be undone.`)) return;
    fetcher(`/api/users/${id}`, { method: "DELETE" })
      .then(async r => {
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
      })
      .then(() => load())
      .catch(err => setError(err.message));
  };

  return (
    <div className="settings-section glass">
      <div className="settings-section-head">USER MANAGEMENT · ADMIN ONLY</div>
      <div className="settings-section-sub">Add, remove, and reassign roles for everyone with access to this tracker.</div>

      {error && <div className="users-error">{error}</div>}

      <div className="users-list">
        {loading && <div className="users-empty">Loading…</div>}
        {!loading && users.length === 0 && <div className="users-empty">No users yet.</div>}
        {!loading && users.map(u => (
          <div key={u.id} className="user-row">
            <span className="user-avatar">{((u.name || u.email).split(/\s+/).map(s => s[0] || "").join("").slice(0,2) || "??").toUpperCase()}</span>
            <div className="user-meta">
              <div className="user-name">{u.name || "(no name)"}</div>
              <div className="user-email">{u.email}</div>
            </div>
            <select
              className={"user-role-select user-role-select--" + (ROLE_OPTIONS.find(r => r.id === u.role) || {}).badge}
              value={u.role}
              onChange={(e) => updateRole(u.id, e.target.value)}
            >
              {ROLE_OPTIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
            </select>
            <button className="user-remove" type="button" onClick={() => remove(u.id, u.email)} title="Remove user">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 5l14 14M19 5L5 19"/></svg>
            </button>
          </div>
        ))}
      </div>

      <form className="user-add-form" onSubmit={submit}>
        <div className="user-add-eyebrow">ADD USER</div>
        <div className="user-add-grid">
          <input className="user-input" placeholder="Email"        type="email"    required value={draft.email}    onChange={(e) => setDraft({ ...draft, email: e.target.value })}/>
          <input className="user-input" placeholder="Name"         type="text"              value={draft.name}     onChange={(e) => setDraft({ ...draft, name: e.target.value })}/>
          <select className="user-input" value={draft.role} onChange={(e) => setDraft({ ...draft, role: e.target.value })}>
            {ROLE_OPTIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
          </select>
          <input className="user-input" placeholder="Password (8+)" type="password" required minLength={8} value={draft.password} onChange={(e) => setDraft({ ...draft, password: e.target.value })}/>
          <button className="user-add-submit" type="submit" disabled={submitting}>{submitting ? "Adding…" : "Add user"}</button>
        </div>
      </form>
    </div>
  );
}

// v1090 — SETTINGS ▸ MENU · PRESETS. Hugo: "I want to have some presets for left side menu
// visibility, so i only see what is important for each stage. i need to be able to edit and save
// them." Pick a preset, tick the pages it shows; it saves as you click, like the other switches
// on this page. "Use this preset" puts it in the left menu — v1093: this card is the ONLY place
// to switch; the dropdown at the top of the left menu is gone (Hugo: "this should be in
// Settings, not here"). The data and the rules live in window.navPresets (src/Sidebar.jsx).
// Fixed sizes throughout, so picking another preset never changes the card's height (#20).
function MenuPresetsSection() {
  const NP = window.navPresets || null;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const fn = () => bump();
    window.addEventListener("filmtracker:navpresets", fn);
    return () => window.removeEventListener("filmtracker:navpresets", fn);
  }, []);
  const state = NP ? NP.read() : { list: [], active: "all" };
  const [selId, setSelId] = React.useState(() => (state.active !== "all" ? state.active : (state.list[0] ? state.list[0].id : "all")));
  const sel = state.list.find(p => p.id === selId) || null;   // null = "All pages"
  React.useEffect(() => { if (selId !== "all" && !sel) setSelId("all"); }, [selId, !!sel]);   // deleted elsewhere
  const [nameDraft, setNameDraft] = React.useState(sel ? sel.name : "All pages");
  React.useEffect(() => { setNameDraft(sel ? sel.name : "All pages"); }, [selId, sel ? sel.name : ""]);
  const [confirmDel, setConfirmDel] = React.useState(false);
  React.useEffect(() => { setConfirmDel(false); }, [selId]);
  const [status, setStatus] = React.useState("");
  const nameRef = React.useRef(null);

  const u = (userCtx && userCtx.user) || window.__currentUser || {};
  const isAdmin = String(u.role || "") === "admin";
  const perms = u.permissions || {};
  const locked = (NP && NP.LOCKED) || ["settings"];
  // The pages this person can open at all: the left menu's own test, before any preset.
  const pages = (NP ? NP.items() : []).filter(it => !it.adminOnly || isAdmin).filter(it => isAdmin || !it.perm || !!perms[it.perm]);
  const SB = window.SBIcon || {};

  const persist = (next, msg) => {
    if (!NP) return;
    setStatus("Saving…");
    NP.save(next).then(r => setStatus(r && r.ok ? (msg || "Saved.")
      : (r && r.reason === "not signed in") ? "Saved in this browser only (not signed in)."
      : "Saved here, but the server did not answer (" + ((r && r.reason) || "error") + "). It will not follow you to Railway yet."));
  };
  const editList = (fn, msg) => persist({ list: fn(state.list.map(p => ({ ...p, hidden: p.hidden.slice() }))) }, msg);
  const togglePage = (id) => {
    if (!sel || locked.indexOf(id) >= 0) return;
    editList(list => list.map(p => p.id !== sel.id ? p
      : { ...p, hidden: p.hidden.indexOf(id) >= 0 ? p.hidden.filter(x => x !== id) : p.hidden.concat([id]) }));
  };
  const commitName = () => {
    if (!sel) return;
    const nm = nameDraft.trim().slice(0, 40);
    if (!nm) { setNameDraft(sel.name); return; }
    if (nm !== sel.name) editList(list => list.map(p => p.id === sel.id ? { ...p, name: nm } : p), "Renamed.");
  };
  const addPreset = () => {
    const id = "p" + Date.now().toString(36);
    editList(list => list.concat([{ id, name: "New preset", hidden: [] }]), "Added. Type its name, then untick the pages you don't need.");
    setSelId(id);
    setTimeout(() => { try { if (nameRef.current) { nameRef.current.focus(); nameRef.current.select(); } } catch (_) {} }, 60);
  };
  const removePreset = () => {
    if (!sel) return;
    if (!confirmDel) { setConfirmDel(true); return; }   // two clicks, no browser pop-up (invariant #22)
    const gone = sel;
    persist({ list: state.list.filter(p => p.id !== gone.id), active: state.active === gone.id ? "all" : state.active },
      "Deleted \u201c" + gone.name + "\u201d.");
    setSelId("all");
    setConfirmDel(false);
  };
  const inUse = state.active === selId;
  const shown = sel ? pages.filter(p => locked.indexOf(p.id) >= 0 || sel.hidden.indexOf(p.id) < 0).length : pages.length;

  return (
    <div className="settings-section glass mp-card">
      <div className="settings-section-head">MENU · PRESETS</div>
      <div className="settings-section-sub">Choose which pages show in the left menu for each stage. Press Use this preset to put one in the menu. Changes save as you click, to your account, so they follow you to Railway. Settings always stays in the menu.</div>
      <div className="mp-presets" role="tablist" aria-label="Presets">
        {[{ id: "all", name: "All pages" }].concat(state.list).map(p => (
          <button key={p.id} type="button" role="tab" aria-selected={selId === p.id}
            className={"mp-chip" + (selId === p.id ? " is-sel" : "") + (state.active === p.id ? " is-active" : "")}
            title={state.active === p.id ? "In use in the left menu" : "Show this preset below"}
            onClick={() => setSelId(p.id)}>
            <i className="mp-chip-dot" aria-hidden="true"/>{p.name}
          </button>
        ))}
        <button type="button" className="mp-chip mp-chip--new" onClick={addPreset} disabled={state.list.length >= 20}>+ New preset</button>
      </div>
      <div className="mp-bar">
        <input ref={nameRef} className="mp-name" value={nameDraft} maxLength={40} disabled={!sel}
          aria-label="Preset name" title={sel ? "Type a new name. Enter saves." : "All pages cannot be renamed"}
          onChange={(e) => setNameDraft(e.target.value)}
          onBlur={commitName}
          onKeyDown={(e) => {
            // v1090b - Enter saves the name ITSELF: a blur event does not fire while the window
            // is in the background, so relying on onBlur alone lost the new name.
            if (e.key === "Enter") { e.preventDefault(); commitName(); e.currentTarget.blur(); }
            if (e.key === "Escape") { e.preventDefault(); setNameDraft(sel ? sel.name : "All pages"); setTimeout(() => { try { nameRef.current.blur(); } catch (_) {} }, 0); }
          }}/>
        <span className="mp-count">{shown} of {pages.length} pages</span>
        <button type="button" className={"bg-picker-btn mp-use" + (inUse ? " is-in-use" : "")} disabled={inUse}
          onClick={() => persist({ active: selId }, "In use in the left menu now.")}>{inUse ? "In use" : "Use this preset"}</button>
        <button type="button" className={"bg-picker-btn bg-picker-btn--ghost mp-del" + (confirmDel ? " is-confirm" : "")} disabled={!sel}
          onClick={removePreset}>{confirmDel ? "Click again to delete" : "Delete preset"}</button>
      </div>
      <div className="mp-pages">
        {pages.map(it => {
          const isLocked = locked.indexOf(it.id) >= 0;
          const on = !sel || isLocked || sel.hidden.indexOf(it.id) < 0;
          return (
            <button key={it.id} type="button" role="switch" aria-checked={on}
              className={"mp-page" + (on ? " is-on" : "") + (isLocked ? " is-locked" : "")}
              disabled={!sel || isLocked}
              title={isLocked ? "Always in the menu" : !sel ? "All pages shows everything. Pick or add a preset to change one." : (on ? "Shown. Click to hide it." : "Hidden. Click to show it.")}
              onClick={() => togglePage(it.id)}>
              <span className="mp-page-icon">{SB[it.icon] || null}</span>
              <span className="mp-page-label">{it.label}</span>
              <span className="mp-page-check" aria-hidden="true">{on ? "✓" : ""}</span>
            </button>
          );
        })}
      </div>
      {/* One line, always rendered, so the card never changes height. */}
      <div className="settings-section-sub mp-status">{status || (sel ? "" : "All pages cannot be changed. Pick a preset above, or add one.")}</div>
    </div>
  );
}

function SettingToggle({ label, sub, value, onChange, disabled }) {
  return (
    <div className="setting-row">
      <div className="setting-text">
        <div className="setting-label">{label}</div>
        <div className="setting-sub">{sub}</div>
      </div>
      <button className={"setting-toggle" + (value ? " is-on" : "")} onClick={() => onChange(!value)} aria-pressed={value} disabled={!!disabled}>
        <span className="setting-toggle-thumb"/>
      </button>
    </div>
  );
}

// 23 Sep 2026 — Settings ▸ HIDDEN TOOLS. Hugo's post-mortem decisions: the Agent does these four
// jobs now, so their entry points are hidden by default; the code stays and one switch per tool
// brings it back for this PROJECT (saved on the server, synced; producers and admins can change
// it). The Chat bubble switch lives here too (this browser only). The switch reads "on" when the
// tool is SHOWN, so the stored map is the inverse: { <key>: hidden }.
const HIDDEN_TOOL_ROWS = [
  // 23 Sep 2026 (image modes) — the Multi-dispatch row left: that tool was REMOVED, not hidden.
  { key: "import_shotlist", label: "Import shotlist",  sub: "The Import shotlist window in the project menu." },
  { key: "notes_digest",    label: "Digest + Updates", sub: "The daily notes digest and the Updates page in the menu." },
  { key: "refine_prompt",   label: "Refine with AI",   sub: "The Refine with AI button under the assembled prompt on Generate." },
];
function HiddenToolsSection({ canChatBubble, chatOn, onChat }) {
  const [hidden, setHidden] = React.useState(() => ({ ...(window.__uiHidden || {}) }));
  const [err, setErr] = React.useState(null);
  React.useEffect(() => {
    const fn = () => setHidden({ ...(window.__uiHidden || {}) });
    window.addEventListener("filmtracker:ui-hidden", fn);
    if (window.__loadUiHidden) window.__loadUiHidden();
    return () => window.removeEventListener("filmtracker:ui-hidden", fn);
  }, []);
  const role = window.__effectiveRole || (window.__currentUser && window.__currentUser.role) || "";
  const canEdit = role === "admin" || role === "producer";
  const isHidden = (k) => hidden[k] !== false;
  const flip = (k, show) => {
    const prev = { ...(window.__uiHidden || {}) };
    if (window.__setUiHiddenLocal) window.__setUiHiddenLocal({ ...prev, [k]: !show });
    setErr(null);
    (window.authFetch || fetch)("/api/settings/ui-hidden", {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ hidden: { [k]: !show } }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error((j && j.error) || "Could not save.");
        if (window.__setUiHiddenLocal) window.__setUiHiddenLocal(j.hidden);
      })
      .catch(e => { if (window.__setUiHiddenLocal) window.__setUiHiddenLocal(prev); setErr(e.message); });
  };
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">HIDDEN TOOLS</div>
      <div className="settings-section-sub">
        The Agent does these jobs now, so they are hidden. Switch one on to bring it back for this project.
        {!canEdit && " Producers and admins can change these."}
        {err && <span className="users-error"> {err}</span>}
      </div>
      <div className="settings-list">
        {HIDDEN_TOOL_ROWS.map(r => (
          <SettingToggle key={r.key} label={r.label} sub={r.sub} value={!isHidden(r.key)}
            disabled={!canEdit} onChange={(on) => flip(r.key, on)}/>
        ))}
        {canChatBubble && <SettingToggle label="Chat button" sub="The floating AI assistant. This browser only." value={chatOn} onChange={onChat}/>}
      </div>
    </div>
  );
}
function SettingSelect({ label, value, onChange, options }) {
  return (
    <div className="setting-row">
      <div className="setting-text"><div className="setting-label">{label}</div></div>
      <div className="setting-select">
        {options.map(o => (
          <button key={o.id}
            className={"setting-select-btn" + (value === o.id ? " is-active" : "")}
            onClick={() => onChange(o.id)}>{o.label}</button>
        ))}
      </div>
    </div>
  );
}

// v1067 — PROJECT · DROPPED FILES. Where a file you drop into a shot's frames/ or video/
// folder lands: Hero, Good or WIP — one choice for images, one for videos (Hugo: "add a
// switch in the settings for both frames and videos, so i can pick what folders dropped
// images or videos are going in, hero, good or wip"). Unlike the rest of this page it is
// NOT a per-user preference: the folder watcher on the machine that watches the project
// applies it for everyone, so it is saved on the server (GET/PUT /api/settings/drop-routing)
// and only a producer or an admin can change it — the same gate as the PUT.
function DropRoutingSection() {
  const OPTS = [{ id: "hero", label: "Hero" }, { id: "good", label: "Good" }, { id: "wip", label: "WIP" }];
  const [routing, setRouting] = React.useState(null);   // null while loading: no button lit
  const [local, setLocal] = React.useState(true);
  const [status, setStatus] = React.useState("");
  const _role = (() => {
    const r = window.__effectiveRole;
    return String((typeof r === "function" ? r() : r) || (window.__currentUser && window.__currentUser.role) || "");
  })();
  const canEdit = _role === "admin" || _role === "producer";
  React.useEffect(() => {
    let alive = true;
    (window.authFetch || fetch)("/api/settings/drop-routing")
      .then(r => (r.ok ? r.json() : null))
      .then(j => { if (alive && j) { setRouting(j.routing || null); setLocal(!!j.local); } })
      .catch(() => {});
    return () => { alive = false; };
  }, []);
  const save = (kind, tier) => {
    if (!canEdit || !local || !routing || routing[kind] === tier) return;
    const prev = routing;
    setRouting({ ...routing, [kind]: tier });
    setStatus("Saving…");
    (window.authFetch || fetch)("/api/settings/drop-routing", {
      method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [kind]: tier }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error((j && j.error) || "Could not save.");
        setRouting(j.routing);
        setStatus("Saved.");
      })
      .catch(e => { setRouting(prev); setStatus(e.message || "Could not save."); });
  };
  const note = status
    || (!local ? "Set this on the computer that watches the project folder."
      : !canEdit ? "Only a producer or an admin can change this."
      : "");
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">PROJECT · DROPPED FILES</div>
      <div className="settings-section-sub">Where a file lands when you drop it into a shot's frames or video folder. Applies to everyone on this project. Generated images still land in WIP, and a file you drop straight into _wip, _good or _archived stays there.</div>
      <div className="settings-list">
        <SettingSelect label="Dropped images" value={routing ? routing.frames : null} options={OPTS} onChange={(v) => save("frames", v)} />
        <SettingSelect label="Dropped videos" value={routing ? routing.videos : null} options={OPTS} onChange={(v) => save("videos", v)} />
      </div>
      {/* One line, always rendered, so the card never changes height (invariant #20). */}
      <div className="settings-section-sub" style={{ minHeight: "1.4em", marginTop: 8 }}>{note}</div>
    </div>
  );
}

// v1081 — PROJECT · PROGRESS WEIGHTS, moved here from a gear on the progress circle (Hugo:
// "i hate the cog thing to be on the donut, needs to be in the settings page"). How much
// Frames / Video / Upscale count in Overall Progress (v1080). Saved on the episode
// (episodes.stage_weights, synced), so it applies to everyone; manage_schedule to change it.
// The line under the boxes is always there, so the card never changes height (#20).
function StageWeightsSection() {
  const data = window.__appData || {};
  const ep = data.episode || null;
  const cur = window.stageGroupWeights ? window.stageGroupWeights(ep) : { frames: 50, video: 40, upscale: 10 };
  // 24 Sep 2026 (G7) — another project with no container yet saves PROJECT-level weights (its settings
  // row, PATCH /api/schedule/stage-weights); with a container, the container's own weights as before.
  const _projLevel = !!(window.__isDefaultProject && !window.__isDefaultProject()) && !(ep && ep.id);
  // v1082 — the default is the schedule: shot-stage weeks scaled to 100 (see App.jsx).
  const sch = window.scheduleGroupWeights ? window.scheduleGroupWeights(data.schedule) : null;
  const defs = sch || window.STAGE_GROUP_DEFAULTS || { frames: 50, video: 40, upscale: 10 };
  const fmtWk = (w) => (Math.round(w * 10) / 10) + (Math.round(w * 10) / 10 === 1 ? " week" : " weeks");
  const schLine = sch
    ? "Default from the schedule: " + ["frames", "video", "upscale"].map(k => sch.names[k].join(" + ") + " " + fmtWk(sch.weeks[k])).join(" · ") + " (Tracker building and Grading have no shots, so they do not count)."
    : "The schedule has no Frames, Video and 4K Upscaling phases, so the default is " + defs.frames + " / " + defs.video + " / " + defs.upscale + ".";
  const canEdit = !window.hasPerm || window.hasPerm("manage_schedule");
  const toVals = (w) => ({ frames: String(w.frames), video: String(w.video), upscale: String(w.upscale) });
  const [vals, setVals] = React.useState(() => toVals(cur));
  // follow a change saved somewhere else (another tab, Railway) while this page is open
  const curKey = cur.frames + "/" + cur.video + "/" + cur.upscale;
  React.useEffect(() => { setVals(toVals(cur)); }, [curKey]);   // eslint-disable-line react-hooks/exhaustive-deps
  const [busy, setBusy] = React.useState(false);
  const [status, setStatus] = React.useState("");
  const num = (x) => { if (String(x).trim() === "") return NaN; const n = Number(x); return Number.isFinite(n) && n >= 0 && n <= 100 ? n : NaN; };
  const g = { frames: num(vals.frames), video: num(vals.video), upscale: num(vals.upscale) };
  const total = (g.frames || 0) + (g.video || 0) + (g.upscale || 0);
  const valid = [g.frames, g.video, g.upscale].every(Number.isFinite) && Math.abs(total - 100) < 0.05;
  const counted = (data.shots || []).filter(s => (window.progressCounted ? window.progressCounted(s) : true));
  const stepDone = window.progressStepDone || ((s, k) => !!(s && s.stage_status && s.stage_status[k] === "done"));
  const pctWith = (groups) => (window.weightedProgress ? Math.round(window.weightedProgress(counted, stepDone, groups).overall) : null);
  const nowPct = pctWith(cur);
  const newPct = valid ? pctWith(g) : null;
  const dirty = valid && (g.frames !== cur.frames || g.video !== cur.video || g.upscale !== cur.upscale);
  const isDefault = _projLevel ? cur.source !== "project" : cur.source !== "saved";
  const save = async (groups) => {
    if ((!_projLevel && (!ep || !ep.id)) || !canEdit) return;
    setBusy(true); setStatus("Saving…");
    try {
      const r = await (window.authFetch || fetch)(_projLevel ? "/api/schedule/stage-weights" : `/api/episodes/${encodeURIComponent(ep.id)}/stage-weights`, {
        method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(groups || { reset: true }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || ("HTTP " + r.status));
      if (!groups) setVals(toVals(defs));
      if (window.reloadAppData) window.reloadAppData();
      setStatus("Saved.");
    } catch (e) { setStatus(e.message || "Could not save."); }
    setBusy(false);
  };
  const field = (k, label) => (
    <label className="sw-field">
      <span className="sw-field-label">{label}</span>
      <span className="sw-field-box">
        <input type="number" min="0" max="100" step="1" value={vals[k]} disabled={!canEdit || busy}
          onChange={(e) => { const x = e.target.value; setVals(p => ({ ...p, [k]: x })); setStatus(""); }}/>
        <span className="sw-field-unit">%</span>
      </span>
    </label>
  );
  const line = status
    || (!canEdit ? "Only people who can manage the schedule can change this."
      : !valid ? `Total ${Math.round(total * 10) / 10} — it must be 100.`
      : dirty ? `Overall Progress would read ${newPct}% (now ${nowPct}%).`
      : `Overall Progress reads ${nowPct}%.`);
  return (
    <div className="settings-section glass">
      <div className="settings-section-head">PROJECT · PROGRESS WEIGHTS</div>
      <div className="settings-section-sub">How much of the whole project each stage is worth in the Overall Progress circle. The three numbers must add up to 100. Applies to everyone on this project. Archived, cut and archive-footage shots do not count.</div>
      <div className="settings-section-sub sw-source">{schLine}{(cur.source === "saved" || (_projLevel && cur.source === "project")) ? " You have your own weights saved; Reset goes back to the schedule." : ""}</div>
      <div className="sw-row">
        {field("frames", "Frames")}
        {field("video", "Video")}
        {field("upscale", "Upscale")}
        <div className="sw-actions">
          <button type="button" className="bg-picker-btn bg-picker-btn--ghost" disabled={!canEdit || busy || isDefault} onClick={() => save(null)}>{sch ? `Reset to the schedule (${defs.frames} / ${defs.video} / ${defs.upscale})` : `Reset to ${defs.frames} / ${defs.video} / ${defs.upscale}`}</button>
          <button type="button" className="bg-picker-btn" disabled={!canEdit || busy || !dirty} onClick={() => save(g)}>{busy ? "Saving…" : "Save"}</button>
        </div>
      </div>
      <div className={"settings-section-sub sw-line" + (canEdit && !valid && !status ? " is-bad" : "")} style={{ minHeight: "1.4em", marginTop: 8 }}>{line}</div>
    </div>
  );
}

/* ─────────────────────────── New Project Modal ─────────────────────────── */

const PRODUCTION_TYPES = [
  { id: "ai_film",    name: "AI Film",            sub: "Fully AI-generated cinematography",        icon: "🎞" },
  { id: "vfx",        name: "VFX-Heavy",          sub: "Live action with major VFX integration", icon: "💥" },
  { id: "live",       name: "Traditional Film",   sub: "Live-action production pipeline",          icon: "📽" },
  { id: "cg",         name: "CG Animation",       sub: "Full 3D CG animation pipeline",            icon: "🧊" },
  { id: "anim_2d",    name: "2D Animation",       sub: "Hand-drawn or vector 2D animation",        icon: "✏" },
  { id: "music_video",name: "Music Video",        sub: "Short-form music + visuals",               icon: "🎵" },
  { id: "doc",        name: "Documentary",        sub: "Real-world story + editorial",             icon: "🎙" },
  { id: "commercial", name: "Commercial",         sub: "Brand-driven 30s to 2min spots",           icon: "📺" },
];

// [mp] P2.4.2 — the old PROJECT_TEMPLATES list (with "Paradise Found" / "North Sea
// Reverie" as previous projects) was superseded by GET /api/templates + GET
// /api/project-seeds and referenced nowhere; removed 15 Sep 2026.

/* ───────────────── [mp] P2.4.2 — NEW PROJECT INTAKE ─────────────────
   "+ New project" is now template-driven:

     1 Template   — the template cards (GET /api/templates) + the saved
                    briefs waiting to become projects (GET /api/project-seeds).
     2 Talk it    — window.AgentChat mode="intake" against /api/intake.
                    When the agent proposes a manifest it calls onManifest
                    and "Next: checklist" arms.
     3 Checklist  — the template's parts, grouped by top folder, required
                    rows ticked and locked; custom folders; the project
                    fields; a debounced POST /api/templates/:id/validate
                    preview of the folders that will be created.
     4 Create     — review + the optional shot-list paste, then the create.

   The create is owned by THIS modal now (it holds the manifest):
     • POST /api/intake/chats/:id/create when a chat exists,
     • else POST /api/projects with template_id + manifest_json,
     • else (both 404) the legacy onCreate(formData) path in App.createProject.
   On a modal-owned create it reloads the project list, switches to the new
   project and closes — onCreate({ created: true, id }) tells ProjectHeader
   the work is already done.

   Every intake route is expected to 404 until the server side lands; a 404
   is a plain inline line, never an exception and never an alert (inv. #22).
   The step body is a FIXED height in every step (inv. #20). */

// Used only when GET /api/templates is not available yet, so the picker is
// never an empty box. The server list wins the moment the route exists.
const NPI_FALLBACK_TEMPLATES = [
  { id: "film-doc",     name: "Documentary / film", description: "Episodes hold shots, shots hold grids, frames and video." },
  { id: "film-styles",  name: "Film — stylistic versions", description: "One master edit and shot list, several stylistic versions of it." },
  { id: "music-artist", name: "Music artist", description: "Songs and lyrics as reference; music videos, release artwork and social posts as the work." },
  { id: "vlog-series",  name: "Vlog series", description: "A recurring character-led series for YouTube and social." },
  { id: "kids-series",  name: "Kids series", description: "A children's series where every episode teaches one lesson." },
  { id: "game-mod",     name: "Game mod", description: "A mod for a city-building game: one container per civilization." },
];

// Top-level folders in the order Hugo reads them.
const NPI_GROUP_ORDER = ["_tracker", "documents", "work", "library", "pipeline", "deliverables"];
const NPI_GROUP_LABEL = {
  _tracker: "_tracker — app state",
  documents: "documents",
  work: "work",
  library: "library",
  pipeline: "pipeline",
  deliverables: "deliverables",
};

// v321 — same transform as db/createProject.js slugify: "Trøpé" → "trope", not "tr-p" (Hugo:
// 'what is this "saved as tr-p"'). Accents drop (é → e); ø, æ, ß … are spelled out.
const NPI_SLUG_LETTERS = { "ø": "o", "æ": "ae", "œ": "oe", "ß": "ss", "đ": "d", "ł": "l", "þ": "th", "ð": "d", "ı": "i" };
function npiSlug(s) {
  return String(s || "")
    .toLowerCase()
    .replace(/[øæœßđłþðı]/g, (ch) => NPI_SLUG_LETTERS[ch] || ch)
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}
// v317 — a path's {placeholders} in words (Hugo: "Its confusing to have videos/{videos}/shots
// etc, make me understand better"): {video} → ‹one per music video›, {shot} → ‹one per shot›,
// {container_folder}/{container_id} → the template's own container folder.
function npiHumanPath(p, tpl) {
  const c = (tpl && tpl.container) || {};
  const label = String(c.label || c.kind || "container").toLowerCase();
  const folder = c.folder || "";
  const words = (name) => {
    if (name === "container_id" || (c.kind && name === c.kind)) return "one per " + label;
    return "one per " + String(name).replace(/_/g, " ");
  };
  const src = String(p || "").replace("{container_folder}", folder || "{container_folder}");
  const out = [];
  const re = /\{([a-z_]+)\}/g;
  let last = 0, m, k = 0;
  while ((m = re.exec(src))) {
    if (m.index > last) out.push(src.slice(last, m.index));
    out.push(<span key={"ph" + k++} className="npi-ph">‹{words(m[1])}›</span>);
    last = m.index + m[0].length;
  }
  if (last < src.length) out.push(src.slice(last));
  return out;
}

function npiTop(p) {
  const s = String(p || "");
  const i = s.indexOf("/");
  return i === -1 ? s : s.slice(0, i);
}
function npiUniq(list) {
  const out = [], seen = {};
  (list || []).forEach((x) => { const s = String(x); if (s && !seen[s]) { seen[s] = 1; out.push(s); } });
  return out;
}

// One fetch helper for every intake call: a 404 carries .status = 404 so the
// caller can fall back instead of showing a scary error.
//
// No `|| fetch` fallback: a plain fetch() drops the auth header AND the
// active-project header window.authFetch adds, which would either 401 or
// silently hit the wrong project. If authFetch hasn't loaded yet, that's a
// real problem to surface inline, not to paper over.
// v312 — "5 min ago" for the resume cards. SQLite's datetime('now') has no zone: it is UTC.
function npiAgo(iso) {
  if (!iso) return "";
  const str = String(iso);
  const zoned = /Z$|[+-]\d\d:\d\d$/.test(str) ? str : str.replace(" ", "T") + "Z";
  const t = Date.parse(zoned);
  if (!Number.isFinite(t)) return "";
  const s = Math.max(0, (Date.now() - t) / 1000);
  if (s < 90) return "just now";
  if (s < 3600) return Math.round(s / 60) + " min ago";
  if (s < 86400) return Math.round(s / 3600) + " h ago";
  const d = Math.round(s / 86400);
  return d === 1 ? "yesterday" : d + " days ago";
}

function npiFetch(url, opts) {
  if (!window.authFetch) {
    const e = new Error("Not signed in yet — reload the page and try again.");
    e.status = 0;
    return Promise.reject(e);
  }
  return Promise.resolve(window.authFetch(url, opts)).then(async (r) => {
    let j = null;
    try { j = await r.json(); } catch (_) {}
    if (!r.ok) {
      // The create/validate routes answer { error, errors: [ … ] } — the array is the
      // useful half ("required part not ticked: …"), so it goes in the message too.
      let msg = (j && j.error) || (r.status === 404 ? "This route isn't available yet." : "Request failed (HTTP " + r.status + ").");
      if (j && Array.isArray(j.errors) && j.errors.length) {
        msg += " — " + j.errors.map((x) => (typeof x === "string" ? x : (x && (x.message || x.error)) || JSON.stringify(x))).join("; ");
      }
      const e = new Error(msg);
      e.status = r.status;
      e.errors = (j && j.errors) || null;
      throw e;
    }
    return j || {};
  });
}

function NewProjectModal({ onClose, onCreate }) {
  // 1 template · 2 talk it through · 3 checklist · 4 create
  const [step, setStep] = React.useState(1);

  // ── step 1: templates + seeds ──
  const [templates, setTemplates] = React.useState([]);
  const [tplListNote, setTplListNote] = React.useState(null);
  const [seeds, setSeeds] = React.useState([]);
  const [seedsNote, setSeedsNote] = React.useState(null);
  const [templateId, setTemplateId] = React.useState(null);
  const [seedId, setSeedId] = React.useState(null);        // null = start blank

  // ── the merged template (parts, intake questions) ──
  const [tpl, setTpl] = React.useState(null);
  const [tplNote, setTplNote] = React.useState(null);

  // ── step 2: the intake chat ──
  const [chat, setChat] = React.useState(null);
  const [chatNote, setChatNote] = React.useState(null);
  // v312 — "Continue where you left off" (Hugo, mid-intake: "if i refresh, will I lose all
  // of this?"). The transcript always lived on the server; the modal now lists the open
  // intake chats and can drop back into one. resumeRef carries the chat's manifest across
  // the template load below, which would otherwise reset the ticks to the required parts.
  const [openChats, setOpenChats] = React.useState([]);
  const [confirmForget, setConfirmForget] = React.useState(null);   // chat id awaiting "Forget"
  const resumeRef = React.useRef(null);
  // v314 — Browse… opens the Windows folder picker through the local server (Hugo: "no
  // Folder Browse to go put the project folder in"). Only offered when the server says it can.
  const [canPick, setCanPick] = React.useState(false);
  const [picking, setPicking] = React.useState(false);
  // v319 — the project's asset categories (Hugo: "we'll also need assets for instruments …
  // when do i get to decide the categories in assets?"). Starts as the template's list; remove
  // or add; saved in the manifest, so the agent files assets only under these.
  const [assetCats, setAssetCats] = React.useState([]);
  const [catDraft, setCatDraft] = React.useState("");
  const [catError, setCatError] = React.useState(null);
  const [provider, setProvider] = React.useState(null);
  const [manifestReady, setManifestReady] = React.useState(false);

  // ── the manifest ──
  const [parts, setParts] = React.useState([]);            // ticked part paths
  const [custom, setCustom] = React.useState([]);          // custom folder paths
  const [customDraft, setCustomDraft] = React.useState("");
  const [customError, setCustomError] = React.useState(null);

  // ── the project fields ──
  const [name, setName] = React.useState("");
  const [projId, setProjId] = React.useState("");
  const [idTouched, setIdTouched] = React.useState(false);
  const [watchPath, setWatchPath] = React.useState("");
  const [client, setClient] = React.useState("");
  // v318 — no Type dropdown (Hugo: "doesnt seem to change the left folders that are being
  // chosen when I change the Type" — it never could: the folders come from the TEMPLATE picked
  // in step 1; "type" is only the small tag on the project card). It now follows the template.
  const type = ({ "film-doc": "doc", "music-artist": "music_video" })[templateId] || "ai_film";
  const [thumbAspect, setThumbAspect] = React.useState("21:9");

  // ── the validate preview ──
  const [validating, setValidating] = React.useState(false);
  const [vErrors, setVErrors] = React.useState([]);
  const [vPlanned, setVPlanned] = React.useState(null);
  const [vNote, setVNote] = React.useState(null);
  const [showPlanned, setShowPlanned] = React.useState(false);

  // ── create + the optional shot list ──
  const [creating, setCreating] = React.useState(false);
  const [createError, setCreateError] = React.useState(null);
  const [createdNote, setCreatedNote] = React.useState(null);
  // Set once the project itself has been created (POST succeeded). From then
  // on doCreate never re-runs for this id — the primary button turns into a
  // plain "Close", even if the follow-up shot-list import below fails.
  const [created, setCreated] = React.useState(null);      // { id } | null
  const [wantShotlist, setWantShotlist] = React.useState(false);
  const [shotlistText, setShotlistText] = React.useState("");
  const fileRef = React.useRef(null);

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // ── load the pickers once ──
  React.useEffect(() => {
    let dead = false;
    npiFetch("/api/templates")
      .then((j) => {
        if (dead) return;
        const list = Array.isArray(j.templates) ? j.templates : [];
        if (list.length) { setTemplates(list); setTplListNote(null); }
        else { setTemplates(NPI_FALLBACK_TEMPLATES); setTplListNote("No templates came back — showing the built-in list."); }
      })
      .catch((e) => {
        if (dead) return;
        setTemplates(NPI_FALLBACK_TEMPLATES);
        setTplListNote(e.status === 404
          ? "The template routes aren't available yet — showing the built-in list."
          : e.message);
      });
    npiFetch("/api/project-seeds")
      .then((j) => { if (!dead) { setSeeds(Array.isArray(j.seeds) ? j.seeds : []); setSeedsNote(null); } })
      .catch((e) => { if (!dead) { setSeeds([]); setSeedsNote(e.status === 404 ? "No saved briefs yet." : e.message); } });
    npiFetch("/api/intake/chats")
      .then((j) => {
        if (dead) return;
        const list = Array.isArray(j.chats) ? j.chats : [];
        setOpenChats(list);
        // v322 — straight back to where you were: the newest chat with a saved working state
        // past the chat step, touched in the last 12 hours (Hugo: "it brings me back on page one").
        const recent = list.find((c) => c && c.draft && Number(c.draft.step) >= 2
          && (Date.now() - Date.parse(String(c.updated_at || "").replace(" ", "T") + "Z")) < 12 * 3600 * 1000);
        if (recent) resumeChat(recent);
      })
      .catch(() => { if (!dead) setOpenChats([]); });
    npiFetch("/api/fs/pick-folder/available")
      .then((j) => { if (!dead) setCanPick(!!(j && j.available)); })
      .catch(() => { if (!dead) setCanPick(false); });
    return () => { dead = true; };
  }, []);

  const pickFolder = () => {
    if (picking) return;
    setPicking(true); setVNote(null);
    npiFetch("/api/fs/pick-folder", { method: "POST", body: JSON.stringify({ initial: watchPath.trim() || undefined }) })
      .then((j) => { if (j && j.path) { setWatchPath(String(j.path)); if (createError) setCreateError(null); } })
      .catch((e) => setVNote(e.message))
      .finally(() => setPicking(false));
  };
  const npiCatLabel = (id) => {
    const t = ((tpl && tpl.asset_categories) || []).find((c) => c && c.id === id);
    return (t && t.label) || (String(id).charAt(0).toUpperCase() + String(id).slice(1).replace(/-/g, " "));
  };
  const addCat = () => {
    const id = catDraft.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
    if (!id) { setCatError("Letters and digits only."); return; }
    if (id.length > 30) { setCatError("Keep it under 30 characters."); return; }
    if (assetCats.indexOf(id) !== -1) { setCatError("Already in the list."); return; }
    setAssetCats((prev) => prev.concat([id])); setCatDraft(""); setCatError(null);
  };

  // A resumed chat's manifest is applied on top of the template's required parts.
  const applyResume = (r, req) => {
    const m = r && r.manifest;
    if (m && Array.isArray(m.parts)) setParts(npiUniq(req.concat(m.parts.filter((x) => typeof x === "string"))));
    if (m && Array.isArray(m.custom)) setCustom(npiUniq(m.custom.filter((x) => typeof x === "string")));
    if (m && Array.isArray(m.asset_categories) && m.asset_categories.length) {
      setAssetCats(m.asset_categories.map((c) => (typeof c === "string" ? c : (c && c.id))).filter(Boolean));
    }
    setManifestReady(!!(r && r.proposed));
  };

  // ── load the merged template + reset the manifest to its required parts ──
  React.useEffect(() => {
    setTpl(null); setTplNote(null);
    setParts([]); setManifestReady(false);
    if (!templateId) return;
    let dead = false;
    npiFetch("/api/templates/" + encodeURIComponent(templateId))
      .then((j) => {
        if (dead) return;
        const t = (j && j.parts) ? j : (j && j.template) ? j.template : null;
        setTpl(t);
        const req = ((t && t.parts) || []).filter((p) => p && p.required).map((p) => p.path);
        setParts(npiUniq(req));
        setAssetCats(((t && t.asset_categories) || []).map((c) => (typeof c === "string" ? c : (c && c.id))).filter(Boolean));
        const r = resumeRef.current;
        if (r && r.templateId === templateId) { resumeRef.current = null; applyResume(r, req); }
      })
      .catch((e) => {
        if (dead) return;
        setTplNote(e.status === 404
          ? "The folder checklist isn't available yet — the project will be created with the template's own default folders."
          : e.message);
      });
    return () => { dead = true; };
  }, [templateId]);

  // ── the effective manifest: required parts are always in it ──
  const tplParts = (tpl && Array.isArray(tpl.parts)) ? tpl.parts : [];
  const requiredPaths = tplParts.filter((p) => p && p.required).map((p) => p.path);
  const effectiveParts = npiUniq(requiredPaths.concat(parts));
  const manifest = { parts: effectiveParts, custom: custom, ...(assetCats.length ? { asset_categories: assetCats } : {}) };

  // v322 — save the working state on the chat (700 ms after the last change) so a refresh
  // brings the wizard back to this page with everything as it was. Only once past step 1
  // and only while a chat exists (a chat is the thing the state hangs on).
  const draftKey = JSON.stringify([step, effectiveParts, custom, assetCats, name, watchPath, client, thumbAspect]);
  const draftSkipRef = React.useRef(true);
  React.useEffect(() => {
    if (!chat || !chat.id || step < 2 || created) return;
    if (draftSkipRef.current) { draftSkipRef.current = false; return; }   // not on the first paint
    const t = setTimeout(() => {
      npiFetch("/api/intake/chats/" + encodeURIComponent(chat.id), {
        method: "PATCH",
        body: JSON.stringify({ draft: { step, parts: effectiveParts, custom, asset_categories: assetCats, name, watch_path: watchPath, client, aspect: thumbAspect } }),
      }).catch(() => {});
    }, 700);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [draftKey, chat && chat.id]);

  // ── the debounced validate preview (step 3 only) ──
  const manifestKey = JSON.stringify([templateId, effectiveParts, custom, assetCats, watchPath.trim()]);
  React.useEffect(() => {
    if (step !== 3 || !templateId) return;
    let dead = false;
    const t = setTimeout(() => {
      setValidating(true);
      npiFetch("/api/templates/" + encodeURIComponent(templateId) + "/validate", {
        method: "POST",
        body: JSON.stringify({ manifest: manifest, watch_path: watchPath.trim() || undefined }),
      })
        .then((j) => {
          if (dead) return;
          setVErrors(Array.isArray(j.errors) ? j.errors : []);
          setVPlanned(Array.isArray(j.planned) ? j.planned : null);
          setVNote(null);
        })
        .catch((e) => {
          if (dead) return;
          setVErrors([]); setVPlanned(null);
          setVNote(e.status === 404 ? "The folder preview isn't available yet." : e.message);
        })
        .finally(() => { if (!dead) setValidating(false); });
    }, 400);
    return () => { dead = true; clearTimeout(t); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [manifestKey, step]);

  // ── step 1 → 2: open the intake chat ──
  const openChat = () => {
    setChatNote(null);
    npiFetch("/api/intake/chats", {
      method: "POST",
      body: JSON.stringify({ template_id: templateId, seed_id: seedId || undefined }),
    })
      .then((j) => {
        setChat((j && j.chat) || null);
        const m = j && j.manifest;
        if (m && Array.isArray(m.parts)) setParts(npiUniq(requiredPaths.concat(m.parts)));
        if (m && Array.isArray(m.custom)) setCustom(npiUniq(m.custom));
      })
      .catch((e) => {
        setChat(null);
        setChatNote(e.status === 404
          ? "The intake chat isn't available yet — skip it and tick the folders yourself."
          : e.message);
      });
  };

  // ── "Continue where you left off": back into an open intake chat at step 2 ──
  const resumeChat = (c) => {
    if (!c || !c.id) return;
    setChatNote(null); setConfirmForget(null);
    npiFetch("/api/intake/chats/" + encodeURIComponent(c.id) + "/messages")
      .then((j) => {
        const msgs = Array.isArray(j.messages) ? j.messages : [];
        const proposed = msgs.some((m) => m && m.role === "assistant" && Array.isArray(m.tool_calls)
          && m.tool_calls.some((x) => x && x.name === "propose_manifest"));
        // v322 — the saved working state wins over the agent's proposal: those are Hugo's own
        // ticks, categories, name and folder, and the page he was on.
        const d = (j.draft && typeof j.draft === "object") ? j.draft : null;
        const manifest = d && Array.isArray(d.parts)
          ? { parts: d.parts, custom: Array.isArray(d.custom) ? d.custom : [], ...(Array.isArray(d.asset_categories) && d.asset_categories.length ? { asset_categories: d.asset_categories } : {}) }
          : (j.manifest || null);
        const payload = { templateId: c.template_id, manifest, proposed: proposed || !!(d && Array.isArray(d.parts)) };
        setSeedId(c.seed_id || null);
        setChat({ id: c.id, template_id: c.template_id, seed_id: c.seed_id || null, title: c.title });
        if (d) {
          if (typeof d.name === "string" && d.name.trim()) { setName(d.name); if (!idTouched) setProjId(npiSlug(d.name)); }
          else if (!name.trim() && c.title) onName(c.title);
          if (typeof d.watch_path === "string") setWatchPath(d.watch_path);
          if (typeof d.client === "string") setClient(d.client);
          if (typeof d.aspect === "string" && d.aspect) setThumbAspect(d.aspect);
        } else if (!name.trim() && c.title) onName(c.title);
        if (templateId === c.template_id && tpl) applyResume(payload, requiredPaths);
        else { resumeRef.current = payload; setTemplateId(c.template_id); }
        const back = d && Number.isFinite(Number(d.step)) ? Math.max(2, Math.min(3, Number(d.step))) : 2;
        setStep(back);
      })
      .catch((e) => setChatNote(e.message));
  };
  const forgetChat = (id) => {
    npiFetch("/api/intake/chats/" + encodeURIComponent(id), { method: "DELETE" })
      .then(() => {
        setOpenChats((l) => l.filter((c) => c.id !== id));
        if (chat && chat.id === id) setChat(null);
      })
      .catch((e) => setChatNote(e.message))
      .finally(() => setConfirmForget(null));
  };
  const npiTplLabel = (id) => { const t = templates.find((x) => x.id === id); return t ? (t.name || t.id) : (id || "no template"); };

  // AgentChat hands the tool's args straight over. Only parts/custom are the
  // manifest; anything else in the payload (template_id, episodes, seeds…) is
  // the agent's business, not the checklist's.
  const onManifest = React.useCallback((args) => {
    if (!args) return;
    let a = args;
    if (typeof a === "string") { try { a = JSON.parse(a); } catch (_) { return; } }
    if (!a || typeof a !== "object") return;
    const p = Array.isArray(a.parts) ? a.parts.filter((x) => typeof x === "string") : null;
    const c = Array.isArray(a.custom) ? a.custom.filter((x) => typeof x === "string") : null;
    if (!p && !c) return;
    if (p) setParts(npiUniq(p));
    if (c) setCustom(npiUniq(c));
    setManifestReady(true);
  }, []);

  // ── name → id, until the id is edited by hand ──
  const onName = (v) => {
    setName(v);
    if (!idTouched) setProjId(npiSlug(v));
    if (createError) setCreateError(null);
  };

  const togglePart = (p) => {
    if (p.required) return;
    setParts((prev) => (prev.indexOf(p.path) === -1 ? prev.concat([p.path]) : prev.filter((x) => x !== p.path)));
  };

  const customRoot = (tpl && tpl.custom_folder_root) || "work/custom";
  // Client-side sanity check only — a friendly, inline first line of defence.
  // The server's own validateManifest/scaffoldProject call is the real guard
  // against path traversal; this just stops an obviously-bad chip from ever
  // being added and explains why, instead of a native alert().
  const npiCustomFolderError = (raw) => {
    if (raw.indexOf("..") !== -1) return 'Folder names can’t contain "..".';
    if (raw.indexOf(":") !== -1) return 'Folder names can’t contain ":".';
    if (/^[\\/]/.test(raw)) return "Folder names can’t start with a slash.";
    if (/[\x00-\x1f\x7f]/.test(raw)) return "Folder names can’t contain control characters.";
    if (!/^[A-Za-z0-9 _\-\/]+$/.test(raw)) return "Only letters, numbers, spaces, \"_\", \"-\" and \"/\" are allowed.";
    return null;
  };
  const addCustom = () => {
    const draft = customDraft.trim();
    if (!draft) return;
    const err = npiCustomFolderError(draft);
    if (err) { setCustomError(err); return; }
    setCustomError(null);
    const raw = draft.replace(/^\/+|\/+$/g, "");
    if (!raw) { setCustomError("That's not a folder name."); return; }
    const full = raw.indexOf("/") === -1 ? customRoot + "/" + raw : raw;
    setCustom((prev) => npiUniq(prev.concat([full])));
    setCustomDraft("");
  };

  // ── grouped checklist rows ──
  const groups = [];
  (function () {
    // "{container_folder}/{container_id}" is the template's own container part —
    // group it where it actually lands (work/videos, work/episodes, …) instead of
    // giving the placeholder a heading of its own.
    const containerFolder = (tpl && tpl.container && tpl.container.folder) || "";
    const byTop = {};
    tplParts.forEach((p) => {
      const k = npiTop(containerFolder ? String(p.path).replace("{container_folder}", containerFolder) : p.path);
      if (!byTop[k]) byTop[k] = [];
      byTop[k].push(p);
    });
    const keys = Object.keys(byTop).sort((a, b) => {
      const ia = NPI_GROUP_ORDER.indexOf(a), ib = NPI_GROUP_ORDER.indexOf(b);
      if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
      return a < b ? -1 : 1;
    });
    keys.forEach((k) => groups.push({ key: k, label: NPI_GROUP_LABEL[k] || k, rows: byTop[k] }));
  })();

  const slug = npiSlug(projId || name);
  const blocked = vErrors.length > 0;
  const canCreate = !!name.trim() && !!slug && !blocked && !creating;

  // ─────────────────────────── the create ───────────────────────────
  // Latched: once `created` is set the project itself exists and this never
  // runs again for it — a stray re-submit (Enter key while `creating` is
  // still true, or the primary button after it turns into "Close") is a
  // no-op instead of a second POST / a second project.
  const doCreate = async () => {
    if (creating || created) return;
    if (!name.trim()) { setCreateError("The project needs a name."); setStep(3); return; }
    if (!slug) { setCreateError("The project id needs at least one letter or digit."); setStep(3); return; }
    setCreating(true); setCreateError(null); setCreatedNote(null);
    try {
      let createdId = null;

      if (chat && chat.id) {
        try {
          const j = await npiFetch("/api/intake/chats/" + encodeURIComponent(chat.id) + "/create", {
            method: "POST",
            body: JSON.stringify({
              display_name: name.trim().toUpperCase(), id: slug, watch_path: watchPath.trim() || null,
              manifest: manifest, client: client.trim() || "", type: type, aspect_ratio: thumbAspect,
            }),
          });
          if (j && j.ok === false) throw new Error(j.error || "Could not create the project.");
          createdId = (j && j.id) || slug;
        } catch (e) {
          if (e.status !== 404) throw e;
        }
      }

      if (!createdId) {
        // The modal owns this POST so the manifest travels with it. seed_id
        // rides along too — the server may not use it yet, but a project
        // started from a saved brief and then "Skip the chat" shouldn't lose
        // the link to that brief.
        try {
          const j = await npiFetch("/api/projects", {
            method: "POST",
            body: JSON.stringify({
              id: slug, display_name: name.trim().toUpperCase(), client: client.trim() || "", type: type,
              subtitle: (tpl && tpl.name) || (templateId || ""),
              status: "DEVELOPMENT", status_color: "#D4A574", aspect_ratio: thumbAspect,
              watch_path: watchPath.trim() || null,
              template_id: templateId || null,
              seed_id: seedId || null,
              manifest_json: JSON.stringify(manifest),
            }),
          });
          createdId = (j && j.id) || slug;
        } catch (e) {
          if (e.status === 404) {
            // Both modal-owned create routes are gone. There's no project-
            // creation API to fall back to here — the old App.createProject
            // path silently dropped the manifest AND the pasted shot list,
            // which is worse than just saying so.
            setCreateError("Project creation API not available.");
            setStep(3);
            return;
          }
          throw e;
        }
      }

      // Modal-owned success: refresh the switcher, jump into the new project.
      if (window.__reloadProjects) { try { await window.__reloadProjects(); } catch (_) {} }
      if (window.__nav && window.__nav.switchProject) window.__nav.switchProject(createdId);
      setCreated({ id: createdId });

      // The optional shot list, explicitly headed at the project we just made.
      if (wantShotlist && shotlistText.trim()) {
        try {
          const pr = await npiFetch("/api/shotlist/parse", {
            method: "POST", headers: { "X-Active-Project": createdId },
            body: JSON.stringify({ text: shotlistText }),
          });
          await npiFetch("/api/shotlist/apply", {
            method: "POST", headers: { "X-Active-Project": createdId },
            body: JSON.stringify({ shots: pr.shots || [], assets: pr.assets || {}, mode: "merge" }),
          });
        } catch (e) {
          // The project is real and already switched to — leave the modal
          // open with the explanation instead of closing over an error, and
          // let the primary button ("Close" now that `created` is set) end
          // the flow instead of the disabled/enabled Create-project button.
          setCreatedNote('"' + name.trim() + '" was created and is open, but the shot list could not be imported: ' + (e.message || "unknown error") + ". Import it from the project switcher when you're ready.");
          return;
        }
      }

      if (onCreate) { try { await onCreate({ created: true, id: createdId }); } catch (_) {} }
      onClose();
    } catch (e) {
      setCreateError((e && e.message) || "Could not create the project.");
    } finally {
      setCreating(false);
    }
  };

  // ─────────────────────────── navigation ───────────────────────────
  const next = () => {
    // Once the project has been created, the primary button (and Enter, via
    // the form's onSubmit) only ever closes the modal — never re-runs doCreate.
    if (created) { onClose(); return; }
    if (step === 1) {
      if (!templateId) return;
      if (!chat) openChat();
      setStep(2);
    } else if (step === 2) {
      // v320 - the name box starts with what the chat already knows (Hugo: "you dont seem to
      // have prefilled this"): the chat's title, else the saved brief's name. Only when empty.
      if (!name.trim()) {
        const seed = seeds.find((s) => s && s.id === seedId);
        const guess = (chat && chat.title) || (seed && seed.name) || "";
        if (guess) onName(guess);
      }
      setStep(3);
    } else if (step === 3) {
      if (!name.trim() || !slug || blocked) return;
      setStep(4);
    } else {
      doCreate();
    }
  };
  const back = () => { if (step > 1 && !created) setStep(step - 1); };
  const skipChat = () => { setManifestReady(true); setStep(3); };

  // ─────────────────────────── step bodies ───────────────────────────
  const renderTemplateStep = () => {
    const matching = seeds.filter((s) => !templateId || s.template_id === templateId);
    const seedList = matching.length ? matching : seeds;
    const seedsAreAll = !matching.length && seeds.length > 0;
    return (
      <div className="npi-pane npi-pane--scroll">
        {openChats.length ? (
          <React.Fragment>
            <div className="npi-secthead">Continue where you left off</div>
            {chatNote && <div className="npi-note">{chatNote}</div>}
            <div className="npi-seedgrid">
              {openChats.map((c) => (
                <div key={c.id} className={"npi-seed npi-resume" + (chat && chat.id === c.id ? " is-active" : "")}>
                  <button type="button" className="npi-resume-main" onClick={() => resumeChat(c)}>
                    <span className="npi-seed-name">{c.title || c.template_id || "Untitled"}</span>
                    <span className="npi-seed-sub">
                      {npiTplLabel(c.template_id)} · {c.message_count} {c.message_count === 1 ? "message" : "messages"} · {npiAgo(c.updated_at)}
                      {c.has_manifest ? " · folder list proposed" : ""}{c.draft && Number(c.draft.step) >= 3 ? " · on the checklist" : ""}
                    </span>
                  </button>
                  {confirmForget === c.id ? (
                    <span className="npi-resume-confirm">
                      <button type="button" className="npi-resume-yes" onClick={() => forgetChat(c.id)}>Forget</button>
                      <button type="button" className="npi-resume-no" onClick={() => setConfirmForget(null)}>Keep</button>
                    </span>
                  ) : (
                    <button type="button" className="npi-resume-x" aria-label="Forget this chat"
                      onClick={() => setConfirmForget(c.id)}>✕</button>
                  )}
                </div>
              ))}
            </div>
          </React.Fragment>
        ) : null}

        <div className="npi-secthead">Start from a template</div>
        {tplListNote && <div className="npi-note">{tplListNote}</div>}
        <div className="npi-tplgrid">
          {templates.map((t) => (
            <button key={t.id} type="button"
              className={"npi-tpl" + (templateId === t.id ? " is-active" : "")}
              onClick={() => { setTemplateId(t.id); setChat(null); setChatNote(null); }}>
              <span className="npi-tpl-name">{t.name || t.id}</span>
              <span className="npi-tpl-desc">{t.description || ""}</span>
            </button>
          ))}
        </div>

        <div className="npi-secthead">Start from a saved brief</div>
        {seedsNote && <div className="npi-note">{seedsNote}</div>}
        {seedsAreAll && <div className="npi-note">No brief is filed under this template — every saved brief is listed, with the template it was written for.</div>}
        <div className="npi-seedgrid">
          <button type="button"
            className={"npi-seed" + (!seedId ? " is-active" : "")}
            onClick={() => setSeedId(null)}>
            <span className="npi-seed-name">Start blank</span>
            <span className="npi-seed-sub">No brief — describe the project in the chat.</span>
          </button>
          {seedList.map((s) => (
            <button key={s.id} type="button"
              className={"npi-seed" + (seedId === s.id ? " is-active" : "")}
              onClick={() => { setSeedId(s.id); if (s.template_id) { setTemplateId(s.template_id); setChat(null); setChatNote(null); } }}>
              <span className="npi-seed-name">{s.name || s.id}</span>
              <span className="npi-seed-sub">{s.template_id ? "for " + s.template_id : "no template"}{s.priority ? " · priority " + s.priority : ""}</span>
            </button>
          ))}
        </div>
      </div>
    );
  };

  const renderChatStep = () => {
    const questions = (tpl && Array.isArray(tpl.intake_questions)) ? tpl.intake_questions : [];
    return (
      <div className="npi-pane npi-pane--chat">
        <div className="npi-chatcol">
          {window.AgentChat ? (
            <window.AgentChat
              mode="intake"
              chatId={chat ? chat.id : null}
              endpointBase="/api/intake"
              onManifest={onManifest}
              provider={provider}
              onProviderChange={setProvider}
            />
          ) : (
            <div className="npi-note">The chat component hasn't loaded — skip the chat and tick the folders yourself.</div>
          )}
        </div>
        <aside className="npi-qs">
          <div className="npi-qs-head">What the intake needs to know</div>
          <div className="npi-qs-list">
            {questions.length ? questions.map((q, i) => (
              <div key={i} className="npi-q"><span className="npi-q-n">{i + 1}</span><span className="npi-q-t">{q}</span></div>
            )) : (
              <div className="npi-note">{tplNote || "This template doesn't list intake questions — describe the project in your own words."}</div>
            )}
          </div>
          <div className="npi-qs-foot">{manifestReady ? "A folder list has been proposed — open the checklist." : "The agent proposes a folder list when it has enough to go on."}</div>
        </aside>
      </div>
    );
  };

  const renderChecklistStep = () => (
    <div className="npi-pane npi-pane--split">
      <div className="npi-col npi-col--list">
        <div className="npi-secthead">Folders for this project</div>
        {tpl && tpl.container && tpl.container.folder ? (
          <div className="npi-note npi-note--flat">
            <span className="npi-ph">‹one per {String(tpl.container.label || tpl.container.kind || "").toLowerCase()}›</span> = the app makes one folder for each {String(tpl.container.label || tpl.container.kind || "").toLowerCase()}, named after it, inside <code>{tpl.container.folder}</code>. Same idea for ‹one per shot›, ‹one per song› and the rest.
          </div>
        ) : null}
        {tplNote && <div className="npi-note">{tplNote}</div>}
        {groups.map((g) => (
          <div key={g.key} className="npi-group">
            <div className="npi-group-head">{g.label}</div>
            {g.rows.map((p) => {
              const on = p.required || parts.indexOf(p.path) !== -1;
              return (
                <label key={p.path} className={"npi-row" + (p.required ? " is-locked" : "")}>
                  <input type="checkbox" className="npi-cb" checked={on} disabled={!!p.required} onChange={() => togglePart(p)}/>
                  <span className="npi-row-main">
                    <span className="npi-row-path">{npiHumanPath(p.path, tpl)}</span>
                    <span className="npi-row-purpose">{p.purpose || ""}</span>
                  </span>
                  <span className="npi-row-tags">
                    {p.required ? <span className="npi-tag npi-tag--lock">required</span> : null}
                    {p.funnel ? <span className="npi-tag npi-tag--funnel">funnel</span> : null}
                  </span>
                </label>
              );
            })}
          </div>
        ))}

        {tpl && Array.isArray(tpl.asset_categories) && tpl.asset_categories.length ? (
          <div className="npi-group">
            <div className="npi-group-head">Asset categories</div>
            <div className="npi-note npi-note--flat">Each one is a shelf inside <code>work/assets</code>. The agent can only file an asset under one of these. Remove what you do not need, add your own.</div>
            <div className="npi-chips">
              {assetCats.map((c) => (
                <span key={c} className="npi-chip">{npiCatLabel(c)}
                  <button type="button" className="npi-chip-x" aria-label={"Remove " + c} onClick={() => setAssetCats((prev) => prev.filter((x) => x !== c))}>✕</button>
                </span>
              ))}
              {!assetCats.length ? <span className="npi-note npi-note--flat npi-err">At least one category is needed.</span> : null}
            </div>
            <div className="npi-customrow">
              <input className="np-input npi-input" type="text" value={catDraft} placeholder="e.g. instruments"
                onChange={(e) => { setCatDraft(e.target.value); if (catError) setCatError(null); }}
                onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addCat(); } }}/>
              <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={addCat} disabled={!catDraft.trim()}>Add</button>
            </div>
            <div className={"npi-note npi-note--flat" + (catError ? " npi-err" : "")} style={{ minHeight: "1.45em" }}>{catError || " "}</div>
          </div>
        ) : null}

        <div className="npi-group">
          <div className="npi-group-head">Custom folders</div>
          <div className="npi-customrow">
            <input className="np-input npi-input" type="text" value={customDraft}
              placeholder={customRoot + "/<name>"}
              onChange={(e) => { setCustomDraft(e.target.value); if (customError) setCustomError(null); }}
              onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addCustom(); } }}/>
            <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={addCustom} disabled={!customDraft.trim()}>Add</button>
          </div>
          {/* Reserved single line so the rejection message never shifts the chips below it (inv. #20). */}
          <div className={"npi-note npi-note--flat" + (customError ? " npi-err" : "")} style={{ minHeight: "1.45em" }}>
            {customError || " "}
          </div>
          <div className="npi-chips">
            {custom.length ? custom.map((c) => (
              <span key={c} className="npi-chip">{c}
                <button type="button" className="npi-chip-x" aria-label={"Remove " + c} onClick={() => setCustom((prev) => prev.filter((x) => x !== c))}>✕</button>
              </span>
            )) : <span className="npi-note npi-note--flat">None yet.</span>}
          </div>
        </div>
      </div>

      <div className="npi-col npi-col--fields">
        <div className="npi-secthead">The project</div>
        {/* v316 — no id box (Hugo: "what is the ID box for?"): the short name the app saves the
            project under comes from the name and is shown, not edited. */}
        <div className="np-fieldset">
          <label className="np-label">Project name</label>
          <input className="np-input npi-input" type="text" value={name} onChange={(e) => onName(e.target.value)} placeholder="e.g. TRØPÉ" autoFocus/>
          <div className="npi-note npi-note--flat">Saved as <code>{slug || "—"}</code></div>
        </div>
        <div className="np-fieldset">
          <label className="np-label">Project folder</label>
          <div className="npi-folderrow">
            <input className="np-input npi-input" type="text" value={watchPath} onChange={(e) => { setWatchPath(e.target.value); if (createError) setCreateError(null); }} placeholder="e.g. W:\PROJECTS\TROPE"/>
            {canPick ? (
              <button type="button" className="np-btn np-btn-cancel npi-browse" onClick={pickFolder} disabled={picking}>{picking ? "Picking…" : "Browse…"}</button>
            ) : null}
          </div>
        </div>
        <div className="np-fieldset">
          <label className="np-label">Client</label>
          <input className="np-input npi-input" type="text" value={client} onChange={(e) => setClient(e.target.value)} placeholder="optional"/>
        </div>
        <div className="np-fieldset">
          <label className="np-label">Thumbnail aspect ratio</label>
          <div className="np-aspect-row">
            {["16:9", "21:9", "4:3", "3:4", "1:1", "9:16"].map((ar) => (
              <button key={ar} type="button" className={"np-aspect" + (thumbAspect === ar ? " is-active" : "")} onClick={() => setThumbAspect(ar)}>{ar}</button>
            ))}
          </div>
        </div>

        {/* Fixed box: the preview never changes the height of anything (inv. #20). */}
        <div className="npi-preview">
          <div className="npi-preview-head">
            <span>Folder preview</span>
            <span className={"npi-preview-state" + (vErrors.length ? " is-err" : "")}>
              {validating ? "checking…"
                : vErrors.length ? vErrors.length + (vErrors.length === 1 ? " problem" : " problems")
                : vNote ? "unavailable"
                : vPlanned ? vPlanned.length + (vPlanned.length === 1 ? " folder" : " folders") : "—"}
            </span>
          </div>
          <div className="npi-preview-body">
            {vErrors.length ? vErrors.map((er, i) => (
              <div key={i} className="npi-err">{typeof er === "string" ? er : (er.message || er.error || JSON.stringify(er))}</div>
            )) : vNote ? (
              <div className="npi-note npi-note--flat">{vNote}</div>
            ) : vPlanned ? (
              <React.Fragment>
                <button type="button" className="npi-linkbtn" onClick={() => setShowPlanned(!showPlanned)}>
                  {showPlanned ? "Hide the list" : vPlanned.length + (vPlanned.length === 1 ? " folder will be created" : " folders will be created")}
                </button>
                {showPlanned ? <div className="npi-planned">{vPlanned.map((f, i) => <div key={i} className="npi-planned-row">{f}</div>)}</div> : null}
              </React.Fragment>
            ) : (
              <div className="npi-note npi-note--flat">Name the folder above to see what will be created.</div>
            )}
          </div>
        </div>
      </div>
    </div>
  );

  const renderCreateStep = () => (
    <div className="npi-pane npi-pane--split">
      <div className="npi-col npi-col--list">
        <div className="npi-secthead">About to be created</div>
        <div className="npi-sum">
          <div className="npi-sum-row"><span>Name</span><b>{name.trim().toUpperCase() || "—"}</b></div>
          <div className="npi-sum-row"><span>Id</span><b>{slug || "—"}</b></div>
          <div className="npi-sum-row"><span>Template</span><b>{(tpl && tpl.name) || templateId || "—"}</b></div>
          <div className="npi-sum-row"><span>Brief</span><b>{seedId || "none"}</b></div>
          <div className="npi-sum-row"><span>Folder</span><b>{watchPath.trim() || "no folder (database only)"}</b></div>
          <div className="npi-sum-row"><span>Client</span><b>{client.trim() || "—"}</b></div>
          <div className="npi-sum-row"><span>Aspect</span><b>{thumbAspect}</b></div>
          <div className="npi-sum-row"><span>Parts ticked</span><b>{effectiveParts.length}</b></div>
          <div className="npi-sum-row"><span>Custom folders</span><b>{custom.length}</b></div>
          <div className="npi-sum-row"><span>Folders planned</span><b>{vPlanned ? vPlanned.length : "—"}</b></div>
        </div>
        {createError && <div className="npi-err npi-err--block">{createError}</div>}
        {createdNote && <div className="npi-note npi-note--warn">{createdNote}</div>}
      </div>

      <div className="npi-col npi-col--fields">
        <div className="npi-secthead">Import a shot list now?</div>
        <div className="np-aspect-row npi-yesno">
          <button type="button" className={"np-aspect" + (!wantShotlist ? " is-active" : "")} onClick={() => setWantShotlist(false)}>Not now</button>
          <button type="button" className={"np-aspect" + (wantShotlist ? " is-active" : "")} onClick={() => setWantShotlist(true)}>Paste / upload</button>
        </div>
        {/* Both states fill the same box, so switching them shifts nothing. */}
        <div className="npi-shotbox">
          {wantShotlist ? (
            <React.Fragment>
              <div className="npi-customrow">
                <input ref={fileRef} type="file" accept=".txt,.md,.csv,.json" style={{ display: "none" }}
                  onChange={async (e) => {
                    const f = e.target.files && e.target.files[0];
                    if (!f) return;
                    try { setShotlistText(await f.text()); } catch (err) { setCreateError("Could not read file: " + err.message); }
                  }}/>
                <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={() => fileRef.current && fileRef.current.click()}>Upload file</button>
                <button type="button" className="np-btn np-btn-cancel npi-addbtn" onClick={() => setShotlistText("")} disabled={!shotlistText}>Clear</button>
                <span className="npi-note npi-note--flat">{shotlistText ? shotlistText.length.toLocaleString() + " chars" : "empty"}</span>
              </div>
              <textarea className="np-input npi-textarea" value={shotlistText} onChange={(e) => setShotlistText(e.target.value)}
                placeholder={"Paste your shot list here…\n\nSH0010 - WIDE on the rolling hills at dawn.\nNarration: \"Long before…\"\nCharacters: Narrator"}/>
            </React.Fragment>
          ) : (
            <div className="npi-note npi-note--flat npi-shotbox-empty">
              The project is created empty. A shot list can be imported any time from the project switcher — it lands in this project, not the one you came from.
            </div>
          )}
        </div>
      </div>
    </div>
  );

  // ─────────────────────────── chrome ───────────────────────────
  const STEPS = [
    { n: 1, label: "Template" },
    { n: 2, label: "Talk it through" },
    { n: 3, label: "Checklist" },
    { n: 4, label: "Create" },
  ];
  const titles = { 1: "Pick a template", 2: "Talk it through", 3: "What gets built", 4: "Review and create" };
  const subs = {
    1: "The template decides the folder shape, the pages and the pipeline. A saved brief fills the intake in for you.",
    2: "Describe the project. When the agent has enough it proposes a folder list you can edit on the next step.",
    3: "Tick what this project needs. Required folders are locked — the app stores its state in them.",
    4: created ? "The project was created and is open — close this dialog to continue."
      : creating ? "Creating the project…"
      : "Nothing has been written yet. Everything below is created when you confirm.",
  };
  const primaryLabel =
    created ? "Close" :
    creating ? "Creating…" :
    step === 1 ? "Next: talk it through →" :
    step === 2 ? "Next: checklist →" :
    step === 3 ? "Next: review →" :
    "Create project →";
  const primaryDisabled =
    !created && (
      creating ||
      (step === 1 && !templateId) ||
      (step === 2 && !manifestReady) ||
      (step === 3 && (!name.trim() || !slug || blocked)) ||
      (step === 4 && !canCreate)
    );

  return (
    <div className="np-modal-backdrop" onClick={onClose}>
      <form className="np-modal np-modal--lg np-modal--intake glass" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); next(); }}>
        <button type="button" className="modal-close np-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div className="np-modal-head npi-head">
          <div className="np-eyebrow">NEW PROJECT · STEP {step} OF 4</div>
          <div className="np-title">{titles[step]}</div>
          <div className="np-sub npi-sub">{subs[step]}</div>
          <div className="npi-steps">
            {STEPS.map((s) => (
              <div key={s.n} className={"npi-stepseg" + (s.n <= step ? " is-on" : "")}>
                <span className="npi-stepseg-bar"/>
                <span className="npi-stepseg-label">{s.label}</span>
              </div>
            ))}
          </div>
        </div>

        {/* One fixed-height body for every step — nothing below it ever moves. */}
        <div className="npi-body">
          {step === 1 && renderTemplateStep()}
          {step === 2 && renderChatStep()}
          {step === 3 && renderChecklistStep()}
          {step === 4 && renderCreateStep()}
        </div>

        <div className="np-actions npi-actions">
          <div className="npi-actions-l">
            {chatNote && step === 2 ? <span className="npi-note npi-note--flat">{chatNote}</span> : null}
            {step === 3 && createError ? <span className="npi-err">{createError}</span> : null}
          </div>
          {step > 1 ? (
            <button type="button" className="np-btn np-btn-cancel" onClick={back} disabled={creating || !!created}>← Back</button>
          ) : (
            <button type="button" className="np-btn np-btn-cancel" onClick={onClose}>Cancel</button>
          )}
          {step === 2 && !manifestReady ? (
            <button type="button" className="np-btn np-btn-cancel" onClick={skipChat}>Skip the chat →</button>
          ) : null}
          {/* v315 — the folder list is in: Next glows (Hugo: "make the Next Checklist button glow
              and get highlighted when it's ready to be clicked on"). */}
          <button type="submit" className={"np-btn np-btn-create" + (step === 2 && manifestReady && !created ? " is-ready" : "")} disabled={primaryDisabled}>{primaryLabel}</button>
        </div>
      </form>
    </div>
  );
}

/* ─────────────────────── IMPORT SHOTLIST MODAL ─────────────────────── */
/* Standalone modal for adding/updating a shotlist on an existing
   project. Same parse + apply flow as NewProjectModal step 2/3,
   without the project-create fields. Opened from ProjectSwitcher
   → "Import shotlist into current project". */

function ImportShotlistModal({ onClose }) {
  const [step, setStep] = React.useState(1);              // 1 = paste, 2 = preview, 3 = done
  const [shotlistText, setShotlistText] = React.useState("");
  const [parsing, setParsing] = React.useState(false);
  const [parseError, setParseError] = React.useState(null);
  const [parsed, setParsed] = React.useState(null);
  const [applying, setApplying] = React.useState(false);
  const [applyResult, setApplyResult] = React.useState(null);
  const fileRef = React.useRef(null);
  // v07zz38 — Apply mode + confirmation. "merge" (default) only
  // upserts; "replace" archives every shot in the episode not present
  // in the new list. Replace requires a typed "REPLACE" confirmation
  // before the button arms because it's a destructive operation
  // (no shots are hard-deleted — they're archived and recoverable —
  // but the daily shotlist view will visibly shrink and that surprise
  // would burn).
  const [applyMode, setApplyMode] = React.useState("merge");
  const [replaceConfirm, setReplaceConfirm] = React.useState("");
  // Compute what would change in replace mode: count current shots NOT
  // in the new list (those that would be archived).
  const replacePreview = React.useMemo(() => {
    if (applyMode !== "replace" || !parsed) return null;
    const appData = (typeof window !== "undefined" && window.__appData) || null;
    const currentShots = (appData && appData.shots) || [];
    const activeCurrent = currentShots.filter(s => !s.is_archive);
    const newIds = new Set((parsed.shots || []).map(s => s.shot_id));
    const wouldArchive = activeCurrent.filter(s => !newIds.has(s.id));
    const wouldRestore = currentShots.filter(s => s.is_archive && newIds.has(s.id));
    return {
      total_current_active: activeCurrent.length,
      would_archive: wouldArchive,
      would_restore: wouldRestore.length,
    };
  }, [applyMode, parsed]);

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  const onPickFile = async (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    const name = String(f.name || "").toLowerCase();
    // v07zz56 — xlsx path. Bypass Gemini text parsing, upload the
    // binary to /api/shotlist/parse-xlsx which uses SheetJS directly
    // (deterministic, fast, free) and skip straight to the preview.
    if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
      setParsing(true); setParseError(null); setParsed(null);
      try {
        const fd = new FormData();
        fd.append("file", f);
        const r = await fetch("/api/shotlist/parse-xlsx", {
          method: "POST",
          credentials: "include",
          body: fd,
        });
        const data = await r.json();
        if (!r.ok) throw new Error(data.error || "Parse failed");
        setParsed(data);
        setStep(2);
      } catch (err) {
        setParseError("xlsx parse failed: " + err.message);
      } finally {
        setParsing(false);
      }
      return;
    }
    // Plain-text path (existing flow).
    try {
      const text = await f.text();
      setShotlistText(text);
    } catch (err) {
      setParseError("Could not read file: " + err.message);
    }
  };

  const parseShotlist = async () => {
    if (!shotlistText.trim()) return;
    setParsing(true); setParseError(null); setParsed(null);
    try {
      const r = await fetch("/api/shotlist/parse", {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: shotlistText }),
      });
      const data = await r.json();
      if (!r.ok) throw new Error(data.error || "Parse failed");
      setParsed(data);
      setStep(2);
    } catch (err) { setParseError(err.message); }
    finally { setParsing(false); }
  };

  const applyShotlist = async () => {
    if (!parsed) return;
    // v07zz38 — Replace mode requires the typed REPLACE confirmation.
    // Defensive: if somehow the button armed without it, refuse.
    if (applyMode === "replace" && replaceConfirm.trim().toUpperCase() !== "REPLACE") {
      setParseError("Type REPLACE in the confirmation box to proceed.");
      return;
    }
    setApplying(true); setParseError(null);
    try {
      const r = await fetch("/api/shotlist/apply", {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          shots: parsed.shots || [],
          assets: parsed.assets || {},
          mode: applyMode,
        }),
      });
      const data = await r.json();
      if (!r.ok) throw new Error(data.error || "Apply failed");
      setApplyResult(data.applied);
      setStep(3);
      // Trigger a global refresh so the new shots show up immediately
      if (window.reloadAppData) { try { window.reloadAppData(); } catch (_) {} }
      else if (window.__nav && typeof window.__refresh === "function") {
        try { window.__refresh(); } catch (_) {}
      }
    } catch (err) { setParseError(err.message); }
    finally { setApplying(false); }
  };

  const sumAssets = (a) => a ? (a.characters || 0) + (a.locations || 0) + (a.animals || 0) + (a.props || 0) : 0;

  const titles = { 1: "Import shotlist", 2: "Review parsed shotlist", 3: "Import complete" };
  const subs = {
    1: "Paste a shotlist or upload a file. The AI will extract shots, characters, and locations.",
    2: applying ? "Applying to the current project…" : "Confirm the detected items before they're added to the project.",
    3: "Your shots and assets are now part of the project.",
  };

  return (
    <div className="np-modal-backdrop" onClick={onClose}>
      <form className="np-modal np-modal--lg glass" onClick={(e) => e.stopPropagation()} onSubmit={(e) => {
        e.preventDefault();
        if (step === 1) parseShotlist();
        else if (step === 2) applyShotlist();
        else onClose();
      }}>
        <button type="button" className="modal-close np-modal-close" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div className="np-modal-head">
          <div className="np-eyebrow">IMPORT SHOTLIST · STEP {step} OF 3</div>
          <div className="np-title">{titles[step]}</div>
          <div className="np-sub">{subs[step]}</div>
          <div style={{display: "flex", gap: 4, marginTop: 10}}>
            {[1, 2, 3].map(s => (
              <div key={s} style={{
                flex: 1, height: 3, borderRadius: 2,
                background: s <= step ? "var(--accent, var(--amber))" : "color-mix(in srgb, var(--white) 12%, transparent)",
                transition: "background 200ms ease",
              }}/>
            ))}
          </div>
        </div>

        {step === 1 && (
          <div className="np-fieldset">
            <label className="np-label">Shotlist text</label>
            <div style={{display: "flex", gap: 8, marginBottom: 8}}>
              <input ref={fileRef} type="file" accept=".txt,.md,.csv,.json" style={{display: "none"}} onChange={onPickFile}/>
              <button type="button" className="np-btn np-btn-cancel" onClick={() => fileRef.current && fileRef.current.click()}>📎 Upload file</button>
              <button type="button" className="np-btn np-btn-cancel" onClick={() => setShotlistText("")} disabled={!shotlistText}>Clear</button>
              <div style={{flex: 1, textAlign: "right", color: "var(--ink-muted)", fontSize: "var(--fs-sm)", alignSelf: "center"}}>
                {shotlistText ? shotlistText.length.toLocaleString() + " chars" : "empty"}
              </div>
            </div>
            <textarea
              className="np-input"
              rows={16}
              placeholder={"Paste your shotlist here…"}
              value={shotlistText}
              onChange={(e) => setShotlistText(e.target.value)}
              style={{fontFamily: "var(--font-mono, monospace)", fontSize: "var(--fs-sm)", resize: "vertical"}}
            />
            {parseError && (
              <div style={{marginTop: 12, padding: 10, background: "color-mix(in srgb, var(--red-27) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--red-27) 35%, transparent)", borderRadius: "var(--r-xs)", color: "var(--red-27-ink)", fontSize: "var(--fs-body)"}}>
                ⚠ {parseError}
              </div>
            )}
            <div className="np-sub" style={{marginTop: 12, fontStyle: "italic"}}>
              You'll choose how to apply (merge or replace) on the next step
              once we've parsed the shotlist. Existing assets are never
              duplicated; shots are never hard-deleted.
            </div>
          </div>
        )}

        {step === 2 && parsed && (
          <React.Fragment>
            <div className="np-fieldset">
              <label className="np-label">Detected</label>
              <div className="np-types np-types--lg" style={{gridTemplateColumns: "repeat(5, 1fr)"}}>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-icon">🎬</span>
                  <span className="np-type-name">{(parsed.summary && parsed.summary.shot_count) || (parsed.shots || []).length}</span>
                  <span className="np-type-sub">Shots</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-icon">🎞</span>
                  <span className="np-type-name">{(parsed.summary && parsed.summary.sequence_count) || 0}</span>
                  <span className="np-type-sub">Sequences</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-icon">🧑‍🎤</span>
                  <span className="np-type-name">{(parsed.assets && parsed.assets.characters || []).length}</span>
                  <span className="np-type-sub">Characters</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-icon">🏞</span>
                  <span className="np-type-name">{(parsed.assets && parsed.assets.locations || []).length}</span>
                  <span className="np-type-sub">Locations</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-icon">🦅</span>
                  <span className="np-type-name">{((parsed.assets && parsed.assets.animals || []).length) + ((parsed.assets && parsed.assets.props || []).length)}</span>
                  <span className="np-type-sub">Animals/Props</span>
                </div>
              </div>
            </div>

            {(parsed.shots || []).length > 0 && (
              <div className="np-fieldset">
                <label className="np-label">Shots ({(parsed.shots || []).length})</label>
                <div style={{maxHeight: 260, overflow: "auto", border: "1px solid color-mix(in srgb, var(--white) 8%, transparent)", borderRadius: "var(--r-xs)", background: "var(--sunken-bg)"}}>
                  <table style={{width: "100%", fontSize: "var(--fs-sm)", borderCollapse: "collapse"}}>
                    <thead style={{position: "sticky", top: 0, background: "color-mix(in srgb, var(--grey-13) 95%, transparent)"}}>
                      <tr style={{textAlign: "left"}}>
                        <th style={{padding: "6px 10px", color: "var(--ink-muted)"}}>ID</th>
                        <th style={{padding: "6px 10px", color: "var(--ink-muted)"}}>Seq</th>
                        <th style={{padding: "6px 10px", color: "var(--ink-muted)"}}>Title</th>
                        <th style={{padding: "6px 10px", color: "var(--ink-muted)"}}>Action</th>
                      </tr>
                    </thead>
                    <tbody>
                      {(parsed.shots || []).slice(0, 100).map((s, i) => (
                        <tr key={i} style={{borderTop: "1px solid color-mix(in srgb, var(--white) 5%, transparent)"}}>
                          <td style={{padding: "5px 10px", fontFamily: "var(--font-mono, monospace)"}}>{s.shot_id}</td>
                          <td style={{padding: "5px 10px"}}>{s.seq || "—"}</td>
                          <td style={{padding: "5px 10px"}}>{s.frame_title || ""}</td>
                          <td style={{padding: "5px 10px", color: "var(--ink-muted)", maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{s.action || ""}</td>
                        </tr>
                      ))}
                      {(parsed.shots || []).length > 100 && (
                        <tr><td colSpan={4} style={{padding: "8px 10px", textAlign: "center", color: "var(--ink-muted)", fontStyle: "italic"}}>… and {(parsed.shots || []).length - 100} more</td></tr>
                      )}
                    </tbody>
                  </table>
                </div>
              </div>
            )}

            {/* v07zz38 — Apply mode selector. Merge is the safe default;
                Replace archives every active shot that's NOT in the new
                list (and unarchives shots that ARE in the list). */}
            <div className="np-fieldset">
              <label className="np-label">How should we apply this?</label>
              <div style={{display: "flex", gap: 10, marginTop: 6}}>
                <label style={{
                  flex: 1, padding: 12, borderRadius: "var(--r-sm)", cursor: "pointer",
                  border: applyMode === "merge" ? "1.5px solid color-mix(in srgb, var(--gold-3) 95%, transparent)" : "1px solid color-mix(in srgb, var(--white) 12%, transparent)",
                  background: applyMode === "merge" ? "color-mix(in srgb, var(--gold-3) 10%, transparent)" : "var(--sunken-bg)",
                  transition: "all 150ms ease",
                }}>
                  <input type="radio" name="apply-mode" value="merge"
                    checked={applyMode === "merge"} onChange={() => { setApplyMode("merge"); setReplaceConfirm(""); }}
                    style={{marginRight: 8}}/>
                  <span style={{fontWeight: "var(--fw-semi)"}}>Merge</span>
                  <div style={{fontSize: "var(--fs-sm)", color: "var(--ink-muted)", marginTop: 4, marginLeft: 22}}>
                    Add new shots, update matching IDs. Nothing is removed
                    or archived. Safe to repeat.
                  </div>
                </label>
                <label style={{
                  flex: 1, padding: 12, borderRadius: "var(--r-sm)", cursor: "pointer",
                  border: applyMode === "replace" ? "1.5px solid color-mix(in srgb, var(--orange-1) 95%, transparent)" : "1px solid color-mix(in srgb, var(--white) 12%, transparent)",
                  background: applyMode === "replace" ? "color-mix(in srgb, var(--orange-1) 12%, transparent)" : "var(--sunken-bg)",
                  transition: "all 150ms ease",
                }}>
                  <input type="radio" name="apply-mode" value="replace"
                    checked={applyMode === "replace"} onChange={() => setApplyMode("replace")}
                    style={{marginRight: 8}}/>
                  <span style={{fontWeight: "var(--fw-semi)"}}>Replace</span>
                  <div style={{fontSize: "var(--fs-sm)", color: "var(--ink-muted)", marginTop: 4, marginLeft: 22}}>
                    Archive every shot not in the new list. Existing IDs
                    in the new list stay (or get unarchived) and update.
                    Asset files are kept — archived shots can be restored.
                  </div>
                </label>
              </div>
              {applyMode === "replace" && replacePreview && (
                <div style={{
                  marginTop: 12, padding: 12,
                  background: "color-mix(in srgb, var(--orange-1) 10%, transparent)",
                  border: "1px solid color-mix(in srgb, var(--orange-1) 45%, transparent)",
                  borderRadius: "var(--r-sm)", fontSize: "var(--fs-body)",
                }}>
                  <div style={{fontWeight: "var(--fw-semi)", color: "var(--orange-1-ink)", marginBottom: 8}}>
                    Replace impact preview
                  </div>
                  <div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12, fontSize: "var(--fs-sm)"}}>
                    <div>
                      <div style={{color: "var(--ink-muted)"}}>Currently active</div>
                      <div style={{fontSize: "var(--fs-20)", fontWeight: "var(--fw-bold)"}}>{replacePreview.total_current_active}</div>
                    </div>
                    <div>
                      <div style={{color: "var(--ink-muted)"}}>Will be archived</div>
                      <div style={{fontSize: "var(--fs-20)", fontWeight: "var(--fw-bold)", color: "var(--orange-1-ink)"}}>{replacePreview.would_archive.length}</div>
                    </div>
                    <div>
                      <div style={{color: "var(--ink-muted)"}}>Will be restored</div>
                      <div style={{fontSize: "var(--fs-20)", fontWeight: "var(--fw-bold)", color: "var(--leaf-pale-2)"}}>{replacePreview.would_restore}</div>
                    </div>
                  </div>
                  {replacePreview.would_archive.length > 0 && (
                    <details style={{marginTop: 10}}>
                      <summary style={{cursor: "pointer", color: "var(--ink-muted)", fontSize: "var(--fs-sm)"}}>
                        Shots to be archived ({replacePreview.would_archive.length})
                      </summary>
                      <div style={{maxHeight: 120, overflow: "auto", marginTop: 6, padding: 8, background: "color-mix(in srgb, var(--black) 25%, transparent)", borderRadius: 4, fontFamily: "var(--font-mono, monospace)", fontSize: "var(--fs-xs)"}}>
                        {replacePreview.would_archive.map(s => (
                          <div key={s.id} style={{padding: "2px 0"}}>
                            {s.id} — {s.frame_title || "(untitled)"}
                          </div>
                        ))}
                      </div>
                    </details>
                  )}
                  <div style={{marginTop: 12}}>
                    <label style={{display: "block", fontSize: "var(--fs-sm)", color: "var(--ink-muted)", marginBottom: 6}}>
                      Type <code style={{background: "color-mix(in srgb, var(--black) 30%, transparent)", padding: "1px 6px", borderRadius: 3, color: "var(--orange-1-ink)"}}>REPLACE</code> to confirm:
                    </label>
                    <input
                      type="text"
                      className="np-input"
                      value={replaceConfirm}
                      onChange={(e) => setReplaceConfirm(e.target.value)}
                      placeholder="REPLACE"
                      style={{fontFamily: "var(--font-mono, monospace)", letterSpacing: 1}}
                    />
                  </div>
                </div>
              )}
            </div>

            {parseError && (
              <div style={{marginTop: 12, padding: 10, background: "color-mix(in srgb, var(--red-27) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--red-27) 35%, transparent)", borderRadius: "var(--r-xs)", color: "var(--red-27-ink)", fontSize: "var(--fs-body)"}}>
                ⚠ {parseError}
              </div>
            )}
          </React.Fragment>
        )}

        {step === 3 && applyResult && (
          <div className="np-fieldset">
            <div style={{textAlign: "center", padding: "20px 0"}}>
              <div style={{fontSize: "var(--fs-48)", marginBottom: 12}}>✅</div>
              <div style={{fontSize: "var(--fs-18)", fontWeight: "var(--fw-semi)", marginBottom: 6}}>Shotlist imported</div>
              <div style={{fontSize: "var(--fs-sm)", color: "var(--ink-muted)", marginBottom: 18, textTransform: "uppercase", letterSpacing: 1}}>
                Mode: {applyResult.mode || "merge"}
              </div>
              <div className="np-types np-types--lg" style={{gridTemplateColumns: "repeat(5, 1fr)"}}>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-name">{applyResult.shots_added || 0}</span>
                  <span className="np-type-sub">Shots added</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-name">{applyResult.shots_updated || 0}</span>
                  <span className="np-type-sub">Shots updated</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-name" style={{color: applyResult.shots_archived > 0 ? "var(--orange-1-ink)" : undefined}}>
                    {applyResult.shots_archived || 0}
                  </span>
                  <span className="np-type-sub">Shots archived</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-name" style={{color: applyResult.shots_restored > 0 ? "var(--leaf-pale-2)" : undefined}}>
                    {applyResult.shots_restored || 0}
                  </span>
                  <span className="np-type-sub">Shots restored</span>
                </div>
                <div className="np-type" style={{cursor: "default"}}>
                  <span className="np-type-name">{sumAssets(applyResult.assets_added)}</span>
                  <span className="np-type-sub">Assets added</span>
                </div>
              </div>
            </div>
          </div>
        )}

        <div className="np-actions">
          {step < 3 ? (
            <button type="button" className="np-btn np-btn-cancel" onClick={step === 2 ? () => setStep(1) : onClose} disabled={parsing || applying}>
              {step === 2 ? "← Back" : "Cancel"}
            </button>
          ) : (
            <div/>
          )}
          <button type="submit" className="np-btn np-btn-create" disabled={
            parsing || applying ||
            (step === 1 && !shotlistText.trim()) ||
            (step === 2 && applyMode === "replace" && replaceConfirm.trim().toUpperCase() !== "REPLACE")
          } style={
            step === 2 && applyMode === "replace"
              ? { background: "linear-gradient(135deg, var(--red-25), var(--red-26))", color: "var(--white)" }
              : undefined
          }>
            {parsing ? "Parsing with AI…" :
             applying ? "Applying…" :
             step === 1 ? "Parse with AI →" :
             step === 2 ? (applyMode === "replace" ? "Replace shotlist →" : "Merge into project →") :
             "Done"}
          </button>
        </div>
      </form>
    </div>
  );
}

/* ─────────────────────────── PRICING / PLAN ─────────────────────────── */

const PLANS = [
  {
    id: "free",
    name: "Free",
    price: "$0",
    pricePer: "forever",
    accent: "var(--leaf)",
    desc: "Get started — manage one project, manually.",
    features: [
      { ok: true,  label: "1 project" },
      { ok: true,  label: "Up to 50 shots" },
      { ok: true,  label: "Manual file paths (no folder sync)" },
      { ok: true,  label: "1 user (you)" },
      { ok: true,  label: "All themes & visual styles" },
      { ok: false, label: "Local file watcher" },
      { ok: false, label: "Cloud sync" },
      { ok: false, label: "Cowork / API integration" },
      { ok: false, label: "Multi-user collaboration" },
      { ok: false, label: "Priority support" },
    ],
    cta: "Current plan",
    current: true,
  },
  {
    id: "pro",
    name: "Pro",
    price: "$24",
    pricePer: "per editor / month",
    accent: "var(--amber)",
    badge: "POPULAR",
    desc: "For independent producers and small studios.",
    features: [
      { ok: true,  label: "5 projects" },
      { ok: true,  label: "Unlimited shots" },
      { ok: true,  label: "Local file watcher (auto-sync from any folder)" },
      { ok: true,  label: "Up to 5 users" },
      { ok: true,  label: "Drag-drop uploads" },
      { ok: true,  label: "In-app notifications" },
      { ok: true,  label: "Document role-based access" },
      { ok: true,  label: "Versioned shot history" },
      { ok: false, label: "Cowork / API integration" },
      { ok: false, label: "Custom branding" },
    ],
    cta: "Upgrade to Pro",
  },
  {
    id: "ultra",
    name: "Ultra",
    price: "$96",
    pricePer: "per editor / month",
    accent: "var(--leaf-bright)",
    desc: "For studios shipping at scale, with full automation.",
    features: [
      { ok: true,  label: "Unlimited projects" },
      { ok: true,  label: "Unlimited shots" },
      { ok: true,  label: "Real-time API sync (Cowork / MCP webhooks)" },
      { ok: true,  label: "Cloud storage option (S3 / R2 / GCS)" },
      { ok: true,  label: "Unlimited users + custom permissions" },
      { ok: true,  label: "Custom domains + branding" },
      { ok: true,  label: "Priority support (4-hour response)" },
      { ok: true,  label: "Audit log & SSO" },
      { ok: true,  label: "Custom integrations" },
      { ok: true,  label: "Dedicated success manager" },
    ],
    cta: "Talk to sales",
  },
];

function PricingView() {
  const [billing, setBilling] = React.useState("monthly");
  return (
    <section className="view-page pricing-view">
      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">PLAN & BILLING</div>
          <div className="vp-title">Pick the plan that fits your studio</div>
        </div>
        <div className="vp-tabs">
          <button className={"vp-tab" + (billing === "monthly" ? " is-active" : "")} onClick={() => setBilling("monthly")}>Monthly</button>
          <button className={"vp-tab" + (billing === "annual" ? " is-active" : "")} onClick={() => setBilling("annual")}>Annual <span className="vp-tab-count">-20%</span></button>
        </div>
      </div>

      <div className="pricing-grid">
        {PLANS.map(p => {
          const monthly = parseInt(p.price.replace(/\$/g, ""), 10);
          const displayPrice = billing === "annual" && monthly > 0 ? `$${Math.round(monthly * 0.8)}` : p.price;
          return (
            <article key={p.id} className={"plan-card glass interactive-card" + (p.current ? " is-current" : "")} style={{"--accent": p.accent}}>
              {p.badge && <div className="plan-badge">{p.badge}</div>}
              {p.current && <div className="plan-current-tag">CURRENT</div>}
              <div className="plan-name">{p.name}</div>
              <div className="plan-desc">{p.desc}</div>
              <div className="plan-price-row">
                <span className="plan-price">{displayPrice}</span>
                <span className="plan-price-per">{p.pricePer}</span>
              </div>
              <button className={"plan-cta" + (p.current ? " is-current" : "")} disabled={p.current}>{p.cta}</button>
              <ul className="plan-features">
                {p.features.map((f, i) => (
                  <li key={i} className={f.ok ? "is-ok" : "is-locked"}>
                    <span className="plan-feat-icon">{f.ok ? "✓" : "—"}</span>
                    <span>{f.label}</span>
                  </li>
                ))}
              </ul>
            </article>
          );
        })}
      </div>

      {/* Feature comparison table */}
      <div className="pricing-compare glass">
        <h3 className="pricing-compare-head">Feature comparison</h3>
        <div className="pricing-compare-table">
          <div className="pct-row pct-row--head">
            <span></span>
            <span>Free</span>
            <span>Pro</span>
            <span className="pct-ultra">Ultra</span>
          </div>
          {[
            ["Projects",                "1",            "5",                "Unlimited"],
            ["Shots per project",       "50",           "Unlimited",        "Unlimited"],
            ["Team members",            "1",            "5",                "Unlimited"],
            ["File paths",              "Manual",       "Watcher (auto)",   "Real-time API"],
            ["Drag-drop uploads",       "—",            "✓",                "✓"],
            ["Cloud storage",           "—",            "—",                "S3 / R2 / GCS"],
            ["Cowork / MCP webhooks",   "—",            "—",                "✓"],
            ["Custom branding",         "—",            "—",                "✓"],
            ["Audit log + SSO",         "—",            "—",                "✓"],
            ["Document role access",    "—",            "✓",                "Custom permissions"],
            ["Versioned shot history",  "—",            "✓",                "✓"],
            ["Storage retention",       "30 days",      "1 year",           "Forever + backups"],
            ["Support response time",   "Community",    "48 hours",         "4 hours"],
            ["Training sessions",       "—",            "—",                "Quarterly + onboarding"],
          ].map((row, i) => (
            <div key={i} className="pct-row">
              <span className="pct-feat">{row[0]}</span>
              <span className={row[1] === "—" ? "pct-locked" : ""}>{row[1]}</span>
              <span className={row[2] === "—" ? "pct-locked" : ""}>{row[2]}</span>
              <span className={(row[3] === "—" ? "pct-locked " : "") + "pct-ultra-cell"}>{row[3]}</span>
            </div>
          ))}
        </div>
      </div>

      {/* How file sync works */}
      <div className="pricing-faq glass">
        <h3 className="pricing-faq-head">How file sync works on each plan</h3>
        <div className="pricing-faq-grid">
          <div>
            <div className="pricing-faq-name pricing-faq-name--free">Free — Manual</div>
            <div className="pricing-faq-text">Add file paths by hand in your project's data files. Good for testing or single-project use. No live sync.</div>
          </div>
          <div>
            <div className="pricing-faq-name pricing-faq-name--pro">Pro — File watcher</div>
            <div className="pricing-faq-text">A small file-watcher daemon ships with the app. Point it at any folder and the tracker updates automatically when files appear or change. Cross-platform.</div>
          </div>
          <div>
            <div className="pricing-faq-name pricing-faq-name--ultra">Ultra — Real-time API</div>
            <div className="pricing-faq-text">Bidirectional API sync. Hook directly into Claude Cowork, MCP servers, or your own pipeline. Real-time updates, no polling, full audit trail.</div>
          </div>
        </div>
      </div>

      {/* FAQ */}
      <div className="pricing-faq glass">
        <h3 className="pricing-faq-head">Common questions</h3>
        <div className="pricing-q-grid">
          <div className="pricing-q">
            <div className="pricing-q-name">Can I upgrade or downgrade anytime?</div>
            <div className="pricing-q-text">Yes — your plan applies the next billing cycle. We pro-rate the difference automatically.</div>
          </div>
          <div className="pricing-q">
            <div className="pricing-q-name">What counts as a "project"?</div>
            <div className="pricing-q-text">A single film, season, or campaign. Multiple episodes within the same project don't count separately.</div>
          </div>
          <div className="pricing-q">
            <div className="pricing-q-name">Do guests / clients count as users?</div>
            <div className="pricing-q-text">No. Read-only viewers (clients, network execs) are free on all plans.</div>
          </div>
          <div className="pricing-q">
            <div className="pricing-q-name">Is the file watcher open source?</div>
            <div className="pricing-q-text">Yes — the watcher is MIT-licensed Node. Pro plan covers hosted updates and support.</div>
          </div>
          <div className="pricing-q">
            <div className="pricing-q-name">Can I bring my own storage?</div>
            <div className="pricing-q-text">Ultra plan supports BYO S3, R2, or GCS buckets. Free and Pro use local files.</div>
          </div>
          <div className="pricing-q">
            <div className="pricing-q-name">Annual discount?</div>
            <div className="pricing-q-text">Annual billing saves 20% on Pro and Ultra. Switch from the toggle at the top.</div>
          </div>
        </div>
      </div>

      {/* CTA strip */}
      <div className="pricing-cta-strip glass">
        <div>
          <div className="pcs-eyebrow">NEED SOMETHING CUSTOM?</div>
          <div className="pcs-title">Talk to sales for studios with custom pipelines, on-prem, or 50+ users.</div>
        </div>
        <button className="pcs-btn">Contact sales →</button>
      </div>
    </section>
  );
}

/* ─────────────────────────── helpers ───────────────────────────
   Note: getCurrentStage is provided by ShotsPanel.jsx (window.getCurrentStage).
   This file does NOT redefine it — global-scope redefinition was overriding the
   canonical 7-stage version, breaking the 4K UPSCALED tag in shot rows. */

function StubPage({ title }) {
  return (
    <section className="stub-page glass">
      <div className="stub-title">{title}</div>
      <div className="stub-sub">Coming soon — this view is part of the next milestone.</div>
    </section>
  );
}

// t09 — Activity / change log view. Read-only chronological feed of every
// write through the API. Pulls from GET /api/logs via window.authFetch
// (so the Bearer token is attached). Filters by entity type are
// supported but the default is "everything, newest first".
function ActivityView() {
  const [logs, setLogs] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  // One filter axis of tabs (team activity / my actions / by entity type), plus an
  // optional per-user dropdown. "All activity" excludes self; the entity tabs
  // (Shots / Assets / Episodes / Sequences) narrow by entity type; "Assets" spans
  // the whole asset family via ?group=asset.
  const [filter, setFilter] = React.useState("all");
  const [userFilter, setUserFilter] = React.useState("");   // "" = all users, else a user id
  const [users, setUsers] = React.useState(() => window.__activityUsers || []);
  const [assetNames, setAssetNames] = React.useState(() => window.__activityAssetNames || {});
  const fetcher = window.authFetch || fetch;

  // One-time: team list (for the per-user filter) + asset slug→name map (so the feed
  // reads "Character · Ponce de Leon" instead of "ASSET_CHARACTER ponce").
  React.useEffect(() => {
    // Harvest the user list from the log itself — its user_id is the numeric
    // users.id that change_log filters on (/api/crew returns slug ids like "john",
    // which don't match). One unfiltered pull so everyone (incl. self) is listed.
    fetcher("/api/logs?limit=500").then(r => r.ok ? r.json() : null).then(d => {
      const rows = (d && d.logs) || [];
      const seen = new Map();
      for (const r of rows) {
        if (r.user_id != null && !seen.has(String(r.user_id))) {
          seen.set(String(r.user_id), { id: r.user_id, name: r.user_name || ("User " + r.user_id) });
        }
      }
      const list = [...seen.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)));
      if (list.length) { window.__activityUsers = list; setUsers(list); }
    }).catch(() => {});
    fetcher("/api/assets").then(r => r.ok ? r.json() : null).then(d => {
      if (!d) return;
      const m = {};
      // 15 Sep 2026 — the project's categories (the film-doc four for Paradise Found).
      for (const cat of (window.__projectCategories ? window.__projectCategories().map(c => c.id) : ["characters", "animals", "locations", "props"])) {
        for (const a of (d[cat] || [])) { const k = a.id || a.slug; if (k && a.name) m[String(k)] = a.name; }
      }
      window.__activityAssetNames = m; setAssetNames(m);
    }).catch(() => {});
  }, []);   // eslint-disable-line react-hooks/exhaustive-deps

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const params = new URLSearchParams();
    params.set("limit", "200");
    const contentType = (filter === "shot" || filter === "episode" || filter === "sequence") ? filter : null;
    if (userFilter) {
      // Specific user picked → everything that user did (optionally narrowed by tab).
      params.set("user_id", userFilter);
      if (filter === "asset") params.set("group", "asset");
      else if (contentType) params.set("entity_type", contentType);
    } else if (filter === "mine") {
      params.set("only_user", "me");
    } else {
      // team-view tabs exclude self so the feed isn't polluted with own actions.
      params.set("exclude_user", "me");
      if (filter === "asset") params.set("group", "asset");
      else if (contentType) params.set("entity_type", contentType);
    }
    fetcher(`/api/logs?${params.toString()}`)
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(({ logs: rows }) => { setLogs(Array.isArray(rows) ? rows : []); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher, filter, userFilter]);

  React.useEffect(() => { load(); }, [load]);

  const FILTERS = [
    { id: "all",      label: "All activity" },
    { id: "mine",     label: "My actions" },
    { id: "shot",     label: "Shots" },
    { id: "asset",    label: "Assets" },
    { id: "episode",  label: "Episodes" },
    { id: "sequence", label: "Sequences" },
  ];
  // Pretty entity-type label + resolved display name for a log row.
  const ENTITY_LABEL = {
    shot: "Shot", episode: "Episode", sequence: "Sequence", asset: "Asset",
    asset_character: "Character", asset_animal: "Animal", asset_location: "Location",
    asset_prop: "Prop", asset_ref: "Asset", reference: "Reference",
    video_review: "Review", review: "Review", document: "Document",
    milestone: "Milestone", schedule_event: "Event", project: "Project",
    presentation: "Presentation", integration: "Integration", note: "Note",
  };
  const _titleCase = (s) => String(s || "").replace(/[-_]+/g, " ").replace(/\b\w/g, c => c.toUpperCase()).trim();
  const prettyTarget = (entry) => {
    const t = String(entry.entity_type || "").toLowerCase();
    const kind = ENTITY_LABEL[t] || _titleCase(t);
    let name = entry.entity_id || "";
    if (t === "asset" || t.indexOf("asset_") === 0 || t === "reference") {
      const slug = String(name).indexOf("/") >= 0 ? String(name).split("/").pop() : String(name);
      name = assetNames[slug] || _titleCase(slug);
    }
    return { kind, name };   // shots/episodes keep their id (e.g. SH0170 — already clear)
  };
  const ACTION_LABEL = {
    create:        "Created",
    rename:        "Renamed",
    edit:          "Edited",
    archive:       "Archived",
    unarchive:     "Restored",
    status_change: "Status change",
    note_added:    "Added note",
  };
  const ACTION_TINT = {
    create:        "var(--st-hero)",
    rename:        "var(--st-prompt)",
    edit:          "var(--st-first-pass)",
    archive:       "var(--st-archive)",
    unarchive:     "var(--st-video-wip)",
    status_change: "var(--st-wip)",
    note_added:    "var(--st-hero)",
  };

  // Render a small old → new diff. Picks the most distinctive value pair
  // available; falls back to a single descriptive line.
  const summariseDiff = (entry) => {
    const o = entry.old_value || {};
    const n = entry.new_value || {};
    if (entry.action === "rename") {
      return <><span className="al-old">{o.frame_title || "—"}</span> <span className="al-arrow">→</span> <span className="al-new">{n.frame_title || "—"}</span></>;
    }
    if (entry.action === "status_change") {
      const newStatus = n.status || "—";
      return <><span className="al-arrow">→</span> <span className="al-new">{newStatus}</span></>;
    }
    if (entry.action === "create") {
      const t = n.title || n.frame_title || "—";
      return <><span className="al-new">{t}</span></>;
    }
    if (entry.action === "archive" || entry.action === "unarchive") {
      return <span className="al-new">{entry.action === "archive" ? "moved to archive" : "restored"}</span>;
    }
    if (entry.action === "edit") {
      const fields = Object.keys(n).slice(0, 3).join(", ");
      return <span className="al-new">edited {fields || "—"}</span>;
    }
    return null;
  };

  const fmtTime = (s) => {
    if (!s) return "";
    // SQLite returns "YYYY-MM-DD HH:MM:SS" in UTC.
    const d = new Date(s.replace(" ", "T") + "Z");
    if (isNaN(d.getTime())) return s;
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 60)        return `${Math.floor(diff)}s ago`;
    if (diff < 3600)      return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400)     return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
    return d.toLocaleDateString();
  };
  const initials = (name) => {
    if (!name) return "??";
    const parts = name.trim().split(/\s+/);
    return ((parts[0] || "")[0] || "") + ((parts[1] || "")[0] || "");
  };

  return (
    <section className="activity-view stub-page glass">
      <header className="activity-head">
        <div>
          <div className="activity-eyebrow">PRODUCTION LOG</div>
          <h1 className="activity-title">Activity</h1>
          <div className="activity-sub">Every status change, rename, archive and create — newest first.</div>
        </div>
        <div className="activity-filter-row">
          {FILTERS.map(f => (
            <button
              key={f.id}
              type="button"
              className={"activity-filter" + (filter === f.id ? " is-active" : "")}
              onClick={() => setFilter(f.id)}
            >{f.label}</button>
          ))}
          {users.length > 0 && (
            <select
              className="activity-user-filter"
              value={userFilter}
              onChange={(e) => setUserFilter(e.target.value)}
              title="Filter by user"
              style={{ font: "inherit", fontSize: "var(--fs-sm)", fontWeight: "var(--fw-semi)", padding: "6px 10px", borderRadius: "var(--r-round)", border: "1px solid color-mix(in srgb, var(--taupe-8) 35%, transparent)", background: "color-mix(in srgb, var(--white) 55%, transparent)", color: "var(--ink-5)", cursor: "pointer", marginLeft: "4px" }}
            >
              <option value="">All users</option>
              {users.map(u => <option key={u.id} value={String(u.id)}>{u.name}</option>)}
            </select>
          )}
          <button type="button" className="activity-refresh" onClick={load} title="Refresh">
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
          </button>
        </div>
      </header>

      {loading && <div className="activity-empty">Loading…</div>}
      {error   && <div className="activity-empty activity-empty--err">Could not load activity: {error}</div>}
      {!loading && !error && logs.length === 0 && (
        <div className="activity-empty">No activity yet — start changing shot statuses or renaming, and entries will appear here.</div>
      )}

      {!loading && !error && logs.length > 0 && (
        <ul className="activity-list">
          {(() => {
            // v07perf — Hugo: "I can get a daily grouped recap of my
            // own actions" via the View all slide. Group entries by
            // calendar date (UTC, since the server stores SQLite
            // datetime which is already UTC). Insert a date header
            // row whenever the date changes between consecutive
            // entries. Logs already arrive newest-first so the first
            // date is "Today" / "Yesterday" most of the time.
            const fmtDateHeader = (iso10) => {
              if (!iso10) return "";
              const d = new Date(iso10 + "T00:00:00Z");
              const today = new Date();
              const isoToday = today.toISOString().slice(0, 10);
              const yesterday = new Date(today.getTime() - 86400_000);
              const isoYesterday = yesterday.toISOString().slice(0, 10);
              if (iso10 === isoToday) return "Today";
              if (iso10 === isoYesterday) return "Yesterday";
              return d.toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" });
            };
            const items = [];
            let lastDate = null;
            for (const entry of logs) {
              const d = entry.timestamp ? String(entry.timestamp).slice(0, 10) : null;
              if (d && d !== lastDate) {
                items.push(
                  <li key={`date-${d}`} className="activity-date-header" style={{
                    listStyle: "none",
                    padding: "16px 20px 8px",
                    marginTop: items.length === 0 ? 0 : "12px",
                    fontFamily: "'Bebas Neue', sans-serif",
                    fontSize: "var(--fs-body)",
                    letterSpacing: "var(--track-12)",
                    color: "var(--ink-grey-15)",
                    borderBottom: "1px solid color-mix(in srgb, var(--grey-14) 15%, transparent)",
                    background: "transparent",
                  }}>
                    {fmtDateHeader(d)}
                  </li>
                );
                lastDate = d;
              }
              const onClick = () => {
                const nav = window.__nav || {};
                const t = (entry.entity_type || "").toLowerCase();
                const id = entry.entity_id;
                if (typeof window.__closeActivitySlide === "function") {
                  window.__closeActivitySlide();
                }
                // v07zz140 — Navigate INSIDE the tracker (Assets tabs for
                // references / asset refs), never open a raw file tab.
                const goAssets = (destTab) => {
                  if (destTab) { try { window.__assetsInitialTab = destTab; } catch (_) {} }
                  // v1091 — the Assets page's view id is "characters" ("assets" opened a blank page).
                  if (nav.setView) nav.setView("characters");
                  if (destTab) { try { window.dispatchEvent(new CustomEvent("paradise-assets-tab", { detail: { tab: destTab } })); } catch (_) {} }
                };
                // v07zz369 — Note activity rows open the Notes page. Was: a
                // project/general note (entity_type "project") matched no case
                // below, so the slide closed onto a blank view. Focus the exact
                // note when the change_log row carries its id.
                if (String(entry.action || "").includes("note")) {
                  let nid = null;
                  try { let nv = entry.new_value; if (typeof nv === "string") nv = JSON.parse(nv); nid = nv && (nv.note_id || nv.id); } catch (_) {}
                  if (nid != null) window.__pendingNoteId = nid;
                  if (nav.setView) nav.setView("notes");
                  if (nid != null) { try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: nid } })); } catch (_) {} }
                  return;
                }
                if (t === "shot" && id && nav.openShot) nav.openShot(id);
                else if (t === "sequence" && id && nav.openSequence) {
                  const n = parseInt(String(id).replace(/[^0-9]/g, ""), 10);
                  if (Number.isFinite(n)) nav.openSequence(n);
                }
                else if (t === "episode" && nav.switchEpisode) nav.switchEpisode(id);
                else if (t === "reference") goAssets("refs");
                else if (t === "asset" || t.startsWith("asset_")) {
                  const cat = String(id || "").split("/")[0];
                  goAssets((window.__projectCategories ? window.__projectCategories().map(c => c.id) : ["characters", "animals", "locations", "props"]).includes(cat) ? cat : "refs");
                }
                else if (t === "video_review" || t === "review") { nav.setView && nav.setView("review"); }
                else if (t === "document") { nav.setView && nav.setView("documents"); }
                else if (t === "schedule_event" || t === "milestone") { nav.setView && nav.setView("schedule"); }
              };
              const clickable = !!entry.entity_id;
              items.push(
                <li
                  key={entry.id}
                  className={"activity-row" + (clickable ? " activity-row--click" : "")}
                  onClick={clickable ? onClick : undefined}
                  role={clickable ? "button" : undefined}
                  tabIndex={clickable ? 0 : undefined}
                  onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } } : undefined}
                >
                  <span className="activity-avatar" title={entry.user_email || ""}>{initials(entry.user_name)}</span>
                  <div className="activity-body">
                    <div className="activity-line-1">
                      <span className="activity-user">{entry.user_name || "system"}</span>
                      <span className="activity-action-pill" style={{
                        borderColor: ACTION_TINT[entry.action] || "var(--st-pending)",
                        color: ACTION_TINT[entry.action] || "var(--st-pending-ink)",
                      }}>{ACTION_LABEL[entry.action] || entry.action}</span>
                      <span className="activity-target">
                        <span className="activity-entity-kind">{prettyTarget(entry).kind}</span>
                        <span className="activity-entity-id">{prettyTarget(entry).name}</span>
                      </span>
                    </div>
                    <div className="activity-line-2">
                      {summariseDiff(entry)}
                    </div>
                  </div>
                  <span className="activity-time">{fmtTime(entry.timestamp)}</span>
                </li>
              );
            }
            return items;
          })()}
        </ul>
      )}
    </section>
  );
}

// t13 — Daily digest. Read+action view: pick a date, browse every note
// posted that day grouped by shot, optionally bulk-resolve a group.
function DigestView() {
  const today = () => new Date().toISOString().slice(0, 10);
  const [date, setDate] = React.useState(today);
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const fetcher = window.authFetch || fetch;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const canResolve = role === "admin" || role === "producer";

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    fetcher(`/api/notes/digest?date=${encodeURIComponent(date)}`)
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(d => { setData(d); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher, date]);
  React.useEffect(() => { load(); }, [load]);

  const resolveGroup = (group) => {
    const ids = group.notes.filter(n => !n.resolved).map(n => n.id);
    if (ids.length === 0) return;
    fetcher("/api/notes/resolve-bulk", {
      method: "PATCH",
      body: JSON.stringify({ ids }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => load())
      .catch(err => setError(err.message));
  };

  const fmtTime = (s) => {
    if (!s) return "";
    const d = new Date(s.replace(" ", "T") + "Z");
    if (isNaN(d.getTime())) return s;
    return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  };
  const initials = (name) => {
    if (!name) return "??";
    const parts = name.trim().split(/\s+/);
    return ((parts[0] || "")[0] || "") + ((parts[1] || "")[0] || "");
  };
  const goPrevDay = () => {
    const d = new Date(date + "T00:00:00Z"); d.setUTCDate(d.getUTCDate() - 1);
    setDate(d.toISOString().slice(0, 10));
  };
  const goNextDay = () => {
    const d = new Date(date + "T00:00:00Z"); d.setUTCDate(d.getUTCDate() + 1);
    setDate(d.toISOString().slice(0, 10));
  };
  const isToday = date === today();
  const dateLabel = (() => {
    const d = new Date(date + "T00:00:00Z");
    if (isNaN(d.getTime())) return date;
    return d.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" });
  })();

  const totalNotes       = (data && data.total_notes)       || 0;
  const totalUnresolved  = (data && data.total_unresolved)  || 0;
  const contributorCount = (data && data.contributor_count) || 0;
  const groups           = (data && data.groups)            || [];

  return (
    <section className="digest-view stub-page glass">
      <header className="digest-head">
        <div>
          <div className="digest-eyebrow">DAILY FEEDBACK</div>
          <h1 className="digest-title">Digest</h1>
          <div className="digest-sub">{dateLabel}{isToday ? " · today" : ""}</div>
        </div>
        <div className="digest-controls">
          <button type="button" className="digest-nav" onClick={goPrevDay} title="Previous day">‹</button>
          <input
            className="digest-date"
            type="date"
            value={date}
            onChange={(e) => setDate(e.target.value)}
            max={today()}
          />
          <button type="button" className="digest-nav" onClick={goNextDay} disabled={isToday} title="Next day">›</button>
          {!isToday && (
            <button type="button" className="digest-today-btn" onClick={() => setDate(today())}>Jump to today</button>
          )}
        </div>
      </header>

      <div className="digest-summary">
        <div className="digest-summary-card">
          <div className="digest-stat-num">{totalNotes}</div>
          <div className="digest-stat-label">{totalNotes === 1 ? "Note" : "Notes"}</div>
        </div>
        <div className="digest-summary-card">
          <div className="digest-stat-num">{contributorCount}</div>
          <div className="digest-stat-label">{contributorCount === 1 ? "Contributor" : "Contributors"}</div>
        </div>
        <div className="digest-summary-card">
          <div className="digest-stat-num">{totalUnresolved}</div>
          <div className="digest-stat-label">Unresolved</div>
        </div>
      </div>

      {error && <div className="digest-empty digest-empty--err">{error}</div>}
      {loading && <div className="digest-empty">Loading…</div>}
      {!loading && !error && groups.length === 0 && (
        <div className="digest-empty">No notes were left on this day.</div>
      )}

      {!loading && !error && groups.length > 0 && (
        <div className="digest-groups">
          {groups.map(g => {
            const seqStr = g.shot_seq != null ? `SEQ ${String(g.shot_seq).padStart(2, "0")} · ` : "";
            return (
              <div key={`${g.entity_type}:${g.entity_id}`} className="digest-group">
                <div className="digest-group-head">
                  <div className="digest-group-target">
                    <span className="digest-target-id">{g.entity_id}</span>
                    {g.shot_title && <span className="digest-target-title">{seqStr}{g.shot_title}</span>}
                  </div>
                  <div className="digest-group-meta">
                    <span className="digest-group-count">{g.notes.length} {g.notes.length === 1 ? "note" : "notes"}</span>
                    {g.unresolved_count > 0 && (
                      <span className="digest-group-unresolved">{g.unresolved_count} unresolved</span>
                    )}
                    {canResolve && g.unresolved_count > 0 && (
                      <button type="button" className="digest-group-resolve" onClick={() => resolveGroup(g)}>
                        Resolve all
                      </button>
                    )}
                  </div>
                </div>
                <ul className="digest-note-list">
                  {g.notes.map(n => (
                    <li key={n.id} className={"digest-note" + (n.resolved ? " is-resolved" : "")}>
                      <span className="digest-note-avatar">{initials(n.user_name).toUpperCase()}</span>
                      <div className="digest-note-body">
                        <div className="digest-note-meta">
                          <span className="digest-note-author">{n.user_name || "system"}</span>
                          <span className="digest-note-time">{fmtTime(n.created_at)}</span>
                          {n.version_label && <span className="digest-note-version">{n.version_label}</span>}
                          {n.resolved && <span className="digest-note-resolved-tag">resolved</span>}
                        </div>
                        <div className="digest-note-text">{n.body}</div>
                      </div>
                    </li>
                  ))}
                </ul>
              </div>
            );
          })}
        </div>
      )}
    </section>
  );
}

// v07zz41 — Right-side tab switcher for AssetReviewModal.
// Two tabs: Notes (the existing notes panel) and Prompt (the prompt
// text that generated the active version, with a copy button).
// State is per-modal-mount, so flipping between versions in the
// filmstrip preserves the current tab.
function ArmSideTabs({ entityType, entityId, cur, NotesPanelComp }) {
  const [tab, setTab] = React.useState("notes");
  // When the active version changes, stay on the user's selected tab.
  // (cur is replaced on every version switch but tab state survives.)
  const prompt = cur && cur.prompt ? String(cur.prompt) : "";
  const rowKind = cur && cur.row_kind;
  const promptLabel = rowKind === "upscale" ? "UPSCALE PROMPT"
                    : rowKind === "video"   ? "VIDEO PROMPT"
                    : "PROMPT";
  const onCopy = () => {
    try { navigator.clipboard && navigator.clipboard.writeText(prompt); } catch (_) {}
  };
  return (
    <div className="arm-side-tabs">
      <div className="arm-side-tabbar">
        <button type="button"
          className={"arm-side-tab" + (tab === "notes" ? " is-active" : "")}
          onClick={() => setTab("notes")}>Notes</button>
        <button type="button"
          className={"arm-side-tab" + (tab === "prompt" ? " is-active" : "")}
          onClick={() => setTab("prompt")}>Prompt</button>
      </div>
      {tab === "notes" ? (
        NotesPanelComp ? (
          <NotesPanelComp entityType={entityType} entityId={entityId} versionLabel={cur && cur.label}/>
        ) : (
          <div className="arm-side-empty">Notes panel not loaded.</div>
        )
      ) : (
        <div className="arm-side-prompt">
          <div className="arm-side-prompt-head">
            <span className="arm-side-prompt-label">
              {promptLabel}{cur && cur.label ? ` — ${cur.label}` : ""}
            </span>
            {prompt && (
              <button type="button" className="arm-side-prompt-copy" onClick={onCopy}>
                Copy
              </button>
            )}
          </div>
          {prompt ? (
            <pre className="arm-side-prompt-body">{prompt}</pre>
          ) : (
            <div className="arm-side-prompt-empty">
              No prompt recorded for {cur && cur.label ? cur.label : "this version"}.
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// t14 — Full-screen asset review modal. Generic over entity_type + a
// versions array; renders a large preview centred (image OR video),
// a clickable filmstrip of versions along the bottom, and the
// version-aware NotesPanel on the right side. Escape / backdrop /
// close button all dismiss.
function AssetReviewModal({
  open,
  onClose,
  entityType = "shot",
  entityId,
  title,           // e.g. "SH0010 — Missouri Farm Morning"
  subtitle,        // e.g. "Sequence 01 · pastAmerica"
  status,          // optional pill text
  versions = [],   // [{ label, image, video, kind, time, model, notes }]
  // v06k — optional split filmstrips: frames row + videos row. When
  // both are passed, AssetReviewModal renders two strips stacked
  // vertically and idx tracks the combined `versions` array. If
  // they're omitted, the legacy single-row layout is used.
  frames = null,
  videos = null,
  initialIndex = 0,
  // v06y — Hugo: the SQA shot-status buttons are gone. The header now
  // shows a single HERO THIS FRAME / HERO THIS VIDEO pill that mirrors
  // the cream popup's pill exactly. Same toggle behaviour: clicking
  // when active reverts to the previous hero.
  // `onHero(kind, baseLabel, isToggleOff)` — promote or toggle off.
  // `heroFrameLabel` / `heroVideoLabel` — current hero base labels for
  // each media type (also drive the ★ star on the filmstrip thumbs).
  onHero = null,
  heroFrameLabel = null,
  heroVideoLabel = null,
  // v07zz59 — Promote callback for archived versions. Receives the
  // current version object; opens the parent's picker modal.
  onPromote = null,
  // v816 — WIP funnel from inside the review. onSetPublished(version, publish) pushes a
  // candidate to the shot or sends a pushed frame back to WIP; onArchiveVersion(version)
  // discards it. Both act on version.av_id (the asset_versions row id the strip carries).
  onSetPublished = null,
  onArchiveVersion = null,
}) {
  const [idx, setIdx] = React.useState(initialIndex);
  // v07zj — Hugo: hide the native scrollbar on the filmstrip; users
  // drag the strip horizontally instead. useDragScroll returns a
  // callback ref that wires up mousedown/move/up + touch handlers.
  const framesDragRef = window.useDragScroll ? window.useDragScroll() : null;
  const videosDragRef = window.useDragScroll ? window.useDragScroll() : null;
  const legacyDragRef = window.useDragScroll ? window.useDragScroll() : null;
  // v07zk — Defensive dedup of versions by label. If upstream feeds us
  // multiple rows with the same version_label (e.g. a kind="frame"
  // and kind="hero" row for the same file — DB has no UNIQUE
  // constraint on (asset_id, version_label) so the bidirectional
  // sync can insert duplicates over time), keep only one entry.
  const dedupedVersions = React.useMemo(() => {
    const seen = new Set();
    const out = [];
    for (const v of versions) {
      const key = v && v.label != null ? String(v.label) : "";
      if (seen.has(key)) continue;
      seen.add(key);
      out.push(v);
    }
    return out;
  }, [versions]);
  // v07zw — Preload EVERY version's image at modal-hero width
  // (1200) on mount so clicking through versions paints
  // synchronously from browser cache instead of waiting on
  // /api/r2-thumb. Same trick used by CharacterDetailModal +
  // AssetItemModal. Videos can't be preloaded the same way —
  // they rely on the poster cache the ReviewCard already builds.
  React.useEffect(() => {
    if (!open || !window.thumbUrl) return;
    for (const v of dedupedVersions) {
      if (!v || !v.image) continue;
      const img = new Image();
      img.src = window.thumbUrl(v.image, 1200);
    }
  }, [open, dedupedVersions]);
  React.useEffect(() => { setIdx(initialIndex); }, [initialIndex, open]);
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => {
      if (e.key === "Escape") { onClose && onClose(); return; }
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      // v816 — never steal the arrows from a text field. The notes box sits inside this
      // modal, so the old unguarded handler moved to another version mid-sentence every
      // time he tried to move the caret.
      const t = e.target;
      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
      e.preventDefault();
      // v816 — clamp against the list actually RENDERED (dedupedVersions), not the raw
      // prop: dedupe can drop rows, and the old bound let the last press land on an
      // index with nothing behind it.
      const n = dedupedVersions.length;
      if (!n) return;
      if (e.key === "ArrowLeft")  setIdx(i => Math.max(0, i - 1));
      else                        setIdx(i => Math.min(n - 1, i + 1));
    };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [open, onClose, dedupedVersions.length]);
  // v07u/v07y — Track the active image's NATURAL aspect ratio. Stored
  // as a CSS-ready "w / h" string so the var pasted onto .arm-shell can
  // feed straight into `aspect-ratio: var(--arm-active-aspect)` on
  // descendants. v07zd: cache the resolved aspect in sessionStorage
  // keyed by media path so repeat opens of the same image paint at
  // the correct aspect from frame one (no taller→shorter shift).
  const armAspectKey = (v) => v && (v.image || v.video) ? "arm-aspect:" + (v.image || v.video) : null;
  const readCached = (v) => {
    const k = armAspectKey(v);
    if (!k) return null;
    try { return sessionStorage.getItem(k); } catch (_) { return null; }
  };
  // v07ze — Default to PROJECT aspect (21:9 for Paradise Found, set
  // via the --project-aspect CSS var), not 16:9. Frames are project
  // aspect by design — defaulting to 16:9 caused a taller→shorter
  // shift when source metadata arrived. Reading the CSS var lets
  // the same fallback work across projects with different aspects.
  const projectAspectDefault = (() => {
    try {
      const raw = getComputedStyle(document.documentElement)
        .getPropertyValue("--project-aspect").trim();
      return raw || "21 / 9";
    } catch (_) { return "21 / 9"; }
  })();
  const [activeAspect, setActiveAspect] = React.useState(() => {
    return readCached(versions[idx]) || projectAspectDefault;
  });
  React.useEffect(() => {
    const cached = readCached(versions[idx]);
    if (cached) setActiveAspect(cached);
    else setActiveAspect(projectAspectDefault);
  }, [versions[idx] && versions[idx].image, versions[idx] && versions[idx].video]);
  const onMediaLoad = React.useCallback((e) => {
    const el = e.currentTarget;
    const w = el.naturalWidth || el.videoWidth || 0;
    const h = el.naturalHeight || el.videoHeight || 0;
    if (w > 0 && h > 0) {
      const a = `${w} / ${h}`;
      setActiveAspect(a);
      const v = versions[idx];
      const k = armAspectKey(v);
      if (k) { try { sessionStorage.setItem(k, a); } catch (_) {} }
    }
  }, [versions, idx]);
  if (!open || versions.length === 0) return null;
  const safeIdx = Math.max(0, Math.min(versions.length - 1, idx));
  const cur = versions[safeIdx];
  const NotesPanelComp = window.NotesPanel;
  // v07u — Portal to #modal-root so the .modal-backdrop covers the
  // ENTIRE viewport. Without this, the modal renders inside MediaView's
  // `.view-page`, which has its own `backdrop-filter`. That establishes
  // a containing block for `position: fixed`, so the backdrop only
  // covers the view region (sidebar/header/footer stay un-blurred).
  const portalRoot = document.getElementById("modal-root") || document.body;
  const shellStyle = { "--arm-active-aspect": activeAspect };
  // v07zz304 — close only when the press STARTS directly on the backdrop. The old
  // onClick closed the modal when a drag that began inside (filmstrip drag-scroll,
  // image drag) released out on the backdrop — the click event fires on the common
  // ancestor (the backdrop), so it shut unexpectedly.
  return ReactDOM.createPortal((
    <div className="modal-backdrop arm-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="arm-shell" style={shellStyle}>
        <button className="arm-close" type="button" onClick={onClose} aria-label="Close review">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 5l14 14M19 5L5 19"/></svg>
        </button>

        <header className="arm-head">
          <div className="arm-head-left">
            <div className="arm-eyebrow">REVIEW</div>
            <div className="arm-title">{title || entityId}</div>
            {subtitle && <div className="arm-subtitle">{subtitle}</div>}
          </div>
          <div className="arm-head-right">
            {/* v07zz182 — Open-in-Explorer (local) + Download (works on Railway
                too) for the currently-shown frame/video. Reveal auto-hides on
                http URLs (Railway); download fetches the file so remote users
                can pull it down. */}
            {window.RevealInFolderBtn && (cur && (cur.image || cur.video)) && (
              <window.RevealInFolderBtn src={cur.image || cur.video} label="Open file location in your explorer"/>
            )}
            {window.DownloadFileBtn && (cur && (cur.image || cur.video)) && (
              <window.DownloadFileBtn src={cur.image || cur.video} filename={(cur.label ? entityId + "_" + cur.label : entityId) + (cur.video ? ".mp4" : ".png")} label="Download this file"/>
            )}
            {/* v07b — HERO pill styled to match the existing
                .arm-status pill (dark-themed, soft cream fill).
                Same toggle semantics as the cream popup. */}
            {onHero && cur && (!window.hasPerm || window.hasPerm("approve_shots")) && (() => {
              const isVideo = !!cur.video;
              const baseLabel = String(cur.label || "").split("_")[0];
              const heroLbl = isVideo ? heroVideoLabel : heroFrameLabel;
              const isHero = !!(heroLbl && heroLbl === baseLabel);
              const label = isVideo
                ? (isHero ? "VIDEO HERO" : "HERO THIS VIDEO")
                : (isHero ? "FRAME HERO" : "HERO THIS FRAME");
              const kind = isVideo ? "video" : "frame";
              return (
                <button
                  type="button"
                  className={"arm-hero-pill" + (isHero ? " is-active" : "")}
                  onClick={(e) => { e.stopPropagation(); onHero(kind, baseLabel, isHero); }}
                  title={isHero ? "Click to un-hero" : `Mark ${baseLabel} as ${isVideo ? "video" : "frame"} hero`}
                  aria-pressed={isHero}
                >
                  {/* v07c — star icon matching the strip-thumb star. */}
                  <span className="arm-hero-pill-star" aria-hidden="true">
                    <svg viewBox="0 0 24 24" width="11" height="11" fill={isHero ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                      <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>
                  </span>
                  <span className="arm-hero-pill-label">{label}</span>
                </button>
              );
            })()}
            {/* v816 — WIP actions. Hugo: "i need to be able to go through WIP images,
                promote them or send them back to archive". The strip now carries
                candidates too, so the judgement can happen here at full size instead of
                pushing a frame just to see it big. A PUSHED frame offers the reverse
                (back to WIP); both offer Discard. Images only — videos ride their own
                funnel in the shot modal. */}
            {onSetPublished && cur && cur.image && cur.av_id && (!window.hasPerm || window.hasPerm("approve_shots")) && (
              <button
                type="button"
                className={"arm-hero-pill" + (cur.published ? "" : " arm-wip-pill")}
                onClick={(e) => { e.stopPropagation(); onSetPublished(cur, !cur.published); }}
                title={cur.published
                  ? "Send back to WIP — moves the file into frames/_wip and drops it out of the shot's pushed frames"
                  : "Push to the shot — moves the file into the frames root as a chosen frame"}
              >
                <span className="arm-hero-pill-label">{cur.published ? "SEND TO WIP" : "PUSH TO SHOT"}</span>
              </button>
            )}
            {onArchiveVersion && cur && cur.image && cur.av_id && (!window.hasPerm || window.hasPerm("edit_shots")) && (
              <button
                type="button"
                className="arm-hero-pill arm-discard-pill"
                onClick={(e) => { e.stopPropagation(); onArchiveVersion(cur); }}
                title="Discard — archives this version and moves the file to _archived. Reversible from the shot modal's Archived tab."
              >
                <span className="arm-hero-pill-label">DISCARD</span>
              </button>
            )}
            {/* v07zz59 — Promote pill (archived versions only). Hugo
                wants it INSIDE the filmstrip review so each version /
                slice can be promoted independently. Opens the picker
                modal so the target shot is chosen explicitly. */}
            {/* v07zz319 — ONE action for every frame here (archived OR live): "Send to shot".
                Opens the shot picker; the frame is copied + renamed into the chosen shot's
                folder as its selected frame. (Dropped the separate archived "Promote".) */}
            {onPromote && cur && (cur.archived || cur.image) && (
              <button
                type="button"
                className="arm-hero-pill arm-promote-pill"
                onClick={(e) => { e.stopPropagation(); onPromote(cur); }}
                title="Send this frame to a shot: pick the target, it's copied + renamed into that shot's folder as its selected frame"
              >
                <span className="arm-hero-pill-star" aria-hidden="true">
                  <svg viewBox="0 0 24 24" width="11" height="11" 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>
                </span>
                <span className="arm-hero-pill-label">SEND TO SHOT…</span>
              </button>
            )}
            {/* v06z — Hugo: dropped the standalone status pill
                (`.arm-status`) from this header. The HERO pill above
                is the only badge here now; the shot's stage is read
                from the cream popup. */}
          </div>
        </header>

        <div className="arm-body">
          <div className="arm-stage">
            {/* v06l — fixed-aspect 16:9 wrapper around the media so
                switching between an <img> and <video> doesn't reflow
                the panel during the video's metadata load. The wrapper
                sets its size; the media absolute-fills it. Aspect 16:9
                is consistent with the rest of the project's frames. */}
            <div className="arm-media-wrap">
              {cur.video ? (
                /* v07zz69 — Hugo: "video should auto play when opening
                   the card" + "thumbnails of videos should always be
                   the first frame of the video". autoPlay (muted to
                   bypass browser autoplay policy), playsInline, plus
                   preload="auto" so the FIRST FRAME paints
                   immediately as the implicit poster. The browser
                   shows frame-0 once metadata loads even if the user
                   hasn't pressed play. */
                <video
                  className="arm-media"
                  src={cur.video}
                  controls
                  autoPlay
                  muted
                  playsInline
                  preload="auto"
                  onLoadedMetadata={onMediaLoad}
                  key={`${entityId}:vid`}
                />
              ) : cur.image ? (
                (() => {
                  // v07zz228 — same warm/cold fade as the shot-modal hero: a URL
                  // already loaded this session (window.__warmedThumbs) paints
                  // INSTANTLY; a cold one fades in via a keyframe animation (a CSS
                  // transition silently no-ops on instant local loads → the
                  // "flicker, no fade" Hugo saw). Route through thumbUrl(1200).
                  const _armSrc = window.thumbUrl ? window.thumbUrl(cur.image, 1200) : cur.image;
                  const _armWarm = !!(window.__warmedThumbs && window.__warmedThumbs.has(_armSrc));
                  return (
                    <img
                      className={"arm-media arm-media-img" + (_armWarm ? " is-warm" : "")}
                      src={_armSrc}
                      alt={`${entityId} ${cur.label}`}
                      key={`${entityId}:img`}
                      ref={(el) => { if (el && (_armWarm || (el.complete && el.naturalWidth > 0))) el.classList.add("is-warm"); }}
                      onLoad={(e) => {
                        try { (window.__warmedThumbs = window.__warmedThumbs || new Set()).add(_armSrc); } catch (_) {}
                        if (!e.currentTarget.classList.contains("is-warm")) e.currentTarget.classList.add("is-loaded");
                        onMediaLoad(e);
                      }}
                    />
                  );
                })()
              ) : (
                <div className="arm-media arm-media--empty">No preview available for {cur.label}.</div>
              )}
              {cur.label && <span className="arm-stage-tag">{cur.label}{cur.time ? ` · ${cur.time}` : ""}</span>}
            </div>
          </div>

          <aside className="arm-side">
            {/* v07zz41 — Notes/Prompt tab switcher. Hugo asked for the
                ability to see the per-version prompt next to the hero
                image so he can compare prompts across versions
                (especially "original gen prompt" vs "upscale prompt"
                which can differ). Notes tab is the existing
                NotesPanelComp; Prompt tab shows cur.prompt with a
                copy button + a small label that distinguishes upscale
                prompts from original-gen prompts. */}
            <ArmSideTabs
              entityType={entityType}
              entityId={entityId}
              cur={cur}
              NotesPanelComp={NotesPanelComp}
            />
          </aside>
        </div>

        <footer className="arm-foot">
          {/* v06k — Hugo: split filmstrip into a Frames row and a
              Videos row so frames stay in order at the top and videos
              line up beneath, instead of interleaved. Renders the
              legacy single-row layout when frames/videos aren't
              passed. */}
          {(Array.isArray(frames) || Array.isArray(videos))
            ? (
              <div className="arm-filmstrip-rows">
                {Array.isArray(frames) && frames.length > 0 && (
                  <div className="arm-filmstrip-row">
                    <div className="arm-filmstrip-label">FRAMES</div>
                    <div className="arm-filmstrip" ref={framesDragRef}>
                      {frames.map((v, i) => {
                        const idxIn = versions.findIndex(rv => rv === v || (rv.label === v.label && rv.kind === v.kind));
                        const vBase = String(v.label || "").split("_")[0];
                        const isHero = heroFrameLabel && heroFrameLabel === vBase;
                        return (
                          <button
                            key={`f-${v.label}-${i}`}
                            type="button"
                            className={"arm-thumb" + (idxIn === safeIdx ? " is-active" : "") + (isHero ? " is-hero" : "")}
                            onClick={() => idxIn >= 0 && setIdx(idxIn)}
                            title={v.label + (v.time ? ` · ${v.time}` : "")}
                          >
                            {v.image
                              ? <img className="arm-thumb-img" src={window.thumbUrl ? window.thumbUrl(v.image, 240) : v.image} alt={v.label} loading="lazy"/>
                              : <span className="arm-thumb-glyph">·</span>}
                            {/* v07zz43 — 4K badge on upscale tiles. */}
                            {v.is4k && <span className="arm-thumb-4k-badge">4K</span>}
                            {/* v816 — candidates are in this strip now, so say which is
                                which at a glance. Only the WIP ones are marked; a pushed
                                frame is the norm and needs no badge. */}
                            {v.image && v.av_id && v.published === false && <span className="arm-thumb-wip-badge">WIP</span>}
                            {/* v06z — hover-revealed star button on
                                every thumb. Clickable to promote (or
                                toggle off) the hero pin for that
                                version. Sticky-visible when active. */}
                            {onHero && (!window.hasPerm || window.hasPerm("approve_shots")) && (
                              <span
                                className={"arm-thumb-hero-btn" + (isHero ? " is-active" : "")}
                                role="button"
                                tabIndex={0}
                                onClick={(e) => { e.stopPropagation(); onHero("frame", vBase, !!isHero); }}
                                onKeyDown={(e) => {
                                  if (e.key === "Enter" || e.key === " ") {
                                    e.preventDefault();
                                    e.stopPropagation();
                                    onHero("frame", vBase, !!isHero);
                                  }
                                }}
                                title={isHero ? "Click to un-hero this frame" : "Hero this frame"}
                                aria-label="Hero this frame"
                              >
                                <svg viewBox="0 0 24 24" width="11" height="11"
                                     fill={isHero ? "currentColor" : "none"}
                                     stroke="currentColor" strokeWidth="1.8"
                                     strokeLinecap="round" strokeLinejoin="round">
                                  <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>
                              </span>
                            )}
                            <span className="arm-thumb-label">{
                              /* v07zz43 — Upscale labels rendered as
                                 "v001 · 4K" instead of raw "v001_4k". */
                              (() => {
                                const m = /^(v\d+)_4k$/i.exec(v.label || "");
                                return m ? `${m[1]} · 4K` : v.label;
                              })()
                            }</span>
                          </button>
                        );
                      })}
                    </div>
                  </div>
                )}
                {Array.isArray(videos) && videos.length > 0 && (
                  <div className="arm-filmstrip-row">
                    <div className="arm-filmstrip-label">VIDEOS</div>
                    <div className="arm-filmstrip" ref={videosDragRef}>
                      {videos.map((v, i) => {
                        const idxIn = versions.findIndex(rv => rv === v || (rv.label === v.label && rv.kind === v.kind));
                        const seekedSrc = v.video ? (v.video + (v.video.includes("#") ? "" : "#t=0.1")) : null;
                        const vBase = String(v.label || "").split("_")[0];
                        const isHero = heroVideoLabel && heroVideoLabel === vBase;
                        return (
                          <button
                            key={`v-${v.label}-${i}`}
                            type="button"
                            className={"arm-thumb arm-thumb--video" + (idxIn === safeIdx ? " is-active" : "") + (isHero ? " is-hero" : "")}
                            onClick={() => idxIn >= 0 && setIdx(idxIn)}
                            title={v.label + (v.time ? ` · ${v.time}` : "")}
                            /* v785 — drag rides the GLOBAL data-dragfile machinery (App.jsx),
                               same proven path as image drags; the per-thumb handlers were a
                               second dataTransfer writer that upload zones rejected. */
                            draggable={!!v.video}
                            data-dragfile={v.video || undefined}
                          >
                            {seekedSrc
                              ? <video className="arm-thumb-vid" src={seekedSrc} preload="metadata" muted playsInline tabIndex={-1} aria-hidden="true"/>
                              : <span className="arm-thumb-glyph">▶</span>}
                            <span className="arm-thumb-play">▶</span>
                            {/* v06z — hover-revealed star on every
                                video thumb. Same toggle semantics as
                                frame thumbs. */}
                            {onHero && (!window.hasPerm || window.hasPerm("approve_shots")) && (() => {
                              const isUpscale = v.kind === "upscale";
                              return (
                                <span
                                  className={"arm-thumb-hero-btn" + (isHero ? " is-active" : "")}
                                  role="button"
                                  tabIndex={0}
                                  onClick={(e) => { e.stopPropagation(); onHero(isUpscale ? "upscale" : "video", vBase, !!isHero); }}
                                  onKeyDown={(e) => {
                                    if (e.key === "Enter" || e.key === " ") {
                                      e.preventDefault();
                                      e.stopPropagation();
                                      onHero(isUpscale ? "upscale" : "video", vBase, !!isHero);
                                    }
                                  }}
                                  title={isHero ? "Click to un-hero this video" : "Hero this video"}
                                  aria-label="Hero this video"
                                >
                                  <svg viewBox="0 0 24 24" width="11" height="11"
                                       fill={isHero ? "currentColor" : "none"}
                                       stroke="currentColor" strokeWidth="1.8"
                                       strokeLinecap="round" strokeLinejoin="round">
                                    <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>
                                </span>
                              );
                            })()}
                            <span className="arm-thumb-label">{
                              /* v07zz43 — Upscale labels rendered as
                                 "v001 · 4K" instead of raw "v001_4k". */
                              (() => {
                                const m = /^(v\d+)_4k$/i.exec(v.label || "");
                                return m ? `${m[1]} · 4K` : v.label;
                              })()
                            }</span>
                          </button>
                        );
                      })}
                    </div>
                  </div>
                )}
              </div>
            )
            : (
              <div className="arm-filmstrip" ref={legacyDragRef}>
                {dedupedVersions.map((v, i) => (
                  <button
                    key={`${v.label}-${i}`}
                    type="button"
                    className={"arm-thumb" + (i === safeIdx ? " is-active" : "")}
                    onClick={() => setIdx(i)}
                    title={v.label + (v.time ? ` · ${v.time}` : "")}
                  >
                    {v.image ? (
                      <img className="arm-thumb-img" src={window.thumbUrl ? window.thumbUrl(v.image, 240) : v.image} alt={v.label} loading="lazy"/>
                    ) : v.video ? (
                      <span className="arm-thumb-glyph">▶</span>
                    ) : (
                      <span className="arm-thumb-glyph">·</span>
                    )}
                    <span className="arm-thumb-label">{v.label}</span>
                  </button>
                ))}
              </div>
            )}
        </footer>
      </div>
    </div>
  ), portalRoot);
}

// t19 — minimal lightbox. Click anywhere outside the image (or hit
// Escape) to close. Used by the reference thumbnails in
// ShotDetailModal — clicking a small circular ref opens the full image.
// v07zz232 — Asset-modal filmstrip: drag-to-scroll fought the new drag-to-
// reorder, so the strip now scrolls horizontally with the mouse wheel instead.
// Callback-ref that stores the element AND wires a non-passive wheel listener
// (React's onWheel is passive, so preventDefault wouldn't take). Guarded so
// re-renders re-calling the ref with the same node don't double-bind.
// v07zz241 — suppress the native HTML5 drag "ghost" image so reordering doesn't
// drag the whole thumbnail around the screen — only the gold drop-line shows
// where it'll land, then the images swap on drop (Hugo's elegant reorder).
let _emptyDragImg = null;
function _hideDragGhost(e) {
  try {
    if (!_emptyDragImg) { _emptyDragImg = new Image(); _emptyDragImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; }
    if (e.dataTransfer && e.dataTransfer.setDragImage) e.dataTransfer.setDragImage(_emptyDragImg, 0, 0);
  } catch (_) {}
}
function _attachWheelScroll(el, ref) {
  if (ref) ref.current = el || null;
  if (el && !el.__wheelScrollWired) {
    el.__wheelScrollWired = true;
    el.addEventListener("wheel", (e) => {
      if (el.scrollWidth <= el.clientWidth) return;   // nothing to scroll horizontally
      const delta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
      if (!delta) return;
      el.scrollLeft += delta;
      e.preventDefault();   // stop the wheel from also scrolling the modal vertically
    }, { passive: false });
  }
}
// v07zz231 — strip thumbnail resizing from a url to get the full-resolution
// original (for 1:1 zoom): unwrap /api/r2-thumb?url=… and drop ?w= query.
function _rawFullUrl(u) {
  if (!u) return u;
  try {
    if (u.indexOf("/api/r2-thumb") !== -1) {
      const q = new URLSearchParams(u.split("?")[1] || "");
      const raw = q.get("url");
      if (raw) return decodeURIComponent(raw);
    }
  } catch (_) {}
  return u.split("?")[0];
}
function Lightbox({ src, alt = "", caption, onClose, onPrev, onNext }) {
  // v07zz185 — optional prev/next so the asset modal can flip through every
  // image full screen. Arrow keys mirror the on-screen chevrons.
  // v07zz231 — click the image to toggle 1:1 actual resolution ↔ fit-to-screen.
  const [zoomed, setZoomed] = React.useState(false);
  React.useEffect(() => { setZoomed(false); }, [src]); // reset zoom when the image changes
  // v07zz238 — when zoomed (1:1), click-and-drag to pan the oversized image
  // around the scrolling backdrop. A drag (moved past threshold) is NOT treated
  // as a click, so it doesn't toggle zoom-out or close the lightbox.
  const scrollRef = React.useRef(null);
  const panRef = React.useRef(null); // { x, y, sl, st, moved } — live only during a drag
  const justPannedRef = React.useRef(false); // set on drag-release; swallows exactly the trailing click
  const panListenersRef = React.useRef(null); // active document listeners, for unmount teardown
  const onPanStart = (e) => {
    if (!zoomed || e.button !== 0) return;
    const el = scrollRef.current; if (!el) return;
    panRef.current = { x: e.clientX, y: e.clientY, sl: el.scrollLeft, st: el.scrollTop, moved: false };
    el.classList.add("is-panning");
    const onMove = (ev) => {
      const p = panRef.current; if (!p) return;
      const dx = ev.clientX - p.x, dy = ev.clientY - p.y;
      if (Math.abs(dx) > 4 || Math.abs(dy) > 4) p.moved = true;
      el.scrollLeft = p.sl - dx; el.scrollTop = p.st - dy;
    };
    const onUp = () => {
      el.classList.remove("is-panning");
      if (panRef.current && panRef.current.moved) justPannedRef.current = true;
      panRef.current = null;
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      panListenersRef.current = null;
    };
    panListenersRef.current = { onMove, onUp };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
    e.preventDefault(); // stop native image drag-ghost
  };
  // A pan-drag ends in a synthetic click; swallow exactly that one click (via the
  // justPanned flag set in onUp) so the image doesn't zoom-out / the backdrop doesn't close.
  const _consumedPan = () => { if (justPannedRef.current) { justPannedRef.current = false; return true; } return false; };
  const onImgClick = (e) => { e.stopPropagation(); if (_consumedPan()) return; setZoomed(z => !z); };
  const onBackdropClick = () => { if (_consumedPan()) return; onClose && onClose(); };
  // v07zz238 — if the lightbox unmounts mid-drag, tear down the document listeners.
  React.useEffect(() => () => {
    const L = panListenersRef.current;
    if (L) { document.removeEventListener("mousemove", L.onMove); document.removeEventListener("mouseup", L.onUp); panListenersRef.current = null; }
  }, []);
  React.useEffect(() => {
    if (!src) return;
    const onKey = (e) => {
      if (e.key === "Escape") { onClose && onClose(); }
      else if (e.key === "ArrowLeft" && onPrev) { e.preventDefault(); onPrev(); }
      else if (e.key === "ArrowRight" && onNext) { e.preventDefault(); onNext(); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [src, onClose, onPrev, onNext]);
  if (!src) return null;
  return (
    <div ref={scrollRef} className={"lightbox-backdrop" + (zoomed ? " is-zoomed" : "")} onClick={onBackdropClick}>
      {onPrev && !zoomed && (
        <button className="lightbox-nav lightbox-nav--prev" type="button" onClick={(e) => { e.stopPropagation(); onPrev(); }} aria-label="Previous image">
          <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 19l-7-7 7-7"/></svg>
        </button>
      )}
      <img className={"lightbox-img" + (zoomed ? " is-zoomed" : "")}
        src={zoomed ? _rawFullUrl(src) : src} alt={alt} draggable={false}
        title={zoomed ? "Drag to pan · click to fit to screen" : "Click for 1:1 actual size"}
        onMouseDown={onPanStart} onClick={onImgClick}/>
      {onNext && !zoomed && (
        <button className="lightbox-nav lightbox-nav--next" type="button" onClick={(e) => { e.stopPropagation(); onNext(); }} aria-label="Next image">
          <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 5l7 7-7 7"/></svg>
        </button>
      )}
      {caption && <div className="lightbox-caption">{caption}</div>}
      <button className="lightbox-close" type="button" onClick={onClose} aria-label="Close">
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 5l14 14M19 5L5 19"/></svg>
      </button>
    </div>
  );
}

// t23 — Calendar / Milestones view. Vertical timeline of milestones
// synced from Google Calendar (or empty if the integration isn't yet
// configured — see CALENDAR_SETUP.md). Admin-only "Sync now" button.
function CalendarView() {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [syncing, setSyncing] = React.useState(false);
  const fetcher = window.authFetch || fetch;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const isAdmin = !!(userCtx && userCtx.user && userCtx.user.role === "admin");

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    fetcher("/api/calendar/milestones")
      .then(r => r.ok ? r.json() : null)
      .then(d => { setData(d || { milestones: [], configured: false }); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);

  const syncNow = () => {
    setSyncing(true); setError(null);
    fetcher("/api/calendar/sync", { method: "POST" })
      .then(async r => {
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
        return body;
      })
      .then(() => load())
      .catch(err => setError(err.message))
      .finally(() => setSyncing(false));
  };

  const fmt = (s) => {
    if (!s) return "—";
    const d = new Date(s);
    if (isNaN(d.getTime())) return s;
    return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
  };
  const milestones = (data && data.milestones) || [];
  const configured = !!(data && data.configured);

  return (
    <section className="calendar-view stub-page glass">
      <header className="calendar-head">
        <div>
          <div className="calendar-eyebrow">SCHEDULE · MILESTONES</div>
          <h1 className="calendar-title">Calendar</h1>
          <div className="calendar-sub">Synced from Google Calendar — deadlines, milestones, and key dates.</div>
        </div>
        {isAdmin && (
          <button type="button" className="user-add-submit" onClick={syncNow} disabled={syncing}>
            {syncing ? "Syncing…" : "Sync now"}
          </button>
        )}
      </header>

      {error && <div className="calendar-empty calendar-empty--err">{error}</div>}

      {!configured && (
        <div className="calendar-empty">
          Google Calendar isn&apos;t connected yet. Follow the steps in
          <code> CALENDAR_SETUP.md</code> to enable sync.
        </div>
      )}

      {configured && !loading && milestones.length === 0 && (
        <div className="calendar-empty">No milestones synced yet — try the Sync now button.</div>
      )}

      {milestones.length > 0 && (
        <ol className="calendar-timeline">
          {milestones.map(m => (
            <li key={m.id} className="calendar-row">
              <div className="calendar-date">
                <div className="calendar-date-day">{fmt(m.starts_at)}</div>
                {m.ends_at && m.ends_at !== m.starts_at && <div className="calendar-date-end">→ {fmt(m.ends_at)}</div>}
              </div>
              <div className="calendar-body">
                <div className="calendar-row-title">
                  {m.url ? <a href={m.url} target="_blank" rel="noreferrer">{m.title}</a> : m.title}
                </div>
                {m.description && <div className="calendar-row-desc">{m.description}</div>}
              </div>
            </li>
          ))}
        </ol>
      )}
    </section>
  );
}

/* v05i — AdminView + HelpDocsView extracted to src/AdminPage.jsx and
   src/HelpDocsPage.jsx so the main bundle doesn't carry ~600 lines of
   admin/help code through every Babel-standalone transpile. Both files
   still expose their root component on window so App.jsx wiring is
   unchanged. */


Object.assign(window, {
  SequencesGrid, SequenceDetailView,
  ScriptView, ScheduleView, AssetsView, CrewView, MediaView, ArchiveView, ReportsView, DocumentsView,
  PricingView, ActivityView, DigestView, AssetReviewModal, Lightbox, CalendarView,
  StubPage, SettingsView, NewProjectModal, ImportShotlistModal, CharacterDetailModal, AssetItemModal,
  GlobalAssetModal,
});
