/* global React, ReactDOM, Sidebar, ProjectHeader, TopBar, ShotsPanel, RightColumn, FooterRow, ShotDetailModal, SequenceDetailModal, SequencesGrid, StubPage, SettingsView, LoginPage, ActivityView, DigestView */

// t11 — user context. Holds the currently-authenticated user; consumed
// by Sidebar (to hide nav items by role) and SettingsView (to show the
// admin-only Users panel). Exposed on window so other top-level scripts
// loaded BEFORE App.jsx (Sidebar, Views) can read it during their
// render — useContext at render time always sees the latest value
// because React resolves the context at component-mount, not at script
// load.
const UserContext = React.createContext({ user: null });
window.UserContext = UserContext;

// t08 — auth-aware fetch. Adds the bearer token (held in module-scope so
// every fetch in the app reads from one place) and on 401 clears auth and
// reloads the login screen. Mirrors window.authFetch so child components
// (ShotsPanel, ProjectHeader, etc.) can adopt it incrementally.
let __authToken = null;
let __onAuthLost = null;
// v974 — PER-TAB ID. Hugo: "i have the local app opened in two browser tabs and I
// cannot see the second one update in real time when I change a shot status in the
// first one." The SSE handler skipped events whose actor_id was the logged-in user,
// to stop the acting tab refetching what it already updated — but a second tab of
// the SAME user looks identical by that test, so it ignored every change too. The
// unit of "self" is the TAB, not the person: each tab mints an id, sends it on every
// request, and the server echoes it back on the broadcast.
const __clientId = (() => {
  try {
    if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
  } catch (_) {}
  return "tab-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
})();
window.__clientId = __clientId;
function authFetch(url, init = {}) {
  const headers = new Headers(init.headers || {});
  if (__authToken) headers.set("Authorization", `Bearer ${__authToken}`);
  if (!headers.has("X-Client-Id")) headers.set("X-Client-Id", __clientId);
  // S5.1 — multi-project routing. Every API call declares which project it is
  // for; the server's projectContext middleware resolves X-Active-Project →
  // ?project= → default and opens that project's DB. window.__activeProjectId
  // is kept in sync by the App's activeProject state (initialiser + switchProject
  // + the server-preferences effect); localStorage is the cold-start fallback for
  // calls that fire before React mounts.
  const __ap = window.__activeProjectId || (function () { try { return localStorage.getItem("frameflow-active-project"); } catch (_) { return null; } })();
  if (__ap && !headers.has("X-Active-Project")) headers.set("X-Active-Project", __ap);
  // v07zz9 — Only auto-set JSON when the body is a string or plain
  // object. NEVER for FormData / Blob / URLSearchParams — those need
  // the browser to set its own Content-Type (with a multipart
  // boundary for FormData). Forcing application/json on a FormData
  // body broke /api/upload (multer saw no file, returned HTTP 400).
  const body = init.body;
  const isFormLike = (typeof FormData !== "undefined" && body instanceof FormData)
                   || (typeof Blob !== "undefined" && body instanceof Blob)
                   || (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams);
  if (body && !isFormLike && !headers.has("Content-Type")) {
    headers.set("Content-Type", "application/json");
  }
  // v885 — RETRY A CONNECTION FAILURE ON READS. Hugo: "why the hell is the whole Media
  // page completely empty now." The server had restarted (nodemon, on a commit) and the
  // page's one big GET was refused during the boot window; fetch rejected with a
  // TypeError, the catch set an empty list, and nothing ever tried again. Same thing
  // bounced the app to the login screen when /api/auth/me was the unlucky request.
  // A refused/reset connection on a GET is retried 3 times (0.8s, 2s, 4s) before the
  // failure is surfaced — long enough to ride out a restart or a momentary stall.
  // GETs only: a retried POST could double a write. HTTP error statuses (4xx/5xx) are
  // real answers and are never retried.
  const method = String((init && init.method) || "GET").toUpperCase();
  const attempt = (n) => fetch(url, { ...init, headers }).then(r => {
    if (r.status === 401 && __onAuthLost) __onAuthLost();
    return r;
  }).catch(err => {
    const netErr = err && (err.name === "TypeError" || /Failed to fetch|NetworkError|ECONNREFUSED|ECONNRESET/i.test(String(err.message)));
    const aborted = err && err.name === "AbortError";
    if (method !== "GET" || !netErr || aborted || n >= 3) throw err;
    return new Promise(res => setTimeout(res, [800, 2000, 4000][n])).then(() => attempt(n + 1));
  });
  return attempt(0);
}
window.authFetch = authFetch;

// v07zz74 — Cache generation invalidation. Hugo: "we still have the
// previous versions of images appearing when opening a shot modal for
// the first time, both local and railway, but it is very apparent on
// railway because it takes half a second to clear, while on local it
// only takes 0.1 second".
//
// Root cause: every shot opens with `_initialCachedVersions` pulled
// synchronously from sessionStorage["asset-versions:<shotId>"]. After
// the v07zz73 wipe, the DB rows are gone but the browser still has the
// pre-wipe versions in sessionStorage from previous sessions. The
// modal happily renders the now-stale R2 URLs for ~100 ms on local
// (re-fetch is fast) and ~500 ms on Railway (R2 round-trip) before the
// fresh fetch lands and reconciles to empty.
//
// Fix: bump ASSET_CACHE_GEN whenever any data wipe could have stranded
// stale asset_version metadata in browsers. On app load we compare
// against localStorage["__assetCacheGen"]; if it differs we sweep ALL
// `asset-versions:*` and `grid-asset-versions:*` keys from
// sessionStorage AND clear the in-memory window.__assetVersionCache
// before any component reads them. Going forward, each subsequent
// fetch repopulates the cache cleanly.
const ASSET_CACHE_GEN = "v07zz310";
(function _wipeStaleAssetCache() {
  try {
    const have = localStorage.getItem("__assetCacheGen");
    if (have === ASSET_CACHE_GEN) return;
    // Sweep every cached asset-versions entry from sessionStorage.
    const keysToKill = [];
    for (let i = 0; i < sessionStorage.length; i++) {
      const k = sessionStorage.key(i);
      if (!k) continue;
      if (k.startsWith("asset-versions:") || k.startsWith("grid-asset-versions:")) {
        keysToKill.push(k);
      }
    }
    for (const k of keysToKill) sessionStorage.removeItem(k);
    // Clear the in-memory Map too — same generation, same logic.
    if (window.__assetVersionCache) window.__assetVersionCache.clear();
    localStorage.setItem("__assetCacheGen", ASSET_CACHE_GEN);
    if (keysToKill.length > 0) {
      console.log(`[asset-cache] wiped ${keysToKill.length} stale sessionStorage entries (gen ${have || "(none)"} → ${ASSET_CACHE_GEN})`);
    }
  } catch (_) { /* sessionStorage disabled — no-op */ }
})();

// v05e — thumbnail URL helper. Appends `?w=NNN` to ANY locally-served
// image URL so the server-side sharp middleware returns a downsized
// variant instead of the full-resolution original. The browser caches
// each (path, width) pair separately, so subsequent loads are instant.
// External URLs (http:// or https://, including R2 cloud URLs) and
// missing values are returned unchanged because we can't proxy them
// through sharp. Width must be one of the allowlisted sizes in
// server.js (160/240/320/400/560/800/1200/1600) — anything else still
// gets the query param, but the middleware will fall through to the
// original file.
// v07zz99 — Thumbnail cache-bust token. Bump this whenever on-disk shot
// images may have been regenerated under the SAME filename (e.g. a fresh
// Cowork first pass over the previous one). It's appended as `&cb=<tok>`
// to every thumbnail URL, so the BROWSER treats the regenerated image as
// a brand-new resource and fetches it fresh — instead of serving the
// stale copy it cached at the identical `…?w=400` URL. (The server-side
// cache + watcher already self-heal; this is purely to defeat the
// browser's HTTP cache without forcing a manual hard-refresh.)
// v792 — the token is now RUNTIME-bumpable (Hugo: "sometimes i update the image in
// photoshop and overwrite so i need to refresh it for it to appear"). The shot modal's
// 🔄 button calls window.__bumpThumbCacheBust(), every thumbUrl() from then on carries
// the new token, and the browser refetches (the server side already re-encodes stale
// thumbs via thumbCacheIsFresh). Persisted in localStorage so a reload doesn't fall
// back to the old token and resurrect the browser's stale day-long cache entries.
let THUMB_CACHE_BUST = "20260602c";
try { THUMB_CACHE_BUST = localStorage.getItem("filmtracker.thumbCb") || THUMB_CACHE_BUST; } catch (_) {}
window.__bumpThumbCacheBust = () => {
  THUMB_CACHE_BUST = "r" + Date.now();
  try { localStorage.setItem("filmtracker.thumbCb", THUMB_CACHE_BUST); } catch (_) {}
  return THUMB_CACHE_BUST;
};
// v07zz481 — CSS-url()-proof local paths. Thumbs paint via UNQUOTED CSS
// `url(${...})` all over the app; an apostrophe (…/John Smith's Ships/…) or
// parens in the path silently invalidates the whole declaration → blank tile
// (Hugo: "why are John Smith's Ships reference thumbnails not showing").
// fileToUrl serves paths raw, so encode just the CSS-hostile characters +
// spaces here; the server decodeURIComponent()s them back on every route.
// Only the path part — the query (?v=… busters) never contains them.
function _cssSafePath(u) {
  const qi = u.indexOf("?");
  const path = qi >= 0 ? u.slice(0, qi) : u;
  const query = qi >= 0 ? u.slice(qi) : "";
  return path.replace(/[ '()]/g, (ch) => ({ " ": "%20", "'": "%27", "(": "%28", ")": "%29" }[ch])) + query;
}
function thumbUrl(src, width) {
  if (!src || !width) return src;
  const s = String(src);
  const lower = s.toLowerCase();
  // Strip any existing ?…/#… suffix, then check extension on the path.
  const pathOnly = lower.split("?")[0].split("#")[0];
  const dot = pathOnly.lastIndexOf(".");
  if (dot < 0) return src;
  const ext = pathOnly.slice(dot + 1);
  // v07zz482 — gif included: tiles get a light first-frame webp via ?w= (and the
  // _cssSafePath encoding — gif filenames with spaces/apostrophes would other-
  // wise hit the blank-CSS-url() bug); full animated gif streams on open/zoom.
  const imageExts = ["jpg", "jpeg", "png", "webp", "tif", "tiff", "avif", "gif"];
  if (imageExts.indexOf(ext) < 0) return src;
  // R2 / external HTTPS URLs — route through /api/r2-thumb so the
  // server fetches, resizes, and caches them once. Without this every
  // Railway-side <img> loads the 4-10MB original. The endpoint
  // validates the URL against R2_PUBLIC_URL to prevent SSRF.
  if (lower.startsWith("http:") || lower.startsWith("https:")) {
    return "/api/r2-thumb?url=" + encodeURIComponent(s) + "&w=" + width + "&cb=" + THUMB_CACHE_BUST;
  }
  // /local/ URLs at HERO sizes (>= 1200 px) — bypass the resizer.
  // The full-res PNG transfers from SSD in ~30 ms, decode parallels
  // it on the GPU, and the sharp output at >=1200w would be larger
  // than the source PNG anyway (re-encode tax with no width win).
  if (lower.startsWith("/local/") && width >= 1200) {
    return _cssSafePath(s) + (s.indexOf("?") >= 0 ? "&" : "?") + "cb=" + THUMB_CACHE_BUST;
  }
  // /local/ URLs at TILE sizes (160 / 240 / 320 / 400 / 560 / 800) —
  // route through `?w=NNN`. The server pre-generates these widths as
  // WebP at boot (see pregenerateThumbnails in server.js), so the
  // disk cache is warm by the time the user opens any tab. A
  // 240-wide tile drops from 2-5 MB → 25-40 KB, which is the
  // difference between "snappy" and "instant" in the Media grid
  // panel. If the cache is somehow missing for a given file, the
  // sharp middleware generates it on first hit and serves
  // everything below it from cache.
  const sep = s.indexOf("?") >= 0 ? "&" : "?";
  return _cssSafePath(s) + sep + "w=" + width + "&cb=" + THUMB_CACHE_BUST;
}
window.thumbUrl = thumbUrl;

// 16 Sep 2026 - ONE copy of the sequence-hue table and of the two placeholder-gradient
// recipes. The table (a Paradise Found mapping: sequence NUMBER -> hue) was pasted into
// five files, and the linear-gradient() string that consumes it was retyped ~20 times.
// Published here beside the other window helpers so every caller reads the same one.
// The table applies to the DEFAULT project only: sequence 11 of another project is not
// sequence 11 of Paradise Found, so it gets the deterministic fallback instead.
const __SEQ_HUE_PF = { 1: 32, 8: 70, 11: 200, 14: 18, 15: 12, 19: 145 };
function __seqHueFor(seq, fallback) {
  const n = Number(seq);
  let pf = true;
  try { pf = !window.__isDefaultProject || window.__isDefaultProject(); } catch (_) { pf = true; }
  const h = pf ? __SEQ_HUE_PF[n] : 0;
  if (h) return h;
  if (typeof fallback === "function") return fallback(n);
  return fallback === undefined ? 100 : fallback;
}
// The two recipes, character-for-character what the call sites used to type inline.
function __seqGradientFor(h) {
  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 __locGradientFor(h) {
  return `linear-gradient(155deg, oklch(0.42 0.06 ${h}), oklch(0.62 0.08 ${h + 30}) 55%, oklch(0.80 0.05 ${h + 60}))`;
}
window.__seqHue = __seqHueFor;
window.__seqGradient = __seqGradientFor;
window.__locGradient = __locGradientFor;

// v1080 — WEIGHTED PROGRESS. Hugo: "the project shows only 63% completion even though the
// upscale part is just literally the last 10% ... the frames part taking a month and a half
// and video part also taking over a month". Every step used to count the same (1/7). Now
// three groups carry the weight — Frames, Video, Upscale — set per episode
// (episodes.stage_weights, synced; the gear on the progress circle) and split over the 7
// steps by a fixed inner ratio. Spec: docs/superpowers/specs/2026-09-14-weighted-progress-design.md
var _WP_KEYS = ["prompt", "first_pass", "refinement", "hero", "video_prompt", "video", "upscale"];
var _WP_DEFAULTS = { frames: 50, video: 40, upscale: 10 };
// Share of its group each step carries. With the defaults: 5 / 20 / 15 / 10 / 5 / 35 / 10.
var _WP_INNER = {
  prompt: ["frames", 0.10], first_pass: ["frames", 0.40], refinement: ["frames", 0.30], hero: ["frames", 0.20],
  video_prompt: ["video", 0.125], video: ["video", 0.875], upscale: ["upscale", 1],
};
// v1082 — the DEFAULT weights come from the schedule. Hugo: "the default proportion should be
// based on the schedule itself, with how many weeks it takes for each stage compared to the
// total length of the project". Only the shot stages count (his pick): Frames = Asset Design
// + Shot Concepting, Video = Video Generation, Upscale = 4K Upscaling, matched by phase NAME,
// scaled to 100. Tracker building and Grading have no shots, so they are left out. Weeks =
// the phase's `weeks`, else its dates. Rounded to whole numbers that add up to 100 (the gap
// goes to the biggest group). All three groups must be in the schedule, else 50 / 40 / 10.
function _wpPhaseGroup(name) {
  const n = String(name || "");
  if (/upscal|\b4k\b/i.test(n)) return "upscale";
  if (/video/i.test(n)) return "video";
  if (/concept|frame|asset/i.test(n)) return "frames";
  return null;
}
function _wpPhaseWeeks(p) {
  const w = Number(p && p.weeks);
  if (Number.isFinite(w) && w > 0) return w;
  const a = Date.parse(p && p.start), b = Date.parse(p && p.end);
  return (Number.isFinite(a) && Number.isFinite(b) && b >= a) ? ((b - a) / 86400000 + 1) / 7 : 0;
}
function scheduleGroupWeights(schedule) {
  const phases = (schedule && Array.isArray(schedule.phases)) ? schedule.phases : [];
  const weeks = { frames: 0, video: 0, upscale: 0 };
  const names = { frames: [], video: [], upscale: [] };
  // v1084 — when each group is planned to happen (first phase start .. last phase end, the
  // end counted to the end of its day), for the schedule-aware Health in TopBar.
  const spans = { frames: null, video: null, upscale: null };
  for (const p of phases) {
    const grp = _wpPhaseGroup(p && p.name);
    const wk = grp ? _wpPhaseWeeks(p) : 0;
    if (!grp || !(wk > 0)) continue;
    weeks[grp] += wk;
    const ps = Date.parse(p && p.start), pe = Date.parse(p && p.end);
    if (Number.isFinite(ps) && Number.isFinite(pe) && pe >= ps) {
      const sp = spans[grp], e = pe + 86400000 - 1;
      spans[grp] = sp ? { start: Math.min(sp.start, ps), end: Math.max(sp.end, e) } : { start: ps, end: e };
    }
    names[grp].push(String(p.name).replace(/^Phase\s*[\d.]+\s*[\u2014\u2013-]\s*/i, ""));
  }
  if (!(weeks.frames > 0 && weeks.video > 0 && weeks.upscale > 0)) return null;
  const tot = weeks.frames + weeks.video + weeks.upscale;
  const raw = { frames: 100 * weeks.frames / tot, video: 100 * weeks.video / tot, upscale: 100 * weeks.upscale / tot };
  const out = { frames: Math.round(raw.frames), video: Math.round(raw.video), upscale: Math.round(raw.upscale) };
  const gap = 100 - (out.frames + out.video + out.upscale);
  if (gap) { const big = ["frames", "video", "upscale"].sort((x, y) => raw[y] - raw[x])[0]; out[big] += gap; }
  return { ...out, weeks, names, spans: (spans.frames && spans.video && spans.upscale) ? spans : null };
}
// Weights in force: the episode's saved weights (Settings) > the schedule > 50 / 40 / 10.
// `source` says which ("saved" | "schedule" | "default").
function stageGroupWeights(episode) {
  let g = null;
  try { g = episode && episode.stage_weights ? JSON.parse(episode.stage_weights) : null; } catch (_) { g = null; }
  if (g) {
    const n = (k) => { const x = Number(g[k]); return Number.isFinite(x) && x >= 0 ? x : NaN; };
    const out = { frames: n("frames"), video: n("video"), upscale: n("upscale") };
    if ([out.frames, out.video, out.upscale].every(Number.isFinite) && out.frames + out.video + out.upscale > 0) return { ...out, source: "saved" };
  }
  // 24 Sep 2026 (G7) — another project's own default weights (Settings, kept with its schedule).
  const pw = window.__appData && window.__appData.schedule && window.__appData.schedule.stage_weights;
  if (pw && typeof pw === "object") {
    const n = (k) => { const x = Number(pw[k]); return Number.isFinite(x) && x >= 0 ? x : NaN; };
    const out = { frames: n("frames"), video: n("video"), upscale: n("upscale") };
    if ([out.frames, out.video, out.upscale].every(Number.isFinite) && out.frames + out.video + out.upscale > 0) return { ...out, source: "project" };
  }
  const sch = scheduleGroupWeights(window.__appData && window.__appData.schedule);
  if (sch) return { frames: sch.frames, video: sch.video, upscale: sch.upscale, source: "schedule" };
  return { ..._WP_DEFAULTS, source: "default" };
}
function _wpStepWeights(groups) {
  const g = groups || _WP_DEFAULTS;
  const tot = (g.frames + g.video + g.upscale) || 1;
  const w = {};
  for (const k of _WP_KEYS) { const [grp, share] = _WP_INNER[k]; w[k] = (g[grp] * share) / tot; }
  return w;
}
// One shot, 0-100. done(k) says whether step k is done (each caller keeps its own rule,
// e.g. the image-derived first pass).
function weightedStagePct(done, groups) {
  const w = _wpStepWeights(groups);
  let p = 0;
  for (const k of _WP_KEYS) if (done(k)) p += w[k];
  return p * 100;
}
// A set of shots: the weighted average (overall) plus how far each group is (0-100).
// done(shot, k) says whether the shot's step k is done.
function weightedProgress(shots, done, groups) {
  const n = (shots && shots.length) || 0;
  if (!n) return { overall: 0, frames: 0, video: 0, upscale: 0 };
  const w = _wpStepWeights(groups);
  const acc = { overall: 0, frames: 0, video: 0, upscale: 0 };
  for (const s of shots) for (const k of _WP_KEYS) if (done(s, k)) { acc.overall += w[k]; const [grp, share] = _WP_INNER[k]; acc[grp] += share; }
  return { overall: 100 * acc.overall / n, frames: 100 * acc.frames / n, video: 100 * acc.video / n, upscale: 100 * acc.upscale / n };
}
window.STAGE_GROUP_DEFAULTS = _WP_DEFAULTS;
window.stageGroupWeights = stageGroupWeights;
window.scheduleGroupWeights = scheduleGroupWeights;
window.weightedStagePct = weightedStagePct;
window.weightedProgress = weightedProgress;
// v1086 — SCHEDULE REVISIONS (spec docs/superpowers/specs/2026-09-14-schedule-revisions-design.md).
// Dates are "YYYY-MM-DD", read as whole UTC days so no time zone moves them by one.
function _sdMs(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || "")); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : NaN; }
function schedDayDiff(a, b) { return Math.round((_sdMs(b) - _sdMs(a)) / 86400000); }   // b - a, in days
function schedAddDays(iso, n) { const t = _sdMs(iso); return (Number.isFinite(t) && Number.isFinite(n)) ? new Date(t + n * 86400000).toISOString().slice(0, 10) : iso; }
var _SD_MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function schedFmtDay(iso) { const t = _sdMs(iso); if (!Number.isFinite(t)) return "—"; const d = new Date(t); return d.getUTCDate() + " " + _SD_MON[d.getUTCMonth()]; }
// The original plan (schedule.baseline, the 28 May dates) as a schedule, with the names and
// colours of today's stages, for scheduleGroupWeights (Health) and the Schedule page. Null
// when the schedule has no baseline.
function baselineSchedule(schedule) {
  const b = schedule && schedule.baseline;
  if (!b || !Array.isArray(b.phases) || !b.phases.length) return null;
  const now = new Map(((schedule && schedule.phases) || []).filter(Boolean).map(p => [p.id, p]));
  return {
    project_start: schedule.project_start,
    project_end: b.project_end,
    phases: b.phases.filter(p => p && p.id).map(p => { const c = now.get(p.id) || {}; return { name: c.name || p.id, color: c.color, ...p }; }),
  };
}
window.schedDayDiff = schedDayDiff;
window.schedAddDays = schedAddDays;
window.schedFmtDay = schedFmtDay;
window.baselineSchedule = baselineSchedule;
// v1081 — the shot set and the step rule Overall Progress uses, for the Settings preview
// (TopBar keeps identical local copies): counted = not archived, not archive footage, not
// omitted, not a linked follow-on; a first pass counts as done when the shot has an image.
function progressCounted(s) { return !!s && !s.is_archive && !s.archive_footage && !s.omitted && !s.linked_to; }
function progressStepDone(s, k) {
  if (!s) return false;
  if (k === "first_pass" && window.hasAnyImage && window.hasAnyImage(s)) return true;
  return !!(s.stage_status && s.stage_status[k] === "done");
}
window.progressCounted = progressCounted;
window.progressStepDone = progressStepDone;

// v07zz535 — Copy a shot's on-disk VIDEO folder path to the clipboard (paste into a
// Kling/Seedance "Save As" dialog when downloading the rendered mp4). Local-only —
// the server returns 503 without WATCH_PATH. Shared by the shot QUEUE (Sidebar), the
// shot ROW (ShotsPanel) and the shot MODAL (ShotDetailModal). Shows a brief toast.
// v1020 — this toast was trapped inside copyShotVideoFolder, so any other
// feature that wanted one had to invent its own or fall back to alert(), which
// invariant #22 forbids. Lifted out and exported as window.__toast(msg, ok).
function _toast(msg, ok) {
    const t = document.createElement("div");
    t.textContent = msg;
    t.style.cssText = "position:fixed;z-index:100000;bottom:24px;left:50%;transform:translateX(-50%);background:" +
      (ok === false ? "color-mix(in srgb, var(--red-29) 94%, transparent)" : "color-mix(in srgb, var(--shade-8) 92%, transparent)") +
      ";color:var(--ink-toast);padding:9px 16px;border-radius:var(--r-round);font:var(--fw-semi) var(--fs-12)/1 Inter,system-ui,sans-serif;letter-spacing:var(--track-03);box-shadow:0 8px 24px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity var(--dur-2) ease";
    document.body.appendChild(t);
    requestAnimationFrame(() => { t.style.opacity = "1"; });
    setTimeout(() => { t.style.opacity = "0"; setTimeout(() => { try { t.remove(); } catch (_) {} }, 200); }, 1500);
}
window.__toast = _toast;

function copyShotVideoFolder(shotId) {
  if (!shotId) return;
  const fetcher = window.authFetch || fetch;
  const toast = _toast;
  fetcher("/api/shots/" + encodeURIComponent(shotId) + "/reveal-output-folder", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ kind: "video", mode: "copy" }),
  })
    .then(r => r.json().then(j => r.ok ? j : Promise.reject(new Error(j && j.error || ("HTTP " + r.status)))))
    .then(d => {
      if (!d || !d.folder) throw new Error("no folder path returned");
      return navigator.clipboard.writeText(d.folder).then(() => toast("Video folder path copied"));
    })
    .catch(err => {
      console.warn("[copyShotVideoFolder]", err && err.message);
      const msg = String(err && err.message || "");
      toast(/WATCH_PATH|local-only/i.test(msg) ? "Only available on the local app" : "Couldn't copy folder path", false);
    });
}
window.__copyShotVideoFolder = copyShotVideoFolder;

// 23 Sep 2026 — HIDDEN TOOLS (Hugo's post-mortem decisions). The Agent now does these four
// jobs, so their entry points leave the UI by DEFAULT; the code stays and Settings ▸ HIDDEN
// TOOLS turns each one back on. Stored per project in the synced `settings` row "ui_hidden"
// as { <key>: true|false }; a key that is missing means hidden.
const UI_HIDDEN_KEYS = ["import_shotlist", "notes_digest", "refine_prompt"];   // 23 Sep 2026 (image modes) — "multi_dispatch" left: Multi-dispatch was REMOVED, not hidden
window.UI_HIDDEN_KEYS = UI_HIDDEN_KEYS;
function _uiHiddenDefaults() { const o = {}; UI_HIDDEN_KEYS.forEach(k => { o[k] = true; }); return o; }
window.__uiHidden = window.__uiHidden || _uiHiddenDefaults();
function _uiHiddenSet(map) {
  const next = _uiHiddenDefaults();
  UI_HIDDEN_KEYS.forEach(k => { if (map && typeof map[k] === "boolean") next[k] = map[k]; });
  window.__uiHidden = next;
  try { window.dispatchEvent(new CustomEvent("filmtracker:ui-hidden")); } catch (_) {}
  return next;
}
window.__setUiHiddenLocal = _uiHiddenSet;
window.__loadUiHidden = async function () {
  try {
    const r = await (window.authFetch || fetch)("/api/settings/ui-hidden");
    if (!r.ok) return window.__uiHidden;
    const d = await r.json();
    return _uiHiddenSet(d && d.hidden);
  } catch (_) { return window.__uiHidden; }
};
window.__isUiHidden = (key) => !!((window.__uiHidden || {})[key] !== false);
// A hook, so a component re-renders when a switch in Settings changes. Always call it at the
// top of a component (never inside a condition).
window.useUiHidden = function useUiHidden(key) {
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    const fn = () => setTick(t => t + 1);
    window.addEventListener("filmtracker:ui-hidden", fn);
    return () => window.removeEventListener("filmtracker:ui-hidden", fn);
  }, []);
  return window.__isUiHidden(key);
};

// v07zz536 — Censored-word client cache + scanner. Fetches the per-platform swap list
// once (cached; refreshed after edits / on sync.applied), then scans a video prompt:
// applies known swaps + flags known-bad words that have no replacement yet. Shared by the
// Censored Words page + the Generate video "Clean & copy" button. Matches whole words/
// phrases, case-insensitive, longest-pattern-first so "machine gun" beats "gun".
window.__censorCache = window.__censorCache || { words: [] };
async function censorRefresh() {
  try {
    const r = await (window.authFetch || fetch)("/api/censor");
    if (!r.ok) return window.__censorCache.words;
    const d = await r.json();
    window.__censorCache = { words: (d && d.words) || [] };
    try { window.dispatchEvent(new CustomEvent("paradise-censor-changed")); } catch (_) {}
    return window.__censorCache.words;
  } catch (_) { return window.__censorCache.words; }
}
window.__censorRefresh = censorRefresh;
function _censorReEscape(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function censorScan(prompt, platform) {
  const text = String(prompt || "");
  const plat = String(platform || "").toLowerCase();
  const list = ((window.__censorCache && window.__censorCache.words) || [])
    .filter(w => w && w.pattern && (!plat || w.platform === plat))
    .slice()
    .sort((a, b) => (b.pattern || "").length - (a.pattern || "").length);   // longest phrase first
  let cleaned = text;
  const applied = [], flaggedUnmapped = [];
  for (const w of list) {
    const rx = new RegExp("\\b" + _censorReEscape(w.pattern) + "\\b", "gi");
    if (!rx.test(cleaned)) continue;
    if (w.replacement) {
      cleaned = cleaned.replace(new RegExp("\\b" + _censorReEscape(w.pattern) + "\\b", "gi"), w.replacement);
      applied.push({ pattern: w.pattern, replacement: w.replacement });
    } else if (flaggedUnmapped.indexOf(w.pattern) === -1) {
      flaggedUnmapped.push(w.pattern);
    }
  }
  return { cleaned, applied, flaggedUnmapped };
}
window.__censorScan = censorScan;

// v07zz — Shared "nice entity name" helper (client mirror of the server's
// emailService.prettyEntityLabel). Turns (entity_type, entity_id) into a
// friendly label so the bell notifications, Recent Activity, To-Do and the
// Activity drawer all read "Character · Ponce de Leon" instead of
// "ASSET_CHARACTER ponce". Asset display names come from window.__appData
// (already loaded); cached + rebuilt when the asset data reference changes.
const __ENTITY_KIND = {
  asset: "Asset", asset_character: "Character", asset_animal: "Animal",
  asset_location: "Location", asset_prop: "Prop", asset_ref: "Asset",
  reference: "Reference", shot: "Shot", episode: "Episode", sequence: "Sequence",
  note: "Note", video_review: "Review", review: "Review", project: "Project",
  milestone: "Milestone", presentation: "Presentation", vo_take: "Voiceover",
};
function __titleCase(s) { return String(s || "").replace(/[-_]+/g, " ").replace(/\b\w/g, c => c.toUpperCase()).trim(); }
// 15 Sep 2026 — PROJECT-AWARE HELPERS (Hugo: the UI shows only what the active
// project declares; Paradise Found keeps its old path byte-for-byte). Every page
// that used to hard-code characters / animals / locations / props, "Episode",
// or a Paradise Found library reads these instead. All four are plain functions
// on window (no React) so module-level code in the other files can call them
// at RENDER time — never at parse time: App.jsx is the LAST script in index.html.
//   __activeProjectRow()   the /api/projects row of the active project (null before it loads)
//   __isDefaultProject()   true for Paradise Found (template_id null / id "paradise-found")
//   __projectCategories()  [{id,label,fields?}] — row.asset_categories when the server sends
//                          it, else the keys /api/assets returned, else [] until data lands.
//                          The default project ALWAYS gets the four film-doc classics.
//   __projectCategoryLabel(id, singular)  "Instruments" / "Instrument"
//   __containerWord(plural)  "Episode" / "Episodes", or the template's container label
//   __projectPages()       the template's page list when the row carries one, else null (= all)
const __CATEGORY_LABELS = { characters: "Characters", animals: "Animals", locations: "Locations", props: "Props", instruments: "Instruments", wardrobe: "Wardrobe", vehicles: "Vehicles" };
const __PF_CATEGORIES = [{ id: "characters", label: "Characters" }, { id: "animals", label: "Animals" }, { id: "locations", label: "Locations" }, { id: "props", label: "Props" }];
window.__activeProjectRow = function () {
  const id = window.__activeProjectId || null;
  if (!id) return null;
  const list = (window.__projects && window.__projects.projects) || [];
  return list.find((p) => p && p.id === id) || null;
};
window.__isDefaultProject = function () {
  const row = window.__activeProjectRow();
  if (row) return !row.template_id || row.id === "paradise-found";
  let id = window.__activeProjectId || null;
  if (!id) { try { id = localStorage.getItem("frameflow-active-project"); } catch (_) { id = null; } }
  return !id || id === "paradise-found" || id === "paradise";
};
window.__projectCategories = function () {
  if (window.__isDefaultProject()) return __PF_CATEGORIES.slice();
  const row = window.__activeProjectRow();
  const fromRow = row && Array.isArray(row.asset_categories) ? row.asset_categories : null;
  if (fromRow && fromRow.length) {
    return fromRow.map((c) => (typeof c === "string"
      ? { id: c, label: __CATEGORY_LABELS[c] || __titleCase(c) }
      : { id: String(c.id), label: c.label || __CATEGORY_LABELS[c.id] || __titleCase(c.id), fields: Array.isArray(c.fields) ? c.fields : undefined,
          template_kinds: Array.isArray(c.template_kinds) ? c.template_kinds : undefined }));   // 24 Sep 2026 — which Generate > Assets templates it offers
  }
  const data = window.__appData || null;
  let ids = data && Array.isArray(data.asset_categories) ? data.asset_categories : null;
  if (!ids || !ids.length) {
    const assets = (data && data.assets) || null;
    ids = assets ? Object.keys(assets).filter((k) => Array.isArray(assets[k]) && k !== "refs") : [];
  }
  return ids.map((id) => ({ id: String(id), label: __CATEGORY_LABELS[id] || __titleCase(id) }));
};
window.__projectCategoryLabel = function (id, singular) {
  const cat = window.__projectCategories().find((c) => c.id === id);
  const label = (cat && cat.label) || __CATEGORY_LABELS[id] || __titleCase(id);
  if (!singular) return label;
  return label.replace(/ies$/i, "y").replace(/([^s])s$/i, "$1");
};
// 24 Sep 2026 (G5) — the categories whose assets are CHARACTERS (voice, direction presets, the VO
// pickers): Paradise Found = its characters; another project = every category whose template kind
// is "character" (characters, people, gods ... — templates/_core/core.json asset_template_kinds).
window.__characterCategoryIds = function () {
  if (window.__isDefaultProject()) return ["characters"];
  const aliases = { characters: 1, people: 1, gods: 1 };
  return window.__projectCategories()
    .filter((c) => c && ((Array.isArray(c.template_kinds) && c.template_kinds.includes("character")) || aliases[String(c.id).toLowerCase()]))
    .map((c) => String(c.id));
};
window.__isCharacterCategory = function (id) { return window.__characterCategoryIds().includes(String(id || "")); };
// Every character of the active project (Paradise Found: assets.characters exactly as before).
window.__projectCharacters = function () {
  const all = (window.__appData && window.__appData.assets) || {};
  if (window.__isDefaultProject()) return Array.isArray(all.characters) ? all.characters : [];
  const out = [];
  for (const id of window.__characterCategoryIds()) for (const c of (Array.isArray(all[id]) ? all[id] : [])) if (c) out.push(c);
  return out;
};
window.__containerWord = function (plural) {
  const row = window.__activeProjectRow();
  const c = row && row.container;
  if (c && (plural ? c.label_plural : c.label)) return plural ? c.label_plural : c.label;
  return plural ? "Episodes" : "Episode";
};
window.__projectPages = function () {
  if (window.__isDefaultProject()) return null;
  const row = window.__activeProjectRow();
  return row && Array.isArray(row.pages) && row.pages.length ? row.pages.map(String) : null;
};
window.__assetNameForSlug = function (slug) {
  const assets = (window.__appData && window.__appData.assets) || null;
  if (!assets) return null;
  if (!window.__assetNameMap || window.__assetNameMapSrc !== assets) {
    const m = {};
    // 15 Sep 2026 — the project's categories (the film-doc four for Paradise Found).
    for (const cat of window.__projectCategories().map((c) => c.id)) {
      for (const a of (assets[cat] || [])) { const k = a.id || a.slug; if (k && a.name) m[String(k)] = a.name; }
    }
    window.__assetNameMap = m; window.__assetNameMapSrc = assets;
  }
  return window.__assetNameMap[String(slug)] || null;
};
window.prettyEntity = function (entityType, entityId) {
  const t = String(entityType || "").toLowerCase();
  const kind = __ENTITY_KIND[t] || (t ? __titleCase(t) : "");
  let name = entityId == null ? "" : String(entityId);
  if (name === "overall") return { kind: kind || "Project", name: "General" };
  if (t === "asset" || t.indexOf("asset_") === 0 || t === "reference") {
    const slug = name.indexOf("/") >= 0 ? name.split("/").pop() : name;
    name = window.__assetNameForSlug(slug) || __titleCase(slug);
  }
  return { kind, name };
};
// String form ("Kind · Name"), matching the server resolver. Shots/episodes
// keep their bare id (already clear); assets get the "Kind · Name" form.
window.prettyEntityLabel = function (entityType, entityId) {
  const t = String(entityType || "").toLowerCase();
  const { kind, name } = window.prettyEntity(entityType, entityId);
  if (t === "asset" || t.indexOf("asset_") === 0 || t === "reference") return `${kind} · ${name}`;
  return name || kind || "";
};

// v07zz264 — episode label. A pilot episode shows "Pilot" instead of a padded
// number ("01"), so the header / switcher / deck subtitles read "Episode Pilot".
// Accepts an episode object {id,title,episode_number} OR an episode_id string.
// Returns "Pilot" for pilots, else the zero-padded number, else "—".
window.epNumLabel = function (ep) {
  if (!ep) return "—";
  var id = typeof ep === "string" ? ep : (ep.id || "");
  var title = typeof ep === "string" ? "" : (ep.title || "");
  if (/pilot/i.test(id) || /\bpilot\b/i.test(title)) return "Pilot";
  var n = typeof ep === "string" ? (id.replace(/[^0-9]/g, "").replace(/^0+(?=\d)/, "") || null) : ep.episode_number;
  return (n != null && n !== "") ? String(n).padStart(2, "0") : "—";
};

// v722 — Turn a NOTE's version_label into the {version, family} pair openShot() wants.
// Hugo: "when I click on the note, it doesnt seem to send me to that exact version the note
// was sent on, but just the latest in the shot. i have to dig through to find the one."
//
// Every layer already carried the answer — notes.version_label, the notification payload,
// and change_log.new_value all store it — but every click handler called openShot(id) with
// the version dropped on the floor. One parser here so the bell, the toast, the activity
// row and the Notes page can never drift apart on the format.
//
// The shapes that actually occur (counted across the real notes table):
//   "video-v004"  a video note   → family video. ShotDetailModal strips the "video-" itself.
//   "v012_f3"     a frame candidate → family frame.
//   "v006_4k"     an upscale — could be an image OR a video upscale. Ambiguous.
//   "v004"        bare — ambiguous the same way.
// For the ambiguous two we pass family:null and let the modal resolve it against the shot's
// actual rows; guessing here would land you on a frame when you meant a video.
function noteVersionTarget(label) {
  const s = String(label || "").trim();
  if (!s) return null;
  if (/^video-v\d/i.test(s)) return { version: s, family: "video" };
  if (/^v\d+_f\d+$/i.test(s)) return { version: s, family: "frame" };
  if (/^v\d+(_\d+k)?$/i.test(s)) return { version: s, family: null };
  return null;   // free-text ranges on project notes etc. — nothing to target
}
window.__noteVersionTarget = noteVersionTarget;

// v06f — RevealInFolderBtn: small "open in OS file explorer" affordance
// that lives on every image / video panel in the app. POSTs the
// asset's src path (a /local/... URL, a project-root-relative path,
// or an absolute disk path) to /api/reveal which runs the OS-specific
// reveal command on the server (explorer / open -R / xdg-open).
//
// Skipped entirely for R2 / HTTPS URLs since there's no local copy to
// reveal. Renders as a single small icon button — designed to layer
// onto the top-right corner of an image area without taking visual
// weight away from the content.
function RevealInFolderBtn({ src, shotId, label = "Open in explorer", className = "" }) {
  // v07zz330 — falls back to opening a SHOT'S folder by id when there's no file yet (so the
  // shot modal's Open-folder button is always present — drop an image into the empty folder).
  if (!src && !shotId) return null;
  if (src && /^https?:\/\//i.test(String(src))) return null;   // remote-only file → nothing local to reveal
  const onClick = (e) => {
    e.stopPropagation();
    e.preventDefault();
    fetch("/api/reveal", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(src ? { path: src } : { shot_id: shotId }),
    })
      .then(r => r.ok ? null : r.json().then(j => Promise.reject(new Error(j.error || "reveal failed"))))
      .catch(err => console.warn("[reveal]", err.message));
  };
  return (
    <button
      type="button"
      className={"reveal-folder-btn " + className}
      onClick={onClick}
      title={label}
      aria-label={label}
    >
      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
        {/* Folder + small arrow pointing up-right ("open elsewhere") */}
        <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>
    </button>
  );
}
window.RevealInFolderBtn = RevealInFolderBtn;

// v07zz182 — DownloadFileBtn: the Railway counterpart to RevealInFolderBtn.
// Reveal-in-folder only works where there's a local disk (it hides on http
// URLs). This downloads the file to the user's machine — works everywhere, so
// remote team members on Railway can pull a frame/edit down. Fetches the URL as
// a blob so the browser saves it (cross-origin <a download> would otherwise just
// navigate). Falls back to opening the URL if the blob fetch is blocked.
function DownloadFileBtn({ src, filename, label = "Download to disk", className = "" }) {
  if (!src) return null;
  // v07zz38 — Single-step download. A cross-origin <a download> is ignored
  // by the browser and a cross-origin fetch() is blocked by CORS (R2 serves
  // no CORS headers) — that's why the old blob path always fell through to
  // opening the image in a new tab. For a remote (http/https) URL we instead
  // point a same-origin <a> at /api/download, which streams the file back
  // with Content-Disposition: attachment → the browser's Save dialog opens
  // directly, no new tab. A local/relative URL is same-origin, so the plain
  // download attribute works on it as-is.
  const onClick = (e) => {
    e.stopPropagation(); e.preventDefault();
    // If we were handed a resized thumb proxy URL (/api/r2-thumb?url=<X>&w=…),
    // unwrap it so we always download the FULL-RES original behind it, not the
    // small webp the UI happens to be showing.
    let target = src;
    if (/\/api\/r2-thumb/i.test(src)) {
      const m = /[?&]url=([^&]+)/.exec(src);
      if (m) { try { target = decodeURIComponent(m[1]); } catch (_) {} }
    }
    const name = filename || String(target).split("?")[0].split(/[\\/]/).pop() || "download";
    const isRemote = /^https?:\/\//i.test(target);
    const href = isRemote
      ? "/api/download?url=" + encodeURIComponent(target) + "&name=" + encodeURIComponent(name)
      : target;
    const a = document.createElement("a");
    a.href = href; a.download = name;
    document.body.appendChild(a); a.click(); a.remove();
  };
  return (
    <button type="button" className={"reveal-folder-btn " + className} onClick={onClick} title={label} aria-label={label}>
      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
        <path d="M12 3v12M7 10l5 5 5-5"/><path d="M5 21h14"/>
      </svg>
    </button>
  );
}
window.DownloadFileBtn = DownloadFileBtn;

// v07zz276 — Reveal + Download together. RevealInFolderBtn self-hides on http
// (Railway) so only the local machine gets "open in folder"; DownloadFileBtn
// works everywhere. Rendering BOTH at every image/video affordance means Hugo
// keeps reveal locally AND every Railway teammate gets a Download button in the
// SAME spots. One component → drop in wherever a reveal button used to live.
function FileActionBtns({ src, filename, label, revealLabel, downloadLabel, className = "" }) {
  if (!src) return null;
  return (
    <>
      <RevealInFolderBtn src={src} label={revealLabel || label || "Open file location on disk"} className={className} />
      <DownloadFileBtn src={src} filename={filename} label={downloadLabel || "Download this file to your computer"} className={className} />
    </>
  );
}
window.FileActionBtns = FileActionBtns;

const PRODUCTION_TYPE_LABELS = {
  ai_film: "AI Film", vfx: "VFX-Heavy", live: "Traditional Film", cg: "CG Animation",
  anim_2d: "2D Animation", music_video: "Music Video", doc: "Documentary", commercial: "Commercial",
};

// Backdrop choices belong to the skin they were made for. Keep older single-backdrop
// styles intact for their current skin, while an unvisited preset starts with its photo.
function _skinPresetStyle(previous, id, currentSkin) {
  const prev = previous || {};
  const backdrops = { ...(prev.backdrops || {}) };
  const from = prev.base || currentSkin;
  if (from) backdrops[from] = { ...(prev.backdrop || { mode: "default" }) };
  return {
    base: id,
    backdrops,
    backdrop: { ...(backdrops[id] || { mode: "default" }) },
    ...(prev.content_tone === "light" || prev.content_tone === "dark" ? { content_tone: prev.content_tone } : {}),
  };
}
// 17 Sep 2026 - PROJECT SWITCH (Hugo: "its pretty slow and buggy switching project").
// Paint a skin on body + html (the treatment rules key on data-skin-family) and leave the boot
// keys for the next page load. Returns true only when something changed, so a caller that just
// re-checks does no work. Called from applyProjectStyle, i.e. in the SAME synchronous step as the
// project's colours and photo, so a frame can never show one project's photo under another's skin.
function _paintSkin(id) {
  const skin = id || "forest";
  const fam = window.__skinFamily ? window.__skinFamily(skin) : null;
  const b = document.body, h = document.documentElement;
  let changed = false;
  for (const el of [b, h]) {
    if (el.getAttribute("data-theme") !== skin) { el.setAttribute("data-theme", skin); changed = true; }
    if ((el.getAttribute("data-skin-family") || null) !== (fam || null)) {
      if (fam) el.setAttribute("data-skin-family", fam); else el.removeAttribute("data-skin-family");
      changed = true;
    }
  }
  try {
    // TWO keys: the boot painter reads this one; "frameflow-theme" is the user's own default and
    // only Settings writes it (a neon project must not become everybody's default).
    localStorage.setItem("frameflow-boot-theme", skin);
    localStorage.setItem("frameflow-boot-skin-family", fam || "");
  } catch (_) {}
  // the header's style switcher shows the tick on the skin in use
  if (changed) { try { window.dispatchEvent(new CustomEvent("filmtracker:skin-changed", { detail: { id: skin } })); } catch (_) {} }
  return changed;
}
// Where a project was left: its episode and album, and its last episode/album list, so a switch
// back opens the same place at once (one data load, the right album skin, the right header label)
// instead of loading the project without an episode and then again with one.
const _EPISODE_KEY = "frameflow-active-episode:";
const _GROUP_KEY = "frameflow-active-group:";
const _EPISODE_LIST_KEY = "frameflow-episode-list:";
function _remembered(prefix, pid) {
  try { return (pid && localStorage.getItem(prefix + pid)) || null; } catch (_) { return null; }
}
function _remember(prefix, pid, value) {
  try { if (!pid) return; if (value) localStorage.setItem(prefix + pid, value); else localStorage.removeItem(prefix + pid); } catch (_) {}
}
function _readEpisodeList(pid) {
  try {
    const j = JSON.parse(localStorage.getItem(_EPISODE_LIST_KEY + pid) || "null");
    return j && Array.isArray(j.episodes) && Array.isArray(j.groups) ? j : null;
  } catch (_) { return null; }
}
function _writeEpisodeList(pid, episodes, groups) {
  try { localStorage.setItem(_EPISODE_LIST_KEY + pid, JSON.stringify({ episodes, groups })); } catch (_) {}
}
// One gate per kind of load (the project's data, its episode list). A load belongs to a KEY
// (project + episode). When the key changes, every load still running for the old key is aborted
// and can never land: no setState, no cache write, no switch-flag change. Loads of the SAME key
// (a sync tick, reloadAppData) are not aborted, so a burst of refreshes cannot starve the page;
// an older one that lands after a newer one is dropped instead.
function _makeLoadGate() { return { key: null, seq: 0, applied: 0, runs: new Set() }; }
function _gateEnter(gate, key) {
  if (gate.key !== key) {
    for (const r of gate.runs) { r.dead = true; if (r.ctrl) { try { r.ctrl.abort(); } catch (_) {} } }
    gate.runs.clear();
    gate.key = key;
  }
  if (key == null) return null;
  const run = { dead: false, seq: ++gate.seq, ctrl: typeof AbortController === "function" ? new AbortController() : null };
  run.live = () => !run.dead && gate.key === key && run.seq >= gate.applied;
  // true when this load may put its result on screen (and marks it as the newest one shown)
  run.land = () => { gate.runs.delete(run); if (!run.live()) return false; gate.applied = run.seq; return true; };
  run.fail = () => { gate.runs.delete(run); return run.live(); };
  gate.runs.add(run);
  return run;
}
// The longest a project switch may keep the content dimmed and unclickable if its load never
// answers at all (a hung request). A normal switch clears the flag in the commit that shows the
// new project; this only rescues the page.
const SWITCH_FLAG_RESCUE_MS = 30000;
// 17 Sep 2026 - every data payload carries the project it belongs to (__project). The page only
// renders rows that belong to the project in the header; a project whose load failed (or never
// answered) is shown EMPTY with a retry line instead of lifting the dim over another project's rows.
function _emptyProjectData(pid, reason) {
  return {
    episode: null, sequences: [], shots: [], crew: [],
    assets: { characters: [], animals: [], locations: [], props: [], refs: [] }, asset_categories: [],
    schedule: {}, script: { title: null, version: null, modified: null, scenes: [] },
    documents: [], doc_roles: [], current_role: "Creative Director",
    __project: pid, __loadError: reason || "failed",
  };
}
function _dataIsFor(d, pid) { return !!d && (!d.__project || d.__project === pid); }
// A switch first makes ONE tiny urgent render (this component), which makes React throw away any
// render it is still preparing - the previous project's rows, or a switch that has not committed -
// instead of finishing it and then undoing it (a second switch 150 ms after the first froze the
// page for 1-2 s building 239 rows nobody would see).
function AppSwitchInterrupt({ bindRef }) {
  const [, bump] = React.useReducer((n) => n + 1, 0);
  React.useLayoutEffect(() => {
    bindRef.current = bump;
    return () => { if (bindRef.current === bump) bindRef.current = null; };
  }, [bindRef]);
  return null;
}

function App() {
  // v07zz31 — Apply the user's chosen background image as early as
  // possible on App mount. Without this, the SettingsView useEffect
  // wouldn't fire until the user actually navigated to Settings — so
  // any reload would briefly flash the default forest backdrop before
  // their custom choice kicked in. Runs once per mount; the in-Settings
  // useEffect handles changes from then on.
  // v192 — the per-user background (localStorage filmtracker.bg-mode) no longer paints the
  // page: the style builder's per-project backdrop does (applyProjectStyle below).
  React.useEffect(() => {}, []);
  // t08 / v01n — auth state. Token persists in localStorage so a page
  // reload doesn't kick the user back to login. handleLogout clears
  // both React state and the persisted copy. The matching module-scope
  // __authToken is mirrored synchronously inside the login handler so
  // the very first fetch after login already carries the bearer header.
  const [authToken, setAuthToken] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.token") || null; } catch (e) { return null; }
  });
  const [currentUser, setCurrentUser] = React.useState(null);
  if (authToken) __authToken = authToken;  // sync module-scope on first render

  // v06p — Mirror permissions on `window` so non-context components
  // (Sidebar, GeneratePage, etc.) can read them with a one-liner
  // `window.hasPerm("generate_assets")` instead of plumbing through
  // props. Admin bypasses every check UNLESS the "See as" persona
  // is active — in that case the admin temporarily takes on the
  // picked role's permission set so the rest of the UI rerenders
  // as that user would see it.
  const [seeAsRole, setSeeAsRole] = React.useState(null);
  const [seeAsPerms, setSeeAsPerms] = React.useState(null);
  React.useEffect(() => {
    window.__setSeeAs = async (role) => {
      if (!role) { setSeeAsRole(null); setSeeAsPerms(null); window.__seeAs = null; return; }
      window.__seeAs = role;
      setSeeAsRole(role);
      // Pull the effective permissions for the target role from
      // the same matrix the server enforces.
      try {
        const r = await (window.authFetch || fetch)("/api/admin/permissions");
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        const j = await r.json();
        const row = (j.matrix && j.matrix[role]) || {};
        const perms = {};
        for (const p of (j.permissions || [])) perms[p.key] = !!row[p.key];
        setSeeAsPerms(perms);
      } catch (e) { setSeeAsPerms({}); }
    };
  }, []);
  React.useEffect(() => {
    const effRole  = seeAsRole || (currentUser && currentUser.role) || null;
    const effPerms = seeAsRole ? (seeAsPerms || {}) : ((currentUser && currentUser.permissions) || {});
    window.__currentUser = currentUser;
    window.__userPerms = effPerms;
    window.__effectiveRole = effRole;
    window.hasPerm = (key) => {
      if (!currentUser) return false;
      // Admin bypasses ONLY when not impersonating.
      if (!seeAsRole && currentUser.role === "admin") return true;
      return !!effPerms[key];
    };
  }, [currentUser, seeAsRole, seeAsPerms]);
  const handleLoggedIn = React.useCallback((token, user) => {
    __authToken = token;
    setAuthToken(token);
    setCurrentUser(user);
    try { localStorage.setItem("filmtracker.token", token); } catch (e) {}
  }, []);
  const handleLogout = React.useCallback(() => {
    __authToken = null;
    setAuthToken(null);
    setCurrentUser(null);
    try { localStorage.removeItem("filmtracker.token"); } catch (e) {}
  }, []);
  React.useEffect(() => { __onAuthLost = handleLogout; return () => { __onAuthLost = null; }; }, [handleLogout]);

  // t09b — auth bootstrap. Two-phase:
  //   1. Probe /api/auth/me WITHOUT a token to detect dev-only
  //      BYPASS_AUTH. If the server returns 200, we're in bypass mode
  //      (no real JWT needed) and we use the returned admin user.
  //   2. Otherwise, if there's a stored token in localStorage, repeat
  //      the call WITH the Bearer header so the server re-identifies
  //      the user across hard-refreshes. Without this second call,
  //      currentUser stayed null on every Railway page reload and the
  //      UI rendered an empty/default-looking user identity (the
  //      "base user" Hugo was seeing).
  const [authChecked, setAuthChecked] = React.useState(false);
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const probeRes = await fetch("/api/auth/me");
        if (probeRes.ok) {
          const user = await probeRes.json();
          if (!cancelled) {
            __authToken = "bypass";
            setAuthToken("bypass");
            setCurrentUser(user);
          }
        } else if (__authToken) {
          // Production: probe was 401, but we have a stored token.
          // Re-identify via an authenticated call.
          const meRes = await fetch("/api/auth/me", {
            headers: { Authorization: `Bearer ${__authToken}` },
          });
          if (meRes.ok) {
            const user = await meRes.json();
            if (!cancelled) setCurrentUser(user);
          } else if (meRes.status === 401) {
            // Token is invalid/expired — wipe it and drop to LoginPage.
            if (!cancelled) {
              __authToken = null;
              setAuthToken(null);
              try { localStorage.removeItem("filmtracker.token"); } catch (e) {}
            }
          }
        }
      } catch (e) { /* network blip — fall through to login */ }
      if (!cancelled) setAuthChecked(true);
    })();
    return () => { cancelled = true; };
  }, []);

  // v01n — last-visited view persists across reloads. Defaults to
  // 'overview' on first run. Persisted on every setView via the
  // useEffect below so reloads land the user back where they were.
  // v01w — 'archive' redirects to 'shots' (Archive page retired,
  // Archival Footage is now a filter row in the Shots panel).
  const [view, setView] = React.useState(() => {
    try {
      // v07zz372 — a URL hash (#/shots) wins, so a shared/bookmarked page link opens that page.
      // v1091 — "assets" is not a page id (the Assets page is "characters"). An old #/assets
      // link or a saved "assets" view used to open a blank page; both now open Assets.
      const h = (location.hash || "").replace(/^#\/?/, "").split("?")[0].trim();
      if (h) return h === "archive" ? "shots" : h === "assets" ? "characters" : h;
      const v = localStorage.getItem("filmtracker.view") || "overview";
      return v === "archive" ? "shots" : v === "assets" ? "characters" : v;
    } catch (e) { return "overview"; }
  });
  // v07zz372 — keep the URL in step with the current page (#/<view>): the address bar shows where
  // you are, page links are shareable, and Back/Forward navigate between pages. First sync uses
  // replaceState (no junk history entry); later changes push a hash entry so Back works.
  const _didInitHashRef = React.useRef(false);
  React.useEffect(() => {
    if (view === "archive") { setView("shots"); return; }
    try { localStorage.setItem("filmtracker.view", view); } catch (e) {}
    try {
      const want = "#/" + view;
      if (location.hash !== want) {
        if (!_didInitHashRef.current) window.history.replaceState(window.history.state, "", want);
        else location.hash = want;
      }
    } catch (_) {}
    _didInitHashRef.current = true;
  }, [view]);
  // Back/Forward (or a manually edited hash) → switch the page.
  React.useEffect(() => {
    const onHash = () => {
      try {
        const h = (location.hash || "").replace(/^#\/?/, "").split("?")[0].trim();
        if (h && h !== view) setView(h === "archive" ? "shots" : h === "assets" ? "characters" : h);
      } catch (_) {}
    };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, [view]);
  // v07zz288 — mobile: the overview's dashboard panels (right column +
  // footer) move behind a Shotlist ⇄ Dashboard toggle so the whole app
  // never scrolls. Desktop ignores both (ovMobile is false there).
  const ovMobile = (window.useUiTier ? window.useUiTier() : "") === "s";
  const [ovTab, setOvTab] = React.useState("list");
  // v05a/v05b — expose setView globally so deep-linked CTAs inside
  // sub-views (e.g. Admin → "see Help & Docs → setup guide") can jump
  // between pages without prop-drilling a callback through every panel.
  React.useEffect(() => { window.__navigate = setView; }, [setView]);

  // v1015 — LAZY PAGES. Generate / Canvas / Presentations / Admin are no longer
  // in index.html; they are compiled the first time their page is opened (see the
  // loader in index.html). Kick the load on every view change — __loadLazyPage
  // no-ops for a page that is not lazy or is already in flight — and bump a
  // counter when one registers so the `window.X &&` render guards re-evaluate.
  const [lazyReady, setLazyReady] = React.useState(0);
  React.useEffect(() => {
    if (window.__loadLazyPage) window.__loadLazyPage(view);
  }, [view]);
  React.useEffect(() => {
    const onLoaded = () => setLazyReady(n => n + 1);
    window.addEventListener("paradise-lazy-page", onLoaded);
    return () => window.removeEventListener("paradise-lazy-page", onLoaded);
  }, []);
  // Read once so the render below genuinely depends on it (const is var here —
  // babel-standalone has no TDZ — so this must sit AFTER the useState above).
  const _lazyTick = lazyReady;
  const lazyPending = (v) => !!(window.__lazyPages && window.__lazyPages[v]) && !window[{
    generate: "GeneratePage", canvas: "CanvasPage", presentations: "PresentationsPage", admin: "AdminView",
  }[v]];
  // v07zz255 — deep link: /?deck=<id> opens that locked presentation straight away
  // in Review › Presentations (for sharing a review link). Stash the id for
  // ReviewPresentations to pick up, jump to Review, and strip the param so a later
  // refresh doesn't re-trigger.
  React.useEffect(() => {
    try {
      const d = new URLSearchParams(location.search).get("deck");
      if (d) {
        window.__pendingDeckId = d; setView("review");
        try { const u = new URL(location.href); u.searchParams.delete("deck"); window.history.replaceState({}, "", u); } catch (_) {}
      }
      // v07zz369 — deep link: /?review=<id> opens that edit in Review (shareable edit link).
      // ReviewPage reads window.__pendingReviewId to select it. Strip the param after.
      const rv = new URLSearchParams(location.search).get("review");
      if (rv) {
        window.__pendingReviewId = rv; setView("review");
        try { const u = new URL(location.href); u.searchParams.delete("review"); window.history.replaceState({}, "", u); } catch (_) {}
      }
    } catch (_) {}
  }, []);
  const [data, setData] = React.useState(null);
  const [openShotId, setOpenShotId] = React.useState(null);
  // v07zz557 — bumps on every openShot(id, version) call so ShotDetailModal re-applies the
  // pending version even when that shot is ALREADY open (same id → no remount otherwise).
  const [pendingShotSeq, setPendingShotSeq] = React.useState(0);
  const pendingShotSeqRef = React.useRef(0);
  // v07zz418 — globally-mounted Grid modal so a grid can be opened from anywhere (e.g. the
  // Shot Queue's grid rows → open the GRID, not the parent shot). window.__openGridDetail(gv).
  const [gridDetailVersion, setGridDetailVersion] = React.useState(null);
  // v06o — shot navigation history. When the user opens another shot
  // from within an already-open shot popup (e.g. a related-shot card),
  // we push the previous shot onto this stack so closing the new one
  // returns to it instead of dropping all the way back to the overview.
  const [shotHistory, setShotHistory] = React.useState([]);
  const [openSequenceId, setOpenSequenceId] = React.useState(null);
  // t17 — SSE live status. "connecting" → "live" once the EventSource
  // open fires, "lost" if it errors. Drives the pulsing LIVE dot in
  // the TopBar (TopBar reads window.__sseStatus).
  const [sseStatus, setSseStatus] = React.useState("connecting");
  React.useEffect(() => { window.__sseStatus = sseStatus; }, [sseStatus]);
  // t22 — list of currently-connected users. Mirrored to window so
  // TopBar can read it without prop-drilling. Refreshed on every
  // presence.* SSE event + an initial REST fetch.
  const [onlineUsers, setOnlineUsers] = React.useState([]);
  React.useEffect(() => { window.__onlineUsers = onlineUsers; }, [onlineUsers]);
  // 16 Sep 2026 - only skins that still exist (see window.__knownSkin in Views.jsx, which loads
  // first). Ten skins were removed; a browser can still hold one of their ids.
  // No skin is named here: the registry is the only list. If it has somehow not loaded, trust the
  // id rather than keep a second hand-written list that the next skin would have to remember.
  const _knownSkin = (id) => !!id && (window.__knownSkin ? window.__knownSkin(id) : true);
  const _pickSkin = (...ids) => ids.find(_knownSkin) || null;
  const [theme, setTheme] = React.useState(() => _pickSkin(localStorage.getItem("frameflow-theme")) || "forest");
  const [style, setStyle] = React.useState(() => localStorage.getItem("frameflow-style") || "glass");
  // v07zz340 — "Hide all money figures" toggle (Settings). Per-user pref; when ON it suppresses
  // every money figure across the app (Schedule amounts, Reports/cost charts) regardless of the
  // view_budget permission — for screen-sharing / presenting without exposing budget. Read at
  // render via window.__hideMoney so the money gates in Views.jsx (canSeeCosts/canBudget) pick it up.
  const [hideMoney, setHideMoney] = React.useState(() => localStorage.getItem("frameflow-hide-money") === "1");
  window.__hideMoney = hideMoney;
  // v1081 — Settings ▸ FLOATING BUTTONS: the Chat bubble can be turned off. Per browser
  // (localStorage "filmtracker.bubbles"); Settings fires "filmtracker:bubbles" on a change.
  // 23 Sep 2026 — the List and Notes bubbles were REMOVED (Hugo's post-mortem decisions);
  // their code sits in _archive/removed-2026-09-23/. Only the chat key is read now.
  const _readBubbles = () => {
    try { const j = JSON.parse(localStorage.getItem("filmtracker.bubbles") || "{}") || {}; return { chat: j.chat !== false }; }
    catch (_) { return { chat: true }; }
  };
  // 23 Sep 2026 — HIDDEN TOOLS. Four features the Agent now does (multi-dispatch, import
  // shotlist, the notes digest + updates page, Refine with AI) leave the UI by default; the
  // code stays and one switch per feature in Settings brings each back. Kept per project in
  // the synced `settings` table (key "ui_hidden") through GET/PUT /api/settings/ui-hidden.
  // Components read window.useUiHidden() at render; "filmtracker:ui-hidden" fires on a change.
  const [uiHiddenTick, setUiHiddenTick] = React.useState(0);
  React.useEffect(() => {
    const fn = () => setUiHiddenTick(t => t + 1);
    window.addEventListener("filmtracker:ui-hidden", fn);
    return () => window.removeEventListener("filmtracker:ui-hidden", fn);
  }, []);
  const [bubbles, setBubbles] = React.useState(_readBubbles);
  React.useEffect(() => {
    const fn = () => setBubbles(_readBubbles());
    window.addEventListener("filmtracker:bubbles", fn);
    window.addEventListener("storage", fn);   // another tab changed it
    return () => { window.removeEventListener("filmtracker:bubbles", fn); window.removeEventListener("storage", fn); };
  }, []);
  React.useEffect(() => { try { localStorage.setItem("frameflow-hide-money", hideMoney ? "1" : "0"); } catch (_) {} }, [hideMoney]);
  // v07zz49 — Default slug renamed from legacy 'paradise' to 'paradise-found'
  // to match the on-disk folder name + the server-side seed. Migrate any
  // localStorage value still set to 'paradise' so existing browsers don't
  // pin the old slug after a hard refresh.
  // S5.1 — window.__activeProjectId mirrors this state so authFetch can stamp
  // X-Active-Project on EVERY request (including ones fired from components
  // that never see the React state).
  // 23 Sep 2026 - the project named in THIS tab's URL (/?project=<slug>#/<page>), read once at
  // load. It wins over localStorage (shared by every tab) and over the saved server preference,
  // so a reload, a bookmark or a second tab opens its own project (Rize reads the URL). It is in
  // the state below BEFORE the first fetch: still one project load.
  const urlProjectRef = React.useRef(undefined);
  if (urlProjectRef.current === undefined) {
    // 24 Sep 2026 - the PATH first (/trope#/shots), then an old /?project=trope link
    try { urlProjectRef.current = (window.__projectUrl && window.__projectUrl.fromLocation(location)) || null; } catch (_) { urlProjectRef.current = null; }
  }
  // the browser-wide "last episode / album" keys belong to the last project opened in ANY tab
  // (measured: a Paradise Found tab left its episode there and a Trøpé reload loaded twice), so a
  // tab whose URL names its project uses only that project's own keys; without one it waits for
  // the episode list, which is still one load
  const bootGlobalKeysRef = React.useRef(!urlProjectRef.current);
  const [activeProject, setActiveProject] = React.useState(() => {
    let v = "paradise-found";
    try {
      v = localStorage.getItem("frameflow-active-project") || "paradise-found";
      if (v === "paradise") { localStorage.setItem("frameflow-active-project", "paradise-found"); v = "paradise-found"; }
    } catch (_) {}
    if (urlProjectRef.current) v = urlProjectRef.current;   // 23 Sep 2026 - the URL's project, per tab
    window.__activeProjectId = v;
    return v;
  });
  const [projectVersion, setProjectVersion] = React.useState(0); // bump to force reload on create
  // 17 Sep 2026 - bumped when the PROJECT LIST (window.__projects) is reloaded. It re-paints the
  // look and the tab title; it does not refetch the project's data (reloadProjects used to bump
  // projectVersion, so every page load fetched the whole project twice).
  const [projectsVersion, setProjectsVersion] = React.useState(0);
  // Expose a global so child components (the shot modal, etc.) can
  // ask the app to refetch /api/data after a backend mutation. Used
  // by promoteHero to refresh the shotlist thumbnail when a frame is
  // promoted to hero. Without this the thumbnail stays cached at the
  // pre-promotion URL until the user reloads the page.
  React.useEffect(() => {
    window.reloadAppData = () => setProjectVersion(v => v + 1);
    // v07zz127 — let the Profile & Account modal apply a self-profile
    // edit (e.g. a newly-uploaded avatar) straight into currentUser so
    // the change is reflected without a full reload.
    window.__setCurrentUser = (u) => { if (u) setCurrentUser(u); };
    return () => { delete window.reloadAppData; delete window.__setCurrentUser; };
  }, []);
  // v07r — Smooth project-switch transition. `isProjectSwitching` is
  // toggled on the moment switchProject() fires and clears when the
  // new data lands. `useTransition` lets us mark the heavy setData()
  // update as non-urgent — React 18 then renders it in chunks instead
  // of one synchronous reconcile, which is what was causing the jump.
  const [isProjectLoading, startProjectTransition] = React.useTransition();
  const [isProjectSwitching, setIsProjectSwitching] = React.useState(false);
  // 17 Sep 2026 - switchProject bumps this; the commit that carries it paints the new look
  const [switchSeq, setSwitchSeq] = React.useState(0);
  const shellRef = React.useRef(null);                     // .app-shell (the switch dim)
  const requestedProjectRef = React.useRef(activeProject); // the project last asked for
  const committedProjectRef = React.useRef(activeProject); // the project whose look is on screen
  const interruptRef = React.useRef(null);                 // AppSwitchInterrupt's bump
  // t07 — multi-episode state. activeEpisodeId is null until the
  // /api/episodes fetch resolves; from then on it points to the episode
  // whose shots/sequences are loaded into `data`.
  // 17 Sep 2026 - a page load starts from the list this project had last time (re-checked when
  // /api/episodes answers), like a switch does
  const [episodes, setEpisodes] = React.useState(() => { const k = _readEpisodeList(activeProject); return k ? k.episodes : []; });
  const [activeEpisodeId, setActiveEpisodeId] = React.useState(() => _remembered(_EPISODE_KEY, activeProject) || (bootGlobalKeysRef.current ? localStorage.getItem("frameflow-active-episode") : null) || null);
  // 16 Sep 2026 — CONTAINER GROUPS. One level above a container: for Trope the
  // groups are albums and the containers inside them are music videos. Every
  // project that has no group level answers with an empty array, so there is no
  // special case anywhere below — the switcher simply does not render.
  const [groups, setGroups] = React.useState(() => { const k = _readEpisodeList(activeProject); return k ? k.groups : []; });
  const [activeGroupId, setActiveGroupId] = React.useState(() => _remembered(_GROUP_KEY, activeProject) || (bootGlobalKeysRef.current ? localStorage.getItem("frameflow-active-group") : null) || null);
  const [episodeVersion, setEpisodeVersion] = React.useState(0); // bump to refetch list after a create
  // 17 Sep 2026 - the project whose episode list has been answered. The data effect waits for it
  // when no episode is known yet, so a switch loads the project ONCE (it used to load it without
  // an episode, then again with the first one).
  const [episodesFor, setEpisodesFor] = React.useState(null);
  // the project whose episode/album list the header is showing (the remembered list counts): until
  // then the header's subtitle line stays invisible, so its episode label never pops in on its own
  const [listShownFor, setListShownFor] = React.useState(() => (_readEpisodeList(activeProject) ? activeProject : null));
  // where each project was left (per-project keys; the global keys stay for the boot path)
  // (the global key too: a page load picked its episode without writing it, so everything that reads
  // it - the shot list's saved scroll position, the ZIP button - read an empty or stale episode)
  React.useEffect(() => {
    if (!activeEpisodeId) return;
    _remember(_EPISODE_KEY, activeProject, activeEpisodeId);
    if (requestedProjectRef.current === activeProject) { try { localStorage.setItem("frameflow-active-episode", activeEpisodeId); } catch (_) {} }
  }, [activeProject, activeEpisodeId]);
  React.useEffect(() => { if (activeGroupId) _remember(_GROUP_KEY, activeProject, activeGroupId); }, [activeProject, activeGroupId]);

  const openShot = (id, version, family) => {
    // v07zz209 — remember the last-opened shot so a reload resumes it.
    try { localStorage.setItem("filmtracker.last-shot-id", id); } catch (e) {}
    // v07zz549 — optional version: open the modal with THIS version already showing at the
    // top (the Updates page's Open-shot button passes the event's version).
    // v07zz557 — optional family ("video"/"frame") so a video event lands on the video slot,
    // plus a seq counter so a pick APPLIES even when that shot's modal is ALREADY open
    // (same id → no remount → without the seq bump no effect re-fires and the pick is lost).
    try { window.__pendingShotVersion = version ? { id, version, family: family || null, seq: pendingShotSeqRef.current + 1 } : null; } catch (_) {}
    if (version) { pendingShotSeqRef.current += 1; setPendingShotSeq(pendingShotSeqRef.current); }
    // v06o — push the previous shot onto the history stack so close
    // pops back to it. Skip self-opens and consecutive duplicates.
    setOpenShotId(prev => {
      if (prev && prev !== id) {
        setShotHistory(h => (h[h.length - 1] === prev ? h : [...h, prev]));
      }
      return id;
    });
  };
  const closeShot = () => {
    // Closing means "don't resume this on reload".
    try { localStorage.removeItem("filmtracker.last-shot-id"); } catch (e) {}
    // v06o — pop the previous shot off the history stack if one exists.
    // Otherwise close back to the overview as before.
    setShotHistory(h => {
      if (h.length === 0) { setOpenShotId(null); return h; }
      const next = h.slice(0, -1);
      setOpenShotId(h[h.length - 1]);
      return next;
    });
  };
  const openSequence = (num) => setOpenSequenceId(num);
  const closeSequence = () => setOpenSequenceId(null);

  // v07zz209 — "Resume where I left off": once the episode's data has loaded,
  // re-open the last shot the user was viewing (if it still exists in this
  // episode and nothing else is open). Fires once per page load.
  const _resumedShotRef = React.useRef(false);
  React.useEffect(() => {
    if (_resumedShotRef.current || openShotId) return;
    if (!data || !Array.isArray(data.shots)) return;
    _resumedShotRef.current = true;   // data is here — only attempt once
    let lastId = null; try { lastId = localStorage.getItem("filmtracker.last-shot-id"); } catch (e) {}
    if (lastId && data.shots.find(s => s.id === lastId)) setOpenShotId(lastId);
  }, [data, openShotId]);

  // S5.1 — Load the LIVE project list from the projects table (was the static
  // data/projects.json seed file). The shape is mapped back to the old JSON's
  // camelCase keys so ProjectHeader's getProjects()/ProjectSwitcher keep working
  // unchanged (name / subtitle / status / statusColor / shotCount / thumbAspect).
  const reloadProjects = React.useCallback(() => {
    // 24 Sep 2026 - the list is the USER's, not the tab's project's: a tab opened at /<slug> for a
    // project this user is not a member of is refused (403) on every request naming it, and without
    // the list the tab could never fall back. Asked again with an empty X-Active-Project (the server
    // then uses the saved preference, else the default), so the list comes and the tab falls back.
    return authFetch("/api/projects")
      .then(r => r.status === 403 ? authFetch("/api/projects", { headers: { "X-Active-Project": "" } }) : r)
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))).then(p => {
      const rows = Array.isArray(p.projects) ? p.projects : [];
      window.__projects = {
        active: window.__activeProjectId || "paradise-found",
        projects: rows.map(r => ({
          id: r.id, name: r.display_name || r.id, subtitle: r.subtitle || "", client: r.client || "",
          type: r.type || "", status: r.status || "", statusColor: r.status_color || "var(--status-accent)",   // 15 Sep 2026 — no stored colour = the preset's accent that reads on the dark header
          shotCount: r.shot_count || 0, duration: r.duration || "", stage: r.stage || "",
          thumbAspect: r.aspect_ratio || "21:9", watch_path: r.watch_path || null, template_id: r.template_id || null,
          // 15 Sep 2026 — per-project look, calendar and the template's container words
          theme: r.theme || null, theme_json: r.theme_json || null, calendar_ics_url: r.calendar_ics_url || null, container: r.container || null,
          logo: !!r.logo, logo_v: r.logo_v || 0,   // 15 Sep 2026 — the wordmark shown instead of the name
          // 15 Sep 2026 — what the project DECLARES (window.__projectCategories / __projectPages /
          // __containerWord read these). asset_categories / pages / stages are server fields the
          // row may carry later ([{id,label,fields}], [page ids], [{id,label}]); until then the
          // client falls back to the /api/assets keys and shows every page. created_at feeds the
          // Project Day card when a project has no schedule start.
          asset_categories: Array.isArray(r.asset_categories) ? r.asset_categories : null,
          pages: Array.isArray(r.pages) ? r.pages : null,
          stages: Array.isArray(r.stages) ? r.stages : (Array.isArray(r.pipeline_stages) ? r.pipeline_stages : null),
          created_at: r.created_at || null, db_path: r.db_path || null,
        })),
      };
      setProjectsVersion(v => v + 1);
    }).catch(console.error);
  }, []);
  // Gated on authToken like the episodes/data effects: /api/projects is behind
  // the auth middleware, so firing it before login (Railway, where BYPASS_AUTH
  // is off) would 401 and leave the switcher permanently empty — the static
  // JSON this replaced had no such constraint.
  React.useEffect(() => { if (!authToken) return; reloadProjects(); }, [reloadProjects, authToken]);
  // Publishing the global during render would re-run on every re-render (and on a
  // render React later throws away). An effect writes it once, after commit.
  React.useEffect(() => { window.__reloadProjects = reloadProjects; }, [reloadProjects]);

  // 15 Sep 2026 — the look belongs to the PROJECT (Hugo: "the app's visual style to change
  // depending on the project and be saved"). projects.theme wins; the user's own preference
  // (`theme`) is the fallback for a project that has none. The Settings picker saves to the
  // project row, so everyone on that project sees the same look.
  const [projectTheme, setProjectTheme] = React.useState(null);
  // an id that no longer exists is treated as "not set" - otherwise body[data-theme] would name
  // a skin with no rules and the page would render the bare forest palette over the project's
  // photo, a look nobody chose (that was Trope's Sin City album, saved as "crimson").
  const appliedTheme = _pickSkin(projectTheme, theme) || "forest";
  // the user's own skin, readable from the style painter (a project with no saved skin wears it)
  const themeRef = React.useRef(theme);
  themeRef.current = theme;
  // the skin a style is painted in, and the skin its project photo is kept for
  const _styleSkin = (st) => _pickSkin(st && st.base, themeRef.current) || "forest";
  // Blob URLs of project photos that were replaced. One is freed only once it is no longer on
  // screen, so a photo can never go blank under the page.
  const retiredBlobsRef = React.useRef(new Set());
  const _sweepRetiredBlobs = () => {
    const set = retiredBlobsRef.current;
    const onScreen = document.body.style.backgroundImage || "";
    for (const u of Array.from(set)) {
      if (u === window.__projectBackdropUrl || onScreen.indexOf(u) >= 0) continue;
      set.delete(u);
      try { URL.revokeObjectURL(u); } catch (_) {}
    }
    // still showing: look again later (the grace is the same 2 s the replaced URL always had)
    if (set.size) setTimeout(_sweepRetiredBlobs, 2000);
  };

  // 15 Sep 2026 — THE STYLE BUILDER (Hugo: "i need a proper style builder for the app itself,
  // the presets i have are garbage"). A project's style = { base, tokens, panel_opacity,
  // backdrop: { mode, tint, tint_opacity, blur } } in projects.theme_json. `base` is the preset
  // (data-theme); the rest are CSS variables written on <html>, so they sit on top of any preset.
  // window.__applyProjectStyle previews a draft; window.__saveProjectStyle keeps it.
  const STYLE_TOKEN_KEYS = ["--bg", "--sidebar-bg", "--card-bg-solid", "--card-border", "--ink", "--ink-muted", "--ink-on-dark", "--leaf", "--amber", "--teal"];
  const hexToRgba = (hex, a) => {
    const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
    if (!m) return null;
    const n = parseInt(m[1], 16);
    return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
  };
  // the last style painted, so the photo can be repainted when only the photo or the skin changed
  const lastStyleRef = React.useRef(null);
  // true while the active project's own photo is on its way (paintProjectStyle)
  const backdropPendingRef = React.useRef(false);
  const applyProjectStyle = React.useCallback((style, opts) => {
    // v191 - on the BODY, inline: every preset sets these same variables with
    // body[data-theme="..."] rules, and an inline value on the body beats them; a value on
    // <html> is only inherited, so the preset won (Hugo: "doesnt seem like changing anything
    // in the style builder does anything in real time").
    const root = document.body;
    const st = style && typeof style === "object" ? style : {};
    // The Overview's middle shot panel can be light or dark independently of the skin.
    // Missing means "keep the skin's original treatment" for projects saved before this setting.
    const toneSkin = opts && opts.keepBase ? document.body.getAttribute("data-theme") : _styleSkin(st);
    const contentTone = st.content_tone === "light" || st.content_tone === "dark" ? st.content_tone
      : (window.__skinContentTone && window.__skinContentTone(toneSkin)) || null;
    for (const el of [document.body, document.documentElement]) {
      if (contentTone) el.setAttribute("data-content-tone", contentTone);
      else el.removeAttribute("data-content-tone");
    }
    // 17 Sep 2026 - the skin is painted HERE, in the same synchronous step as the colours and the
    // photo below (it used to wait for a React render, so for 30-190 ms a frame showed the new
    // photo under the old skin, or the old skin's wash with no photo). keepBase = repaint only.
    const paintedSkin = (opts && opts.keepBase) ? null : _styleSkin(st);
    if (paintedSkin) _paintSkin(paintedSkin);
    const tokens = st.tokens && typeof st.tokens === "object" ? st.tokens : {};
    for (const k of STYLE_TOKEN_KEYS) {
      const v = tokens[k];
      if (typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v)) root.style.setProperty(k, v); else root.style.removeProperty(k);
    }
    const po = Number(st.panel_opacity);
    const solid = tokens["--card-bg-solid"];
    if (Number.isFinite(po) && solid && hexToRgba(solid, po)) {
      root.style.setProperty("--card-bg", hexToRgba(solid, po));
      root.style.setProperty("--card-bg-light", hexToRgba(solid, Math.max(0.3, po - 0.14)));
    } else { root.style.removeProperty("--card-bg"); root.style.removeProperty("--card-bg-light"); }
    const sb = tokens["--sidebar-bg"];
    if (sb && hexToRgba(sb, 0.82)) root.style.setProperty("--sidebar-bg-trans", hexToRgba(sb, 0.82)); else root.style.removeProperty("--sidebar-bg-trans");
    // the page colour itself (seen with a plain-colour backdrop, and behind a translucent tint)
    const bg = tokens["--bg"];
    if (typeof bg === "string" && /^#[0-9a-f]{6}$/i.test(bg)) root.style.backgroundColor = bg; else root.style.removeProperty("background-color");
    const bd = st.backdrop && typeof st.backdrop === "object" ? st.backdrop : {};
    // 0 = the photo is untouched; 100 = it is fully faded into the theme's page colour.
    const backdropFade = Number(st.backdrop && st.backdrop.fade);
    if (Number.isFinite(backdropFade)) root.style.setProperty("--backdrop-fade-pct", `${Math.max(0, Math.min(100, backdropFade))}%`);
    else root.style.removeProperty("--backdrop-fade-pct");
    // the page photo, inline (an inline value beats every stylesheet rule)
    // 16 Sep 2026 - Hugo: "I added the backdrop image but it didnt do anything."
    // A skin can paint its own fallback wash on body::before, which sits ON TOP of
    // the body's background and hid the photo completely. The class says whether a
    // real photo is in play, so the skin's fallback can step out of the way.
    // 17 Sep 2026 - the photo travels with the SKIN (Hugo: "the background image isnt coming
    // through when i change the style, it should travel with it"). In order: the project's own
    // photo (only while "This project's photo" is chosen and one loaded) -> the photo the skin
    // ships with (PALETTE_THEMES `photo`) -> none, i.e. the skin's own ground (forest-bg.png via
    // backdrop.css, the glass wash). The photo is shown exactly as it is: nothing filters it.
    const skinId = paintedSkin || _pickSkin(st.base) || document.body.getAttribute("data-theme") || "forest";
    const projectPhoto = bd.mode === "image" ? (window.__projectBackdropUrl || null) : null;
    // while the project's own photo is still downloading there is no photo at all: the skin's stock
    // photo used to show for 30-430 ms first (every page load of a photo project). A project with no
    // photo for this skin still falls back to the skin's photo once the download has answered.
    const photoPending = backdropPendingRef.current && bd.mode === "image" && !projectPhoto;
    const skinPhoto = bd.mode !== "none" && !photoPending && window.__skinPhoto ? window.__skinPhoto(skinId) : null;
    const photo = projectPhoto || skinPhoto;
    const _hasPhoto = !!photo;
    if (bd.mode === "none") root.style.backgroundImage = "none";
    else if (photo) root.style.backgroundImage = `url("${photo}")`;
    else root.style.removeProperty("background-image");
    document.body.classList.toggle("has-backdrop-image", _hasPhoto);
    // the boot painter shows a shipped skin photo before React exists (a project blob cannot
    // survive a reload, so only a static /assets/ path is kept)
    try { localStorage.setItem("frameflow-boot-photo", photo && /^\/assets\//.test(photo) ? photo : ""); } catch (_) {}
    lastStyleRef.current = st;
    const tintA = Number.isFinite(Number(bd.tint_opacity)) ? Math.max(0, Math.min(0.9, Number(bd.tint_opacity))) : 0.3;
    const tint = bd.tint ? hexToRgba(bd.tint, tintA) : null;
    if (tint) { root.style.setProperty("--backdrop-veil", `linear-gradient(${tint}, ${tint})`); document.body.classList.add("has-style-veil"); }
    else { root.style.removeProperty("--backdrop-veil"); document.body.classList.remove("has-style-veil"); }
    if (Number.isFinite(Number(bd.blur))) root.style.setProperty("--glass-blur", `${Math.max(0, Math.min(40, Number(bd.blur)))}px`); else root.style.removeProperty("--glass-blur");
    if (typeof window.__syncProjectBrand === "function") window.__syncProjectBrand(window.__activeProjectId);
    // noState: the caller already put the skin in state in the same batch (a project switch)
    if (!(opts && (opts.keepBase || opts.noState))) setProjectTheme(st.base || null);
  }, []);
  const parseStyle = (v) => { if (!v) return null; if (typeof v === "object") return v; try { return JSON.parse(v); } catch (_) { return null; } };
  // 16 Sep 2026 — a GROUP may carry its own skin, and it wins over the project's.
  // The group stores exactly what a project stores (a `theme` preset id and a
  // `theme_json` builder state), so the same applyProjectStyle call paints both
  // and there is no second code path. A group with no skin of its own inherits
  // the project's, which is what every project without groups already does.
  const _groupStyleKey = React.useMemo(() => {
    const g = (groups || []).find((x) => x.id === activeGroupId);
    return g ? `${g.theme || ""}|${g.theme_json || ""}` : "";
  }, [groups, activeGroupId]);
  // 17 Sep 2026 - a project keeps one photo PER SKIN (server: _tracker/backdrop-<skin>.jpg, with the
  // old single backdrop.jpg as the fallback), so switching skin brings that skin's photo back.
  // Blob URLs are kept per project+skin, so switching back and forth does not refetch or flash.
  // 17 Sep 2026 - each entry is { url, done, promise }: a download still on its way is shared, so a
  // project's photo is never fetched twice (the album list arriving re-ran the style and started a
  // second download). The header is the project's own: the server only serves the photo of the
  // project a request is for, and the picker pre-loads projects that are not active yet.
  const backdropCacheRef = React.useRef(new Map());
  const loadProjectBackdrop = React.useCallback((pid, skin, fresh) => {
    const key = pid + "|" + (skin || "");
    const cache = backdropCacheRef.current;
    const cur = cache.get(key);
    if (cur && !fresh) return cur.promise;
    const entry = { url: cur ? cur.url : null, done: false, promise: null };
    const q = "?skin=" + encodeURIComponent(skin || "") + (fresh ? "&t=" + Date.now() : "");
    entry.promise = authFetch("/api/projects/" + encodeURIComponent(pid) + "/backdrop" + q, { headers: { "X-Active-Project": pid } })
      // only "no photo" (404) and "not yours" (403) are final answers; any other status is a hiccup
      // and takes the network-failure branch below, which lets a later paint ask again
      .then((r) => (r.ok ? r.blob() : (r.status === 404 || r.status === 403) ? null : Promise.reject(new Error("backdrop " + r.status))))
      .then((b) => {
        const url = b ? URL.createObjectURL(b) : null;
        const now = cache.get(key);
        if (now !== entry) {
          // a newer download replaced this one before it finished: this URL was never handed out
          if (url) { try { URL.revokeObjectURL(url); } catch (_) {} }
          return now ? now.promise : entry.url;
        }
        const old = entry.url;
        entry.url = url;
        entry.done = true;
        // the old picture may still be on screen: it is freed once nothing shows it
        if (old && old !== url) { retiredBlobsRef.current.add(old); setTimeout(_sweepRetiredBlobs, 2000); }
        return url;
      })
      .catch(() => {
        // a network failure: keep the picture we had; with none, forget the entry so a later call retries
        if (entry.url) entry.done = true;
        else if (cache.get(key) === entry) cache.delete(key);
        return entry.url;
      });
    cache.set(key, entry);
    return entry.promise;
  }, []);
  // the style a project wears, with its active album's own skin on top
  const _styleFor = (pid, groupList, groupId) => {
    const list = (window.__projects && window.__projects.projects) || [];
    const row = list.find((p) => p.id === pid);
    const style = row ? parseStyle(row.theme_json) : null;
    let merged = style ? { ...style, base: _pickSkin(style.base, row && row.theme) } : { base: _pickSkin(row && row.theme) };
    const g = (groupList || []).find((x) => x.id === groupId);
    if (g && (g.theme || g.theme_json)) {
      const gs = parseStyle(g.theme_json);
      merged = { ...merged, ...(gs || {}), base: _pickSkin(gs && gs.base, g.theme, merged.base) };
    }
    return merged;
  };
  // 17 Sep 2026 - paint a project's look in ONE step and never wait for the photo download: the
  // cached project photo if there is one, else the skin's own photo, else none; the downloaded
  // photo replaces it when it lands. A style already on screen is not painted again (the album
  // list, a sync tick or the theme effect used to repaint the same look 2-3 times per switch).
  const paintedSigRef = React.useRef(null);
  // (17 Sep 2026 review) the look whose photo download failed (server down, say): the next run of the
  // style effect (a sync tick, "Try again") asks for the photo again instead of keeping the skin's
  // stock photo until the next switch or F5
  const photoRetryRef = React.useRef(null);
  const paintProjectStyle = React.useCallback((pid, merged, opts) => {
    const skin = _styleSkin(merged);
    const sig = pid + "|" + skin + "|" + JSON.stringify(merged);
    if (paintedSigRef.current === sig) {
      const k = pid + "|" + skin;
      if (photoRetryRef.current === sig && !backdropCacheRef.current.has(k)) {
        photoRetryRef.current = null;
        loadProjectBackdrop(pid, skin).then((url) => {
          if (paintedSigRef.current !== sig) return;
          if (!url) { if (!backdropCacheRef.current.has(k)) photoRetryRef.current = sig; return; }
          window.__projectBackdropUrl = url;
          applyProjectStyle(merged, { keepBase: true });
        });
      }
      return;
    }
    paintedSigRef.current = sig;
    photoRetryRef.current = null;
    window.__projectStyle = merged;
    const wantsPhoto = !!(pid && merged.backdrop && merged.backdrop.mode === "image");
    const entry = wantsPhoto ? backdropCacheRef.current.get(pid + "|" + skin) : null;
    window.__projectBackdropUrl = (entry && entry.url) || null;
    const pending = wantsPhoto && !(entry && entry.done);
    backdropPendingRef.current = pending;
    applyProjectStyle(merged, opts && opts.noState ? { noState: true } : undefined);
    if (pending) {
      loadProjectBackdrop(pid, skin).then((url) => {
        if (paintedSigRef.current !== sig) return;   // the user moved on
        backdropPendingRef.current = false;
        if (url && url === window.__projectBackdropUrl) return;
        // a failed download leaves no cache entry: ask again on the next style-effect run
        if (!url && !backdropCacheRef.current.has(pid + "|" + skin)) photoRetryRef.current = sig;
        window.__projectBackdropUrl = url;
        // no photo for this skin: the skin's own photo now shows
        applyProjectStyle(merged, { keepBase: true });
      });
    }
  }, [applyProjectStyle, loadProjectBackdrop]);
  React.useEffect(() => {
    // before the project list is known the boot painter's skin stays (it is the last one shown);
    // reloadProjects bumps projectsVersion and this runs again
    if (!window.__projects) return;
    paintProjectStyle(activeProject, _styleFor(activeProject, groups, activeGroupId));
    // `theme`: a project with no saved skin wears the user's, and its photo is kept per skin
  }, [activeProject, projectVersion, projectsVersion, paintProjectStyle, _groupStyleKey, theme]);
  // 15 Sep 2026 — the browser TAB names the active project (Hugo: "the name in the tab does not
  // reflect the project name"). index.html ships a neutral title; this is the only steady-state
  // writer (EditPlanPage / PresentationsPage swap the title for a PDF export and restore it).
  React.useEffect(() => {
    const list = (window.__projects && window.__projects.projects) || [];
    const row = list.find((p) => p.id === activeProject);
    // reloadProjects maps display_name -> name; display_name is the raw DB field, kept as a
    // fallback. The stored names are SHOUTED ("PARADISE FOUND"), which reads badly in a tab, so
    // an all-caps name is title-cased ("Paradise Found", "Trøpé").
    const raw = (row && (row.name || row.display_name || row.id)) || "";
    // 23 Sep 2026 - with an album open its name comes first ("Cold Champagne · Trøpé · Production
    // Tracker"), so Rize can match the album or the project; window.__projectUrl.tabTitle
    const g = (groups || []).length ? (groups || []).find((x) => x && x.id === activeGroupId) : null;
    if (window.__projectUrl) { document.title = window.__projectUrl.tabTitle(raw, g && g.title); return; }
    const name = /[a-z]/.test(raw) ? raw : raw.toLowerCase().replace(/(^|[\s\-–—·/])(\S)/g, (m, a, b) => a + b.toUpperCase());
    document.title = name ? name + " · TrueNorths" : "TrueNorths";   // 24 Sep 2026 - the app is TrueNorths
    // Favicon and splash are painted with the resolved visual style in applyProjectStyle,
    // not with this project's production-status colour.
  }, [activeProject, projectVersion, projectsVersion, groups, activeGroupId]);
  // 23 Sep 2026 - the address always carries this tab's project: /?project=<slug>#/<page> (Hugo:
  // "we ABSOLUTELY need to have the project name in the URL of the app so the Rize app can track
  // which project i'm on"). replaceState: no reload, no extra history entry, the #/<page> and any
  // other param (?deck=, ?review=) stay. Back/Forward lands on an entry written while another
  // project was open; popstate stamps it with the project on screen, so the project stays.
  // 24 Sep 2026 - the path form now: /<slug>#/<page> (/trope#/shots), an old ?project= is dropped.
  // Only once someone is logged in: the login page never writes a project into the address (a
  // logged-out /trope keeps what was typed, so the login lands there; a logged-out / stays /).
  const signedIn = !!(currentUser && authToken);
  React.useEffect(() => {
    const P = window.__projectUrl;
    if (!P || !signedIn) return;
    const stamp = () => {
      try {
        const want = P.withProject(location.href, activeProject);
        if (want !== location.href) window.history.replaceState(window.history.state, "", want);
      } catch (_) {}
    };
    stamp();
    window.addEventListener("popstate", stamp);
    return () => window.removeEventListener("popstate", stamp);
  }, [activeProject, signedIn]);
  // A URL project this user cannot open (unknown, archived, not a member: not in /api/projects)
  // falls back to what a load without it would have opened - the saved preference, this
  // browser's last project, the default - and the stamp above fixes the URL. Checked once, as soon
  // as the list is known (the earlier, the fewer loads for the wrong id); a project the user has
  // already switched away from is left.
  const urlCheckedRef = React.useRef(false);
  React.useEffect(() => {
    const want = urlProjectRef.current;
    if (urlCheckedRef.current || !want || !window.__projectUrl || !window.__projects) return;
    const ids = (window.__projects.projects || []).map((p) => p && p.id).filter(Boolean);
    if (!ids.length) return;
    urlCheckedRef.current = true;
    if (activeProject !== want || ids.indexOf(want) >= 0) return;
    let prefs = currentUser && currentUser.preferences;
    try { if (typeof prefs === "string") prefs = JSON.parse(prefs || "{}"); } catch (_) { prefs = null; }
    let stored = null;
    try { stored = localStorage.getItem("frameflow-active-project"); } catch (_) {}
    const next = window.__projectUrl.resolveProject(want, ids, [prefs && prefs.activeProject, stored]);
    if (next && next !== activeProject) switchProject(next, { fromEffect: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [projectsVersion]);
  const saveProjectStyle = React.useCallback((style) => {
    const st = style && typeof style === "object" ? style : {};
    const g = (groups || []).find((x) => x.id === activeGroupId);
    const gs = g ? parseStyle(g.theme_json) : null;
    const ownerGroup = g && _pickSkin(gs && gs.base, g.theme) ? g : null;
    window.__projectStyle = st;
    // The same owner as the preset picker: an album's look must not be saved on
    // its parent project, where the next album repaint would override it again.
    if (ownerGroup) {
      const next = groups.map((x) => x.id === ownerGroup.id ? { ...x, theme: st.base || null, theme_json: JSON.stringify(st) } : x);
      setGroups(next);
      window.__groups = next;
      _writeEpisodeList(activeProject, episodes, next);
    } else if (window.__projects) {
      const row = (window.__projects.projects || []).find((p) => p.id === activeProject);
      if (row) { row.theme = st.base || null; row.theme_json = JSON.stringify(st); }
    }
    // "This project's photo": show the photo this project keeps for THIS skin. A skin switch (or
    // picking the option) does not re-run the load effect above, so it is fetched here; until it
    // arrives the page shows what that skin would show without it, never the previous skin's photo.
    const wantsPhoto = !!(st.backdrop && st.backdrop.mode === "image" && activeProject);
    const skin = _styleSkin(st);
    if (wantsPhoto) {
      const entry = backdropCacheRef.current.get(activeProject + "|" + skin);
      window.__projectBackdropUrl = (entry && entry.url) || null;
    }
    // the next style-effect run paints the saved look again (it is not the one it painted last)
    paintedSigRef.current = null;
    // a skin picked in Settings shows that skin's own photo until the project's arrives (as designed)
    backdropPendingRef.current = false;
    applyProjectStyle(st);
    if (wantsPhoto) {
      loadProjectBackdrop(activeProject, skin).then((url) => {
        if (window.__projectStyle !== st) return;   // the user moved on
        if (url === window.__projectBackdropUrl) return;
        window.__projectBackdropUrl = url;
        applyProjectStyle(st, { keepBase: true });
      });
    }
    if (!activeProject) return Promise.resolve();
    return authFetch(ownerGroup ? "/api/groups/" + encodeURIComponent(ownerGroup.id) : "/api/projects/" + encodeURIComponent(activeProject), {
      method: "PATCH", body: JSON.stringify({ theme: st.base || null, theme_json: JSON.stringify(st) }),
    });
  }, [activeProject, applyProjectStyle, groups, activeGroupId, episodes]);
  React.useEffect(() => {
    // a preview is not the saved look: the next style-effect run puts the saved one back, as before
    window.__applyProjectStyle = (style) => { paintedSigRef.current = null; backdropPendingRef.current = false; applyProjectStyle(style); };
    window.__saveProjectStyle = saveProjectStyle;
    // repaint the photo with the last style (after an upload, or when the applied skin changed)
    window.__repaintBackdrop = () => { if (lastStyleRef.current) applyProjectStyle(lastStyleRef.current, { keepBase: true }); };
    // fetch this skin's photo again (after an upload) and repaint. (17 Sep 2026 review) `forPid` is the
    // project the upload was for: once another project is active, nothing is loaded, painted or saved
    // (resolves false), so an upload that lands after a switch cannot change the new project's look.
    window.__reloadProjectBackdrop = (skin, forPid) => {
      const pid = forPid || activeProject;
      if (!pid || pid !== activeProject) return Promise.resolve(false);
      const s = skin || (window.__projectStyle && window.__projectStyle.base) || document.body.getAttribute("data-theme") || "forest";
      return loadProjectBackdrop(pid, s, true).then((url) => {
        if (window.__activeProjectId !== pid) return false;
        window.__projectBackdropUrl = url;
        // 17 Sep 2026 - an upload used to show nothing until the next data reload: the URL
        // was set here but the page was never repainted with it
        if (window.__repaintBackdrop) window.__repaintBackdrop();
        return true;
      });
    };
  }, [applyProjectStyle, saveProjectStyle, activeProject, loadProjectBackdrop]);
  // The preset picker in Settings is the style's `base`.
  const setThemeForProject = React.useCallback((id) => {
    // 15 Sep 2026 — picking a preset card means "use this preset": the builder's saved colours
    // are dropped. They sit inline on the body and beat every preset rule, which is why Trøpé
    // stayed forest with Neon marked ACTIVE (Hugo: "the styles do not work at all"). The
    // Overview panel's light/dark tone stays. Backdrops are remembered per skin:
    // a previous plain-colour or custom-photo choice must not hide another preset's photo.
    // (17 Sep 2026 review) the active ALBUM's own skin beats the project's (_styleFor), so a pick made
    // while the album wears a skin of its own is saved on the album, where it is painted; saved on the
    // project it snapped back on the next repaint (Trope's main album wears neon). The header only
    // offers the switcher there to someone the album route allows (manage_episodes).
    const g = (groups || []).find((x) => x.id === activeGroupId);
    const gs = g ? parseStyle(g.theme_json) : null;
    if (g && _pickSkin(gs && gs.base, g.theme)) {
      // Legacy albums can inherit the project's backdrop even though their skin
      // is their own. Remember the visible outgoing choice before changing it.
      const previous = {
        ...(gs || {}), base: _pickSkin(gs && gs.base, g.theme),
        backdrop: (gs && gs.backdrop) || (window.__projectStyle || {}).backdrop,
      };
      const gjson = JSON.stringify(_skinPresetStyle(previous, id, previous.base));
      const next = groups.map((x) => (x.id === g.id ? { ...x, theme: id, theme_json: gjson } : x));
      setGroups(next);
      window.__groups = next;
      _writeEpisodeList(activeProject, episodes, next);
      authFetch("/api/groups/" + encodeURIComponent(g.id), {
        method: "PATCH", body: JSON.stringify({ theme: id, theme_json: gjson }),
      }).catch(() => {});
      return;
    }
    const prev = window.__projectStyle || {};
    const st = _skinPresetStyle(prev, id, _styleSkin(prev));
    saveProjectStyle(st).catch(() => {});
  }, [saveProjectStyle, groups, activeGroupId, activeProject, episodes]);
  // the header's style switcher (ProjectHeader.jsx StylePill, on every page) saves through the same path
  React.useEffect(() => {
    window.__setThemeForProject = setThemeForProject;
  }, [setThemeForProject]);
  // 17 Sep 2026 - PRE-FETCH. When the project picker opens (ProjectHeader dispatches
  // "filmtracker:picker-open" with the projects it lists), each listed project's photo for the skin
  // it will wear is downloaded into the cache, and that skin's own shipped photo is warmed, so the
  // switch paints the finished look at once. detail may be an array of ids or of rows, or
  // { ids } / { projectIds } / { projects }.
  const warmedSkinPhotosRef = React.useRef(new Set());
  React.useEffect(() => {
    const onPickerOpen = (e) => {
      const d = e && e.detail;
      const raw = Array.isArray(d) ? d : (d && (d.ids || d.projectIds || d.projects)) || [];
      const ids = (Array.isArray(raw) ? raw : []).map((x) => (typeof x === "string" ? x : x && x.id)).filter(Boolean);
      if (!ids.length || !window.__projects) return;
      for (const pid of ids) {
        const known = _readEpisodeList(pid);
        const groupList = known ? known.groups : [];
        let gid = _remembered(_GROUP_KEY, pid);
        if (groupList.length && !groupList.some((g) => g.id === gid)) gid = groupList[0].id;
        const st = _styleFor(pid, groupList, gid);
        const skin = _styleSkin(st);
        if (st.backdrop && st.backdrop.mode === "image") loadProjectBackdrop(pid, skin);
        const shipped = st.backdrop && st.backdrop.mode === "none" ? null : (window.__skinPhoto ? window.__skinPhoto(skin) : null);
        if (shipped && !warmedSkinPhotosRef.current.has(shipped)) {
          warmedSkinPhotosRef.current.add(shipped);
          try { const img = new Image(); img.decoding = "async"; img.src = shipped; } catch (_) {}
        }
      }
    };
    window.addEventListener("filmtracker:picker-open", onPickerOpen);
    return () => window.removeEventListener("filmtracker:picker-open", onPickerOpen);
    // _styleFor / _styleSkin read only window.__projects, the stored album and themeRef
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loadProjectBackdrop]);

  // v07zz49 — Per-user UI preferences. Apply server-side preferences
  // (theme/style/view/activeProject/bg-mode) on login so the user's
  // custom interface follows them across browsers. localStorage stays
  // the source of truth for the running tab; server is the source of
  // truth for "first paint after login on a different device".
  React.useEffect(() => {
    if (!currentUser || !currentUser.preferences) return;
    const p = currentUser.preferences;
    try {
      if (p.theme && typeof p.theme === "string" && _knownSkin(p.theme)) {
        localStorage.setItem("frameflow-theme", p.theme);
        setTheme(p.theme);
      }
      if (p.style && typeof p.style === "string") {
        localStorage.setItem("frameflow-style", p.style);
        setStyle(p.style);
      }
      if (p.view && typeof p.view === "string") {
        localStorage.setItem("filmtracker.view", p.view);
        // Don't auto-navigate; just persist the default landing page
        // for next session. setView() here would clobber a deep-link.
      }
      // 23 Sep 2026 - not when this tab's URL named a project: the URL wins (per tab)
      if (p.activeProject && typeof p.activeProject === "string" && !urlProjectRef.current) {
        // Only adopt the server's activeProject if it's a valid slug.
        // (Skip the legacy 'paradise' value — migrated to 'paradise-found'.)
        const slug = p.activeProject === "paradise" ? "paradise-found" : p.activeProject;
        // 17 Sep 2026 - a different project goes through switchProject, so its own episode, album
        // and look come with it (setting the id alone kept this browser's episode and album)
        if (slug !== activeProject) switchProject(slug, { fromEffect: true });
        localStorage.setItem("frameflow-active-project", slug);
        window.__activeProjectId = slug;   // S5.1 — keep the authFetch header in sync
      }
      if (p.bgMode && typeof p.bgMode === "string") {
        localStorage.setItem("filmtracker.bg-mode", p.bgMode);
      }
      if (p.hide_money != null) setHideMoney(!!p.hide_money);
    } catch (_) { /* localStorage may be disabled */ }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [currentUser && currentUser.id]);

  // v07zz49 — Debounced upstream sync. When the user changes theme /
  // style / active project / landing view, fire-and-forget PATCH to
  // /api/users/me/preferences so the change follows them.
  React.useEffect(() => {
    if (!authToken || !currentUser) return;
    const t = setTimeout(() => {
      try {
        (window.authFetch || fetch)("/api/users/me/preferences", {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ theme, style, view, activeProject, hide_money: hideMoney }),
        }).catch(() => {});
      } catch (_) {}
    }, 800);
    return () => clearTimeout(t);
  }, [theme, style, view, activeProject, hideMoney, authToken, currentUser]);

  // t07 — load the episodes for the active project from the backend.
  // Gated on authToken so the request only fires once we have a valid
  // session — otherwise the server's auth middleware would 401 and a
  // login page would race with the data fetch.
  // S5.2 — ONE path for every project. The old paradise-only fork (and its
  // "other projects have no backend yet" early return) is gone: authFetch
  // stamps X-Active-Project, so /api/episodes is already scoped to the active
  // project's DB and a project with no episodes simply answers {episodes: []}.
  // 17 Sep 2026 - a reply for a project that is no longer active is dropped (and its request
  // aborted): it used to set the old project's episodes, album and episode on the new one.
  const episodesGateRef = React.useRef(_makeLoadGate());
  // 17 Sep 2026 - switchProject asks for the new project's episode list at the click; the effect
  // below uses that answer instead of asking again once the switch has rendered. { pid, ctrl, promise }
  const episodesSpecRef = React.useRef(null);
  const _startEpisodesLoad = (pid) => {
    const cur = episodesSpecRef.current;
    if (cur && cur.pid === pid) return;
    if (cur && cur.ctrl) { try { cur.ctrl.abort(); } catch (_) {} }
    const ctrl = typeof AbortController === "function" ? new AbortController() : null;
    const init = { headers: { "X-Active-Project": pid } };
    if (ctrl) init.signal = ctrl.signal;
    const promise = authFetch("/api/episodes", init).then(r => r.json());
    promise.catch(() => {});
    episodesSpecRef.current = { pid, ctrl, promise };
  };
  React.useEffect(() => {
    const pid = activeProject;
    const spec = episodesSpecRef.current;
    episodesSpecRef.current = null;
    const run = _gateEnter(episodesGateRef.current, authToken ? pid : null);
    if (spec && spec.pid !== pid && spec.pid === requestedProjectRef.current && authToken) {
      episodesSpecRef.current = spec;   // the switch that asked for it has not rendered yet: keep it for that run
    } else if (spec && (!run || spec.pid !== pid) && spec.ctrl) {
      try { spec.ctrl.abort(); } catch (_) {}
    }
    if (!run) return;
    let source;
    if (spec && spec.pid === pid) {
      if (run.ctrl && spec.ctrl) run.ctrl.signal.addEventListener("abort", () => { try { spec.ctrl.abort(); } catch (_) {} });
      source = spec.promise;
    } else {
      source = authFetch("/api/episodes", run.ctrl ? { signal: run.ctrl.signal, headers: { "X-Active-Project": pid } } : { headers: { "X-Active-Project": pid } })
        .then(r => r.json());
    }
    source
      .then(({ episodes: list, groups: gl }) => {
        const arr = Array.isArray(list) ? list : [];
        const garr = Array.isArray(gl) ? gl : [];
        _writeEpisodeList(pid, arr, garr);   // the next switch into this project starts from it
        // Another project already asked for (its switch has not committed yet): these updates
        // would be re-applied ON TOP of that switch when React renders it, and put this project's
        // episode on the other one. The switch's own effects load its list.
        if (requestedProjectRef.current !== pid) return;
        if (!run.land()) return;
        setEpisodes(arr);
        setGroups(garr);
        window.__groups = garr;
        // v06b — mirror to window so non-prop'd components (the Media
        // Uploads modal in FooterRow) can populate their episode picker
        // without prop-drilling through the whole tree.
        window.__episodes = arr;
        window.__episodesLoadedFor = pid;   // v190 — the header's "no music videos yet" waits for this
        // Pick the group first, then an episode INSIDE it — otherwise the header
        // would show one album and the shotlist another album's video.
        let gid = null;
        if (garr.length) {
          const has = (id) => !!id && garr.some(g => g.id === id);
          const stored = localStorage.getItem("frameflow-active-group");
          const mine = _remembered(_GROUP_KEY, pid);
          gid = has(stored) ? stored : (has(mine) ? mine : garr[0].id);
          setActiveGroupId(gid);
          localStorage.setItem("frameflow-active-group", gid);
        } else {
          setActiveGroupId(null);
          localStorage.removeItem("frameflow-active-group");
          _remember(_GROUP_KEY, pid, null);
        }
        const inGroup = gid ? arr.filter(e => e.master_id === gid) : arr;
        if (!inGroup.length) _remember(_EPISODE_KEY, pid, null);
        setActiveEpisodeId(prev => {
          if (requestedProjectRef.current !== pid) return prev;   // never re-applied onto another project
          if (prev && inGroup.find(e => e.id === prev)) return prev;
          return (inGroup[0] && inGroup[0].id) || null;
        });
        setEpisodesFor(pid);
        setListShownFor(pid);
      })
      .catch(err => {
        if (requestedProjectRef.current !== pid || !run.fail()) return;
        console.warn("[episodes] fetch failed:", err.message);
        // the project's data still loads (without an episode), as it always did
        setEpisodesFor(pid);
        setListShownFor(pid);
      });
  }, [activeProject, episodeVersion, authToken]);
  React.useEffect(() => () => { _gateEnter(episodesGateRef.current, null); }, []);

  // Load project-scoped data whenever the active project (or episode) changes.
  // v821 — GUARANTEE the assets payload has every category as an array.
  // AssetsView reads assets.characters.length / .locations.length / .props.length
  // unguarded in its tab row, so ANY payload without those keys white-screened the
  // whole app ("Cannot read properties of undefined (reading 'length')"). Two ways
  // that happened: the /api/assets fetch below had no r.ok check, so an error body
  // ({error:"…"}) became data.assets verbatim; and the result is written to
  // sessionStorage, so a bad shape was replayed on EVERY reload — which is why the
  // crash card's Reload button couldn't clear it. Normalising on both the network
  // and the cache path un-sticks an already-poisoned cache.
  function _safeAssets(a) {
    const src = (a && typeof a === "object" && !Array.isArray(a)) ? a : {};
    const out = { ...src };
    for (const k of ["characters", "animals", "locations", "props", "refs"]) {
      if (!Array.isArray(out[k])) out[k] = [];
    }
    return out;
  }
  // Stale-while-revalidate via sessionStorage: if there's a cached payload
  // for this project + episode key, render it IMMEDIATELY so hard-refresh
  // is near-instant, then fire the real network fetch and overwrite when
  // fresh data arrives. The cache is per-tab (sessionStorage) so it goes
  // away on browser close and never grows indefinitely.
  // 17 Sep 2026 - PROJECT SWITCH. The load belongs to its project + episode (dataGateRef, see
  // _gateEnter): a reply for a view that is no longer on screen is aborted and never lands (it
  // used to put Paradise Found's 239 rows under another project's title for up to 3 s). The
  // switch flag is cleared only by the load of the view on screen, in the same commit that
  // shows its rows. The load waits for the project's episode list when no episode is known yet
  // (_dataReady), so a switch loads the project once instead of twice.
  // One view of a project (its episode, or the server's first one): the six requests, merged, and
  // tagged with the project they belong to.
  const _loadViewData = (pid, episodeId, init) => Promise.all([
    // S5.2 — an EMPTY project (no episodes in its DB yet) answers
    // 404 {"error":"No episodes in database."}. Render it as an empty
    // project instead of letting the error body become `data` (which
    // would leave data.shots/.sequences undefined and white-screen the
    // shotlist). Same three keys /api/data returns on the happy path.
    // A thrown network error is deliberately NOT swallowed here: it still
    // rejects into the outer .catch, which leaves the last good data on
    // screen rather than blanking the app on a blip.
    authFetch(episodeId ? `/api/data?episode=${encodeURIComponent(episodeId)}` : "/api/data", init)
      .then(r => (r.ok ? r.json() : { episode: null, sequences: [], shots: [] })),
    // v07zz28 — Was plain fetch() — no JWT attached. Local has
    // AUTH_BYPASSED so it sailed through; Railway's authMiddleware
    // rejected with 401 and the fallback {members: []} silently
    // produced the empty Crew page Hugo kept seeing.
    authFetch("/api/crew", init).then(r => r.ok ? r.json() : { members: [] }),
    // v06f — /api/assets returns data/assets.json enriched with disk
    // scans for every character/animal/location/prop: a references[]
    // array of every image file in that asset's WATCH_PATH folder,
    // a cover_url that picks the `*_charRef_face*` file when present,
    // and the static `image` field is overwritten with that cover so
    // the AssetsView cards no longer rely on stale paths in the
    // static JSON. Falls back to the raw JSON when WATCH_PATH isn't
    // set on the server.
    // v821 — r.ok guard. Without it a 401/500 error BODY ({error:"…"}) became
    // data.assets verbatim, and AssetsView's unguarded assets.characters.length
    // white-screened the app.
    authFetch(episodeId
      ? `/api/assets?episode=${encodeURIComponent(episodeId)}`
      : "/api/assets",
      init,
    ).then(r => r.ok ? r.json() : {}).catch(() => ({})),
    // 🔒 v07zz277 — schedule comes from the AUTH + view_budget-filtered API
    // (raw data/schedule.json is now blocked from static): money fields are
    // stripped server-side for roles without view_budget, so a director's
    // browser never even receives the budget/rates/invoice amounts.
    authFetch("/api/schedule", init).then(r => r.ok ? r.json() : {}).catch(() => ({})),
    // S5.4 — the script comes from the project-scoped API, not the static seed
    // file: a fetch() to data/script.json carries no X-Active-Project header, so
    // every project used to render Paradise Found's narration.
    authFetch("/api/script", init)
      .then(r => r.ok ? r.json() : { title: null, version: null, modified: null, scenes: [] })
      .catch(() => ({ title: null, version: null, modified: null, scenes: [] })),
    // v07zz133 — documents now come from the live API (persistent
    // store + per-user visibility), not the static seed file.
    authFetch("/api/documents", init).then(r => r.ok ? r.json() : { documents: [], roles: [], current_role: "Creative Director" }).catch(() => ({ documents: [], roles: [], current_role: "Creative Director" })),
  ]).then(([tracker, crew, assets, schedule, script, documents]) => {
    // 15 Sep 2026 — asset_categories = the category keys /api/assets actually returned (a
    // templated project's own ids, e.g. instruments), recorded BEFORE _safeAssets pads the
    // film-doc keys in. window.__projectCategories reads it; it rides the sessionStorage cache.
    const asset_categories = (assets && typeof assets === "object" && !Array.isArray(assets))
      ? Object.keys(assets).filter((k) => Array.isArray(assets[k]) && k !== "refs") : [];
    return { ...tracker, crew: crew.members, assets: _safeAssets(assets), asset_categories, schedule, script, documents: documents.documents, doc_roles: documents.roles, current_role: documents.current_role, __project: pid };
  });
  const dataGateRef = React.useRef(_makeLoadGate());
  const lastDataKeyRef = React.useRef(null);
  const shownDataKeyRef = React.useRef(null);   // the view whose data was last put on screen
  // 17 Sep 2026 - a view asked for AHEAD of the data effect: { pid, ep, ctrl, promise }.
  //  - switchProject asks for the new project's view at the click (the effect only runs once the
  //    switch has rendered, 70-250 ms later);
  //  - a project opened for the first time in this browser has no episode yet and waits for its
  //    episode list: its data (the server's first episode, ep null) is asked for at the same moment
  //    and used once the list picks that same episode, so a first visit is one round trip, not two
  //    in a row (it took 4.1 s against HEAD's 3.4 s on a busy server).
  const specDataRef = React.useRef(null);
  const _startSpecLoad = (pid, ep) => {
    const cur = specDataRef.current;
    if (cur && cur.pid === pid && cur.ep === (ep || null)) return;
    if (cur && cur.ctrl) { try { cur.ctrl.abort(); } catch (_) {} }
    const ctrl = typeof AbortController === "function" ? new AbortController() : null;
    const init = { headers: { "X-Active-Project": pid } };
    if (ctrl) init.signal = ctrl.signal;
    const promise = _loadViewData(pid, ep || null, init);
    promise.catch(() => {});
    specDataRef.current = { pid, ep: ep || null, ctrl, promise };
  };
  const _dataReady = !!activeEpisodeId || episodesFor === activeProject;   // below every useState it reads
  React.useEffect(() => {
    const pid = activeProject;
    const spec0 = specDataRef.current;
    // a view asked for by a switch that has not rendered yet is kept for that switch
    if (spec0 && ((spec0.pid !== pid && spec0.pid !== requestedProjectRef.current) || !authToken)) {
      specDataRef.current = null;
      if (spec0.ctrl) { try { spec0.ctrl.abort(); } catch (_) {} }
    }
    const cacheKey = `ft-cache.app.${pid}.${activeEpisodeId || "default"}`;
    const run = _gateEnter(dataGateRef.current, authToken && _dataReady ? cacheKey : null);
    if (!run) {
      if (authToken && !_dataReady && requestedProjectRef.current === pid && !(specDataRef.current && specDataRef.current.pid === pid)) {
        _startSpecLoad(pid, null);
      }
      return;
    }
    const init = { headers: { "X-Active-Project": pid } };
    if (run.ctrl) init.signal = run.ctrl.signal;
    // v07w — the cached copy is shown only when this view is not already on screen: a re-fire
    // of the SAME view (projectVersion bumped by an SSE event, manual refresh, or sync.applied)
    // already shows fresh data, and rendering the stale cache would "flash to old state" (the
    // Railway-side status flicker). 17 Sep 2026 - it used to test projectVersion === 0, which
    // the project-list load bumped on every boot, so a revisit never painted from the cache.
    // (not while another project is already asked for: that render would only be thrown away)
    if (lastDataKeyRef.current !== cacheKey && requestedProjectRef.current === pid) {
      lastDataKeyRef.current = cacheKey;
      try {
        const cached = sessionStorage.getItem(cacheKey);
        if (cached) {
          const parsed = JSON.parse(cached);
          // v821 — repair a poisoned cache on the way in, so a bad shape
          // written by an earlier session can't crash every reload forever.
          parsed.assets = _safeAssets(parsed.assets);
          parsed.__project = pid;
          window.__appData = parsed;
          shownDataKeyRef.current = cacheKey;
          // the cached rows ARE this project's: the dim lifts with them, the refresh follows
          startProjectTransition(() => { setData(parsed); setIsProjectSwitching(false); });
        }
      } catch (_) {}
    }

    // a view asked for ahead (above) is used: the same episode as is; the server's first episode
    // when the list picked that same one (or the project has no episode); anything else is dropped
    const spec = specDataRef.current && specDataRef.current.pid === pid ? specDataRef.current : null;
    specDataRef.current = null;
    const again = () => _loadViewData(pid, activeEpisodeId, init);
    const want = activeEpisodeId || null;
    let source;
    if (spec && (spec.ep === want || spec.ep === null)) {
      if (run.ctrl && spec.ctrl) run.ctrl.signal.addEventListener("abort", () => { try { spec.ctrl.abort(); } catch (_) {} });
      // (a failed early load is this load failing: authFetch has already retried it three times)
      source = spec.ep === want ? spec.promise : spec.promise.then(
        (m) => (!want || (m.episode && m.episode.id) === want ? m : again()));
    } else {
      if (spec && spec.ctrl) { try { spec.ctrl.abort(); } catch (_) {} }
      source = again();
    }
    source.then((merged) => {
      // a reply for a project/episode that is no longer on screen never lands (nothing at all)
      if (!run.live()) return;
      try { sessionStorage.setItem(cacheKey, JSON.stringify(merged)); } catch (_) {}
      // still this view, but another project is already asked for: kept in the cache only (its
      // rows would be rendered and thrown away, and would lift the dim over the next switch)
      if (requestedProjectRef.current !== pid) return;
      if (!run.land()) return;
      window.__appData = merged;
      shownDataKeyRef.current = cacheKey;
      // v07zz536 — warm the censored-word cache so the Generate video "Clean & copy" scan
      // has the list even before the Censored Words page is opened.
      try { if (window.__censorRefresh) window.__censorRefresh(); } catch (_) {}
      // 23 Sep 2026 — the per-project HIDDEN TOOLS switches follow the project that just landed.
      try { if (window.__loadUiHidden) window.__loadUiHidden(); } catch (_) {}
      // v07r — Mark the heavy data swap as a non-urgent transition.
      // React 18 then renders it in chunks instead of one synchronous
      // reconcile, eliminating the freeze/jump when 500+ shots arrive.
      // 17 Sep 2026 - the switch flag clears in the SAME commit that shows the new rows: the old
      // rows stay dimmed and unclickable until then, and the dim lifts the moment they are
      // replaced (it used to be held 500 ms more by a timer that any load, even a superseded
      // one or a background reload of the previous project, could fire).
      startProjectTransition(() => {
        setData(merged);
        setIsProjectSwitching(false);
      });
    }).catch((err) => {
      // aborted, a newer load of this view already landed, or another project is asked for
      if (requestedProjectRef.current !== pid || !run.fail()) return;
      console.error(err);
      // The dim never lifts over another project's content: a project whose load failed before any
      // of its data was shown is shown EMPTY, with a retry line (ShotsPanel). A failed refresh of
      // the project on screen keeps its rows.
      // (window.__appData too, before the render: the top bar and the side cards read it there)
      startProjectTransition(() => {
        setData((d) => { const next = _dataIsFor(d, pid) ? d : _emptyProjectData(pid, "failed"); window.__appData = next; return next; });
        setIsProjectSwitching(false);
      });
    });
  }, [activeProject, projectVersion, activeEpisodeId, authToken, _dataReady]);
  React.useEffect(() => () => {
    _gateEnter(dataGateRef.current, null);
    const spec = specDataRef.current;
    specDataRef.current = null;
    if (spec && spec.ctrl) { try { spec.ctrl.abort(); } catch (_) {} }
  }, []);
  // The retry line under a project that did not load asks for its episode list and its data again.
  React.useEffect(() => {
    window.__retryProjectLoad = () => { setEpisodeVersion((v) => v + 1); setProjectVersion((v) => v + 1); };
    return () => { delete window.__retryProjectLoad; };
  }, []);
  // The rescue: if the load of a switch never answers at all, the page does not stay dimmed and
  // unclickable forever (and shows the project as not loaded yet, not the previous project's rows).
  React.useEffect(() => {
    if (!isProjectSwitching) return;
    const pid = activeProject;
    const t = setTimeout(() => {
      if (requestedProjectRef.current !== pid) return;   // another switch is on its way with its own rescue
      startProjectTransition(() => {
        setData((d) => { const next = _dataIsFor(d, pid) ? d : _emptyProjectData(pid, "slow"); window.__appData = next; return next; });
        setIsProjectSwitching(false);
      });
    }, SWITCH_FLAG_RESCUE_MS);
    return () => clearTimeout(t);
  }, [isProjectSwitching, activeProject]);

  // v06p — Prefetch the ElevenLabs voice list in the background once
  // the user is authed. The /v1/voices endpoint is consistently slow
  // (10–20 s cold), so we warm the server-side cache here instead of
  // waiting until Hugo first opens VO mode or a character modal.
  // Fire-and-forget: any failure (missing key, network, etc.) is
  // silently ignored — the consumer endpoints will surface their
  // own errors when actually invoked.
  React.useEffect(() => {
    if (!authToken) return;
    try {
      // 24 Sep 2026 (G5 review) — Paradise Found warms ONCE per login, exactly as before; another
      // project is warmed by the effect below.
      if (window.__isDefaultProject && !window.__isDefaultProject()) return;
      authFetch("/api/voiceover/voices").catch(() => {});
    } catch (_) {}
  }, [authToken]);
  // 24 Sep 2026 (G5) — another project warms ITS list (one cached list per project on the server)
  // only when it has its OWN ElevenLabs key (key-status answers { has_key }, never the key):
  // without one, every page load logged a 400. Never runs for Paradise Found.
  React.useEffect(() => {
    if (!authToken) return;
    if (!(window.__isDefaultProject && !window.__isDefaultProject())) return;
    try {
      authFetch("/api/voiceover/key-status")
        .then((r) => (r && r.ok ? r.json() : null))
        .then((j) => { if (j && j.has_key) authFetch("/api/voiceover/voices").catch(() => {}); })
        .catch(() => {});
    } catch (_) {}
  }, [authToken, activeProject]);

  // v07zz210 — Dismiss the logo loading screen (index.html #app-loader) once the
  // app is actually ready to show real content: either the login page is up
  // (no token) or the main project data has loaded. The loader has its own
  // safety-timeout fallback in index.html in case neither ever fires.
  // 17 Sep 2026 - once the splash has been told to go, this never runs again: it used to re-run on
  // every data load (every project switch, every sync tick) and force a full style recalculation
  // and layout (getComputedStyle + document.fonts) in the middle of the switch.
  const loaderRevealedRef = React.useRef(false);
  React.useEffect(() => {
    if (loaderRevealedRef.current || !document.getElementById("app-loader")) { loaderRevealedRef.current = true; return; }
    if (!(!authToken || data)) return;   // not ready: logged in but data still loading
    let cancelled = false;
    const reveal = () => { if (!cancelled) { loaderRevealedRef.current = true; try { window.__hideAppLoader && window.__hideAppLoader(); } catch (_) {} } };
    // v07zz224 — Hold the splash until the app has actually PAINTED behind it,
    // not merely until /api/data resolved. Hiding on data-ready let the splash
    // fade over a still-rendering screen (bg image + panels popping in = the
    // stutter Hugo saw). Wait for the background image to decode AND web-fonts
    // to be ready (so headings don't FOUT right after the reveal), then two
    // animation frames so the browser has committed one real frame of the
    // finished UI. A 2.5s safety cap means a missing signal can't strand the
    // splash (the index.html controller also hard-caps at 10s).
    const waitBg = new Promise(res => {
      try {
        const cs = getComputedStyle(document.body).backgroundImage || "";
        const m = /url\(["']?(.*?)["']?\)/.exec(cs);
        if (!m || !m[1] || m[1].startsWith("data:")) return res();
        const img = new Image();
        img.onload = img.onerror = () => res();
        img.src = m[1];
        if (img.complete) res();
      } catch (_) { res(); }
    });
    const waitFonts = (document.fonts && document.fonts.ready) ? document.fonts.ready.catch(() => {}) : Promise.resolve();
    // v07zz226 — Also wait for the Sidebar's bottom-left Shot Queue card to
    // have its data. The two other lower-left cards (Project Day, Pipeline
    // Stage) read window.__appData, so they're already populated by the time
    // `data` is set — but the Shot Queue card fetches /api/generation on its
    // own (QueueBreakdown's refreshQueue), independently of /api/data. If we
    // fade before that lands, the card pops in afterwards and visibly shifts
    // the sidebar height (the bug Hugo flagged on Railway). QueueBreakdown
    // sets window.__queueReady + fires 'paradise-queue-ready' on its FIRST
    // completed fetch (success OR failure), so a dead endpoint can't strand
    // us; the 2.5s safety cap below is the additional hard fallback. On the
    // login screen (no token) there's no queue to load, so we skip the wait.
    const waitQueue = (!authToken)
      ? Promise.resolve()
      : (window.__queueReady
          ? Promise.resolve()
          : new Promise(res => {
              const onReady = () => { window.removeEventListener("paradise-queue-ready", onReady); res(); };
              window.addEventListener("paradise-queue-ready", onReady);
              // If the flag flipped between the check and the listener attach,
              // resolve immediately so we never miss the one-shot event.
              if (window.__queueReady) onReady();
            }));
    const safety = setTimeout(reveal, 2500);
    Promise.all([waitBg, waitFonts, waitQueue]).then(() => {
      requestAnimationFrame(() => requestAnimationFrame(reveal));
    });
    return () => { cancelled = true; clearTimeout(safety); };
  }, [authToken, data]);

  // Mirror the React `data` state onto window.__appData so children that read
  // it (TopBar, RightColumn, etc.) see fresh data on every state change. Without
  // this mirror, an immutable setData(...) updates the React tree but stale
  // window data still feeds the donut/stats.
  React.useEffect(() => {
    if (data) window.__appData = data;
    // v04i / v04u — set --thumb-ratio on :root whenever episode data
    // changes. The ratio is the W:H "21:9" string from the API;
    // padding-top trick = (H / W) * 100. Falls back to 21:9 cinema.
    try {
      // v06q — project aspect ratio is now wired into TWO :root tokens:
      // 1. --thumb-ratio  → legacy padding-top trick (H/W percentage).
      // 2. --project-aspect → native CSS aspect-ratio value (e.g. 21/9).
      // The user can override the project default with a localStorage
      // setting (frameflow.aspect-override = "16:9" etc.); when present
      // we use it; otherwise we read from /api/data's episode field.
      const override = (() => {
        try { return localStorage.getItem("frameflow.aspect-override"); } catch (e) { return null; }
      })();
      const ar = override
        || (data && data.episode && data.episode.aspect_ratio)
        || "21:9";
      const [w, h] = String(ar).split(":").map(Number);
      if (Number.isFinite(w) && Number.isFinite(h) && w > 0) {
        const pct = ((h / w) * 100).toFixed(4) + "%";
        document.documentElement.style.setProperty("--thumb-ratio", pct);
        document.documentElement.style.setProperty("--project-aspect", `${w} / ${h}`);
        window.__projectAspectRatio = ar;
      }
    } catch (e) {}
  }, [data]);

  // v07zz182 — Pre-warm asset thumbnails so every Assets surface (the
  // Generate asset picker, the AssetsView cards, the asset modals) paints
  // INSTANTLY instead of fading the gradient placeholder → image on first
  // view. Hugo: "why can't it always be instant, specially locally?"
  // Two effects in one pass:
  //   1. We record each warmed URL in window.__warmedThumbs so the <img>
  //      gates (character cards) can add .is-cached and skip the fade.
  //   2. The browser HTTP-caches the bitmap, so CSS background-image cards
  //      (the picker, prop/location frames) cache-hit with NO gradient→image
  //      flicker the first time they render.
  // Idle-scheduled + bounded concurrency so it never blocks first paint.
  React.useEffect(() => {
    if (!data || !data.assets || typeof window.thumbUrl !== "function") return;
    window.__warmedThumbs = window.__warmedThumbs || new Set();
    const srcs = [];
    // 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"])) {
      const arr = data.assets[cat] || [];
      for (const a of arr) { if (a && a.image) srcs.push(a.image); }
    }
    if (!srcs.length) return;
    // Widths actually used by the asset surfaces: 320 (Generate picker),
    // 560 (AssetsView cards). Modals warm 800 themselves on open.
    const jobs = [];
    for (const s of srcs) for (const w of [320, 560]) {
      let u; try { u = window.thumbUrl(s, w); } catch (_) { u = null; }
      if (u && !window.__warmedThumbs.has(u)) jobs.push(u);
    }
    if (!jobs.length) return;
    let i = 0, active = 0;
    // 17 Sep 2026 - stops when the data changes (a project switch): Paradise Found's 122 asset
    // pictures (3 MB) used to keep loading for a second into the next project
    let dead = false;
    const pid = window.__activeProjectId;
    const loading = new Set();
    const PARALLEL = 4;
    const next = () => {
      while (!dead && active < PARALLEL && i < jobs.length) {
        const u = jobs[i++]; active++;
        const img = new Image();
        loading.add(img);
        const done = () => { loading.delete(img); active--; next(); };
        img.onload = () => { try { window.__warmedThumbs.add(u); } catch (_) {} done(); };
        img.onerror = done;
        img.src = u;
      }
    };
    const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
    idle(next);
    return () => {
      dead = true;
      // another project: the pictures still downloading are the old project's, so drop them too
      if (window.__activeProjectId !== pid) {
        for (const img of loading) { img.onload = img.onerror = null; try { img.removeAttribute("src"); } catch (_) {} }
        loading.clear();
      }
    };
  }, [data]);

  // v07zz185 — Global "Copy image" right-click menu. Hugo: "I need to be able
  // to Copy Image on ANY image." Native right-click → Copy Image only works on
  // <img> tags, but most of the app paints images as CSS background-image (shot
  // thumbs, asset cards, gallery), where the browser offers no copy. This adds
  // a small custom menu on ANY image (<img> OR CSS background) with Copy image
  // + Open in new tab. Copy fetches the image SAME-ORIGIN (R2 urls route
  // through our /api/r2-thumb proxy so there's no CORS/canvas taint), converts
  // to PNG, and writes it to the clipboard.
  React.useEffect(() => {
    let menuEl = null;
    const removeMenu = () => { if (menuEl) { try { menuEl.remove(); } catch (_) {} menuEl = null; } };
    // v07zz480 — Copy/open the ORIGINAL, never the thumbnail the screen happens
    // to render (Hugo: "whenever I copy an image it copies the thumbnail").
    // Rendered srcs are ?w=NNN sharp thumbs or /api/r2-thumb re-encodes; resolve
    // them back to the source: strip w/cb from same-origin URLs, unwrap the
    // r2-thumb proxy to the real R2 URL. Cross-origin originals are FETCHED via
    // the same-origin /api/download proxy (no CORS/canvas taint) but OPENED at
    // their real URL. Exposed as window.__imgOriginal for reuse/tests.
    const original = (u) => {
      if (!u) return { fetchUrl: u, openUrl: u };
      try {
        const abs = new URL(u, location.href);
        if (abs.origin === location.origin) {
          if (abs.pathname === "/api/r2-thumb") {
            const inner = abs.searchParams.get("url");
            if (inner) return { fetchUrl: "/api/download?url=" + encodeURIComponent(inner), openUrl: inner };
          }
          abs.searchParams.delete("w");
          abs.searchParams.delete("cb");
          const rel = abs.pathname + abs.search;
          return { fetchUrl: rel, openUrl: rel };
        }
        return { fetchUrl: "/api/download?url=" + encodeURIComponent(abs.href), openUrl: abs.href };
      } catch (_) { return { fetchUrl: u, openUrl: u }; }
    };
    window.__imgOriginal = original;
    // v07zz593 — drag a VIDEO out of the app (Hugo: "drag videos from the tracker
    // page to another website, same as images"). A dragged <video> carries NO file
    // payload (unlike <img>, whose resource Chrome attaches automatically), so:
    // prefetch the bytes on hover (cloud URLs go through the same-origin
    // /api/download proxy — no CORS), then attach a REAL File via
    // dataTransfer.items.add() at dragstart → dropping on another site's upload
    // zone delivers the actual video file. If the bytes aren't ready when the drag
    // starts, the DownloadURL stamp still saves the real file on a desktop drop
    // (drag it to the desktop, then into the site). One-slot cache — only the
    // last-hovered video is held in memory.
    const _vdCache = { url: null, file: null, inflight: null };
    // v07zz610 — back to the v593 400MB ceiling (the v609 24MB cap broke Hugo's
    // full-quality drag contract). The Content-Length pre-check stays: it only
    // avoids buffering files the cache would refuse anyway.
    const VDRAG_FILE_CAP = 400 * 1024 * 1024;
    window.__videoDragPrefetch = (url) => {
      try {
        if (!url) return;
        const clean = String(url).split("#")[0];
        if (_vdCache.url === clean || _vdCache.inflight === clean) return;
        _vdCache.inflight = clean;
        const o = original(clean);
        fetch(o.fetchUrl).then(r => {
          if (!r.ok) return null;
          const len = parseInt(r.headers.get("content-length") || "0", 10);
          if (len > VDRAG_FILE_CAP) { try { r.body && r.body.cancel && r.body.cancel(); } catch (_) {} return null; }
          return r.blob();
        }).then(b => {
          if (!b || b.size > VDRAG_FILE_CAP) return;
          const base = (window.__aliasFileName || (s => s))(decodeURIComponent(String(o.openUrl || clean).split("?")[0].split(/[\\/]/).pop() || "video.mp4"));   // v791 — outbound name scrub
          const type = /\.mov$/i.test(base) ? "video/quicktime" : "video/mp4";
          _vdCache.url = clean; _vdCache.file = new File([b], base, { type });
        }).catch(() => {}).finally(() => { if (_vdCache.inflight === clean) _vdCache.inflight = null; });
      } catch (_) {}
    };
    window.__videoDragStart = (e, url) => {
      try {
        const clean = String(url || "").split("#")[0];
        const o = original(clean);
        const abs = new URL(o.openUrl || clean, location.href).href;
        const base = decodeURIComponent(abs.split("?")[0].split("/").pop() || "video.mp4");
        const mime = /\.mov$/i.test(base) ? "video/quicktime" : "video/mp4";
        const dt = e.dataTransfer;
        dt.effectAllowed = "copy";
        if (_vdCache.file && _vdCache.url === clean && _vdCache.file.size <= VDRAG_FILE_CAP && localStorage.getItem("drag-file-off") !== "1") dt.items.add(_vdCache.file);
        // v07zz611 — DownloadURL off by default (chromium DownloadURL-freeze bug — see onDragStart).
        if (localStorage.getItem("drag-dlurl") === "1") dt.setData("DownloadURL", `${mime}:${base}:${abs}`);
        dt.setData("text/uri-list", abs);
        dt.setData("text/plain", abs);
      } catch (_) {}
    };
    // v785 — the data-dragfile contract: ANY element carrying data-dragfile="<url>"
    // gets the full proven image drag path (hover/press prefetch → real File attached
    // at dragstart). Audio chips + video tiles now use THIS instead of their own
    // dragstart handlers — two writers on one dataTransfer (the global capture handler
    // plus a component handler) produced mixed payloads that upload zones rejected.
    const resolveDragFile = (target) => {
      try {
        const el = target && target.closest ? target.closest("[data-dragfile]") : null;
        const u = el && el.getAttribute("data-dragfile");
        return u ? String(u).split("#")[0] : null;
      } catch (_) { return null; }
    };
    const resolveUrl = (target) => {
      if (!target || !target.closest) return null;
      const img = target.closest("img");
      if (img && (img.currentSrc || img.src)) return img.currentSrc || img.src;
      const childImg = target.querySelector && target.querySelector("img");
      if (childImg && (childImg.currentSrc || childImg.src)) return childImg.currentSrc || childImg.src;
      // v07zz529 — also resolve VIDEO thumbnails (a shot with a hero video renders a
      // <video> instead of an <img>), so dragging one exports the mp4.
      const vid = target.closest("video");
      if (vid && (vid.currentSrc || vid.src)) return vid.currentSrc || vid.src;
      const childVid = target.querySelector && target.querySelector("video");
      if (childVid && (childVid.currentSrc || childVid.src)) return childVid.currentSrc || childVid.src;
      let el = target;
      for (let i = 0; el && el !== document.body && i < 6; i++, el = el.parentElement) {
        let bg = "";
        try { bg = getComputedStyle(el).backgroundImage || ""; } catch (_) {}
        if (bg.indexOf("url(") !== -1) {
          const m = bg.match(/url\(["']?(.*?)["']?\)/);
          if (m && m[1] && !m[1].startsWith("data:")) return m[1];
        }
      }
      return null;
    };
    const copyImage = async (url) => {
      const { fetchUrl, openUrl } = original(url);
      try {
        const blobPromise = (async () => {
          const resp = await fetch(fetchUrl);
          if (!resp.ok) throw new Error("HTTP " + resp.status);
          const b = await resp.blob();
          if (b.type === "image/png") return b;
          const bmp = await createImageBitmap(b);
          const canvas = document.createElement("canvas");
          canvas.width = bmp.width; canvas.height = bmp.height;
          canvas.getContext("2d").drawImage(bmp, 0, 0);
          return await new Promise(res => canvas.toBlob(res, "image/png"));
        })();
        await navigator.clipboard.write([new ClipboardItem({ "image/png": blobPromise })]);
        flash("Image copied (full resolution)");
      } catch (err) {
        // Fallback: copy the (absolute) ORIGINAL URL so the user still gets something.
        try { await navigator.clipboard.writeText(new URL(openUrl, location.href).href); flash("Image URL copied"); }
        catch (_) { flash("Copy failed"); }
      }
    };
    const flash = (msg) => {
      const t = document.createElement("div");
      t.textContent = msg;
      t.style.cssText = "position:fixed;z-index:100000;bottom:24px;left:50%;transform:translateX(-50%);background:color-mix(in srgb, var(--shade-8) 92%, transparent);color:var(--ink-toast);padding:9px 16px;border-radius:var(--r-round);font:var(--fw-semi) var(--fs-12)/1 Inter,system-ui,sans-serif;letter-spacing:var(--track-04);box-shadow:0 8px 24px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity var(--dur-2) ease";
      document.body.appendChild(t);
      requestAnimationFrame(() => { t.style.opacity = "1"; });
      setTimeout(() => { t.style.opacity = "0"; setTimeout(() => { try { t.remove(); } catch (_) {} }, 200); }, 1400);
    };
    const onCtx = (e) => {
      const url = resolveUrl(e.target);
      if (!url) return; // not an image → let the native menu show
      e.preventDefault();
      removeMenu();
      menuEl = document.createElement("div");
      menuEl.className = "img-ctx-menu";
      // v817 — H was sized for two items; there can be three now (Open in explorer on a
      // local file), so a right-click near the bottom edge would have run the menu off
      // screen. ~38px per row + the 10px of padding.
      const _localFile = (() => { try { const u = original(url).openUrl; return !!u && !/^https?:\/\//i.test(String(u)); } catch (_) { return false; } })();
      const W = 220, H = (_localFile ? 4 : 2) * 38 + 10;
      const x = Math.min(e.clientX, window.innerWidth - W - 8);
      const y = Math.min(e.clientY, window.innerHeight - H - 8);
      menuEl.style.cssText = "position:fixed;z-index:99999;left:" + x + "px;top:" + y + "px;min-width:200px;background:var(--cream-19);color:var(--ink-umber);border:1px solid color-mix(in srgb, var(--card-border) 60%, transparent);border-radius:var(--r-panel);padding:5px;box-shadow:0 18px 50px color-mix(in srgb, var(--shade) 40%, transparent);font:var(--fw-med) var(--fs-13)/1 Inter,system-ui,sans-serif";
      const mk = (label, fn) => {
        const b = document.createElement("button");
        b.type = "button";
        b.textContent = label;
        b.style.cssText = "display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:var(--r-7);padding:9px 12px;color:inherit;font:inherit;cursor:pointer";
        b.onmouseenter = () => { b.style.background = "color-mix(in srgb, var(--leaf) 16%, transparent)"; };
        b.onmouseleave = () => { b.style.background = "transparent"; };
        b.onclick = (ev) => { ev.stopPropagation(); removeMenu(); fn(); };
        menuEl.appendChild(b);
      };
      mk("Copy image", () => copyImage(url));
      mk("Open image in new tab", () => { try { window.open(original(url).openUrl, "_blank", "noopener"); } catch (_) {} });
      // v817 — Hugo: "add Open In Explorer to the right click options of any image
      // anywhere in the references picker". This menu is the ONE place every image in the
      // app goes through (resolveUrl catches <img>, <video> and CSS-background thumbs), so
      // adding it here covers the picker, the filmstrips, the asset cards and the Media
      // grids in a single edit instead of a reveal button per surface.
      // Local files only: /api/reveal drives the OS file browser, so a cloud-only image
      // (Railway, or an R2 URL with no local copy) has nothing to reveal — the item is
      // hidden rather than shown and failing, matching RevealInFolderBtn's own gate.
      if (_localFile) {
        const _openUrl = original(url).openUrl;
        mk("Open in explorer", () => {
          fetch("/api/reveal", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ path: _openUrl }),
          })
            .then(r => r.ok ? null : r.json().then(j => Promise.reject(new Error(j.error || "reveal failed"))))
            .catch(err => { console.warn("[reveal]", err.message); flash("Couldn't open that folder"); });
        });
        // v1050 — Hugo: "can we add an open in photoshop button on the right click of
        // images". Same door as Open in explorer: the server resolves /local/<rel> to the
        // real path (the W:\ root never reaches the browser), checks it is inside the
        // sandbox, then launches Photoshop on it. Release build preferred over Beta.
        mk("Open in Photoshop", () => {
          flash("Opening in Photoshop…");
          fetch("/api/open-in-photoshop", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ path: _openUrl }),
          })
            .then(r => r.ok ? null : r.json().then(j => Promise.reject(new Error(j.error || "failed"))))
            .catch(err => { console.warn("[photoshop]", err.message); flash(err.message); });
        });
        // v817 — Copy path. The client only knows "/local/<rel>", so the absolute W:\ path
        // comes back from the same endpoint in resolve_only mode rather than the WATCH_PATH
        // root being shipped to the browser.
        mk("Copy path", () => {
          fetch("/api/reveal", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ path: _openUrl, resolve_only: true }),
          })
            .then(r => r.json())
            .then(j => {
              if (!j || !j.path) throw new Error(j && j.error ? j.error : "no path");
              return navigator.clipboard.writeText(j.path).then(() => flash("Path copied"));
            })
            .catch(err => { console.warn("[copy path]", err.message); flash("Couldn't copy the path"); });
        });
      }
      document.body.appendChild(menuEl);
    };
    // v07zz529 — DRAG ANY IMAGE OUT of the app to external targets (desktop apps, Finder,
    // image-accepting web editors) at FULL RESOLUTION, regardless of the on-screen thumbnail
    // size. Two parts:
    //  (1) An enabler makes images draggable: <img> is draggable by default, but the app
    //      disables it on many thumbnails (draggable={false}) and CSS-background thumbnails
    //      can't drag natively. A MutationObserver flips draggable=true on added <img> +
    //      known background-image thumbnail elements. (childList only → no attribute-loop.)
    //  (2) A capture-phase dragstart handler resolves the dragged element to its ORIGINAL
    //      full-res URL (strips the ?w= thumbnail, unwraps the r2-thumb proxy) and fills the
    //      DataTransfer. It only ADDS types + never preventDefaults, so the app's internal
    //      drag-scroll / drag-reorder keep working (they read their own data in the bubble phase).
    const DRAG_BG_SEL = "img, .shot-thumb, .gen-ref-tile-img, .vt-img, [class*='thumb-img'], [class*='-thumb'][style*='background-image']";
    const enableDrag = (node) => {
      try {
        if (!node || node.nodeType !== 1) return;
        const mark = (el) => { if (el && el.getAttribute && el.getAttribute("draggable") !== "true") el.setAttribute("draggable", "true"); };
        if (node.matches && node.matches(DRAG_BG_SEL)) mark(node);
        if (node.querySelectorAll) node.querySelectorAll(DRAG_BG_SEL).forEach(mark);
      } catch (_) {}
    };
    enableDrag(document.body);
    const dragMO = new MutationObserver((muts) => {
      for (const m of muts) { if (m.addedNodes) m.addedNodes.forEach(enableDrag); }
    });
    try { dragMO.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
    // v07zz532 — To drop into ANOTHER WEBSITE from localhost, a URL is useless: the
    // target site cannot fetch a localhost URL (that's why Hugo had to "open image in
    // new tab" first — a real image tab lets the browser hand over bytes). So we hand
    // the drop target the actual image BYTES as a File (dataTransfer.items.add) — every
    // web dropzone reads dataTransfer.files, and Finder/desktop accept a File too. Full
    // resolution regardless of the on-screen thumbnail. The bytes must be present
    // SYNCHRONOUSLY at dragstart (HTML5 drag has no async), so we pre-fetch the original
    // blob on pointerdown — a deliberate press on ONE image, and on localhost the fetch
    // completes long before the ~5px drag threshold is crossed. LRU-capped so a mouse
    // sweep across a grid can't balloon memory. Images only (videos are too big to buffer
    // on press — they fall back to the DownloadURL/URL path below).
    const dragBlobCache = new Map(); // fetchUrl -> {blob,name,mime} once resolved, or an in-flight Promise
    const DRAG_BLOB_MAX = 8;
    // v791 — outbound FILENAMES are scrubbed of real-person names, same doctrine as the
    // v775 prompt alias sweep: the gen platforms read upload filenames, so a dropped
    // "markTwain_CafeVoice_v01.mp3" trips the same likeness filter the prompt text would.
    // Internal names are untouched (disk, DB, R2 keys — the watcher's identity contract);
    // ONLY the File handed to the drop target is renamed. Covers "mark twain"/"markTwain"/
    // "mark-twain"/bare "twain" + "sam(uel) clemens"/bare "clemens" (his real name — same
    // filter risk). Twin copy in GeneratePage (_aliasFileNameOut) — keep them identical.
    // 15 Sep 2026 — the Twain / Clemens rules are Paradise Found's cast doctrine: they
    // run only on the default project. (A per-project alias table is a later server field.)
    const aliasFileName = (n) => (window.__isDefaultProject && !window.__isDefaultProject()) ? String(n || "") : String(n || "")
      .replace(/mark[\s._-]*twain/gi, "character")
      .replace(/twain/gi, "character")
      .replace(/sam(?:uel)?[\s._-]*clemens/gi, "character")
      .replace(/clemens/gi, "character");
    window.__aliasFileName = aliasFileName;
    const dragMeta = (url) => {
      const o = original(url);
      let fetchUrl = o.fetchUrl;
      let abs = (() => { try { return new URL(o.openUrl, location.href).href; } catch (_) { return o.openUrl; } })();
      let name = (() => { try { return aliasFileName(decodeURIComponent((abs || "").split(/[?#]/)[0].split("/").pop())) || "image.png"; } catch (_) { return "image.png"; } })();
      let ext = (name.split(".").pop() || "png").toLowerCase();
      // v07zz619 — Kling & co. reject webp uploads: for webp ORIGINALS (grid-slice
      // frames) the drag carries the server's full-res PNG transcode instead
      // (?fmt=png route). Name/mime follow so the dropped file is a real *.png.
      if (ext === "webp") {
        const glue = (u) => u + (u.indexOf("?") >= 0 ? "&" : "?") + "fmt=png";
        fetchUrl = glue(fetchUrl); abs = glue(abs);
        name = name.replace(/\.webp$/i, ".png"); ext = "png";
      }
      // v785 — audio + video mimes join the map: data-dragfile elements (audio chips,
      // video tiles) now ride this same proven prefetch/attach path as images.
      const mime = (ext === "jpg" || ext === "jpeg") ? "image/jpeg"
        : ext === "gif" ? "image/gif"
        : ext === "mp4" || ext === "m4v" ? "video/mp4"
        : ext === "mov" ? "video/quicktime"
        : ext === "webm" ? "video/webm"
        : ext === "mp3" ? "audio/mpeg"
        : ext === "wav" ? "audio/wav"
        : ext === "m4a" ? "audio/mp4"
        : ext === "aac" ? "audio/aac"
        : ext === "ogg" ? "audio/ogg"
        : ext === "flac" ? "audio/flac"
        : "image/png";
      return { fetchUrl, abs, name, mime };
    };
    const DRAG_MEDIA_CAP = 400 * 1024 * 1024;   // v785 — same ceiling as the old video path
    const prefetchDragBlob = (url) => {
      const { fetchUrl, name, mime } = dragMeta(url);
      if (!fetchUrl) return;
      if (dragBlobCache.has(fetchUrl)) return;               // already cached / in-flight
      const p = (async () => {
        const resp = await fetch(fetchUrl);
        if (!resp.ok) throw new Error("HTTP " + resp.status);
        const blob = await resp.blob();
        if (blob.size > DRAG_MEDIA_CAP) throw new Error("too big to buffer");
        const rec = { blob, name, mime: blob.type || mime };
        dragBlobCache.set(fetchUrl, rec);                    // swap the promise for the resolved record
        // v785 — SIZE-aware eviction: 8 images was fine, 8 buffered videos is not.
        // Evict oldest entries until the cached total is back under ~600MB.
        let total = 0;
        for (const v of dragBlobCache.values()) { if (v && v.blob) total += v.blob.size; }
        const it = dragBlobCache.keys();
        while ((total > 600 * 1024 * 1024 || dragBlobCache.size > DRAG_BLOB_MAX) && dragBlobCache.size > 1) {
          const k = it.next().value;
          if (k === undefined) break;     // iterator exhausted — nothing left to evict
          if (k === fetchUrl) continue;   // never evict the record we just added
          const v = dragBlobCache.get(k);
          if (v && v.blob) total -= v.blob.size;
          dragBlobCache.delete(k);
        }
        return rec;
      })().catch(() => { dragBlobCache.delete(fetchUrl); });
      dragBlobCache.set(fetchUrl, p);
    };
    const onPointerDownDrag = (e) => {
      try { const url = resolveDragFile(e.target) || resolveUrl(e.target); if (url) prefetchDragBlob(url); warmGhost(e.target); } catch (_) {}
    };
    // v07zz614 — warm the drag cache on HOVER, not just on press. Press→dragstart is
    // ~100ms; a cold multi-MB original loses that race and the drag leaves carrying
    // only the on-screen thumb (Hugo's asset-gen chips delivered 2KB webps while the
    // warm shot-gen chips delivered full-res). Hover precedes a grab by hundreds of
    // ms, so the bytes are ready by dragstart. 120ms dwell so sweeping a grid doesn't
    // fetch every original; dedupe + LRU(8) live inside prefetchDragBlob.
    let _hovTimer = null, _hovEl = null;
    const onHoverPrefetch = (e) => {
      const t = e.target;
      if (!t || (t.tagName !== "IMG" && t.tagName !== "VIDEO")) return;
      if (t === _hovEl) return;
      _hovEl = t;
      if (_hovTimer) clearTimeout(_hovTimer);
      _hovTimer = setTimeout(() => { try { const url = resolveDragFile(t) || resolveUrl(t); if (url) prefetchDragBlob(url); warmGhost(t); } catch (_) {} }, 120);
    };
    document.addEventListener("mouseover", onHoverPrefetch, true);
    // v07zz609 — drag freeze forensics. sendBeacon hands the payload to the BROWSER
    // process synchronously, so these breadcrumbs survive even a hard renderer hang:
    // whichever line is the LAST one in db/client-diag.log names the step that froze.
    const DIAG_BUILD = "v1077";   // bump when the drag payload logic changes — makes the log self-identifying
    // v1077 - one id per tab (sessionStorage), so the log can follow ONE tab from its first drag
    // to the moment it gets stuck and Hugo closes it. The browser line goes out once per tab.
    const DIAG_TAB = (() => { try { let t = sessionStorage.getItem("drag-diag-tab"); if (!t) { t = Math.random().toString(36).slice(2, 8); sessionStorage.setItem("drag-diag-tab", t); } return t; } catch (_) { return "?"; } })();
    let _diagUaSent = false;
    const diag = (data) => {
      try {
        const extra = { b: DIAG_BUILD, tab: DIAG_TAB };
        if (!_diagUaSent) { _diagUaSent = true; try { extra.ua = String(navigator.userAgent).slice(0, 200); extra.brave = !!navigator.brave; } catch (_) {} }
        navigator.sendBeacon("/api/client-diag", new Blob([JSON.stringify({ ...extra, ...data })], { type: "application/json" }));
      } catch (_) {}
    };
    // v1077 - a SMALL drag picture for big images. Hugo: the ref pill "gets stuck and i cant do
    // anything on the page anymore" (the fix was to close the tab). The log: since Brave 153
    // (2026-09-12) about 1 in 4 drags of a big image never got its dragend (19 of 84; 18 were
    // full-res 4.4-8.1 MB pictures). Drags WITHOUT the attached File stick too, so the File is not
    // the cause. Brave/Chromium on Windows has open bugs of exactly this kind: big <img> drags that
    // fail (crbug 518012712, brave#56124) and a page that stops taking clicks after image drags
    // (brave#55397). setDragImage swaps ONLY the picture under the cursor. The payload (the native
    // image, the attached File, the URLs) is untouched, so the drop stays full-res (the contract).
    // The picture is the 160px server thumb when it is already loaded (warmed on hover), else the
    // on-screen image drawn into a small off-screen canvas.
    const GHOST_MAX = 160;
    const ghostThumbs = new Map();   // on-screen src -> small Image (last 12)
    const isBigImg = (img) => { const w = img.naturalWidth || 0, h = img.naturalHeight || 0; return w > 1400 || h > 1400 || w * h > 1200000; };
    const warmGhost = (img) => {
      try {
        if (!img || img.tagName !== "IMG" || !img.complete || !isBigImg(img) || !window.thumbUrl) return;
        const src = img.currentSrc || img.src;
        if (!src || ghostThumbs.has(src)) return;
        // thumbUrl wants the /local/ PATH: an absolute localhost URL would be sent through
        // /api/r2-thumb (invariant #1) and be refused.
        let rel = src;
        try { const u = new URL(src, location.href); if (u.origin === location.origin) rel = u.pathname + u.search; } catch (_) {}
        const t = new Image();
        t.decoding = "async";
        t.src = window.thumbUrl(rel, GHOST_MAX);
        ghostThumbs.set(src, t);
        while (ghostThumbs.size > 12) ghostThumbs.delete(ghostThumbs.keys().next().value);
      } catch (_) {}
    };
    let _ghostCanvas = null;
    const ghostFor = (img) => {
      try {
        if (!img || img.tagName !== "IMG" || !isBigImg(img)) return null;
        const t = ghostThumbs.get(img.currentSrc || img.src);
        if (t && t.complete && t.naturalWidth) return { el: t, x: Math.round(t.naturalWidth / 2), y: Math.round(t.naturalHeight / 2), kind: "thumb" };
        const w = img.naturalWidth, h = img.naturalHeight, k = GHOST_MAX / Math.max(w, h);
        const cw = Math.max(1, Math.round(w * k)), ch = Math.max(1, Math.round(h * k));
        if (!_ghostCanvas) {
          _ghostCanvas = document.createElement("canvas");
          _ghostCanvas.setAttribute("aria-hidden", "true");
          // In the page (setDragImage paints an element) but far off-screen and inert.
          _ghostCanvas.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none";
          document.body.appendChild(_ghostCanvas);
        }
        _ghostCanvas.width = cw; _ghostCanvas.height = ch;
        _ghostCanvas.style.width = cw + "px"; _ghostCanvas.style.height = ch + "px";
        _ghostCanvas.getContext("2d").drawImage(img, 0, 0, cw, ch);
        return { el: _ghostCanvas, x: Math.round(cw / 2), y: Math.round(ch / 2), kind: "canvas" };
      } catch (_) { return null; }
    };
    // v1077 - follow each drag to its end. `live` = a drag started here and its dragend has not
    // come yet. While live, log what this tab sees: hidden/visible, blur/focus, and the first
    // input after 3 s (input arriving = the page is NOT frozen, only the dragend was lost). A tab
    // that closes while live = the stuck tab Hugo had to close.
    let live = null;
    const liveAge = () => (live ? Math.round(performance.now() - live.t0) : null);
    const onLiveVis = () => { if (live) diag({ ev: "drag:" + document.visibilityState, age: liveAge() }); };
    const onLiveBlur = () => { if (live) diag({ ev: "drag:blur", age: liveAge() }); };
    const onLiveFocus = () => { if (live) diag({ ev: "drag:focus", age: liveAge() }); };
    const onLiveInput = (e) => {
      if (!live || liveAge() < 3000) return;
      diag({ ev: "drag:input-after", type: e.type, age: liveAge() });
      live = null;
    };
    const onLivePageHide = () => { if (live) diag({ ev: "drag:pagehide", age: liveAge() }); };
    const onDragStart = (e) => {
      // v785 — data-dragfile wins (audio chips / video tiles declare their file
      // explicitly); the img/video/bg resolution stays for everything else.
      const df = resolveDragFile(e.target);
      const url = df || resolveUrl(e.target);
      if (!url) return;                         // nothing draggable here → leave the drag alone
      const t0 = performance.now();
      diag({ ev: "dragstart:enter", url: String(url).slice(0, 300) });
      const { fetchUrl, abs, name, mime } = dragMeta(url);
      if (!abs) return;
      const isMedia = !mime.startsWith("image/");
      let blobSize = 0, fileAttached = false, dlurlStamped = false, ghost = null;
      try {
        const dt = e.dataTransfer;
        dt.effectAllowed = isMedia ? "copy" : "copyLink";
        // Real bytes as a File — the only payload a remote website's dropzone can
        // consume from localhost (appears in dataTransfer.files). v07zz610: the v609
        // size cap is GONE — full-res drag is the contract (Hugo), whatever the size.
        // Kill-switch (diagnostics only): localStorage["drag-file-off"] = "1".
        const rec = dragBlobCache.get(fetchUrl);
        blobSize = (rec && rec.blob && rec.blob.size) || 0;
        if (rec && rec.blob && localStorage.getItem("drag-file-off") !== "1") {
          try { dt.items.add(new File([rec.blob], rec.name || name, { type: rec.blob.type || mime })); fileAttached = true; } catch (_) {}
        }
        // v07zz611 — DownloadURL is OFF by default: chromium bug 163387-class freeze
        // ("Chrome freezes (all windows) when setting DownloadURL in ondragstart",
        // ghost stuck at start) resurfaced in Hugo's Chrome 150 — HIS drags hung in
        // the OS drag loop while Google-Images drags (no DownloadURL) worked fine.
        // Website drops keep FULL-RES via the File attach above, URLs stay stamped.
        // Re-enable for testing: localStorage["drag-dlurl"] = "1".
        if (localStorage.getItem("drag-dlurl") === "1") {
          try { dt.setData("DownloadURL", `${mime}:${name}:${abs}`); dlurlStamped = true; } catch (_) {}
        }
        dt.setData("text/uri-list", abs);
        dt.setData("text/plain", abs);
        // v785 — the <img> html stamp is for IMAGE drags only: on audio/video drags it
        // made upload zones read the drop as a link/markup drop and reject the file.
        if (!isMedia) dt.setData("text/html", `<img src="${abs}" alt="">`);
        // v1077 - big picture -> small drag picture (see ghostFor). The payload above is unchanged.
        if (!isMedia && e.target && e.target.tagName === "IMG") {
          const g = ghostFor(e.target);
          if (g) { try { dt.setDragImage(g.el, g.x, g.y); ghost = g.kind; } catch (_) {} }
        }
      } catch (_) {}
      const _img = e.target && e.target.tagName === "IMG" ? e.target : null;
      diag({ ev: "dragstart:exit", url: String(url).slice(0, 300), ms: +(performance.now() - t0).toFixed(1), blobSize, fileAttached, dlurl: dlurlStamped, ghost, nat: _img && _img.naturalWidth ? _img.naturalWidth + "x" + _img.naturalHeight : null });
      live = { t0 };
      // If the renderer freezes AFTER the handler (ghost raster / OS drag loop), this
      // frame beacon never sends — its absence in the log is the diagnosis.
      try { requestAnimationFrame(() => diag({ ev: "dragstart:frame", ms: +(performance.now() - t0).toFixed(1) })); } catch (_) {}
    };
    const onDragEndDiag = (e) => {
      const age = liveAge();
      live = null;
      // fx = what the drop target did with it ("copy" = taken, "none" = refused or cancelled).
      try { if (resolveUrl(e.target)) diag({ ev: "dragend", age, fx: e.dataTransfer ? e.dataTransfer.dropEffect : null }); } catch (_) {}
    };
    document.addEventListener("pointerdown", onPointerDownDrag, true);
    document.addEventListener("dragstart", onDragStart, true);
    document.addEventListener("dragend", onDragEndDiag, true);
    document.addEventListener("visibilitychange", onLiveVis);
    window.addEventListener("blur", onLiveBlur);
    window.addEventListener("focus", onLiveFocus);
    document.addEventListener("pointerdown", onLiveInput, true);
    document.addEventListener("keydown", onLiveInput, true);
    document.addEventListener("mousemove", onLiveInput, { capture: true, passive: true });
    window.addEventListener("pagehide", onLivePageHide);

    document.addEventListener("contextmenu", onCtx);
    document.addEventListener("click", removeMenu);
    document.addEventListener("scroll", removeMenu, true);
    window.addEventListener("blur", removeMenu);
    window.addEventListener("resize", removeMenu);
    // v07zz613 — Ctrl+Shift+D: in-app DROP TEST panel. Shows exactly what any drop
    // target receives from a drag (every file with its size, plus the URL types)
    // and beacons the result to client-diag so the payload question is settled
    // with evidence, not theories. Toggle on/off with the same shortcut.
    let dropPanel = null;
    const removeDropPanel = () => { if (dropPanel) { try { dropPanel.remove(); } catch (_) {} dropPanel = null; } };
    const onDropTestKey = (e) => {
      if (!(e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d"))) return;
      e.preventDefault();
      if (dropPanel) return removeDropPanel();
      dropPanel = document.createElement("div");
      dropPanel.style.cssText = "position:fixed;z-index:100000;right:24px;bottom:24px;width:320px;min-height:150px;background:var(--shade-55);color:var(--ink-toast);border:2px dashed var(--tan);border-radius:var(--r-card);padding:16px;font:var(--fw-semi) var(--fs-13)/1.5 Inter,system-ui,sans-serif;box-shadow:0 18px 50px rgba(0,0,0,.45)";
      dropPanel.innerHTML = "<div style='font-size:var(--fs-15);margin-bottom:6px'>DROP TEST</div><div style='font-weight:var(--fw-med);opacity:.8'>Drag any image from the app and drop it HERE. I'll show exactly what a website would receive.</div>";
      dropPanel.addEventListener("dragover", (ev) => { ev.preventDefault(); dropPanel.style.borderColor = "var(--leaf)"; });
      dropPanel.addEventListener("dragleave", () => { dropPanel.style.borderColor = "var(--tan)"; });
      dropPanel.addEventListener("drop", (ev) => {
        ev.preventDefault();
        const dt = ev.dataTransfer;
        const files = [...(dt.files || [])].map(f => ({ name: f.name, size: f.size, type: f.type }));
        const info = { ev: "droptest", files, types: [...dt.types], uri: String(dt.getData("text/uri-list") || "").slice(0, 200) };
        diag(info);
        const big = files.some(f => f.size > 1024 * 1024);
        const list = files.map(f => `${f.name} — ${(f.size / 1024 / 1024).toFixed(2)} MB (${f.type})`).join("<br>") || "(no files — only URLs)";
        dropPanel.innerHTML = `<div style='font-size:var(--fs-15);margin-bottom:6px'>${big ? "✅ FULL-RES file received" : "⚠️ only small/no file received"}</div><div style='font-weight:var(--fw-med);word-break:break-all'>${list}</div><div style='margin-top:8px;font-weight:var(--fw-med);opacity:.7'>Logged. Ctrl+Shift+D to close.</div>`;
      });
      document.body.appendChild(dropPanel);
    };
    document.addEventListener("keydown", onDropTestKey);
    return () => {
      document.removeEventListener("contextmenu", onCtx);
      document.removeEventListener("click", removeMenu);
      document.removeEventListener("scroll", removeMenu, true);
      window.removeEventListener("blur", removeMenu);
      window.removeEventListener("resize", removeMenu);
      document.removeEventListener("pointerdown", onPointerDownDrag, true);
      document.removeEventListener("mouseover", onHoverPrefetch, true);
      if (_hovTimer) clearTimeout(_hovTimer);
      document.removeEventListener("dragstart", onDragStart, true);
      document.removeEventListener("dragend", onDragEndDiag, true);
      document.removeEventListener("visibilitychange", onLiveVis);
      window.removeEventListener("blur", onLiveBlur);
      window.removeEventListener("focus", onLiveFocus);
      document.removeEventListener("pointerdown", onLiveInput, true);
      document.removeEventListener("keydown", onLiveInput, true);
      document.removeEventListener("mousemove", onLiveInput, { capture: true });
      window.removeEventListener("pagehide", onLivePageHide);
      if (_ghostCanvas) { try { _ghostCanvas.remove(); } catch (_) {} }
      document.removeEventListener("keydown", onDropTestKey);
      removeDropPanel();
      try { dragMO.disconnect(); } catch (_) {}
      removeMenu();
    };
  }, []);

  // Immutable shot-status update — single source of truth for status changes
  // anywhere in the app. Replaces the shot inside data.shots so derived stats
  // (donut, top-bar columns) recalculate live across the whole tree.
  // CRITICAL: window.__appData is updated SYNCHRONOUSLY inside the state
  // updater (not via the useEffect mirror) so that components reading from
  // the global on the very next render see fresh data on a single click.
  // The useEffect mirror runs after the render that needs the data, which
  // caused the "needs two clicks to update" bug.
  // t06b — insertShot / renameShot / archiveShot callbacks retired
  // alongside the manual UI elements. The backend routes still exist
  // for future folder-watcher / webhook flows, but App.jsx no longer
  // exposes a path to fire them from the UI.

  // v07perf — Debounce the sync.applied refetch storm. A burst of
  // status changes on Railway fires 5+ sync.applied events within ~2 s
  // (one per upstream batch). Each one was bumping projectVersion +
  // episodeVersion + genJobsVersion immediately, which triggered the
  // /api/data + /api/crew + /api/assets refetch chain N times. With
  // a 500 ms debounce, the burst collapses to one refetch.
  const _syncAppliedDebounceRef = React.useRef(null);

  // v07zz154 — ONE debounced "heavy refetch" shared by every event that needs
  // /api/data refreshed: webhook image_started/completed (which fire on EVERY
  // shot as a batch runs), shot.* from teammates, and sync.applied. Coalescing
  // them into a single trailing refetch stops the whole-tree re-render that
  // Hugo sees as "the panel flickers every 5s" while a batch generates.
  const scheduleHeavyRefetch = React.useCallback(() => {
    if (_syncAppliedDebounceRef.current) clearTimeout(_syncAppliedDebounceRef.current);
    _syncAppliedDebounceRef.current = setTimeout(() => {
      setProjectVersion(v => v + 1);
      setEpisodeVersion(v => v + 1);
      setGenJobsVersion(v => v + 1);
      _syncAppliedDebounceRef.current = null;
    }, 1200);
  }, []);

  // t17 — SSE stream for real-time updates. Opens once we're authed,
  // listens for { type, action, shot_id, ... } events, and triggers a
  // light refresh of the affected slices. Reconnects on error.
  React.useEffect(() => {
    if (!authToken) return;
    let es = null;
    let retryTimer = null;
    let cancelled = false;
    const open = () => {
      if (cancelled) return;
      try {
        // Authorization headers can't ride on EventSource, so pass the
        // JWT via ?token=… — the server's authMiddleware accepts it
        // only on this one route to limit token exposure in logs.
        const tokenParam = __authToken && __authToken !== "bypass"
          ? "?token=" + encodeURIComponent(__authToken)
          : "";
        es = new EventSource("/api/events" + tokenParam);
      } catch (err) {
        setSseStatus("lost");
        return;
      }
      es.onopen = () => setSseStatus("live");
      es.onerror = () => {
        setSseStatus("lost");
        try { es && es.close(); } catch (e) {}
        // reconnect after 4s — long enough not to hammer the server
        // but short enough that a brief network blip is invisible.
        if (!cancelled) retryTimer = setTimeout(open, 4000);
      };
      es.onmessage = (ev) => {
        let msg = null;
        try { msg = JSON.parse(ev.data); } catch (e) {}
        if (!msg) return;
        // Webhook events: refresh the underlying data so the new
        // version / status appears without a full reload.
        if (msg.type === "webhook") {
          // v07zz154 — debounced (was immediate). During a batch, image_started/
          // image_completed webhooks fire continuously; an immediate /api/data
          // refetch on each was the 5s whole-panel flicker. Note counts are a
          // cheap state bump so they stay immediate.
          setNoteCountsVersion(v => v + 1);
          scheduleHeavyRefetch();
        }
        // t18 — explicit generation.* events (manual /api/generation/*).
        if (msg.type === "generation.start" || msg.type === "generation.complete" || msg.type === "generation.fail") {
          setGenJobsVersion(v => v + 1);
        }
        // t22 — refresh affected slices on any shot.* / episode.* /
        // note.* event. Skip self-originated events (actor_id matches
        // currentUser.id) so the user's own action doesn't trigger an
        // unnecessary refetch + flicker.
        const selfId = (currentUser && currentUser.id) || null;
        // v974 — prefer the TAB id: an event this very tab caused is still skipped,
        // but one from another tab (even mine, even the same login) now refreshes.
        // Falls back to the old user comparison for events with no tab id (the folder
        // watcher, sync, anything server-originated).
        const fromOther = msg.actor_client_id
          ? msg.actor_client_id !== window.__clientId
          : (msg.actor_id != null && msg.actor_id !== selfId);
        if (msg.type && msg.type.startsWith("shot.") && fromOther) {
          scheduleHeavyRefetch();  // v07zz154 — debounced (was immediate)
        }
        // v07zc — Recent Activity card listens for "shot_status_change"
        // (underscore) but the server SSE uses "shot.status_change"
        // (dot). Without translation, status changes never appeared
        // in the local dashboard's activity panel — only after a sync
        // cycle ran. Fan a paradise-sse synthetic event on EVERY
        // shot.* event (including own actions) so the activity card
        // refetches /api/logs the moment the change_log row lands.
        if (msg.type && msg.type.startsWith("shot.")) {
          try {
            window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "shot_status_change" } }));
          } catch (_) {}
        }
        if (msg.type && msg.type.startsWith("note.") && fromOther) {
          setNoteCountsVersion(v => v + 1);
        }
        // v07zb/v07zc — Fan paradise-sse note_added on EVERY note.*
        // event so RecentActivityCard + NotesCard refetch whether
        // the actor was the local user or someone else. Server uses
        // dotted "note.add" / "note.resolve"; clients listen for
        // underscored "note_added" via paradise-sse.
        if (msg.type && msg.type.startsWith("note.")) {
          try {
            window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_added" } }));
          } catch (_) {}
          // v07zz583 — notes now drive the shotlist reviewed-chips (note_authors
          // ride /api/data), so a fresh note — mine or a teammate's — refreshes
          // the main data slice. Debounced via the shared heavy-refetch.
          scheduleHeavyRefetch();
        }
        // v07zc — Same translation for asset_version / review events
        // so newly uploaded media + edits also appear in Recent
        // Activity without waiting for the sync tick.
        if (msg.type && (msg.type.startsWith("asset_version.") || msg.type.startsWith("review."))) {
          try {
            window.dispatchEvent(new CustomEvent("paradise-sse", {
              detail: { type: msg.type.startsWith("review.") ? "review_changed" : "new_asset_version" },
            }));
          } catch (_) {}
        }
        // v07zz410 — Forward raw generation-completion events (the folder watcher's
        // image_completed / video_completed / upscale_completed, each carrying shot_id —
        // server.js ~2127) onto the paradise-sse bus. The Generate-page Generations drawer
        // listens for /^(generate|image_|video_|upscale_)/ and filters on msg.shot_id, so
        // without this forward a freshly-generated frame/grid/video never appeared in the
        // drawer until a manual refresh — these raw events were consumed here and dropped.
        if (msg.type && /^(image_|video_|upscale_)/.test(msg.type)) {
          try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: msg })); } catch (_) {}
        }
        // v773 — live depth-map progress. POST /api/depth's child parses the Python
        // script's PROGRESS/tqdm output and broadcasts depth.progress {job_id, pct,
        // label} ~1/s. Forward as underscored depth_progress; QueueBreakdown draws
        // the bar on the matching "Depth map" queue row.
        if (msg.type === "depth.progress") {
          try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "depth_progress", job_id: msg.job_id, shot_id: msg.shot_id, pct: msg.pct, label: msg.label } })); } catch (_) {}
        }
        if (msg.type === "episode.create" && fromOther) {
          setEpisodeVersion(v => v + 1);
        }
        // v07zz240 — another client changed an asset's workflow status. The
        // broadcast carries no data, so refetch assets to refresh the cards +
        // the To-Do milestone readiness panel. Gated to OTHER actors so the
        // person who made the change keeps their instant optimistic update.
        if (msg.type === "asset_status_changed") {
          if (fromOther && typeof window.reloadAppData === "function") window.reloadAppData();
          try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "asset_status_changed" } })); } catch (_) {}
        }
        // v07zz210 — Presentations: the folder watcher fires presentation.added
        // when a saved PDF lands in the watch folder and presentation.removed
        // when one is deleted (from disk or via the tracker). Re-dispatch a
        // synthetic paradise-sse event so an open Presentations page refreshes
        // its Past Presentations list without a manual refresh.
        if (msg.type === "presentation.added" || msg.type === "presentation.removed") {
          try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "presentation_changed" } })); } catch (_) {}
        }
        // v07zz441 — an asset was renamed / had a field edited. The server broadcasts
        // `asset_edited` from PATCH /api/assets/:kind/:id/fields, but this handler used
        // to drop it entirely — so a rename never reached the Generate page's PRIVATE
        // asset list (its own /api/assets copy), and its header + {{name}} prompt kept
        // the OLD name until the page remounted. Refresh the shared __appData.assets AND
        // forward the event onto the paradise-sse bus so the Generate asset panel (which
        // holds its own copy) refetches /api/assets. Asset edits are rare, so an
        // unconditional refresh here is fine.
        if (msg.type === "asset_edited") {
          if (typeof window.reloadAppData === "function") window.reloadAppData();
          try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "asset_edited", kind: msg.kind, id: msg.id } })); } catch (_) {}
        }
        // v818 — a picture was dropped into an asset folder on disk and the
        // folder watcher just ingested it. Refresh the shared assets payload
        // (the Assets page cards read it) AND forward onto the paradise-sse bus
        // so an OPEN asset modal re-pulls its buckets — the modal captures
        // `item` at click time, so reloadAppData alone never reaches it (v706).
        // Not actor-gated: this comes from the watcher, so actor_id is null.
        if (msg.type === "asset.reference_added") {
          if (typeof window.reloadAppData === "function") window.reloadAppData();
          try {
            window.dispatchEvent(new CustomEvent("paradise-sse", { detail: {
              type: "asset_reference_added", category: msg.category, slug: msg.slug,
              filename: msg.filename, is_wip: msg.is_wip,
            } }));
          } catch (_) {}
        }
        // v1008 — the ASSET-GENERATION side of the same problem. When an asset
        // gen finishes the server broadcasts `asset_wip_added` (server.js
        // ~13772), and Send-to-WIP / Promote broadcast `asset.wip_stage` and
        // `asset.promoted`. None of those were relayed onto the paradise-sse
        // bus, so a landing generation never reached an open asset modal or the
        // Generate page and Hugo had to hard-refresh to see his own renders.
        // Relay all three as `asset_reference_added`, which the mounted modals
        // (AssetItemModal, CharacterDetailModal) already handle — is_wip picks
        // the right refetch: 1 re-pulls the WIP bucket, 0 re-pulls the hero.
        // `slug` may arrive on its own or inside entity_id as "<category>/<slug>".
        if (msg.type === "asset_wip_added" || msg.type === "asset.wip_stage" || msg.type === "asset.promoted") {
          try {
            const ent = String(msg.entity_id || "");
            const slug = msg.slug || (ent.includes("/") ? ent.split("/").pop() : ent) || "";
            const category = msg.category || (ent.includes("/") ? ent.split("/")[0] : "");
            window.dispatchEvent(new CustomEvent("paradise-sse", { detail: {
              type: "asset_reference_added", category, slug,
              reference_id: msg.reference_id,
              is_wip: msg.type === "asset.promoted" ? 0 : 1,
            } }));
          } catch (_) {}
        }
        // v949 — a file dropped into a reference library on disk (historical /
        // external / archival) — the watcher just saw it land. Fire the same
        // window event the in-app uploads use, so AssetsView + MediaView
        // refetch their batch lists. Debounced: dropping six files at once
        // is one refetch, not six (each refetch re-scans the folders).
        if (msg.type === "archival.reference_added") {
          try {
            clearTimeout(window.__archivalRefreshTimer);
            window.__archivalRefreshTimer = setTimeout(() => {
              try { window.dispatchEvent(new CustomEvent("paradise-archival-refresh")); } catch (_) {}
            }, 800);
          } catch (_) {}
        }
        // v07j — sync.applied fires whenever the bidirectional sync
        // lands new rows in this server's DB. Without this, Hugo's
        // team would have to hard-refresh after every cycle to see
        // each other's changes. Refresh the data slices that map to
        // the changed tables. The server emits this with actor_id
        // = null, so the self-event filter above doesn't apply.
        if (msg.type === "sync.applied") {
          const tables = Array.isArray(msg.tables) ? msg.tables : [];
          // v07zz536 — refresh the censored-word cache when a peer edits the list.
          if (tables.includes("censored_words") && window.__censorRefresh) window.__censorRefresh();
          // 23 Sep 2026 — a HIDDEN TOOLS switch flipped on the other machine.
          if (tables.includes("settings") && window.__loadUiHidden) window.__loadUiHidden();
          // v07zz32 — Bump projectVersion on user-affecting tables too.
          // Previously only shots/episodes/asset_versions forced a
          // refetch, so a teammate created on Railway took the next
          // hard refresh to appear on local — Hugo's exact complaint.
          // The main data effect re-pulls /api/crew alongside the
          // tracker payload, so bumping the version is the single
          // signal that triggers the crew page to update.
          // Debounced batch refetch — see _syncAppliedDebounceRef above
          // for rationale. The synthetic paradise-sse dispatches stay
          // immediate because they're cheap (just window event emits).
          const scheduleRefetch = () => {
            if (_syncAppliedDebounceRef.current) clearTimeout(_syncAppliedDebounceRef.current);
            _syncAppliedDebounceRef.current = setTimeout(() => {
              setProjectVersion(v => v + 1);
              setEpisodeVersion(v => v + 1);
              setGenJobsVersion(v => v + 1);
              _syncAppliedDebounceRef.current = null;
            }, 500);
          };
          // What reads those three tables: /api/crew (users), and for permissions also the money
          // fields of /api/schedule and the money documents of /api/documents (reqCanBudget). The
          // answers are merged into the data on screen. A crew member whose name or email changed,
          // joined or left also changes the note-author chips inside /api/data, so that case still
          // takes the full reload. A full reload already queued makes this one unnecessary.
          const scheduleCrewRefetch = (withPermissions) => {
            clearTimeout(window.__syncCrewRefetchTimer);
            window.__syncCrewRefetchTimer = setTimeout(() => {
              if (_syncAppliedDebounceRef.current) return;
              const pid = window.__appData && window.__appData.__project;
              if (!pid) { scheduleRefetch(); return; }
              const init = { headers: { "X-Active-Project": pid } };
              const want = (url) => authFetch(url, init).then((r) => (r.ok ? r.json() : null)).catch(() => null);
              Promise.all([
                want("/api/crew"),
                withPermissions ? want("/api/schedule") : null,
                withPermissions ? want("/api/documents") : null,
              ]).then(([crew, schedule, documents]) => {
                if (!crew || !Array.isArray(crew.members)) return;
                const who = (list) => (list || []).map((m) => `${m.user_id || m.id}|${m.name || ""}|${m.email || ""}`).sort().join("\n");
                const shown = window.__appData;
                if (!shown || shown.__project !== pid) return;
                if (who(shown.crew) !== who(crew.members)) { scheduleRefetch(); return; }
                setData((d) => {
                  if (!d || !_dataIsFor(d, pid)) return d;
                  const next = { ...d, crew: crew.members };
                  if (schedule) next.schedule = schedule;
                  if (documents) { next.documents = documents.documents; next.doc_roles = documents.roles; next.current_role = documents.current_role; }
                  window.__appData = next;
                  try { if (shownDataKeyRef.current) sessionStorage.setItem(shownDataKeyRef.current, JSON.stringify(next)); } catch (_) {}
                  return next;
                });
              });
            }, 500);
          };
          // 17 Sep 2026 - users / permissions / user_notification_prefs no longer reload the whole
          // project (/api/data + /api/assets + ...): one synced login stamp cost the server two
          // 700-800 ms stalls. They refetch only what reads them (scheduleCrewRefetch below).
          const _fullReload = (
            tables.includes("shots")
            || tables.includes("episodes")
            || tables.includes("asset_versions")
            // v07zz373 — sequences now sync; refetch so renamed sequence labels + cover picks refresh.
            || tables.includes("sequences")
            // v07zz72 — Audit fix: asset_references, archival_stills,
            // and audio_assets also flow into the main /api/data +
            // /api/assets fetch. Previously a sync.applied burst that
            // only contained these tables (e.g. someone uploaded a
            // character ref on Railway → it syncs back to local)
            // wouldn't trigger a refetch, so Hugo's AssetsView /
            // ArchivalModal / AudioPage stayed stale until a manual
            // reload.
            || tables.includes("asset_references")
            || tables.includes("archival_stills")
            || tables.includes("audio_assets")
            // v07zz583 — notes ride /api/data now (reviewed-by chips per shot), so a
            // synced-in note from the peer must refetch or the chips stay stale.
            || tables.includes("notes")
          );
          if (_fullReload) {
            scheduleRefetch();
          } else if (!tables.includes("generation_queue") && !tables.includes("_assets_json")
              && (tables.includes("users") || tables.includes("permissions") || tables.includes("user_notification_prefs"))) {
            // (generation_queue and _assets_json reload everything just below)
            // 17 Sep 2026 - users too: with BYPASS_AUTH the server reads the viewer's role from the
            // users table on every request, so a synced users row can change which money fields
            // /api/schedule and /api/documents show (the full reload used to refetch both).
            scheduleCrewRefetch(tables.includes("permissions") || tables.includes("users"));
          }
          // v07zz18 — When generation_queue lands via sync (e.g.
          // Railway just learned about local's status update from
          // "queued" → "generating" → "completed"), force a queue
          // refetch by re-firing a synthetic paradise-sse "generate"
          // event. QueueBreakdown.jsx already listens for /^generate/
          // events and refetches /api/generation on them.
          if (tables.includes("generation_queue")) {
            scheduleRefetch();
            try {
              window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "generate" } }));
            } catch (_) {}
          }
          // v07zt — Inbound _assets_json merge → bump projectVersion
          // so the App data effect refetches /api/assets and the new
          // / edited asset rows render.
          if (tables.includes("_assets_json")) {
            setProjectVersion(v => v + 1);
            try {
              window.dispatchEvent(new CustomEvent("paradise-asset-lock-changed"));
            } catch (_) {}
          }
          if (tables.includes("video_comments") || tables.includes("in_app_notifications") || tables.includes("notes")) {
            setNoteCountsVersion(v => v + 1);
            // Also re-broadcast a synthetic paradise-sse event so the
            // NotificationsPopover + per-shot note lists refetch
            // immediately. They listen for these event names already.
            try {
              window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_added" } }));
            } catch (_) {}
          }
          // v07zz325 — a teammate's activity (change_log rows) arriving via sync should refresh
          // the Recent Activity panel without a manual poll. RecentActivityCard refetches /api/logs
          // on a paradise-sse "shot_status_change" (see the live shot.* handler above), so fan one.
          if (tables.includes("change_log")) {
            try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "shot_status_change" } })); } catch (_) {}
          }
          if (tables.includes("video_reviews") || tables.includes("video_comments")) {
            // Review page comments + the parent review row both fire
            // a review_changed nudge so the open review page reloads
            // its filmstrip + comments.
            try {
              window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "review_changed" } }));
            } catch (_) {}
          }
          // v07zz452 — a saved combined-shot set arrived via sync (saved on another
          // machine); nudge the Generate page to reload its set list so re-selecting
          // those shots restores the latest customisations.
          if (tables.includes("shot_gen_sets")) {
            try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "shot_gen_sets_changed" } })); } catch (_) {}
          }
          // 16 Sep 2026 — Reports → AI footprint: AI calls logged on the other machine, or its
          // assumptions changed there (or here: the settings PUT broadcasts the same event).
          if (tables.includes("ai_usage") || tables.includes("settings")) {
            try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "footprint_changed" } })); } catch (_) {}
          }
          // v07zz254 — a deck locked locally (presentations) or a deck comment now
          // syncs; nudge the Review › Presentations tab to reload so it appears live.
          if (tables.includes("presentations") || tables.includes("presentation_comments")) {
            try {
              window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "presentations_changed" } }));
            } catch (_) {}
          }
        }
        // t22 — presence updates: refetch /api/online so the indicator
        // reflects who is connected right now.
        // v07zz12 — Filter the current user OUT of the list here so
        // TopBar's totalCount = others + 1 stops double-counting Hugo
        // (was producing "2 ONLINE" with both avatars being him).
        if (msg.type === "presence.join" || msg.type === "presence.leave" || msg.type === "hello") {
          fetch("/api/online", { headers: __authToken ? { Authorization: `Bearer ${__authToken}` } : {} })
            .then(r => r.ok ? r.json() : null)
            .then(d => {
              if (d && Array.isArray(d.users)) {
                const myId = (window.__currentUser && window.__currentUser.id) || null;
                const others = myId
                  ? d.users.filter(u => String(u.id) !== String(myId))
                  : d.users;
                // v866 — bail out when the list has not actually changed. This built a NEW
            // array every 30s and set it unconditionally, and since nothing in this tree is
            // memoised that re-rendered Sidebar, TopBar, ProjectHeader, ShotsPanel and the
            // rest on a metronome — on a completely idle app. Compare by id so a swap of one
            // user for another still updates (a length check would miss it).
            setOnlineUsers(prev => {
              const a = (prev || []).map(u => String(u.id)).join(",");
              const b = others.map(u => String(u.id)).join(",");
              return a === b ? prev : others;
            });
              }
            })
            .catch(() => {});
        }
      };
    };
    open();
    // v07zz12 — Initial fetch + 30s heartbeat so the online indicator
    // stays accurate even if SSE drops a presence event. Filters self
    // out by id like the SSE-triggered fetch above.
    const refreshOnline = () => {
      fetch("/api/online", { headers: __authToken ? { Authorization: `Bearer ${__authToken}` } : {} })
        .then(r => r.ok ? r.json() : null)
        .then(d => {
          if (d && Array.isArray(d.users)) {
            const myId = (window.__currentUser && window.__currentUser.id) || null;
            const others = myId
              ? d.users.filter(u => String(u.id) !== String(myId))
              : d.users;
            setOnlineUsers(others);
          }
        })
        .catch(() => {});
    };
    refreshOnline();
    const onlineHeartbeat = setInterval(refreshOnline, 30_000);
    return () => {
      cancelled = true;
      if (retryTimer) clearTimeout(retryTimer);
      clearInterval(onlineHeartbeat);
      try { es && es.close(); } catch (e) {}
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [authToken]);

  // t12 — note counts per shot for the badge on each row.
  // shape: { [shot_id]: { unresolved, total } }
  const [noteCounts, setNoteCounts] = React.useState({});
  const [noteCountsVersion, setNoteCountsVersion] = React.useState(0);
  // t05h — global pending-notes count fed into TopBar Project mode.
  // Mirrored to window so TopBar can read it without prop-drilling.
  const reloadNoteCounts = React.useCallback(() => {
    if (!authToken) return;
    authFetch("/api/notes/counts?entity_type=shot")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!d) return;
        setNoteCounts(d.counts || {});
        const totalUnresolved = (d.totals && d.totals.unresolved) || 0;
        window.__pendingNotesTotal = totalUnresolved;
      })
      .catch(() => {});
  }, [authToken]);
  React.useEffect(() => { reloadNoteCounts(); }, [reloadNoteCounts, noteCountsVersion]);

  // t18 — set of shot ids currently generating, refreshed on every
  // webhook / generation event. Drives the pulse animation on shot rows
  // and feeds RightColumn's queue panel via window.__generationJobs.
  // Stale-row guard (v07zz34): if a queue row has been sitting in 'queued'
  // (or 'generating') for >6h it's almost certainly abandoned — exclude it
  // from the pulse set so SH#### doesn't keep a green outline forever.
  const [generatingSet, setGeneratingSet] = React.useState(() => new Set());
  const [genJobsVersion, setGenJobsVersion] = React.useState(0);
  React.useEffect(() => {
    if (!authToken) return;
    authFetch("/api/generation")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const jobs = (d && d.jobs) || [];
        window.__generationJobs = jobs;
        const STALE_MS = 6 * 60 * 60 * 1000; // 6 hours
        const now = Date.now();
        const isFresh = (j) => {
          if (!j || !j.started_at) return true;
          // SQLite datetime() format is "YYYY-MM-DD HH:MM:SS" (UTC).
          // Append 'Z' so Date parses as UTC (avoids local-tz drift).
          const startedRaw = String(j.started_at).includes("T")
            ? j.started_at
            : j.started_at.replace(" ", "T") + "Z";
          const t = Date.parse(startedRaw);
          if (Number.isNaN(t)) return true;
          return (now - t) < STALE_MS;
        };
        // v07zz365 — keep the SAME Set reference when the generating shot-ids are unchanged.
        // A brand-new Set every bump re-rendered the whole shotlist (and reloaded every video
        // thumbnail) even when nothing was actually generating — contributing to the flicker.
        const nextIds = jobs
          .filter(j => (j.status === "generating" || j.status === "queued") && isFresh(j))
          .map(j => j.shot_id);
        setGeneratingSet(prev => {
          const next = new Set(nextIds);
          if (prev.size === next.size && [...next].every(id => prev.has(id))) return prev;
          return next;
        });
      })
      .catch(() => {});
  }, [authToken, genJobsVersion]);
  // Refresh counts when a modal closes — feedback might have been added.
  const closeShotWithRefresh = React.useCallback(() => {
    closeShot();
    setNoteCountsVersion(v => v + 1);
  }, []);

  const updateShotStatus = React.useCallback((id, statusKey) => {
    setData(prev => {
      if (!prev) return prev;
      const order = ["prompt", "first_pass", "refinement", "hero", "video_prompt", "video", "upscale"];
      const stageMap = {
        "PROMPT": "prompt",
        "FIRST-PASS": "first_pass",
        "CONCEPT-WIP": "refinement",
        "CONCEPT-APPROVED": "hero",
        "VIDEO-WIP": "video_prompt",
        "VIDEO-APPROVED": "video",
        "UPSCALED": "upscale",
      };
      const shots = prev.shots.map(s => {
        if (s.id !== id) return s;
        const next = { ...s };
        if (statusKey === "ARCHIVE") {
          next.is_archive = true;
        } else if (statusKey === "PENDING") {
          next.stage_status = order.reduce((acc, k) => { acc[k] = "pending"; return acc; }, {});
          next.is_archive = false;
        } else {
          const stage = stageMap[statusKey];
          if (!stage) return s;
          const idx = order.indexOf(stage);
          next.stage_status = order.reduce((acc, k, i) => { acc[k] = i <= idx ? "done" : "pending"; return acc; }, {});
          next.is_archive = false;
        }
        return next;
      });
      const updated = { ...prev, shots };
      // Sync update so TopBar's first render after this state change reads fresh data.
      window.__appData = updated;
      return updated;
    });
    // v06o — persist to backend so the change survives reload. Mirrors the
    // same PATCH call ShotsPanel.handleStatusChange makes when the row pill
    // (now retired in v01f) was directly editable. Fire-and-forget; the
    // optimistic local update above gives the user instant feedback.
    try {
      const fetcher = window.authFetch || fetch;
      fetcher(`/api/shots/${encodeURIComponent(id)}/status`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ status: statusKey }),
      }).catch(() => {});
    } catch (e) { /* ignore */ }
  }, []);

  // v06y — generic per-shot field patch (used to clear hero_video_label
  // optimistically when the user toggles the HERO THIS VIDEO pill off).
  // Mirrors the same pattern as updateShotStatus.
  const updateShotField = React.useCallback((id, patch) => {
    setData(prev => {
      if (!prev) return prev;
      const shots = prev.shots.map(s => (s.id === id ? { ...s, ...patch } : s));
      const updated = { ...prev, shots };
      window.__appData = updated;
      return updated;
    });
  }, []);
  React.useEffect(() => {
    window.__updateShotField = updateShotField;
    return () => { if (window.__updateShotField === updateShotField) delete window.__updateShotField; };
  }, [updateShotField]);

  // v06o — expose the status updater to the shot detail modal so its
  // new hover-reveal quick-action bar can change a shot's stage.
  React.useEffect(() => {
    window.__updateShotStatus = updateShotStatus;
    return () => { if (window.__updateShotStatus === updateShotStatus) delete window.__updateShotStatus; };
  }, [updateShotStatus]);

  React.useEffect(() => {
    // v07zz192 — keep both body + documentElement in sync (skin CSS is body-scoped;
    // documentElement mirrors it for the no-flash inline loader in index.html).
    // appliedTheme is always a skin that exists, so writing it back also heals a stale
    // boot key left by a removed skin after one load.
    // 17 Sep 2026 - applyProjectStyle already painted the skin together with the photo
    // (_paintSkin: data-theme, data-skin-family, the boot keys, "filmtracker:skin-changed"), so this
    // only RE-CHECKS. Before the project list has loaded, the boot painter's skin (the last one
    // shown) stays, instead of flashing the user's own skin until the project's look is known.
    if (!window.__projects && document.body.hasAttribute("data-theme")) return;
    // the skin's own photo follows the applied skin (a project with no saved base changes skin here)
    if (_paintSkin(appliedTheme) && window.__repaintBackdrop) window.__repaintBackdrop();
  }, [appliedTheme]);

  React.useEffect(() => {
    document.body.setAttribute("data-style", style);
    localStorage.setItem("frameflow-style", style);
  }, [style]);

  const switchProject = (id, opts) => {
    // the project last ASKED for (a switch that has not committed yet counts)
    if (!id || id === (requestedProjectRef.current || activeProject)) return;
    requestedProjectRef.current = id;
    // 17 Sep 2026 - any render React is still preparing (the previous project's 239 rows from its
    // cache, or a switch that has not committed) is thrown away NOW by one tiny urgent render; the
    // switch below is then rendered together with whatever was queued, so rows nobody will see are
    // never built. Not from inside an effect (React flushes it at the end of the effects anyway).
    if (!(opts && opts.fromEffect) && interruptRef.current && typeof ReactDOM !== "undefined" && ReactDOM.flushSync) {
      try { ReactDOM.flushSync(() => { if (interruptRef.current) interruptRef.current(); }); } catch (_) {}
    }
    // v07r — Flip the loading flag immediately so .app-shell crossfades
    // the moment the user clicks (rather than freezing the old project
    // visible until the fetch resolves a few hundred ms later).
    // 17 Sep 2026 - the header runs the switch as a React transition, so the state below commits
    // only once the new tree has rendered (100-300 ms when leaving Paradise Found). The attribute
    // is written on the shell NOW as well, so the old rows are dimmed and unclickable from the
    // click on (React writes the same value when the state commits).
    if (shellRef.current) shellRef.current.setAttribute("data-project-switching", "true");
    setIsProjectSwitching(true);
    // 17 Sep 2026 - open the project where it was left: its episode and album, and the episode /
    // album list it had last time (checked again when /api/episodes answers). With an episode
    // known the data loads once, at once, and a revisit paints from the cache. A project never
    // seen in this browser starts empty and waits for its list. Never the OLD project's list:
    // its episode label used to show under the new title.
    const known = _readEpisodeList(id);
    const nextEpisodes = known ? known.episodes : [];
    const nextGroups = known ? known.groups : [];
    let nextGroup = _remembered(_GROUP_KEY, id);
    if (known) nextGroup = nextGroups.length ? (nextGroups.some((g) => g.id === nextGroup) ? nextGroup : nextGroups[0].id) : null;
    let nextEpisode = _remembered(_EPISODE_KEY, id);
    if (known) {
      const inGroup = nextGroup ? nextEpisodes.filter((e) => e.master_id === nextGroup) : nextEpisodes;
      if (!inGroup.some((e) => e.id === nextEpisode)) nextEpisode = (inGroup[0] && inGroup[0].id) || null;
    }
    // the new project's episode list and data are asked for NOW, not once the switch has rendered
    if (authToken) { _startEpisodesLoad(id); _startSpecLoad(id, nextEpisode || null); }
    setActiveProject(id);
    window.__activeProjectId = id;   // S5.1 — authFetch stamps X-Active-Project from this
    localStorage.setItem("frameflow-active-project", id);
    if (window.__projects) window.__projects.active = id;
    // 17 Sep 2026 - the new project's skin, colours and photo are painted in the COMMIT that shows
    // its title (the layout effect below, before the browser paints that frame), so no frame
    // shows the new look under the old title or the old look under the new one. The skin goes
    // into state in this same batch, so the commit needs no second render for it.
    if (window.__projects) setProjectTheme(_styleFor(id, nextGroups, nextGroup).base || null);
    setSwitchSeq((n) => n + 1);
    // 15 Sep 2026 — the footer cards keep module-level caches; they listen for this and
    // drop them (Hugo saw Paradise Found's activity on Trøpé).
    try { window.dispatchEvent(new CustomEvent("filmtracker:project-changed", { detail: { id } })); } catch (_) {}
    setActiveEpisodeId(nextEpisode);
    if (nextEpisode) localStorage.setItem("frameflow-active-episode", nextEpisode);
    else localStorage.removeItem("frameflow-active-episode");
    setEpisodes(nextEpisodes);
    window.__episodes = nextEpisodes;
    // a remembered list is shown at once; a project never seen here keeps its subtitle line
    // invisible until its list arrives, so the label appears once, complete
    setListShownFor(known ? id : null);
    // The groups are per-project as well; keeping the old id would hide every
    // container in the new project (none of them carry it).
    setGroups(nextGroups);
    window.__groups = nextGroups;
    setActiveGroupId(nextGroup);
    if (nextGroup) localStorage.setItem("frameflow-active-group", nextGroup);
    else localStorage.removeItem("frameflow-active-group");
  };
  // The commit of a switch: paint the committed project's look (skin + colours + photo, one
  // synchronous step) before the browser paints the frame that shows its title. A switch back to
  // the project already on screen before the first one committed (A -> B -> A) changes nothing,
  // so its dim is lifted once no other switch is on its way.
  React.useLayoutEffect(() => {
    if (!switchSeq) return;
    const before = committedProjectRef.current;
    committedProjectRef.current = activeProject;
    if (before === activeProject) {
      if (requestedProjectRef.current === activeProject) {
        // replies for this project that came back while the other switch was on its way were
        // kept off screen: ask again (a background refresh; the rows on screen stay)
        setEpisodeVersion((v) => v + 1);
        setProjectVersion((v) => v + 1);
        // the rows on screen are this project's: nothing to wait for
        if ((shownDataKeyRef.current || "").indexOf("ft-cache.app." + activeProject + ".") === 0) {
          if (shellRef.current) shellRef.current.setAttribute("data-project-switching", "false");
          setIsProjectSwitching(false);
        }
      }
      return;
    }
    if (!window.__projects) return;   // the style effect paints it once the list is known
    const look = _styleFor(activeProject, groups, activeGroupId);
    // the skin is normally already in state (switchProject put it in the same batch)
    paintProjectStyle(activeProject, look, { noState: (look.base || null) === projectTheme });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [switchSeq]);
  // Every commit: while the project on screen is not the one last asked for (a render of the
  // previous project, queued before the next switch, landing while that switch is still on its
  // way), the content stays dimmed and unclickable. React writes the attribute again on the next
  // change of the flag.
  React.useLayoutEffect(() => {
    if (shellRef.current && requestedProjectRef.current !== activeProject) shellRef.current.setAttribute("data-project-switching", "true");
  });

  // t07 — switch / create episode within the current project.
  const switchEpisode = (id) => {
    setActiveEpisodeId(id);
    localStorage.setItem("frameflow-active-episode", id);
    // 16 Sep 2026 — switchProject has always dropped the module-level caches in
    // FooterRow / GeneratePage; switchEpisode never did, so changing container
    // left the old container's activity and notes on screen. An album switch
    // makes that obvious, so both paths fire the same event now.
    try { window.dispatchEvent(new CustomEvent("filmtracker:project-changed", { detail: { id: window.__activeProjectId, episode: id } })); } catch (_) {}
  };

  // Switch ALBUM (or season / chapter — whatever this project's groups are).
  // Jumping group must also jump to a container inside it, or the header and the
  // shotlist disagree about where you are.
  const switchGroup = (id) => {
    if (id === activeGroupId) return;
    setActiveGroupId(id);
    localStorage.setItem("frameflow-active-group", id);
    const first = (episodes || []).find(e => e.master_id === id);
    setActiveEpisodeId(first ? first.id : null);
    if (first) localStorage.setItem("frameflow-active-episode", first.id);
    else { localStorage.removeItem("frameflow-active-episode"); _remember(_EPISODE_KEY, activeProject, null); }
    try { window.dispatchEvent(new CustomEvent("filmtracker:project-changed", { detail: { id: window.__activeProjectId, group: id } })); } catch (_) {}
  };
  // The containers inside the ACTIVE group. With no groups this is every
  // container, which is what the switcher always showed.
  const _episodesInGroup = React.useMemo(
    () => (activeGroupId ? (episodes || []).filter((e) => e.master_id === activeGroupId) : (episodes || [])),
    [episodes, activeGroupId],
  );

  // Create an ALBUM. This is the same call the agent's create_group tool makes,
  // so the folder is created in the same place by the same code — the UI never
  // grows a second way of making one.
  const createGroup = (titleOrPayload) => {
    const payload = typeof titleOrPayload === "string" ? { title: titleOrPayload } : (titleOrPayload || {});
    const title = String(payload.title || "").trim();
    if (!title) return;
    authFetch("/api/groups", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, description: payload.description || undefined }),
    })
      .then((r) => r.json())
      .then((out) => {
        if (!out || out.ok === false) { console.warn("[createGroup]", out && out.error); return; }
        const g = out.group || out.result;
        if (!g || !g.id) return;
        setGroups((prev) => [...prev, g]);
        window.__groups = [...(window.__groups || []), g];
        switchGroup(g.id);
      })
      .catch((e) => console.warn("[createGroup]", e.message));
  };

  // v01b — accepts either a string (legacy single-arg) or an object with
  // {title, episode_number?, description?}. The EpisodeSwitcher's expanded
  // form passes the object form. Backwards-compatible with any other call
  // site that still passes just a title string.
  const createEpisode = (titleOrPayload) => {
    const base = typeof titleOrPayload === "string"
      ? { title: titleOrPayload }
      : (titleOrPayload || {});
    if (!base.title || !String(base.title).trim()) return;
    // A grouped project refuses a container with no group, so say which album
    // the switcher is currently showing.
    const payload = activeGroupId && !base.group ? { ...base, group: activeGroupId } : base;
    const body = { title: payload.title.trim(), project_id: activeProject };
    if (payload.episode_number != null && Number.isFinite(Number(payload.episode_number))) {
      body.episode_number = Number(payload.episode_number);
    }
    if (payload.description) body.description = String(payload.description);
    // The album this container belongs to. A grouped project refuses one without it.
    if (payload.group) body.group = String(payload.group);
    // The template's container kind. EpisodeSwitcher has always put this in the
    // payload and the server has always read body.kind, but the body was built
    // without it, so it never arrived.
    if (payload.kind) body.kind = String(payload.kind);
    authFetch("/api/episodes", {
      method: "POST",
      body: JSON.stringify(body),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(newEp => {
        setEpisodeVersion(v => v + 1);   // refetch the episode list
        switchEpisode(newEp.id);          // jump straight into the new (empty) episode
      })
      .catch(err => console.warn("[create episode]", err.message));
  };

  // S5.3 — creating a project is a SERVER action now: POST /api/projects writes the
  // control row, opens (and therefore creates) the project's own database and drops
  // a _tracker/project.json in its folder. The old version only pushed an object into
  // window.__projects, so the "project" vanished on the next reload and had nowhere
  // to store anything. An imported shotlist is applied AFTER creation, explicitly
  // headed at the new project so it can't land in Paradise Found.
  const createProject = async (formData) => {
    const newId = formData.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || ("project-" + Date.now());
    const body = {
      id: newId, display_name: formData.name, client: formData.client || "", type: formData.type || "",
      subtitle: `${formData.duration || ""} · ${PRODUCTION_TYPE_LABELS[formData.type] || formData.type}`,
      status: "DEVELOPMENT", aspect_ratio: formData.thumbAspect || "21:9",
      watch_path: formData.watchPath || null, template_id: formData.seed || null,
    };
    const r = await authFetch("/api/projects", { method: "POST", body: JSON.stringify(body) });
    const data = await r.json().catch(() => ({}));
    if (!r.ok) { console.error("[createProject]", data.error || r.status); return { ok: false, error: data.error || String(r.status) }; }
    await reloadProjects();
    if (formData.parsed && (formData.parsed.shots?.length || formData.parsed.assets)) {
      try {
        await authFetch("/api/shotlist/apply", { method: "POST", headers: { "X-Active-Project": data.id || newId }, body: JSON.stringify({ shots: formData.parsed.shots || [], assets: formData.parsed.assets || {}, mode: "merge" }) });
      } catch (e) { console.warn("[createProject] shotlist apply failed", e); }
    }
    switchProject(data.id || newId);
    return { ok: true, id: data.id || newId };
  };

  // 15 Sep 2026 — re-publish after EVERY commit, not once. With `[]` these globals froze the
  // first render's closures, so switchProject's `if (id === activeProject) return` compared against
  // the project the page LOADED with: switching back to it through window.__nav did nothing at all.
  // Assigning a small object per commit is cheap; a stale closure on a global is not.
  React.useEffect(() => {
    window.__nav = { setView, openSequence, openShot, setTheme, setStyle, switchProject, createProject };
    window.__openGridDetail = setGridDetailVersion;   // v07zz418 — open the Grid modal from anywhere
  });

  // 17 Sep 2026 - the rows on screen always belong to the project in the header. While a switch is
  // on its way (the previous project's data is still in `data`), the list shows placeholder rows
  // instead of the previous project's shots (they used to sit dimmed under the new title for up to
  // 2.9 s), and their 239 rows are unmounted in the switch's own commit instead of re-rendered.
  const viewData = _dataIsFor(data, activeProject) ? data : null;
  const dataPending = !!data && !viewData;
  const sequences = (viewData && viewData.sequences) || [];
  const shots = (viewData && viewData.shots) || [];
  const dataLoadError = (viewData && viewData.__loadError) || null;
  // the other pages take everything from `data` (script, schedule, assets...), so their shots come
  // from the same payload: dimmed, the previous project's page stays whole until the new one lands
  const pageSequences = (data && data.sequences) || [];
  const pageShots = (data && data.shots) || [];

  // synthetic sequence padding for modal preview when not in seed
  const getSeq = (num) => {
    const real = sequences.find(s => s.number === num);
    if (real) return real;
    // 15 Sep 2026 — the pilot's sequence slugs are Paradise Found's; another project
    // gets the plain sequence_NN padding (no film-doc names, no fake shot counts).
    const SEQ_LABELS = (window.__isDefaultProject && !window.__isDefaultProject()) ? {} : {
      1: "pastAmerica", 8: "columbus1492_curlews", 11: "archive",
      14: "caribbean1500s_bahamas", 15: "caribbean1500s_deck", 19: "florida1513",
    };
    return num ? { number: num, slug: SEQ_LABELS[num] || `sequence_${String(num).padStart(2,"0")}`, shot_count: (window.__isDefaultProject && !window.__isDefaultProject()) ? 0 : 5 + (num*3 % 9) } : null;
  };
  const activeSequence = openSequenceId ? getSeq(openSequenceId) : null;

  // t08 / t09b — pre-auth gate. While the bootstrap probe is in flight,
  // render nothing (avoids flashing the login form for a frame in
  // BYPASS_AUTH mode). Then either show the login screen or boot the
  // main app depending on whether a token is held.
  if (!authChecked) return null;
  if (!authToken) {
    return <LoginPage onLoggedIn={handleLoggedIn}/>;
  }

  // v07r — While a project switch is in flight (either the explicit
  // setIsProjectSwitching flag from the click, or the React transition
  // for the heavy setData reconcile), tag the shell so CSS can
  // crossfade the content area instead of letting it pop.
  // v866 — THE BLURRY FLICKER. base.css blurs every .main-content child by 1.5px while
  // data-project-switching="true", with a 520ms filter transition. That attribute was wired
  // to isProjectLoading as well — the isPending half of a useTransition that fires on EVERY
  // re-run of the main data effect, i.e. on all 114 window.reloadAppData() calls and on every
  // sync.applied tick. With the 5s sync loop that meant a 1.5px blur pulsing across the whole
  // app every few seconds, which is exactly what Hugo reported. isProjectLoading is a
  // RENDER-COST signal, not a navigation signal; only the explicit flag means he actually
  // switched project, and only that should crossfade.
  const isSwitching = isProjectSwitching;

  // v06p — When "See as" is active, replace the user object the
  // UserContext provider exposes with a synthetic one that wears
  // the impersonated role + its effective permissions. Components
  // reading `userCtx.user.role` / `.permissions` see the persona,
  // not the admin's true identity. The real admin user is still
  // accessible via window.__currentUser if anything needs it.
  const effectiveUser = (currentUser && seeAsRole) ? {
    ...currentUser,
    role: seeAsRole,
    permissions: seeAsPerms || {},
    __seeingAs: true,
    __realRole: currentUser.role,
  } : currentUser;
  const ROLE_LABEL = window.ROLE_LABEL || ((id) => id);

  return (
    <UserContext.Provider value={{ user: effectiveUser, logout: handleLogout }}>
    {/* v07zz22 — Blocking profile-completion modal. Renders when the
        authenticated user's must_complete_profile flag is 1 (set on
        invite). The user can't interact with anything else until
        they pick a new password + name + company + role_title.
        Avatar upload is optional. */}
    {currentUser && currentUser.must_complete_profile && window.CompleteProfileModal && (
      <window.CompleteProfileModal
        user={currentUser}
        onComplete={(updated) => setCurrentUser(updated)}
      />
    )}
    <div className="app-shell" ref={shellRef} data-project-switching={isSwitching ? "true" : "false"}>
      <AppSwitchInterrupt bindRef={interruptRef}/>
      <Sidebar active={view} onNavigate={setView}/>
      <main className="main-content">
        <ProjectHeader
          activeProjectId={activeProject}
          onSwitchProject={switchProject}
          onCreateProject={createProject}
          currentView={view}
          episodes={_episodesInGroup}
          activeEpisodeId={activeEpisodeId}
          onSwitchEpisode={switchEpisode}
          onCreateEpisode={createEpisode}
          groups={groups}
          activeGroupId={activeGroupId}
          onSwitchGroup={switchGroup}
          onCreateGroup={createGroup}
          metaPending={listShownFor !== activeProject}/>

        {view === "overview" && data && (
          <>
            <TopBar/>
            {ovMobile && (
              <div className="ov-mtoggle" role="tablist" aria-label="Overview view">
                <button type="button" role="tab" aria-selected={ovTab === "list"}
                  className={"ov-mtab" + (ovTab === "list" ? " is-active" : "")}
                  onClick={() => setOvTab("list")}>Shotlist</button>
                <button type="button" role="tab" aria-selected={ovTab === "dash"}
                  className={"ov-mtab" + (ovTab === "dash" ? " is-active" : "")}
                  onClick={() => setOvTab("dash")}>Dashboard</button>
              </div>
            )}
            {(!ovMobile || ovTab === "list") && (
              <div className="split-row overview-main">
                <ShotsPanel
                  shots={shots} sequences={sequences}
                  pending={dataPending} loadError={dataLoadError}
                  openShotId={openShotId}
                  onOpenShot={(s) => openShot(s.id)}
                  onOpenSequence={openSequence}
                  onShotStatusChange={updateShotStatus}
                  noteCounts={noteCounts}
                  generatingSet={generatingSet}
                  showAssetsTab={true}/>
                {!ovMobile && <RightColumn/>}
              </div>
            )}
            {ovMobile && ovTab === "dash" && (
              <div className="ov-dashboard">
                <RightColumn/>
                {/* v07zz286 — mount the DESKTOP bottom-left Shot Queue card
                    (SidebarShotQueueCard), not the divergent RightColumn
                    ShotQueueCard. Hugo: mobile shot queue must be "the same,
                    only adapted for mobile" as the desktop sidebar one. */}
                {window.SidebarShotQueueCard && <window.SidebarShotQueueCard/>}
                <FooterRow/>
              </div>
            )}
            {!ovMobile && <FooterRow/>}
          </>
        )}

        {view === "shots" && data && (
          <ShotsPanel
            shots={shots} sequences={sequences}
            pending={dataPending} loadError={dataLoadError}
            openShotId={openShotId}
            onOpenShot={(s) => openShot(s.id)}
            onOpenSequence={openSequence}
            onShotStatusChange={updateShotStatus}
            noteCounts={noteCounts}
            generatingSet={generatingSet}/>
        )}

        {view === "sequences" && data && (
          <SequencesGrid sequences={sequences} shots={shots} onOpenSequence={openSequence}/>
        )}

        {view === "script" && data && <ScriptView script={data.script} shots={pageShots} sequences={pageSequences}/>}
        {view === "schedule" && data && <ScheduleView schedule={data.schedule}/>}
        {view === "characters" && data && <AssetsView assets={data.assets}/>}
        {view === "crew" && data && <CrewView crew={data.crew}/>}
        {/* keyed by project (17 Sep 2026 review): Media loads its list once, and its prompts on demand
            from the ACTIVE project, so a switch starts the page again for the new project */}
        {view === "media" && data && <MediaView key={activeProject} shots={pageShots}/>}
        {/* v07zz281 — Generate is admin-only (generate_assets). Render-level
            guard so it can't be reached by ANY path — not the sidebar (nav
            hidden), not the asset/shot Generate pills (gated), and not the
            Reports "View all models" drill-down links. Without permission we
            show a short notice instead of the full generation UI. */}
        {view === "generate" && data && window.GeneratePage && (
          (!window.hasPerm || window.hasPerm("generate_assets"))
            ? <window.GeneratePage/>
            : <div className="view-page"><div className="vp-head"><div><div className="vp-eyebrow">GENERATE</div><div className="vp-title">Generate</div></div></div><div style={{ padding: "48px 24px", opacity: 0.6, fontSize: "var(--fs-14)" }}>Your role doesn’t have access to the Generate page.</div></div>
        )}
        {view === "archive" && data && <ArchiveView shots={pageShots} sequences={pageSequences}/>}
        {view === "reports" && data && <ReportsView shots={pageShots} schedule={data.schedule}/>}
        {/* t07b — Activity, Audio, Calendar pages removed from sidebar.
            Activity is now a slide-over from the dashboard's Recent
            Activity card. Audio content moved into Assets tabs.
            Calendar Sync button moved into SettingsView. */}
        {view === "digest" && !window.__isUiHidden("notes_digest") && <DigestView/>}
        {view === "review" && window.ReviewPage && <window.ReviewPage/>}
        {/* v07zz557 — the activity digest formerly at the top of Review, now its own page. */}
        {view === "updates" && !window.__isUiHidden("notes_digest") && window.UpdatesPage && <window.UpdatesPage/>}
        {view === "presentations" && window.PresentationsPage && <window.PresentationsPage/>}
        {/* v1015 — a lazy page takes ~0.3-1.3s to fetch + compile on first open. */}
        {lazyPending(view) && (
          <div className="view-page"><div className="vp-head"><div>
            <div className="vp-eyebrow">LOADING</div>
            <div className="vp-title">Opening this page for the first time…</div>
          </div></div></div>
        )}
        {view === "todo" && window.TodoPage && <window.TodoPage/>}
        {view === "notes" && window.NotesPage && <window.NotesPage/>}
        {view === "canvas" && window.CanvasPage && <window.CanvasPage/>}
        {/* P2.4.1 — the Agent: chat → proposed plan → approve. */}
        {view === "agent" && window.AgentPage && <window.AgentPage/>}
        {view === "editplan" && window.EditPlanPage && <window.EditPlanPage/>}
        {view === "documents" && data && <DocumentsView documents={data.documents} roles={data.doc_roles} currentRole={data.current_role}/>}
        {view === "pricing" && <PricingView/>}

        {/* v05b — Admin dashboard (server-side gated by REQ_ADMIN; the
            sidebar entry is also hidden for non-admins). v05a — Help &
            Docs is visible to everyone. */}
        {view === "admin" && window.AdminView && <window.AdminView/>}
        {view === "help"  && window.HelpDocsView && <window.HelpDocsView/>}
        {view === "settings" && <SettingsView theme={appliedTheme} onTheme={setThemeForProject} style={style} onStyle={setStyle} hideMoney={hideMoney} onHideMoney={setHideMoney}/>}
      </main>
      {/* v07zz286 — mobile bottom tab bar (renders only on tier "s"). */}
      {window.MobileTabBar && <window.MobileTabBar view={view} onNavigate={setView}/>}
      {/* v06p — Mounted once at app root so the activity slide-over
          opens from any page (was previously trapped inside the
          Overview's RecentActivityCard). */}
      {window.GlobalActivitySlide && <window.GlobalActivitySlide/>}
      {/* v07ze — Asset modal mounted at the App root so notification
          clicks for character / animal / location / prop / ref
          entities pop the asset modal as an overlay on whatever
          view is open — same UX as the shot modal. */}
      {window.GlobalAssetModal && <window.GlobalAssetModal/>}
      {/* v07zz221 — global video-review modal so To-Do review items open on top */}
      {window.GlobalReviewModal && <window.GlobalReviewModal/>}
      {/* v07w — pop-down toast for incoming notifications. Sits at
          App root so it persists across page navigations. */}
      {window.NotificationToaster && <window.NotificationToaster/>}
      {/* key={openShotId} forces ShotDetailModal to fully unmount + remount
          when the user clicks a different shot. Without this, internal
          state (active version, version filmstrip, image gallery, comment
          drafts) leaks across shot changes — Hugo saw version counts and
          frames from a previous shot for a fraction of a second on every
          click. Remounting is cheap because /api/data is already cached
          at the top of App and the modal reads shot data from that
          memo — no new network round-trip per shot. */}
      {/* v07zz64 — Hugo: stacking bug fix. Arrow nav routes through
          setOpenShotId directly to bypass openShot()'s history push
          (closeShot was popping history and re-opening the previous
          shot — exactly the "stacked modals" illusion). The
          `key={openShotId}` is BACK so the modal remounts cleanly
          on every shot change; the previous attempt to drop it
          triggered a render crash that blanked the UI. */}
      <ShotDetailModal
        key={openShotId || "no-shot"}
        openShotId={openShotId}
        pendingSeq={pendingShotSeq}
        shots={shots} sequences={sequences}
        onClose={closeShotWithRefresh}
        onNavigate={(targetId) => setOpenShotId(targetId)}/>
      <SequenceDetailModal
        sequence={activeSequence}
        shots={shots}
        onClose={closeSequence}
        onOpenShot={openShot}/>
      {/* v07zz418 — globally-mounted Grid modal (opened via window.__openGridDetail, e.g. from
          the Shot Queue's grid rows). gridDetailVersion = { asset_id, version_label } minimal. */}
      {gridDetailVersion && window.GridDetailModal && (
        <window.GridDetailModal
          gridVersion={gridDetailVersion}
          onClose={() => setGridDetailVersion(null)}/>
      )}
      {/* v07zz241/242 — floating AI assistant (draggable, on every page).
          v07zz278 — gated on the `use_assistant` permission (Admin → Project
          actions matrix). Admin-only by default; admins bypass automatically.
          The /api/assistant/chat endpoint is gated on the same key. */}
      {bubbles.chat && window.AssistantChat && currentUser && window.hasPerm && window.hasPerm("use_assistant") && <window.AssistantChat/>}
      {/* 23 Sep 2026 — the to-do and notepad bubbles were removed (see _archive/removed-2026-09-23/). */}
    </div>
    </UserContext.Provider>
  );
}

// v07zz329 — Root error boundary. Before, ANY uncaught render exception in ANY component
// unmounted the WHOLE tree → the app went fully BLANK (only the body background) with no clue
// (Hugo has hit this repeatedly). Now a render crash shows a small recoverable card + a Reload
// button, the rest of the app state is preserved on disk/DB, and the error + componentStack are
// logged to the console so the underlying cause is diagnosable. Data is never touched by a render
// crash — it's purely a display failure.
class AppErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null, stack: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) {
    const stack = (info && info.componentStack) || "";
    try { console.error("[AppErrorBoundary] render crash:", err, stack); } catch (_) {}
    try { this.setState({ stack }); } catch (_) {}
  }
  render() {
    if (!this.state.err) return this.props.children;
    // v07zz351 — surface the ACTUAL error + the component that threw on the card, so a
    // screenshot of this crash pinpoints the cause without opening the console.
    const err = this.state.err;
    const msg = String((err && err.message) || err || "Unknown error");
    const where = String(this.state.stack || "")
      .split("\n").map(s => s.trim()).filter(s => s.indexOf("in ") === 0)
      .slice(0, 3).map(s => s.replace(/\s*\(created by[^)]*\)/g, "")).join(" › ");
    // v07zz352 — the production React build leaves the component stack empty, so also show the
    // JS call stack. Even minified, the app's OWN render functions keep their names (Babel doesn't
    // mangle), so the offending component (e.g. "at ShotsPanel") appears here — enough to pinpoint
    // a #310 / hooks crash from a screenshot. Prefer app frames (named, non-react-dom) first.
    const _stackLines = String((err && err.stack) || "").split("\n").map(s => s.trim()).filter(Boolean);
    const _appFrames = _stackLines.filter(l => /\b[A-Z][A-Za-z0-9]+\b/.test(l) && l.indexOf("react-dom") < 0);
    const trace = (_appFrames.length ? _appFrames : _stackLines).slice(0, 10).join("\n");
    return (
      <div style={{ position: "fixed", inset: 0, display: "grid", placeItems: "center", padding: 24, zIndex: "var(--z-crash)" }}>
        <div style={{ background: "var(--cream)", color: "var(--ink)", border: "1px solid var(--card-border)", borderRadius: "var(--r-card)", padding: "22px 26px", maxWidth: 460, textAlign: "center", boxShadow: "0 14px 44px rgba(0,0,0,0.28)", fontFamily: "Inter, system-ui, sans-serif" }}>
          <div style={{ fontSize: "var(--fs-15)", fontWeight: "var(--fw-bold)", marginBottom: 8 }}>Something hit a display error</div>
          <div style={{ fontSize: "var(--fs-13)", opacity: 0.82, lineHeight: 1.5, marginBottom: 14 }}>The view crashed, but your data is safe — nothing was lost. Reload to continue.</div>
          <div style={{ fontFamily: "var(--font-mono)", fontSize: "var(--fs-11)", opacity: 0.75, lineHeight: 1.45, marginBottom: 16, textAlign: "left", background: "rgba(0,0,0,0.05)", borderRadius: "var(--r-sm)", padding: "8px 10px", maxHeight: 140, overflowY: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
            {msg}{where ? "\n@ " + where : ""}{!where && trace ? "\n\n" + trace : ""}
          </div>
          <button onClick={() => window.location.reload()} style={{ background: "var(--blue-2)", color: "var(--white)", border: "none", borderRadius: "var(--r-sm)", padding: "9px 20px", fontSize: "var(--fs-13)", fontWeight: "var(--fw-semi)", cursor: "pointer" }}>Reload</button>
        </div>
      </div>
    );
  }
}
ReactDOM.createRoot(document.getElementById("app")).render(<AppErrorBoundary><App/></AppErrorBoundary>);
