/* global React, ReactDOM */

const SBIcon = {
  leaf: <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 1 L13.4 10.6 L21 12 L13.4 13.4 L12 23 L10.6 13.4 L3 12 L10.6 10.6 Z"/></svg>,
  overview: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z"/></svg>,
  shots: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="6" width="14" height="12" rx="2"/><path d="M17 10l4-2v8l-4-2"/></svg>,
  sequences: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="6" height="6" rx="1"/><rect x="13" y="4" width="8" height="6" rx="1"/><rect x="3" y="14" width="8" height="6" rx="1"/><rect x="15" y="14" width="6" height="6" rx="1"/></svg>,
  schedule: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>,
  crew: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="9" cy="9" r="3.2"/><path d="M3 19c1-3 3.5-4.5 6-4.5S14 16 15 19"/><circle cx="17" cy="8" r="2.5"/><path d="M16 14c2 .2 4 1.6 5 4"/></svg>,
  media: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M3 17l5-4 4 3 3-2 6 4"/></svg>,
  script: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 3h11l4 4v14H6z"/><path d="M9 9h8M9 13h8M9 17h5"/></svg>,
  characters: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="9" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/></svg>,
  archive: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"/></svg>,
  reports: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16"/><path d="M8 17v-5M12 17v-8M16 17v-3"/></svg>,
  docs: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h4"/></svg>,
  pricing: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>,
  settings: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>,
  // v05b — Admin glyph: a shield with a checkmark. Reads as "trusted
  // access" / "verified privilege" — distinct from the settings cog.
  admin: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l8 3v5c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="m9 12 2 2 4-4"/></svg>,
  help: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.5 2.5 0 1 1 3.7 2.2c-.7.4-1.2 1-1.2 1.8V14"/><circle cx="12" cy="17" r="0.7" fill="currentColor"/></svg>,
  // v06a — Generate glyph: sparkle / wand. Reads as "create" / "AI".
  generate: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M5.5 18.5l2-2M16.5 7.5l2-2"/><circle cx="12" cy="12" r="2.5"/></svg>,
  // v07zz191 — Presentations glyph: a slide deck (screen on a stand).
  presentations: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 8h8M8 11h5"/><path d="M12 16v4M8.5 20h7"/></svg>,
  // v07zz209 — To-Do glyph: a checklist clipboard.
  todo: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 3.5h6v3H9z"/><path d="M8.5 11l1.5 1.5L13 9.5M8.5 16l1.5 1.5L13 14.5"/></svg>,
  // v07zz279 — Notes glyph: a sticky-note page with text lines.
  notes: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 4h14v11l-5 5H5z"/><path d="M14 20v-5h5"/><path d="M9 9h6M9 13h4"/></svg>,
  updates: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l2.5-6.5 5 13L17 12h4"/></svg>,
  // v740 — Canvas glyph: scattered frames on a board (the infinite canvas).
  canvas: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="9" height="7" rx="1.2"/><rect x="14" y="7" width="7" height="9" rx="1.2"/><rect x="5" y="14" width="7" height="6" rx="1.2"/></svg>,
  // 15 Sep 2026 — Agent glyph: the AI sparkle (one big four-point star, two small sparks), the
  // same 24-box, 1.5 stroke and round caps as every other glyph here. Hugo: "icon for the Agent
  // is absolutely disgusting. make it the spark AI type icon and make it the same quality as
  // the other ones." (The bubble-with-a-spark it replaces was P2.4.1.)
  agent: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>,
  // v1011 — Edit Plan glyph: a checklist on a board (the ranked to-do).
  editplan: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 4h6a1 1 0 0 1 1 1v1H8V5a1 1 0 0 1 1-1z"/><rect x="4" y="6" width="16" height="15" rx="2"/><path d="M8.2 12.2l1.7 1.7 3.4-3.6"/><path d="M8.2 17.6h7.6"/></svg>,
};
// v07zz286 — shared with the mobile bottom tab bar (MobileChrome.jsx).
window.SBIcon = SBIcon;

// v06p — Each nav item declares the permission key that gates its
// visibility. Overview / Help / Settings have NO `perm` so they're
// always shown (essentials). The Admin item still uses adminOnly so
// the manage_users matrix cell controls it.
const NAV_ITEMS = [
  { id: "overview",  label: "Overview",     icon: "overview" },
  { id: "shots",     label: "Shots",        icon: "shots",     perm: "nav_shots" },
  { id: "characters",label: "Assets",       icon: "characters",perm: "nav_assets" },
  { id: "script",    label: "Script",       icon: "script",    perm: "nav_script" },
  { id: "schedule",  label: "Schedule",     icon: "schedule",  perm: "nav_schedule" },
  { id: "crew",      label: "Crew",         icon: "crew",      perm: "nav_crew" },
  { id: "media",     label: "Media",        icon: "media",     perm: "nav_media" },
  // v07zz277 — Generate is gated on the ACTION permission `generate_assets` (the
  // "Generate (image/video/VO)" matrix row), not a separate nav_* menu key — so
  // the matrix row Hugo expected to control it actually does. It's admin-only by
  // default, so only Admin sees the Generate menu unless explicitly granted.
  { id: "generate",  label: "Generate",     icon: "generate",  perm: "generate_assets" },
  // P2.4.1 — the Agent: ask for a change, approve the plan it proposes.
  { id: "agent",     label: "Agent",        icon: "agent",     perm: "nav_agent" },
  { id: "canvas",    label: "Canvas",       icon: "canvas",    perm: "nav_canvas" },
  { id: "review",    label: "Review",       icon: "media",     perm: "nav_review" },
  // v1011 — the notes on an edit, ranked 1-3 and given a status. Sits next to
  // Review because that is where the notes are written.
  { id: "editplan",  label: "Edit Plan",    icon: "editplan",  perm: "nav_editplan" },
  // v07zz557 — the daily/weekly activity digest, moved out of the Review page.
  { id: "updates",   label: "Updates",      icon: "updates",   perm: "nav_updates" },
  { id: "documents", label: "Documents",    icon: "docs",      perm: "nav_documents" },
  { id: "reports",   label: "Reports",      icon: "reports",   perm: "nav_reports" },
  { id: "pricing",   label: "Plan",         icon: "pricing",   perm: "nav_plan" },
  { id: "presentations", label: "Presentations", icon: "presentations", perm: "nav_presentations" },
  { id: "todo",      label: "To-Do",        icon: "todo",      perm: "nav_todo" },
  { id: "notes",     label: "Notes",        icon: "notes",     perm: "nav_notes" },
  // 23 Sep 2026 — the Censored Words page was removed (_archive/removed-2026-09-23/).
  { id: "help",      label: "Help & Docs",  icon: "help" },
  { id: "admin",     label: "Admin",        icon: "admin", adminOnly: true },
  { id: "settings",  label: "Settings",     icon: "settings" },
];

// ── v1090 — MENU PRESETS ──────────────────────────────────────────────────────
// Hugo: "I want to have some presets for left side menu visibility, so i only see what is
// important for each stage. i need to be able to edit and save them."
// A preset is a name + the pages it HIDES, so a page added to the app later shows up until you
// hide it. "All pages" is built in and cannot change. Presets and the one in use are saved in
// the user's preferences (users.preferences: nav_presets, nav_preset_active), which sync, so
// they follow the person to Railway; localStorage keeps a copy for the first paint. A preset
// only HIDES: permissions still decide what a person can see at all. Settings is never
// hidden, so the editor (Settings > MENU · PRESETS) is always one click away, and the page you
// are on stays in the menu while you are on it.
// NOTE: users rows sync as a whole (last write wins), so a preset saved on one machine can be
// overwritten if the other machine writes its preferences in the same few seconds.
const NAV_PRESET_LOCKED = ["settings"];
const NAV_PRESET_KEY = "filmtracker.navPresets";
const _navHideAllBut = (show) => NAV_ITEMS.map(it => it.id).filter(id => show.indexOf(id) < 0 && NAV_PRESET_LOCKED.indexOf(id) < 0);
// Starting presets, one per stage of the film. Only a guess at what each stage needs: every one
// can be edited, renamed or deleted in Settings. "All pages" stays in use until you pick one.
const NAV_PRESET_STARTERS = [
  { id: "stage-assets", name: "Assets",         hidden: _navHideAllBut(["overview", "characters", "generate", "canvas", "media", "script", "todo", "notes"]) },
  { id: "stage-frames", name: "Frames",         hidden: _navHideAllBut(["overview", "shots", "characters", "generate", "canvas", "script", "media", "todo", "notes"]) },
  { id: "stage-video",  name: "Video",          hidden: _navHideAllBut(["overview", "shots", "generate", "review", "todo", "notes"]) },
  { id: "stage-edit",   name: "Upscale & edit", hidden: _navHideAllBut(["overview", "shots", "review", "editplan", "updates", "schedule", "todo", "notes"]) },
];
function _navPresetsClean(list) {
  const known = new Set(NAV_ITEMS.map(it => it.id));
  const seen = new Set();
  return (Array.isArray(list) ? list : [])
    .filter(p => p && p.id && String(p.id) !== "all" && !seen.has(String(p.id)) && (seen.add(String(p.id)), true))
    .slice(0, 20)
    .map(p => ({
      id: String(p.id),
      name: String(p.name == null ? "" : p.name).trim().slice(0, 40) || "Preset",
      hidden: (Array.isArray(p.hidden) ? p.hidden : []).map(String)
        .filter((id, i, a) => known.has(id) && NAV_PRESET_LOCKED.indexOf(id) < 0 && a.indexOf(id) === i),
    }));
}
// The presets and the one in use: the user's saved preferences, else this browser's copy,
// else the starting presets with "All pages" in use.
function navPresetsRead() {
  const u = window.__currentUser || null;
  const p = (u && u.preferences) || {};
  let list = null, active = null;
  if (p.nav_presets && Array.isArray(p.nav_presets.list)) { list = p.nav_presets.list; active = p.nav_preset_active; }
  if (!list) {
    try {
      const j = JSON.parse(localStorage.getItem(NAV_PRESET_KEY) || "null");
      if (j && Array.isArray(j.list) && (!u || !j.user || String(j.user) === String(u.id))) { list = j.list; active = j.active; }
    } catch (_) {}
  }
  list = _navPresetsClean(list || NAV_PRESET_STARTERS);
  active = active ? String(active) : "all";
  if (active !== "all" && !list.some(x => x.id === active)) active = "all";
  return { list, active };
}
// Save {list?, active?}: this tab at once (the menu and Settings redraw on the event), then the
// server. Resolves {ok} so Settings can say whether it reached the server.
function navPresetsSave(next) {
  const cur = navPresetsRead();
  const list = _navPresetsClean(next && next.list != null ? next.list : cur.list);
  let active = next && next.active != null ? String(next.active) : cur.active;
  if (active !== "all" && !list.some(x => x.id === active)) active = "all";
  const u = window.__currentUser || null;
  if (u) u.preferences = { ...(u.preferences || {}), nav_presets: { v: 1, list }, nav_preset_active: active };
  try { localStorage.setItem(NAV_PRESET_KEY, JSON.stringify({ user: u ? u.id : null, list, active })); } catch (_) {}
  try { window.dispatchEvent(new CustomEvent("filmtracker:navpresets")); } catch (_) {}
  if (!u) return Promise.resolve({ ok: false, reason: "not signed in" });
  return (window.authFetch || fetch)("/api/users/me/preferences", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    // The server merges only the TOP level, so the whole presets object goes every time.
    body: JSON.stringify({ nav_presets: { v: 1, list }, nav_preset_active: active }),
  }).then(r => ({ ok: r.ok, reason: r.ok ? null : "HTTP " + r.status }))
    .catch(e => ({ ok: false, reason: (e && e.message) || "network" }));
}
// Is this page in the menu under the preset in use? Settings and the page you are on always are.
function navPresetAllows(id, state, currentView) {
  if (NAV_PRESET_LOCKED.indexOf(id) >= 0 || (currentView && id === currentView)) return true;
  const s = state || navPresetsRead();
  if (s.active === "all") return true;
  const p = s.list.find(x => x.id === s.active);
  return !p || p.hidden.indexOf(id) < 0;
}
window.navPresets = {
  read: navPresetsRead, save: navPresetsSave, allows: navPresetAllows,
  LOCKED: NAV_PRESET_LOCKED, STARTERS: NAV_PRESET_STARTERS, items: () => NAV_ITEMS,
};

// v01w — small inline icons used inside the Shot Queue popup header
// stat cards. Stroke colour comes from currentColor so each card can
// tint its own icon via the parent's `color`.
const SQMIcon = {
  bars: (
    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M7 20V10"/>
      <path d="M12 20V4"/>
      <path d="M17 20v-6"/>
    </svg>
  ),
  clock: (
    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="9"/>
      <path d="M12 7v5l3.5 2.5"/>
    </svg>
  ),
  // v1063 — DEPTH JOBS card: three stacked planes, near to far.
  layers: (
    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 3l9 5-9 5-9-5 9-5z"/>
      <path d="M3 13l9 5 9-5"/>
      <path d="M3 17.5l9 5 9-5" opacity="0.55"/>
    </svg>
  ),
};

// Six tiny dots + a hollow ring at the end — the "submitting" loading
// glyph in the reference image. Pure SVG so it renders crisp at any
// DPR and animates via CSS.
function SQMSubmittingDots() {
  return (
    <span className="sqm-dots" aria-hidden="true">
      {[0,1,2,3,4,5].map(i => <span key={i} className="sqm-dots-dot" style={{ ['--di']: i }}/>)}
      <span className="sqm-dots-ring"/>
    </span>
  );
}

// Sidebar "shot queue" mini-panel + the full-page popup that opens
// when it's clicked. Data is sourced from window.__imageQueue and
// window.__videoQueue (populated by QueueBreakdown.jsx). The popup
// uses the shared `.modal-backdrop` class so backdrop blur + dim
// match every other modal in the app.
// v07zw — Error boundary so an unexpected throw in the queue card / modal
// can't unmount the whole React app. Catches the error, logs it, and
// shows a small recovery placeholder instead of an empty page.
class SidebarShotQueueErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { hasError: false, error: null }; }
  static getDerivedStateFromError(error) { return { hasError: true, error }; }
  componentDidCatch(error, info) { console.error("[shot-queue] crashed:", error, info); }
  render() {
    if (this.state.hasError) {
      return (
        <section className="sb-queue glass-dark" style={{ padding: 12, fontSize: "var(--fs-11)" }}>
          <div style={{ color: "var(--red-30)", fontWeight: "var(--fw-bold)" }}>Shot queue crashed</div>
          <div style={{ color: "color-mix(in srgb, var(--ink-on-accent) 70%, transparent)", marginTop: 4 }}>{String(this.state.error && this.state.error.message || this.state.error || "Unknown error")}</div>
          <button
            type="button"
            style={{ marginTop: 8, font: "inherit", fontSize: "var(--fs-11)", padding: "4px 10px", borderRadius: "var(--r-xs)", background: "color-mix(in srgb, var(--panel-cream) 8%, transparent)", border: "1px solid color-mix(in srgb, var(--panel-cream) 20%, transparent)", color: "color-mix(in srgb, var(--ink-on-accent) 85%, transparent)", cursor: "pointer" }}
            onClick={() => this.setState({ hasError: false, error: null })}
          >Retry</button>
        </section>
      );
    }
    return this.props.children;
  }
}

// v07zz530 — resolve a small preview thumbnail for a queue row's shot. Looks the shot up
// in the app's shots list and picks its current image (hero → frame → first pass). Returns
// a width-80 thumb URL, or null if the shot has no rendered image yet.
function _queueThumb(shotId) {
  try {
    const shots = (window.__appData && window.__appData.shots) || [];
    const s = shots.find(x => x && x.id === shotId);
    if (!s) return null;
    let ip = s.image_paths;
    if (typeof ip === "string") { try { ip = JSON.parse(ip); } catch (_) { ip = null; } }
    if (!ip) return null;
    const url = ip.hero || ip.frame || ip.first_pass
      || (Array.isArray(ip.first_pass_variants) && ip.first_pass_variants[0]) || ip.firstPass || null;
    if (!url) return null;
    return window.thumbUrl ? window.thumbUrl(url, 160) : url;
  } catch (_) { return null; }
}

function SidebarShotQueueCard() {
  const [modalOpen, setModalOpen] = React.useState(false);
  // v07zw — Queue data is now live. QueueBreakdown polls /api/generation
  // every 8s, refreshes immediately on SSE pokes, and dispatches a
  // 'paradise-queue-changed' CustomEvent whenever the underlying arrays
  // mutate. We bump a counter on every event to force a re-render so
  // the panel shows the latest rows.
  const [, _bump] = React.useState(0);
  React.useEffect(() => {
    const fn = () => _bump(n => n + 1);
    window.addEventListener("paradise-queue-changed", fn);
    return () => window.removeEventListener("paradise-queue-changed", fn);
  }, []);
  // v07zz16 — Live tick so the "Xs ago" / "X min ago" relative-time
  // labels in the queue rows refresh without waiting for the next
  // poll. 10 s cadence matches the 10s-rounding under the minute
  // mark and the minute-mark threshold above it.
  React.useEffect(() => {
    const id = setInterval(() => _bump(n => n + 1), 10_000);
    return () => clearInterval(id);
  }, []);
  // v07zw — Hugo's revised mental model:
  //   - Completed rows STAY in their IMAGE/VIDEO section with a green
  //     check + 100% bar + completed_at "when" until the user
  //     manually archives them via the red X button.
  //   - Archived rows move to a separate "Archive" section at the
  //     bottom of the modal (still accessible, just out of the way).
  //   - Failed/cancelled rows behave like completed (visible until
  //     archived).
  // Archive state is purely client-side (sessionStorage of queue_id
  // set). When the user archives a row it disappears from the main
  // section; "Show archive" reveals it again until they hit Clear.
  // v1063 — DEPTH jobs get their own section (Hugo: "the Depth video should appear in the
  // video queue, as a Depth section"). They still ride window.__imageQueue for its other
  // readers (TopBar's In Queue count, the Generate drawer's bars) — only this card splits
  // them out, so nothing else changes.
  const _allImage = (window.__imageQueue || []);
  const fullImage = _allImage.filter(r => r.kind !== "depth");
  const fullDepth = _allImage.filter(r => r.kind === "depth");
  const fullVideo = (window.__videoQueue || []);
  const ARCHIVE_KEY = "filmtracker.queueArchive";
  const [archivedIds, setArchivedIds] = React.useState(() => {
    try {
      const raw = sessionStorage.getItem(ARCHIVE_KEY);
      const arr = raw ? JSON.parse(raw) : [];
      return new Set(Array.isArray(arr) ? arr : []);
    } catch (_) { return new Set(); }
  });
  const archiveRow = (queue_id) => {
    setArchivedIds(prev => {
      const next = new Set(prev); next.add(queue_id);
      try { sessionStorage.setItem(ARCHIVE_KEY, JSON.stringify([...next])); } catch (_) {}
      return next;
    });
  };
  const restoreRow = (queue_id) => {
    setArchivedIds(prev => {
      const next = new Set(prev); next.delete(queue_id);
      try { sessionStorage.setItem(ARCHIVE_KEY, JSON.stringify([...next])); } catch (_) {}
      return next;
    });
  };
  // v07zz30 — Cancel a still-running queue row (waiting / submitting /
  // rendering). DELETE /api/generation/:id soft-cancels the row (sets
  // status='cancelled' + bumps updated_at so bisync propagates). Used
  // on Railway where there is no Python worker attached, so WAITING
  // jobs never progress and Hugo previously had no way to clear them
  // from the UI — the actions column was empty unless the row was
  // already in a terminal state.
  //
  // v07zz32 — Hugo: no confirmation popup. The X already lives in a
  // dedicated amber tile, the action is reversible (the cancelled
  // row just becomes archive-able), and Railway's native window.confirm
  // looks ugly. Click is one-shot — fires, refreshes, done.
  const cancelRow = (queue_id) => {
    if (!queue_id) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/generation/${queue_id}`, { method: "DELETE" })
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(() => {
        if (typeof window.__refreshQueue === "function") window.__refreshQueue();
      })
      .catch(err => console.warn("[shot-queue] cancel failed:", err.message));
  };
  // v07zz177 — "Restart Job": re-fire a failed job with its exact original
  // shot + prompt + model + refs (server re-queues from worker_payload).
  const restartRow = (queue_id) => {
    if (!queue_id) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/generation/${queue_id}/retry`, { method: "POST" })
      .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
      .then(() => { if (typeof window.__refreshQueue === "function") window.__refreshQueue(); })
      .catch(err => console.warn("[shot-queue] restart failed:", err.message));
  };
  // v07perf — Hugo: "pending and then I can click on Pending and it
  // transforms to Rendering or Sent, then once the file hits the
  // right place, it updates as Complete." Cycles the manual/external
  // queue row's status. pending → rendering → completed → (no-op).
  // Folder watcher independently flips rendering → completed when the
  // file lands.
  const advanceManualStatus = (queue_id, currentStatus) => {
    if (!queue_id) return;
    const next = currentStatus === "pending"   ? "generating"
               : currentStatus === "rendering" ? "completed"
               : null;
    if (!next) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/generation/${queue_id}/status`, {
      method: "PATCH",
      body: JSON.stringify({ status: next }),
    })
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(() => { if (typeof window.__refreshQueue === "function") window.__refreshQueue(); })
      .catch(err => console.warn("[shot-queue] advance status failed:", err.message));
  };
  // v07perf — Hugo (round 2): "instead of opening the folder from the
  // shot queue, it should copy the folder path, so when I click
  // download on kling or seedance, and it opens the save as folder,
  // I can paste the folder path there." Endpoint now called with
  // mode="copy" so the server returns the path WITHOUT spawning
  // Explorer; client copies to clipboard. Falls back to a tiny inline
  // toast via console + window flag if clipboard access fails.
  const copyOutputFolderPath = (shotId, kind = "video") => {
    if (!shotId) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/shots/${encodeURIComponent(shotId)}/reveal-output-folder`, {
      method: "POST",
      body: JSON.stringify({ kind, mode: "copy" }),
    })
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(d => {
        if (!d || !d.folder) throw new Error("no folder path in response");
        try {
          navigator.clipboard.writeText(d.folder);
          console.log(`[shot-queue] folder path copied: ${d.folder}`);
        } catch (e) {
          console.warn("[shot-queue] clipboard write failed:", e.message);
        }
      })
      .catch(err => console.warn("[shot-queue] get folder failed:", err.message));
  };
  const isArchived = (r) => r && archivedIds.has(r.queue_id);
  const image = fullImage.filter(r => !isArchived(r));
  const video = fullVideo.filter(r => !isArchived(r));
  const archivedImage = fullImage.filter(isArchived);
  const archivedVideo = fullVideo.filter(isArchived);
  const depth = fullDepth.filter(r => !isArchived(r));   // v1063
  const archivedDepth = fullDepth.filter(isArchived);
  const archivedCount = [archivedImage, archivedVideo, archivedDepth].reduce((n, a) => n + a.length, 0);

  // v815 — Hugo: "expand the shot queue small panel to be much taller and show at least
  // the last 10 shots." Was 2 image + 1 video. Terminal rows still count toward this;
  // he wants completed jobs to stay visible in the small card until they're checked.
  // The list scrolls inside its own box past this, so the card gets much taller without
  // ever running past the sidebar. Same number as QueueBreakdown's QUEUE_ROWS — the two
  // queue cards are separate components and BOTH have to change (App.jsx:2194: the
  // mobile queue must be this one, "not the divergent RightColumn ShotQueueCard").
  const SB_QUEUE_ROWS = 10;
  const visImg = image.slice(0, SB_QUEUE_ROWS);
  const visVid = video.slice(0, SB_QUEUE_ROWS);
  const visDep = depth.slice(0, SB_QUEUE_ROWS);   // v1063
  const moreCount = (image.length - visImg.length) + (video.length - visVid.length)
                  + (depth.length - visDep.length) + archivedCount;
  const isTerminalStatus = (s) => s === "completed" || s === "failed" || s === "cancelled";
  const countBy = (arr, k) => arr.filter(r => r.status === k).length;
  const imgCounts = { rendering: countBy(image, "rendering"), waiting: countBy(image, "waiting"), submitting: countBy(image, "submitting") };
  const vidCounts = { rendering: countBy(video, "rendering"), waiting: countBy(video, "waiting"), submitting: countBy(video, "submitting") };
  const depCounts = { rendering: countBy(depth, "rendering"), waiting: countBy(depth, "waiting"), submitting: countBy(depth, "submitting") };   // v1063
  // v07zz183 — Headline counts must reflect ACTIVE jobs only. Hugo deleted a
  // hanging job and the card still read "1 live" because the count was
  // image.length, which includes COMPLETED rows that stay visible in the list.
  // "live" = actively rendering; "queued" = waiting/submitting. Terminal rows
  // still show in the list/archive but never inflate the in-flight numbers.
  const imgActive = imgCounts.rendering + imgCounts.waiting + imgCounts.submitting;
  const vidActive = vidCounts.rendering + vidCounts.waiting + vidCounts.submitting;
  const depActive = depCounts.rendering + depCounts.waiting + depCounts.submitting;   // v1063
  const liveCount = imgCounts.rendering + vidCounts.rendering + depCounts.rendering;
  const queuedCount = imgCounts.waiting + imgCounts.submitting + vidCounts.waiting + vidCounts.submitting
                    + depCounts.waiting + depCounts.submitting;
  const inFlightCount = imgActive + vidActive + depActive;
  // "Clear archived" wipes the archived rows from the DB so they
  // don't reappear next session. Producers can also leave them in
  // sessionStorage — they'll vanish on browser restart anyway.
  const clearArchived = (e) => {
    e && e.stopPropagation();
    const fetcher = window.authFetch || fetch;
    fetcher("/api/generation/cleared", { method: "DELETE" })
      .then(() => {
        setArchivedIds(new Set());
        try { sessionStorage.removeItem(ARCHIVE_KEY); } catch (_) {}
        if (typeof window.__refreshQueue === "function") window.__refreshQueue();
      })
      .catch(err => console.warn("[shot-queue] clear failed:", err.message));
  };
  // v07zz116 — "Clear all" bulk-archives every FINISHED row (completed /
  // failed / cancelled) across both queues in one click, so Hugo can wipe
  // a cluttered queue without X-ing each row. Active jobs (rendering /
  // waiting / submitting) are left alone — they're still working, and the
  // per-row X already cancels those. Same client-side archive mechanism as
  // the single-row button, so it's instant + reversible (rows move to the
  // Archive section; "Clear archived" then wipes them from the DB).
  const terminalRows = [...image, ...video, ...depth].filter(r => isTerminalStatus(r.status));   // v1063 — + depth
  const archiveAllTerminal = (e) => {
    e && e.stopPropagation();
    const ids = terminalRows.map(r => r.queue_id).filter(Boolean);
    if (!ids.length) return;
    setArchivedIds(prev => {
      const next = new Set(prev);
      ids.forEach(id => next.add(id));
      try { sessionStorage.setItem(ARCHIVE_KEY, JSON.stringify([...next])); } catch (_) {}
      return next;
    });
  };
  const [showArchive, setShowArchive] = React.useState(false);
  // Close on Escape — matches the rest of the modals.
  React.useEffect(() => {
    if (!modalOpen) return;
    const onKey = (e) => { if (e.key === "Escape") setModalOpen(false); };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
  }, [modalOpen]);
  const pad2 = (n) => String(n).padStart(2, "0");
  // v07zz174 — Hugo: "we need some sort of logs to see why they failed." The
  // worker stores the Python error in error_message (surfaced as r.error). It's
  // a multi-line traceback; pull out the human-meaningful tail (the API status /
  // RuntimeError message) so the failed row shows e.g. "403 PERMISSION_DENIED"
  // inline instead of a bare "FAILED". Full text is still on hover (title).
  const shortErr = (msg) => {
    if (!msg) return "failed — no error captured";
    let s = String(msg).replace(/\r/g, "");
    const m = s.match(/(?:RuntimeError|Error|Exception|error)\s*:\s*([^\n]+)/i);
    if (m && m[1]) s = m[1];
    s = s.replace(/\s+/g, " ").trim();
    const key = s.match(/PERMISSION_DENIED|RESOURCE_EXHAUSTED|quota|rate.?limit|timed? ?out|\b(4\d\d|5\d\d)\b/i);
    if (key) { const i = s.toLowerCase().indexOf(key[0].toLowerCase()); if (i > 0) s = s.slice(i); }
    return s.length > 72 ? s.slice(0, 72) + "…" : s;
  };
  // v952 — group queue rows by SHOT (Hugo: "group those generations by shots in the
  // shot queue, it's getting too busy, only row per shot, that has as many progress
  // bars as jobs going. rest is unchanged"). Key = display label, so a combined-grid
  // job (SH0010–SH0040) stays its own row and never folds into SH0010's. Group order
  // = first appearance in the newest-first list; the row tint follows the most ACTIVE
  // job (a group with anything still rendering never dims like a completed row).
  // v958 — groups are COLLAPSED by default: one line only — the NEWEST job (Hugo:
  // "collapse all of that to only show one progress bar and being the last one.
  // I can expand each card to see everything"). Click the row (or the chevron)
  // to expand; clicks on the action buttons never toggle.
  const [openGroups, setOpenGroups] = React.useState(() => new Set());
  const toggleGroup = (key) => setOpenGroups(prev => {
    const next = new Set(prev);
    if (next.has(key)) next.delete(key); else next.add(key);
    return next;
  });
  const _groupByShot = (rows) => {
    const PRIO = { rendering: 0, submitting: 1, waiting: 2, pending: 3, failed: 4, cancelled: 5, completed: 6 };
    const map = new Map();
    for (const r of rows) {
      const key = String(r.shotLabel || r.id);
      if (!map.has(key)) map.set(key, { key, id: r.id, shotTitle: r.shotTitle, label: r.shotLabel || r.id, jobs: [], lead: r.status });
      const g = map.get(key);
      g.jobs.push(r);
      if ((PRIO[r.status] ?? 9) < (PRIO[g.lead] ?? 9)) g.lead = r.status;
    }
    return [...map.values()];
  };
  return (
    <section
      className="sb-queue glass-dark sb-queue--clickable"
      onClick={() => setModalOpen(true)}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setModalOpen(true); } }}>
      <div className="sb-queue-head">
        <span className="sb-queue-title">SHOT QUEUE</span>
        <span className="sb-queue-meta">{liveCount} live · {queuedCount} queued</span>
      </div>

      <div className="sb-q-section">
        <div className="sb-q-sub">IMAGE</div>
        {visImg.map(r => (
          <div className={"sb-q-row" + (isTerminalStatus(r.status) ? " sb-q-row--" + r.status : "")} key={r.queue_id || r.id}>
            <div className="sb-q-line1">
              <span className="sb-q-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
              {/* v07zw — Show completed checkmark / failed cross / live
                  ETA in the right slot instead of disappearing the row. */}
              <span className="sb-q-eta">
                {r.status === "completed" ? "✓ done"
                 : r.status === "failed"    ? "✕ failed"
                 : r.status === "cancelled" ? "✕ cancelled"
                 : r.eta}
              </span>
            </div>
            <div className="sb-q-line2">
              <div className="sb-q-track"><div className="sb-q-fill" style={{width: (r.pct || 0) + "%"}}/></div>
              <span className="sb-q-stage">{r.stage}</span>
            </div>
          </div>
        ))}
      </div>

      <div className="sb-q-section">
        <div className="sb-q-sub">VIDEO</div>
        {visVid.map(r => (
          <div className={"sb-q-row" + (isTerminalStatus(r.status) ? " sb-q-row--" + r.status : "")} key={r.queue_id || r.id}>
            <div className="sb-q-line1">
              <span className="sb-q-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
              <span className="sb-q-eta">
                {r.status === "completed" ? "✓ done"
                 : r.status === "failed"    ? "✕ failed"
                 : r.status === "cancelled" ? "✕ cancelled"
                 : r.eta}
              </span>
            </div>
            <div className="sb-q-line2">
              <span className="sb-q-model">{r.model}</span>
              <span className={"sb-q-badge " + (r.status === "waiting" ? "relax" : "standard")}>{(r.status || "").toUpperCase()}</span>
            </div>
          </div>
        ))}
      </div>

      {/* v1063 — depth jobs are listed on their own (they used to sit under IMAGE). Shown
          only while there is one, so an idle card looks exactly as before. */}
      {visDep.length > 0 && (
        <div className="sb-q-section">
          <div className="sb-q-sub">DEPTH</div>
          {visDep.map(r => (
            <div className={"sb-q-row" + (isTerminalStatus(r.status) ? " sb-q-row--" + r.status : "")} key={r.queue_id || r.id}>
              <div className="sb-q-line1">
                <span className="sb-q-id" title={r.depthSource || r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                <span className="sb-q-eta">
                  {r.status === "completed" ? "✓ done"
                   : r.status === "failed"    ? "✕ failed"
                   : r.status === "cancelled" ? "✕ cancelled"
                   : r.eta}
                </span>
              </div>
              <div className="sb-q-line2">
                <div className="sb-q-track"><div className="sb-q-fill" style={{width: (r.pct || 0) + "%"}}/></div>
                <span className="sb-q-stage">{r.depthOf ? (r.depthIsVideo ? "Video " : "Frame ") + r.depthOf : r.stage}</span>
              </div>
            </div>
          ))}
        </div>
      )}

      {moreCount > 0 && (
        <div className="sb-q-hint">+{moreCount} more · click to expand</div>
      )}
      {modalOpen && ReactDOM.createPortal(
        <div className="modal-backdrop" onClick={(e) => { e.stopPropagation(); setModalOpen(false); }}>
          <div className="sqm-card" onClick={(e) => e.stopPropagation()}>
            <button className="sqm-close" onClick={() => setModalOpen(false)} aria-label="Close">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
            </button>

            <header className="sqm-header">
              <div className="sqm-header-left">
                <div className="sqm-eyebrow">SHOT QUEUE</div>
                <h2 className="sqm-title">{inFlightCount} JOBS IN FLIGHT</h2>
                <div className="sqm-sub">{imgActive} image · {vidActive} video · {depActive} depth</div>
              </div>
              <div className="sqm-stats">
                <div className="sqm-stat sqm-stat--image">
                  <span className="sqm-stat-icon">{SQMIcon.bars}</span>
                  <div className="sqm-stat-text">
                    <div className="sqm-stat-num">{imgActive}</div>
                    <div className="sqm-stat-label">IMAGE JOBS</div>
                  </div>
                </div>
                <div className="sqm-stat sqm-stat--video">
                  <span className="sqm-stat-icon">{SQMIcon.clock}</span>
                  <div className="sqm-stat-text">
                    <div className="sqm-stat-num">{vidActive}</div>
                    <div className="sqm-stat-label">VIDEO JOBS</div>
                  </div>
                </div>
                <div className="sqm-stat sqm-stat--depth">
                  <span className="sqm-stat-icon">{SQMIcon.layers}</span>
                  <div className="sqm-stat-text">
                    <div className="sqm-stat-num">{depActive}</div>
                    <div className="sqm-stat-label">DEPTH JOBS</div>
                  </div>
                </div>
              </div>
            </header>

            <section className="sqm-section">
              <div className="sqm-section-head">
                <span className="sqm-section-title">IMAGE QUEUE</span>
                <div className="sqm-chips">
                  {/* v07zz116 — Bulk-archive every finished row at once. */}
                  {terminalRows.length > 0 && (
                    <button type="button" className="sqm-chip sqm-chip--clear-all" onClick={archiveAllTerminal} title="Move all completed / failed / cancelled jobs to the archive">
                      🧹 Clear all ({terminalRows.length})
                    </button>
                  )}
                  {/* v07zz10 — Archive toggle moved to the LEFT of the
                      Rendering / Waiting / Submitting chips so it
                      reads as a UI control first, status second. */}
                  {(archivedCount) > 0 && (
                    <button type="button" className="sqm-chip sqm-chip--archive-toggle" onClick={(e) => { e.stopPropagation(); setShowArchive(v => !v); }}>
                      📦 Archive ({archivedCount})
                    </button>
                  )}
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--rendering"/>{imgCounts.rendering} Rendering</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--waiting"/>{imgCounts.waiting} Waiting</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--submitting"/>{imgCounts.submitting} Submitting</span>
                </div>
              </div>
              <div className="sqm-table sqm-table--image">
                <div className="sqm-cols">
                  {/* v07zz31 — Hugo: kill SEQ, surface USER as its own
                       column (was a tiny sub-line under the shot id).
                       Same column order across image + video queue so
                       the eye doesn't have to retrain between sections. */}
                  <span>SHOT</span><span>THUMB</span><span>USER</span><span>MODEL</span><span>STAGE</span><span>PROGRESS</span><span>STATUS</span><span>ETA</span><span>ACTIONS</span>
                </div>
                {/* v952 — ONE row per shot. v958 — collapsed by default: only the
                    NEWEST job's line shows; click the card to expand the rest. */}
                {_groupByShot(image).map(g => (
                  <div className={"sqm-row sqm-row--group sqm-row--" + g.lead + (g.jobs.length > 1 ? " sqm-row--expandable" : "")}
                    key={"g:" + g.key}
                    onClick={g.jobs.length > 1 ? (e) => { if (!e.target.closest("button, select, a")) toggleGroup(g.key); } : undefined}>
                    <span className="sqm-shot">
                      {g.jobs.length > 1 && <span className="sqm-group-chev">{openGroups.has(g.key) ? "▾" : "▸"}</span>}
                      <span className="sqm-shot-id" title={g.shotTitle || g.id}>{g.label}</span>
                      {g.jobs.length > 1 && <span className="sqm-shot-count">×{g.jobs.length}</span>}
                    </span>
                    {(() => { const _qt = _queueThumb(g.id); return (
                      <span className="sqm-thumb">{_qt
                        ? <img className="sqm-thumb-img" src={_qt} alt="" loading="lazy" />
                        : <span className="sqm-thumb-ph" />}</span>
                    ); })()}
                    <div className="sqm-jobs">
                    {(openGroups.has(g.key) ? g.jobs : g.jobs.slice(0, 1)).map(r => (
                    <div className={"sqm-jobline sqm-jobline--" + r.status} key={r.queue_id || r.id}>
                    <span className="sqm-user" title={r.submitted_by || ""}>
                      {r.submitted_by || "—"}
                    </span>
                    <span className="sqm-model">{r.model}</span>
                    <span className="sqm-stage">{r.status === "submitting" ? "Initializing" : r.stage}</span>
                    {/* PROGRESS column: just the bar (or empty for waiting/etc) */}
                    <span className="sqm-progress">
                      {r.status === "rendering" && (
                        <>
                          <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: (r.pct || 0) + "%"}}/></span>
                          <span className="sqm-pct">{r.pct}%</span>
                        </>
                      )}
                      {r.status === "completed" && (
                        <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: "100%", background: "color-mix(in srgb, var(--ok) 85%, transparent)"}}/></span>
                      )}
                      {r.status === "submitting" && <SQMSubmittingDots/>}
                      {/* v07zz174 — show the failure reason inline (full text on hover). */}
                      {r.status === "failed" && (
                        <span className="sqm-errtext" title={r.error || "Generation failed (no error captured)"}
                          style={{ color: "var(--danger-2)", fontSize: "var(--fs-10-5)", lineHeight: 1.2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                          {shortErr(r.error)}
                        </span>
                      )}
                    </span>
                    {/* STATUS column: the pill that says WAITING/SUBMITTING/COMPLETED/FAILED */}
                    <span className="sqm-status">
                      {r.status === "waiting"    && <span className="sqm-pill sqm-pill--waiting">WAITING</span>}
                      {r.status === "submitting" && <span className="sqm-pill sqm-pill--submitting">SUBMITTING</span>}
                      {r.status === "rendering"  && <span className="sqm-pill sqm-pill--rendering">RENDERING</span>}
                      {r.status === "completed"  && <span className="sqm-pill sqm-pill--completed">✓ COMPLETED</span>}
                      {r.status === "failed"     && (
                        <span className="sqm-pill sqm-pill--failed" title={r.error || ""}>
                          ✕ FAILED{r.retry_count > 0 ? ` (${r.retry_count})` : ""}
                        </span>
                      )}
                      {r.status === "cancelled"  && <span className="sqm-pill sqm-pill--cancelled">CANCELLED</span>}
                    </span>
                    <span className="sqm-eta">{isTerminalStatus(r.status) ? r.when_finished : r.eta}</span>
                    <span className="sqm-action-cell" style={{ display: "inline-flex", gap: 4 }}>
                      {/* v07zz177 — Restart Job: re-fire a failed job with its
                          exact shot + prompt + model + refs. */}
                      {r.status === "failed" && (
                        <button type="button"
                          className="sqm-archive-btn sqm-archive-btn--restart"
                          title={`Restart ${r.id} (re-run with the same prompt)`}
                          aria-label="Restart job"
                          onClick={(e) => { e.stopPropagation(); restartRow(r.queue_id); }}>
                          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
                        </button>
                      )}
                      {/* v07zz40 — Open-shot button. Jumps straight to
                          the shot modal so Hugo can review or iterate
                          without manually scrolling to the row. */}
                      <button type="button"
                        className="sqm-archive-btn sqm-archive-btn--open"
                        title={`Open ${r.id}`}
                        onClick={(e) => {
                          e.stopPropagation();
                          setModalOpen(false);
                          // v07zz418 — for a GRID job, open the GRID modal (the 2×2), not the parent shot.
                          // The queue row has no version_label, so fetch the shot's newest grid + open it.
                          const isGrid = r.mode === "grid" || /grid/i.test(r.stage || "");
                          if (isGrid && window.__openGridDetail) {
                            (window.authFetch || fetch)(`/api/asset-versions?asset_id=${encodeURIComponent(r.id)}&all_versions=1`)
                              .then(res => res.ok ? res.json() : null)
                              .then(d => {
                                const grids = ((d && d.versions) || []).filter(v => v.kind === "grid");
                                grids.sort((a, b) => (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0) || ((b.id || 0) - (a.id || 0)));
                                if (grids[0]) window.__openGridDetail(grids[0]);
                                else { try { window.__nav.openShot(r.id); } catch (_) {} }
                              })
                              .catch(() => { try { window.__nav.openShot(r.id); } catch (_) {} });
                            return;
                          }
                          try { window.__nav && window.__nav.openShot && window.__nav.openShot(r.id); } catch (_) {}
                        }}
                      >
                        <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <path d="M9 6l6 6-6 6"/>
                        </svg>
                      </button>
                      {/* v07zz45 — X always red (base .sqm-archive-btn). */}
                      <button type="button"
                        className="sqm-archive-btn"
                        title={isTerminalStatus(r.status) ? "Move to archive" : "Cancel and remove from queue"}
                        onClick={(e) => {
                          e.stopPropagation();
                          if (isTerminalStatus(r.status)) archiveRow(r.queue_id);
                          else cancelRow(r.queue_id);
                        }}
                      >
                        <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
                      </button>
                    </span>
                    </div>
                    ))}
                    </div>
                  </div>
                ))}
                {image.length === 0 && <div className="sqm-empty">No image jobs in flight.</div>}
              </div>
            </section>

            <section className="sqm-section">
              <div className="sqm-section-head">
                <span className="sqm-section-title">VIDEO QUEUE</span>
                <div className="sqm-chips">
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--rendering"/>{vidCounts.rendering} Rendering</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--waiting"/>{vidCounts.waiting} Waiting</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--submitting"/>{vidCounts.submitting} Submitting</span>
                </div>
              </div>
              {/* v07zz31 — Video queue now uses the same 8-column grid as
                   the image queue. STAGE shows a video-specific label
                   (Encoding / Rendering / Submitted / …) instead of an
                   image stage. SUBMITTED + QUEUE columns are gone — the
                   ETA already carries the time-since-submission for
                   active rows and the when_finished for terminal rows,
                   matching how image queue works. */}
              <div className="sqm-table sqm-table--image">
                <div className="sqm-cols">
                  <span>SHOT</span><span>THUMB</span><span>USER</span><span>MODEL</span><span>STAGE</span><span>PROGRESS</span><span>STATUS</span><span>ETA</span><span>ACTIONS</span>
                </div>
                {/* v952 — grouped like the image queue. v958 — collapsed to the newest
                    job's line; click to expand. */}
                {_groupByShot(video).map(g => (
                  <div className={"sqm-row sqm-row--group sqm-row--" + g.lead + (g.jobs.length > 1 ? " sqm-row--expandable" : "")}
                    key={"g:" + g.key}
                    onClick={g.jobs.length > 1 ? (e) => { if (!e.target.closest("button, select, a")) toggleGroup(g.key); } : undefined}>
                    <span className="sqm-shot">
                      {g.jobs.length > 1 && <span className="sqm-group-chev">{openGroups.has(g.key) ? "▾" : "▸"}</span>}
                      <span className="sqm-shot-id" title={g.shotTitle || g.id}>{g.label}</span>
                      {g.jobs.length > 1 && <span className="sqm-shot-count">×{g.jobs.length}</span>}
                    </span>
                    {(() => { const _qt = _queueThumb(g.id); return (
                      <span className="sqm-thumb">{_qt
                        ? <img className="sqm-thumb-img" src={_qt} alt="" loading="lazy" />
                        : <span className="sqm-thumb-ph" />}</span>
                    ); })()}
                    <div className="sqm-jobs">
                    {(openGroups.has(g.key) ? g.jobs : g.jobs.slice(0, 1)).map(r => {
                  // Video stage label per status. Falls back to whatever
                  // the server sent in r.stage if it isn't one of the
                  // known status states (lets the worker surface custom
                  // labels like "Awaiting frames" without a UI change).
                  const stageText = r.stage
                    || (r.status === "pending"    ? "Awaiting send"
                      : r.status === "submitting" ? "Initializing"
                      : r.status === "waiting"    ? "Queued"
                      : r.status === "rendering"  ? "Encoding"
                      : r.status === "completed"  ? "Done"
                      : r.status === "failed"     ? "Errored"
                      : "—");
                  // v07perf — Pending pill is clickable for video rows;
                  // each click advances status (pending → rendering →
                  // completed). Title tells Hugo what the next state will
                  // be so the affordance is discoverable.
                  const isCycleable = r.is_manual_external && (r.status === "pending" || r.status === "rendering");
                  const nextLabel = r.status === "pending" ? "RENDERING"
                                  : r.status === "rendering" ? "COMPLETE"
                                  : null;
                  return (
                  <div className={"sqm-jobline sqm-jobline--" + r.status} key={r.queue_id || r.id}>
                    <span className="sqm-user" title={r.submitted_by || ""}>
                      {r.submitted_by || "—"}
                    </span>
                    <span className="sqm-model">{r.model}</span>
                    <span className="sqm-stage">{stageText}</span>
                    <span className="sqm-progress">
                      {r.status === "rendering" && (
                        <>
                          <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: (r.pct || 0) + "%"}}/></span>
                          <span className="sqm-pct">{r.pct}%</span>
                        </>
                      )}
                      {r.status === "completed" && (
                        <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: "100%", background: "color-mix(in srgb, var(--ok) 85%, transparent)"}}/></span>
                      )}
                      {r.status === "submitting" && <SQMSubmittingDots/>}
                      {/* v07zz174 — show the failure reason inline (full text on hover). */}
                      {r.status === "failed" && (
                        <span className="sqm-errtext" title={r.error || "Generation failed (no error captured)"}
                          style={{ color: "var(--danger-2)", fontSize: "var(--fs-10-5)", lineHeight: 1.2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                          {shortErr(r.error)}
                        </span>
                      )}
                    </span>
                    <span className="sqm-status">
                      {r.status === "pending" && (
                        <span
                          className="sqm-pill sqm-pill--waiting"
                          onClick={isCycleable ? (e) => { e.stopPropagation(); advanceManualStatus(r.queue_id, r.status); } : undefined}
                          style={isCycleable ? { cursor: "pointer", userSelect: "none", borderStyle: "dashed" } : undefined}
                          title={isCycleable ? `Click to mark as ${nextLabel}` : "Pending — waiting to be sent"}
                        >PENDING</span>
                      )}
                      {r.status === "waiting"    && <span className="sqm-pill sqm-pill--waiting">WAITING</span>}
                      {r.status === "submitting" && <span className="sqm-pill sqm-pill--submitting">SUBMITTING</span>}
                      {r.status === "rendering"  && (
                        <span
                          className="sqm-pill sqm-pill--rendering"
                          onClick={isCycleable ? (e) => { e.stopPropagation(); advanceManualStatus(r.queue_id, r.status); } : undefined}
                          style={isCycleable ? { cursor: "pointer", userSelect: "none" } : undefined}
                          title={isCycleable ? `Click to mark as ${nextLabel}` : "Rendering"}
                        >RENDERING</span>
                      )}
                      {r.status === "completed"  && <span className="sqm-pill sqm-pill--completed">✓ COMPLETED</span>}
                      {r.status === "failed"     && (
                        <span className="sqm-pill sqm-pill--failed" title={r.error || ""}>
                          ✕ FAILED{r.retry_count > 0 ? ` (${r.retry_count})` : ""}
                        </span>
                      )}
                      {r.status === "cancelled"  && <span className="sqm-pill sqm-pill--cancelled">CANCELLED</span>}
                    </span>
                    <span className="sqm-eta">{isTerminalStatus(r.status) ? r.when_finished : r.eta}</span>
                    <span className="sqm-action-cell" style={{ display: "inline-flex", gap: 4 }}>
                      {/* v07zz40 — Open-shot button (same as image queue). */}
                      <button type="button"
                        className="sqm-archive-btn sqm-archive-btn--open"
                        title={`Open ${r.id}`}
                        onClick={(e) => {
                          e.stopPropagation();
                          setModalOpen(false);
                          // v07zz418 — for a GRID job, open the GRID modal (the 2×2), not the parent shot.
                          // The queue row has no version_label, so fetch the shot's newest grid + open it.
                          const isGrid = r.mode === "grid" || /grid/i.test(r.stage || "");
                          if (isGrid && window.__openGridDetail) {
                            (window.authFetch || fetch)(`/api/asset-versions?asset_id=${encodeURIComponent(r.id)}&all_versions=1`)
                              .then(res => res.ok ? res.json() : null)
                              .then(d => {
                                const grids = ((d && d.versions) || []).filter(v => v.kind === "grid");
                                grids.sort((a, b) => (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0) || ((b.id || 0) - (a.id || 0)));
                                if (grids[0]) window.__openGridDetail(grids[0]);
                                else { try { window.__nav.openShot(r.id); } catch (_) {} }
                              })
                              .catch(() => { try { window.__nav.openShot(r.id); } catch (_) {} });
                            return;
                          }
                          try { window.__nav && window.__nav.openShot && window.__nav.openShot(r.id); } catch (_) {}
                        }}
                      >
                        <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <path d="M9 6l6 6-6 6"/>
                        </svg>
                      </button>
                      {/* v07perf — Copy the shot's video output folder path so Hugo can
                          paste it into Kling/Seedance's Save As dialog.
                          v07zz664 — it used to hide on terminal rows ("the folder isn't
                          useful for cancelled/failed"), which was wrong about how this is
                          actually used. Hugo: "when I send one shot, i usually send 4
                          versions at the same time. so i need to be able to click that
                          button again and again so dont remove it when the job is
                          complete." One queue row is one DISPATCH, but he downloads
                          several takes against it, and each download needs the path
                          pasted again. The destination folder exists for the shot
                          regardless of job state, so the button is always valid — show it
                          on every manual row. */}
                      {r.is_manual_external && (
                        <button type="button"
                          className="sqm-archive-btn sqm-archive-btn--copy"
                          title="Copy destination folder path (paste into Kling/Seedance Save As dialog)"
                          onClick={(e) => { e.stopPropagation(); copyOutputFolderPath(r.id, "video"); }}
                        >
                          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                            {/* Clipboard icon — signals "copy path" rather than "open folder". */}
                            <rect x="6" y="3" width="12" height="18" rx="1.5"/>
                            <path d="M9 3v2h6V3"/>
                            <path d="M9 12h6M9 16h4"/>
                          </svg>
                        </button>
                      )}
                      {/* v07zz45 — X is ALWAYS red. Removed the
                          --cancel modifier so the cancel-and-remove
                          button uses the same red base as the archive
                          X. Hugo's rule: red = destructive, amber =
                          secondary action, green = navigate/restore. */}
                      <button type="button"
                        className="sqm-archive-btn"
                        title={isTerminalStatus(r.status) ? "Move to archive" : "Cancel and remove from queue"}
                        onClick={(e) => {
                          e.stopPropagation();
                          if (isTerminalStatus(r.status)) archiveRow(r.queue_id);
                          else cancelRow(r.queue_id);
                        }}
                      >
                        <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
                      </button>
                    </span>
                  </div>
                  );
                })}
                    </div>
                  </div>
                ))}
                {video.length === 0 && <div className="sqm-empty">No video jobs in flight.</div>}
              </div>
            </section>

            {/* v1063 — DEPTH QUEUE (Hugo: "the Depth video should appear in the video queue,
                as a Depth section"). Every depth job — of a video or of a frame — lands here
                instead of in IMAGE QUEUE. The DEPTH OF cell names the source version, the
                same thing the depth file's name now carries. No Restart button: the generic
                retry cannot start a depth job — re-run it from the shot's depth button. */}
            <section className="sqm-section">
              <div className="sqm-section-head">
                <span className="sqm-section-title">DEPTH QUEUE</span>
                <div className="sqm-chips">
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--rendering"/>{depCounts.rendering} Rendering</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--waiting"/>{depCounts.waiting} Waiting</span>
                  <span className="sqm-chip"><span className="sqm-chip-dot sqm-chip-dot--submitting"/>{depCounts.submitting} Submitting</span>
                </div>
              </div>
              <div className="sqm-table sqm-table--image">
                <div className="sqm-cols">
                  <span>SHOT</span><span>THUMB</span><span>USER</span><span>DEPTH OF</span><span>STAGE</span><span>PROGRESS</span><span>STATUS</span><span>ETA</span><span>ACTIONS</span>
                </div>
                {_groupByShot(depth).map(g => {
                  // Own key space: expanding SH0190 here must not also expand SH0190's
                  // image or video group (openGroups is shared by every section).
                  const gk = "depth:" + g.key;
                  return (
                  <div className={"sqm-row sqm-row--group sqm-row--" + g.lead + (g.jobs.length > 1 ? " sqm-row--expandable" : "")}
                    key={"g:" + gk}
                    onClick={g.jobs.length > 1 ? (e) => { if (!e.target.closest("button, select, a")) toggleGroup(gk); } : undefined}>
                    <span className="sqm-shot">
                      {g.jobs.length > 1 && <span className="sqm-group-chev">{openGroups.has(gk) ? "▾" : "▸"}</span>}
                      <span className="sqm-shot-id" title={g.shotTitle || g.id}>{g.label}</span>
                      {g.jobs.length > 1 && <span className="sqm-shot-count">×{g.jobs.length}</span>}
                    </span>
                    {(() => { const _qt = _queueThumb(g.id); return (
                      <span className="sqm-thumb">{_qt
                        ? <img className="sqm-thumb-img" src={_qt} alt="" loading="lazy" />
                        : <span className="sqm-thumb-ph" />}</span>
                    ); })()}
                    <div className="sqm-jobs">
                    {(openGroups.has(gk) ? g.jobs : g.jobs.slice(0, 1)).map(r => (
                    <div className={"sqm-jobline sqm-jobline--" + r.status} key={r.queue_id || r.id}>
                      <span className="sqm-user" title={r.submitted_by || ""}>{r.submitted_by || "—"}</span>
                      <span className="sqm-model" title={r.depthSource || ""}>
                        {r.depthOf ? (r.depthIsVideo ? "Video " : "Frame ") + r.depthOf : r.model}
                      </span>
                      <span className="sqm-stage">{r.status === "submitting" ? "Initializing" : r.stage}</span>
                      <span className="sqm-progress" title={r.progressLabel || ""}>
                        {r.status === "rendering" && (
                          <>
                            <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: (r.pct || 0) + "%"}}/></span>
                            <span className="sqm-pct">{r.pct}%</span>
                          </>
                        )}
                        {r.status === "completed" && (
                          <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: "100%", background: "color-mix(in srgb, var(--ok) 85%, transparent)"}}/></span>
                        )}
                        {r.status === "submitting" && <SQMSubmittingDots/>}
                        {r.status === "failed" && (
                          <span className="sqm-errtext" title={r.error || "Depth map failed (no error captured)"}
                            style={{ color: "var(--danger-2)", fontSize: "var(--fs-10-5)", lineHeight: 1.2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                            {shortErr(r.error)}
                          </span>
                        )}
                      </span>
                      <span className="sqm-status">
                        {r.status === "waiting"    && <span className="sqm-pill sqm-pill--waiting">WAITING</span>}
                        {r.status === "submitting" && <span className="sqm-pill sqm-pill--submitting">SUBMITTING</span>}
                        {r.status === "rendering"  && <span className="sqm-pill sqm-pill--rendering">RENDERING</span>}
                        {r.status === "completed"  && <span className="sqm-pill sqm-pill--completed">✓ COMPLETED</span>}
                        {r.status === "failed"     && <span className="sqm-pill sqm-pill--failed" title={r.error || ""}>✕ FAILED</span>}
                        {r.status === "cancelled"  && <span className="sqm-pill sqm-pill--cancelled">CANCELLED</span>}
                      </span>
                      <span className="sqm-eta">{isTerminalStatus(r.status) ? r.when_finished : r.eta}</span>
                      <span className="sqm-action-cell" style={{ display: "inline-flex", gap: 4 }}>
                        <button type="button"
                          className="sqm-archive-btn sqm-archive-btn--open"
                          title={`Open ${r.id}`}
                          onClick={(e) => {
                            e.stopPropagation();
                            setModalOpen(false);
                            try { window.__nav && window.__nav.openShot && window.__nav.openShot(r.id); } catch (_) {}
                          }}>
                          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
                        </button>
                        <button type="button"
                          className="sqm-archive-btn"
                          title={isTerminalStatus(r.status) ? "Move to archive" : "Cancel and remove from queue"}
                          onClick={(e) => {
                            e.stopPropagation();
                            if (isTerminalStatus(r.status)) archiveRow(r.queue_id);
                            else cancelRow(r.queue_id);
                          }}>
                          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
                        </button>
                      </span>
                    </div>
                    ))}
                    </div>
                  </div>
                  );
                })}
                {depth.length === 0 && <div className="sqm-empty">No depth jobs in flight.</div>}
              </div>
            </section>

            {/* v07zw — Archive section. Rows the user X-ed out of their
                main image/video sections land here. Collapsed by default;
                "Show archive" chip in the section head opens it. */}
            {(archivedCount) > 0 && showArchive && (
              <section className="sqm-section sqm-section--archive">
                <div className="sqm-section-head">
                  <span className="sqm-section-title">ARCHIVE · {archivedCount}</span>
                  <div className="sqm-chips">
                    <button type="button" className="sqm-chip sqm-chip--clear" onClick={clearArchived}>
                      🗑 Permanently clear from DB
                    </button>
                  </div>
                </div>
                <div className="sqm-table sqm-table--image">
                  <div className="sqm-cols">
                    <span>SHOT</span><span>THUMB</span><span>USER</span><span>MODEL</span><span>STAGE</span><span>PROGRESS</span><span>STATUS</span><span>WHEN</span><span>ACTIONS</span>
                  </div>
                  {/* v07zz36 — Hugo: archive order should be latest on top
                      to oldest at the bottom. Previously we just
                      concatenated image-then-video, so a brand-new failed
                      video sat below a week-old completed image. Sort the
                      merged list by sort_ts (completed_at || started_at,
                      added on the row in QueueBreakdown._toQueueRow)
                      descending. Rows missing a timestamp sink to the
                      bottom. */}
                  {[...archivedImage, ...archivedVideo, ...archivedDepth].sort((a, b) => {
                    const at = a && a.sort_ts ? new Date(a.sort_ts).getTime() : 0;
                    const bt = b && b.sort_ts ? new Date(b.sort_ts).getTime() : 0;
                    return bt - at;
                  }).map(r => (
                    <div className="sqm-row sqm-row--archived" key={"arch-" + r.queue_id}>
                      <span className="sqm-shot">
                        <span className="sqm-shot-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                      </span>
                      {(() => { const _qt = _queueThumb(r.id); return (
                      <span className="sqm-thumb">{_qt
                        ? <img className="sqm-thumb-img" src={_qt} alt="" loading="lazy" />
                        : <span className="sqm-thumb-ph" />}</span>
                    ); })()}
                    <span className="sqm-user" title={r.submitted_by || ""}>
                        {r.submitted_by || "—"}
                      </span>
                      <span className="sqm-model">{r.model}</span>
                      <span className="sqm-stage">{r.stage || "—"}</span>
                      <span className="sqm-progress">
                        {r.status === "completed" && (
                          <span className="sqm-bar"><span className="sqm-bar-fill" style={{width: "100%", background: "color-mix(in srgb, var(--ok) 85%, transparent)"}}/></span>
                        )}
                      </span>
                      <span className="sqm-status">
                        <span className={"sqm-pill sqm-pill--" + r.status} title={r.error || ""}>{(r.status || "").toUpperCase()}</span>
                      </span>
                      <span className="sqm-eta">{r.when_finished}</span>
                      <span className="sqm-action-cell">
                        <button type="button"
                          className="sqm-archive-btn sqm-archive-btn--restore"
                          title="Restore to main queue"
                          onClick={(e) => { e.stopPropagation(); restoreRow(r.queue_id); }}
                        >
                          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
                        </button>
                      </span>
                    </div>
                  ))}
                </div>
              </section>
            )}
          </div>
        </div>,
        document.getElementById("modal-root") || document.body
      )}
    </section>
  );
}

// t11 — nav items reviewers cannot reach. Pulled out so the filter is
// trivial to read.
const NAV_HIDDEN_FOR_REVIEWER = new Set(["activity", "settings", "reports"]);

// v07zz279 — Below 1600px of EFFECTIVE width the sidebar auto-collapses to
// the 64px icon rail. v07zz282 (Hugo): when the App scale is active
// (zoom ≠ 100%), the scale IS the space-saving mechanism — the sidebar must
// NOT also auto-collapse. So auto-collapse only fires at 100% scale on a
// genuinely narrow window. The user's manual toggle is a PIN: once they
// click expand/collapse, their choice is stored and the auto behaviour
// stops second-guessing them.
const SB_NARROW_PX = 1600;
const SB_STATE_KEY = "filmtracker.sbState";   // "open" | "collapsed" | absent = auto

// The scale script (index.html) runs before React mounts, so
// __uiScaleEffective is always set by the time this is called.
function sbAutoCollapsed() {
  try {
    const z = window.__uiScaleEffective || 1;
    if (z !== 1) return false;                 // scale active → never auto-collapse
    return window.innerWidth < SB_NARROW_PX;
  } catch (_) { return false; }
}

function Sidebar({ active = "overview", onNavigate = () => {} }) {
  // 23 Sep 2026 — HIDDEN TOOLS: the Updates page (activity digest) leaves the menu while its
  // switch in Settings is off. useUiHidden is defined in App.jsx before anything renders.
  const updatesHidden = window.useUiHidden("notes_digest");
  const [collapsed, setCollapsed] = React.useState(() => {
    try {
      const saved = localStorage.getItem(SB_STATE_KEY);
      if (saved === "open") return false;
      if (saved === "collapsed") return true;
    } catch (_) {}
    return sbAutoCollapsed();
  });
  // Re-evaluate on resize AND on scale changes (the Settings App-scale
  // control dispatches paradise-ui-scale) — only while the user hasn't
  // pinned an explicit choice.
  React.useEffect(() => {
    const apply = () => {
      try { if (localStorage.getItem(SB_STATE_KEY)) return; } catch (_) {}
      setCollapsed(sbAutoCollapsed());
    };
    let t = null;
    const onResize = () => { clearTimeout(t); t = setTimeout(apply, 150); };
    window.addEventListener("resize", onResize);
    window.addEventListener("paradise-ui-scale", apply);
    return () => {
      clearTimeout(t);
      window.removeEventListener("resize", onResize);
      window.removeEventListener("paradise-ui-scale", apply);
    };
  }, []);
  // Manual toggle pins the choice.
  const setCollapsedPinned = React.useCallback((next) => {
    setCollapsed(c => {
      const v = typeof next === "boolean" ? next : !c;
      try { localStorage.setItem(SB_STATE_KEY, v ? "collapsed" : "open"); } catch (_) {}
      return v;
    });
  }, []);
  // v07zz283 — MOBILE (effective tier "s"): the sidebar becomes an
  // off-canvas drawer opened by a fixed hamburger button. Collapse state
  // is ignored on mobile — the drawer always shows full labels.
  const [uiTier, setUiTier] = React.useState(() => {
    try { return document.body.getAttribute("data-ui-tier") || ""; } catch (_) { return ""; }
  });
  React.useEffect(() => {
    const fn = () => { try { setUiTier(document.body.getAttribute("data-ui-tier") || ""); } catch (_) {} };
    window.addEventListener("paradise-ui-scale", fn);
    return () => window.removeEventListener("paradise-ui-scale", fn);
  }, []);
  const isMobile = uiTier === "s";
  const [mobileOpen, setMobileOpen] = React.useState(false);
  React.useLayoutEffect(() => {
    document.body.classList.toggle("sb-mobile-open", isMobile && mobileOpen);
    return () => { document.body.classList.remove("sb-mobile-open"); };
  }, [isMobile, mobileOpen]);
  // Leaving mobile tier always closes the drawer state.
  React.useEffect(() => { if (!isMobile && mobileOpen) setMobileOpen(false); }, [isMobile, mobileOpen]);
  // v07zz286 — the mobile header bar's hamburger + the bottom tab bar's
  // "More" both open the drawer via this event (Sidebar no longer
  // renders its own floating burger).
  React.useEffect(() => {
    const fn = () => setMobileOpen(o => !o);
    window.addEventListener("ff-mobile-nav-toggle", fn);
    return () => window.removeEventListener("ff-mobile-nav-toggle", fn);
  }, []);
  // t11 — read currentUser from the context provided by App.jsx. Falls
  // back to a permissive value if the context isn't installed yet (e.g.
  // first render before App mounts).
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const perms = (userCtx && userCtx.user && userCtx.user.permissions) || {};
  // v05b — Admin entry is admin-only. Apply BEFORE the reviewer trim
  // so reviewers don't accidentally see Admin in their filtered list.
  const isAdmin = role === "admin";

  // v1090 — redraw when the menu preset changes (the switcher, Settings, another tab).
  const [, bumpNavPresets] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const fn = () => bumpNavPresets();
    const onStorage = (e) => { if (!e || e.key === NAV_PRESET_KEY) bumpNavPresets(); };
    window.addEventListener("filmtracker:navpresets", fn);
    window.addEventListener("storage", onStorage);
    return () => { window.removeEventListener("filmtracker:navpresets", fn); window.removeEventListener("storage", onStorage); };
  }, []);
  const navPresetState = navPresetsRead();

  // v07zw — User-defined nav order persisted to localStorage. Drag any
  // item to reorder; new NAV_ITEMS added in future versions are
  // appended at the bottom so the saved order never goes stale.
  const NAV_ORDER_KEY = "filmtracker.navOrder";
  const defaultOrder = React.useMemo(() => NAV_ITEMS.map(it => it.id), []);
  const [navOrder, setNavOrder] = React.useState(() => {
    try {
      const raw = localStorage.getItem(NAV_ORDER_KEY);
      const saved = raw ? JSON.parse(raw) : null;
      if (Array.isArray(saved) && saved.length) {
        const known = new Set(defaultOrder);
        const valid = saved.filter(id => known.has(id));
        for (const id of defaultOrder) if (!valid.includes(id)) valid.push(id);
        return valid;
      }
    } catch (_) {}
    return defaultOrder.slice();
  });
  const persistNavOrder = React.useCallback((next) => {
    setNavOrder(next);
    try { localStorage.setItem(NAV_ORDER_KEY, JSON.stringify(next)); } catch (_) {}
  }, []);
  const [dragId, setDragId] = React.useState(null);
  // The moving selection highlight. Measured from the ACTIVE button's own box, so it
  // lands exactly on it whatever the preset, the drag order or the collapsed width.
  const navListRef = React.useRef(null);
  const [navSlider, setNavSlider] = React.useState(null);
  React.useLayoutEffect(() => {
    const list = navListRef.current;
    if (!list) return;
    // Only write when the numbers actually MOVE. Without this guard the effect sets
    // state on every render, React re-renders, the effect runs again — error #185,
    // "maximum update depth exceeded", which is exactly what shipped for a minute here.
    const measure = () => {
      const el = list.querySelector('.nav-item.active');
      if (!el) { setNavSlider((p) => (p === null ? p : null)); return; }
      const a = el.getBoundingClientRect(), b = list.getBoundingClientRect();
      const next = { top: Math.round(a.top - b.top), height: Math.round(a.height) };
      setNavSlider((p) => (p && p.top === next.top && p.height === next.height ? p : next));
    };
    measure();
    // the rail collapses and the preset filters the list, both of which move it
    const ro = new ResizeObserver(measure);
    ro.observe(list);
    window.addEventListener('resize', measure);
    return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  // visibleNavItems is declared BELOW this hook, so naming it here would read undefined
  // on every render. The ResizeObserver on the list already catches items being added,
  // removed or reordered, because any of those changes the list's height.
  }, [active, collapsed]);
  const [overId, setOverId] = React.useState(null);

  // Apply user-defined order BEFORE filtering by permission. That way
  // hidden items keep their position if/when they become visible.
  const orderedItems = navOrder
    .map(id => NAV_ITEMS.find(it => it.id === id))
    .filter(Boolean);
  // v06p — Permission-based visibility. Items with a `perm` key are
  // filtered against the user's effective permissions. Items
  // without a `perm` (Overview / Help / Settings) are always
  // shown. Admin bypasses everything.
  const visibleNavItems = orderedItems
    .filter(it => !it.adminOnly || isAdmin)
    .filter(it => !(it.id === "updates" && updatesHidden))
    .filter(it => {
      if (isAdmin) return true;
      if (!it.perm) return true;
      return !!perms[it.perm];
    })
    // v1090 — then the MENU PRESET in use. It only hides; Settings and the page you are on stay.
    // 24 Sep 2026 — and NOTHING else. The template's `pages` list (window.__projectPages()) used
    // to hard-filter the menu here, so two music projects lost Script, Edit Plan and
    // Reports while Settings > MENU · PRESETS still showed them ticked. Every project gets every
    // page Paradise Found has; the menu preset in Settings is the one place that hides pages.
    .filter(it => navPresetAllows(it.id, navPresetState, active));
  // Mirror the collapsed state onto <body> so the sidebar gradient extension
  // can react via a simple class selector (faster than :has() and triggers a
  // single repaint instead of the descendant-tree re-evaluation :has() needs).
  // t06d — switched useEffect → useLayoutEffect so the body class change is
  // committed BEFORE the browser paints the React render. The previous
  // useEffect ran post-paint, so the sidebar would start collapsing one
  // frame before the gradient extension on body.sb-collapsed kicked in,
  // giving a visible stutter. Both transitions now start on the same frame.
  // v07zz283 — on mobile the drawer ignores collapse entirely.
  const collapsedFx = isMobile ? false : collapsed;
  React.useLayoutEffect(() => {
    document.body.classList.toggle("sb-collapsed", collapsedFx);
    return () => { document.body.classList.remove("sb-collapsed"); };
  }, [collapsedFx]);
  return (
    <>
    {isMobile && mobileOpen && <div className="sb-mobile-veil" onClick={() => setMobileOpen(false)}/>}
    <aside className={"sidebar" + (collapsedFx ? " is-collapsed" : "")}>
      <div className="sidebar-logo">
        <button
          type="button"
          className="sb-glyph"
          onClick={() => { if (isMobile) setMobileOpen(false); else setCollapsedPinned(); }}
          aria-label={isMobile ? "Close menu" : (collapsed ? "Expand sidebar" : "Collapse sidebar")}
          title={isMobile ? "Close menu" : (collapsed ? "Expand sidebar" : "Collapse sidebar")}
        >
          {SBIcon.leaf}
        </button>
        <div className="sb-wordmark">
          <div className="sb-title">TrueNorths</div>
        </div>
        <button
          className="sb-collapse-btn"
          onClick={() => { if (isMobile) setMobileOpen(false); else setCollapsedPinned(true); }}
          aria-label={isMobile ? "Close menu" : "Collapse sidebar"}
          title={isMobile ? "Close" : "Collapse"}
        >
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M15 6l-6 6 6 6"/>
          </svg>
        </button>
      </div>

      {/* v1093 — the menu-preset dropdown that sat here moved to Settings ▸ MENU · PRESETS
          (Hugo: "this should be in Settings, not here"). The preset in use still filters the list. */}
      <nav className="nav-list" ref={navListRef}>
        {/* 16 Sep 2026 — Hugo: "the animation im talking about is when we click on a
            new one, the Selected button should slide to the new one."
            ONE highlight for the whole menu, moved to wherever the active item is,
            instead of each item painting and un-painting its own. It carries the pill,
            its ring and its sheen, so the entire look travels rather than the colour
            fading in one place and out of another. Hidden until it has been measured,
            so it never flashes at the top-left on first paint. */}
        <span className="nav-slider" aria-hidden="true"
              style={navSlider ? { transform: `translateY(${navSlider.top}px)`, height: navSlider.height } : { opacity: 0 }}/>
        {visibleNavItems.map(item => {
          const isDragging = dragId === item.id;
          const isOver     = overId === item.id && dragId && dragId !== item.id;
          return (
            <div
              key={item.id}
              className={
                "nav-item-wrap"
                + (isDragging ? " is-dragging" : "")
                + (isOver     ? " is-drop-target" : "")
              }
              draggable
              onDragStart={(e) => {
                setDragId(item.id);
                try {
                  e.dataTransfer.effectAllowed = "move";
                  e.dataTransfer.setData("text/plain", item.id);
                } catch (_) {}
              }}
              onDragEnter={(e) => {
                if (!dragId || dragId === item.id) return;
                e.preventDefault();
                setOverId(item.id);
              }}
              onDragOver={(e) => {
                if (!dragId || dragId === item.id) return;
                e.preventDefault();
                e.dataTransfer.dropEffect = "move";
                if (overId !== item.id) setOverId(item.id);
              }}
              onDragLeave={(e) => {
                if (e.currentTarget.contains(e.relatedTarget)) return;
                if (overId === item.id) setOverId(null);
              }}
              onDrop={(e) => {
                if (!dragId || dragId === item.id) return;
                e.preventDefault();
                // Insert dragId at item.id's slot (push item.id down).
                const next = navOrder.filter(x => x !== dragId);
                const targetIdx = next.indexOf(item.id);
                if (targetIdx >= 0) {
                  next.splice(targetIdx, 0, dragId);
                  persistNavOrder(next);
                }
                setDragId(null);
                setOverId(null);
              }}
              onDragEnd={() => { setDragId(null); setOverId(null); }}
            >
              <button
                className={"nav-item" + (active === item.id ? " active" : "")}
                data-nav={item.id}
                onClick={() => { onNavigate(item.id); if (isMobile) setMobileOpen(false); }}
                title={collapsedFx ? item.label : undefined}
              >
                <span className="nav-icon">{SBIcon[item.icon]}</span>
                <span className="nav-label">{item.label}</span>
              </button>
            </div>
          );
        })}
      </nav>

      <div className="sidebar-spacer"/>

      {!collapsedFx && <SidebarBottomZone/>}
    </aside>
    </>
  );
}

function ProjectDayCard() {
  // v01u / v04i — derive Project Day from data. Race-condition-proof:
  // tick a re-render every 500ms until window.__appData arrives, so
  // the panel never reverts to a wrong/zero value after a refresh.
  const [_tick, setTick] = React.useState(0);
  React.useEffect(() => {
    if (window.__appData) return;
    const id = setInterval(() => {
      if (window.__appData) { setTick(t => t + 1); clearInterval(id); }
    }, 300);
    return () => clearInterval(id);
  }, []);
  // Resolution order:
  //   1. data.episode.start_date (per-episode kickoff, when present)
  //   2. data.schedule.project_start (legacy)
  //   3. hardcoded 2026-04-20 (Paradise Found pilot kickoff)
  const data = window.__appData || null;
  const epStart = data && data.episode && data.episode.start_date;
  const schedStart = data && data.schedule && data.schedule.project_start;
  // 15 Sep 2026 — no start date → no number (Hugo saw Paradise Found's "149" on Trøpé). The
  // Paradise Found kickoff is no longer a fallback; its own schedule carries the date.
  // A templated project with no schedule counts from the day it was created (projects.created_at).
  const _createdAt = (() => { try { const r = (window.__isDefaultProject && !window.__isDefaultProject() && window.__activeProjectRow) ? window.__activeProjectRow() : null; return (r && r.created_at) || null; } catch (_) { return null; } })();
  const startIso = epStart || schedStart || _createdAt || null;
  const startMs = startIso ? new Date(startIso).getTime() : NaN;
  const today = new Date();
  let dayNum = "—";
  let dateLine = data ? "No start date yet" : "…";
  if (Number.isFinite(startMs)) {
    dayNum = Math.max(1, Math.floor((today.getTime() - startMs) / 86400000) + 1);
    const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
    const days = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
    dateLine = `${months[today.getMonth()]} ${today.getDate()}, ${today.getFullYear()} · ${days[today.getDay()]}`;
  }
  return (
    <div className="project-day">
      <div className="pd-label">PROJECT DAY</div>
      <div className="pd-num">{dayNum}</div>
      <div className="pd-date">{dateLine}</div>
    </div>
  );
}

// v03c — Pipeline workflow names align with v03a terminology.
// 8 stages from kickoff to delivery.
const PIPELINE_STAGES = [
  "Ready to Start",
  "First Pass",
  "Frame WIP",
  "Frame Hero",
  "Video WIP",
  "Video Hero",
  "4K Upscaling",
  "Delivered",
];

function PipelineStageCard() {
  // v01u / v03c — derive current pipeline stage from shot counts.
  // Pick the most-advanced stage that has at least one shot in it
  // (≥ 1 done — v03c says "at least one"). Show current name bold +
  // "Next: <next>" muted underneath.
  const data = window.__appData;
  // 15 Sep 2026 — the eight film stages are Paradise Found's pipeline. A templated project
  // shows the stages its row declares (`stages` [{id,label}] — a server field the template
  // can feed), else the single neutral "Ready to start".
  const _isPF = !(typeof window.__isDefaultProject === "function") || window.__isDefaultProject();
  const _row = (!_isPF && window.__activeProjectRow) ? window.__activeProjectRow() : null;
  const STAGES = _isPF ? PIPELINE_STAGES
    : ((_row && Array.isArray(_row.stages) && _row.stages.length) ? _row.stages.map(s => (typeof s === "string" ? s : String((s && (s.label || s.id)) || ""))) : ["Ready to start"]);
  const shots = (data && Array.isArray(data.shots)) ? data.shots.filter(s => !s.is_archive) : [];
  const total = shots.length;
  let phaseIdx = 0;
  if (total > 0) {
    const stageDone = (s, k) => s && s.stage_status && s.stage_status[k] === "done";
    const counts = {
      first_pass:   shots.filter(s => stageDone(s, "first_pass")).length,
      refinement:   shots.filter(s => stageDone(s, "refinement")).length,
      hero:         shots.filter(s => stageDone(s, "hero")).length,
      video_prompt: shots.filter(s => stageDone(s, "video_prompt")).length,
      video:        shots.filter(s => stageDone(s, "video")).length,
      upscale:      shots.filter(s => stageDone(s, "upscale")).length,
    };
    // All shots upscaled → Delivered.
    if (counts.upscale >= total)                          phaseIdx = 7;
    else if (counts.upscale >= 1)                         phaseIdx = 6; // 4K Upscaling
    else if (counts.video >= 1)                           phaseIdx = 5; // Video Hero
    else if (counts.video_prompt >= 1)                    phaseIdx = 4; // Video WIP
    else if (counts.hero >= 1)                            phaseIdx = 3; // Frame Hero
    else if (counts.refinement >= 1)                      phaseIdx = 2; // Frame WIP
    else if (counts.first_pass >= 1)                      phaseIdx = 1; // First Pass
    else                                                  phaseIdx = 0; // Ready to Start
  }
  const derivedIdx = Math.min(phaseIdx, STAGES.length - 1);
  // v07zz366 — admins can pin the stage manually (persisted per project). When pinned, that wins
  // over the auto-derived value; "Auto" clears it. Non-admins see the (auto or pinned) value only.
  const [override, setOverride] = React.useState(null);
  const [picking, setPicking] = React.useState(false);
  const isAdmin = (window.__effectiveRole === "admin");
  React.useEffect(() => {
    const f = window.authFetch || fetch;
    const load = () => f("/api/project/pipeline-stage").then(r => r.ok ? r.json() : null)
      .then(d => setOverride(d && d.stage != null ? d.stage : null)).catch(() => {});
    load();
    const h = () => load();
    window.addEventListener("paradise-pipeline-stage-changed", h);
    return () => window.removeEventListener("paradise-pipeline-stage-changed", h);
  }, []);
  const effectiveIdx = (override != null) ? override : derivedIdx;
  const setStage = async (idx) => {
    setOverride(idx); setPicking(false);
    try { await (window.authFetch || fetch)("/api/project/pipeline-stage", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stage: idx }) }); } catch (_) {}
    try { window.dispatchEvent(new CustomEvent("paradise-pipeline-stage-changed")); } catch (_) {}
  };
  const clearStage = async () => {
    setOverride(null); setPicking(false);
    try { await (window.authFetch || fetch)("/api/project/pipeline-stage", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stage: null }) }); } catch (_) {}
  };
  const currentName = STAGES[Math.min(effectiveIdx, STAGES.length - 1)];
  const nextName    = STAGES[effectiveIdx + 1] || null;
  return (
    <div className={"pipeline-stage" + (isAdmin ? " is-settable" : "")}
      /* v07zz372 — gate the click on the role read AT CLICK TIME, not the render-time snapshot of
         window.__effectiveRole (which can still be undefined on the first sidebar paint, leaving
         onClick permanently undefined → "clicking does nothing"). */
      onClick={() => { if (window.__effectiveRole === "admin") setPicking(p => !p); }}
      title={isAdmin ? "Click to set the pipeline stage manually" : undefined}>
      <div className="ps-label">PIPELINE STAGE{override != null && <span className="ps-manual">· manual</span>}</div>
      <div className="ps-text"><strong>{currentName}</strong></div>
      {nextName && <div className="ps-next">Next: {nextName}</div>}
      <div className="ps-dots">
        {STAGES.slice(0, 7).map((_, i) => (
          <span key={i} className={"dot" + (i < effectiveIdx ? " done" : i === effectiveIdx ? " current" : "")}/>
        ))}
      </div>
      {isAdmin && picking && (
        <div className="ps-picker" onClick={(e) => e.stopPropagation()}>
          {STAGES.map((nm, i) => (
            <button key={i} type="button" className={"ps-pick" + (override === i ? " is-on" : "")} onClick={() => setStage(i)}>{nm}</button>
          ))}
          <button type="button" className={"ps-pick ps-pick-auto" + (override == null ? " is-on" : "")} onClick={clearStage}>Auto — from shots ({STAGES[derivedIdx]})</button>
        </div>
      )}
    </div>
  );
}

const SIDEBAR_DEFAULT = ["project-day", "pipeline-stage", "shot-queue"];
const SIDEBAR_PANELS = {
  "project-day":    <ProjectDayCard/>,
  "pipeline-stage": <PipelineStageCard/>,
  "shot-queue":     <SidebarShotQueueErrorBoundary><SidebarShotQueueCard/></SidebarShotQueueErrorBoundary>,
};

function SidebarBottomZone() {
  // re-init on global reset
  const [bump, setBump] = React.useState(0);
  React.useEffect(() => {
    const fn = () => setBump(b => b + 1);
    window.addEventListener("paradise-layout-reset", fn);
    return () => window.removeEventListener("paradise-layout-reset", fn);
  }, []);
  const { order, getProps } = window.useSortableZone("sidebar", SIDEBAR_DEFAULT, { axis: "y" });
  return (
    <div className="sidebar-footer sortable-zone-sidebar" key={bump}>
      {order.map(id => (
        <window.SortablePanel key={id} {...getProps(id)}>
          {SIDEBAR_PANELS[id]}
        </window.SortablePanel>
      ))}
    </div>
  );
}

Object.assign(window, { Sidebar, SidebarShotQueueCard, ProjectDayCard, PipelineStageCard, SidebarBottomZone, NAV_ITEMS, SBIcon });
