/* global React */

// 16 Sep 2026 - the colour a project row carries of its own, or null when it has none.
// App.jsx hands us the string var(--status-accent) when projects.status_color is empty, and
// that is exactly what the pill paints with by default, so treat it as no colour of its own.
// The status tags only PASS this colour on, as --project-accent; header.css paints them through
// tokens, so a skin can recolour them (an inline colour here would beat every skin).
function _phRowAccent(p) {
  const c = p && p.statusColor;
  if (!c || String(c).indexOf("var(--status-accent)") >= 0) return null;
  return c;
}

// v07zz — Re-prettify a notification title client-side. Titles are baked at
// insert time ("New note on ponce"), so older notifications keep raw slugs
// even after the server composer was fixed. The payload carries entity_type
// + entity_id, so we swap the raw slug in the title for the nice label
// ("New note on Character · Ponce de Leon") — works for old + new rows.
function _prettyNotifTitle(rawTitle, payload) {
  let t = String(rawTitle || "");
  const et = payload && payload.entity_type;
  const eid = payload && payload.entity_id;
  if (et && eid != null && typeof window !== "undefined" && typeof window.prettyEntityLabel === "function") {
    const raw = String(eid);
    const nice = window.prettyEntityLabel(et, eid);
    if (nice && nice !== raw && t.indexOf(raw) >= 0) t = t.split(raw).join(nice);
  }
  return t;
}

const HIcon = {
  caret: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"/></svg>,
  search: <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-3.5-3.5"/></svg>,
  bell: <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M6 16V11a6 6 0 1 1 12 0v5l1.5 2H4.5z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>,
  x: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>,
  user: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="9" r="3.5"/><path d="M5 20c1.4-3.4 4-5 7-5s5.6 1.6 7 5"/></svg>,
  cog: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12.5a7.5 7.5 0 0 0-.1-1.4l2-1.5-2-3.4-2.4.9a7.4 7.4 0 0 0-2.5-1.4L13.5 3h-3l-.5 2.7a7.4 7.4 0 0 0-2.5 1.4l-2.4-.9-2 3.4 2 1.5a7.5 7.5 0 0 0 0 2.8l-2 1.5 2 3.4 2.4-.9a7.4 7.4 0 0 0 2.5 1.4l.5 2.7h3l.5-2.7a7.4 7.4 0 0 0 2.5-1.4l2.4.9 2-3.4-2-1.5c.07-.46.1-.93.1-1.4z"/></svg>,
  build: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21h18"/><path d="M5 21V8l5-3 5 3v13"/><path d="M15 21V12l4 2v7"/><path d="M9 12h2M9 16h2"/></svg>,
  help: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/></svg>,
  out: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-4"/><path d="M10 17l-5-5 5-5"/><path d="M5 12h11"/></svg>,
  // v01a — "Plan" menu icon: a small card with a star, fits the existing 1.7-stroke leaf-line aesthetic.
  plan: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="m9 15 1 1 4-4"/></svg>,
  check: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="m5 12 5 5 9-11"/></svg>,
  plus: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>,
  // notification glyphs
  nDone: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>,
  nStar: <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>,
  nLoop: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>,
  nPlay: <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>,
  nNote: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 20h4l11-11-4-4L4 16z"/><path d="M14 6l4 4"/></svg>,
  // v07r — manual refresh button (Hugo asked for one in the top
  // header next to the bell and one on the Overview page header).
  refresh: <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.4"/><path d="M21 12a9 9 0 0 1-15.5 6.4"/><path d="M18 4v4.5h-4.5"/><path d="M6 20v-4.5h4.5"/></svg>,
};

// v07r — RefreshButton. Forces an immediate sync push (so any local
// changes go to Railway in seconds) AND a full data refetch on this
// client. Spins for the duration of the round-trip so the user has a
// visible "yes, I just refreshed" signal. Reusable on the top bar and
// in the Overview header.
function RefreshButton({ className }) {
  const [spinning, setSpinning] = React.useState(false);
  const onClick = React.useCallback(() => {
    if (spinning) return;
    setSpinning(true);
    // 1. Bump every data version so this client refetches /api/data,
    //    /api/notifications, etc.
    try { if (typeof window.reloadAppData === "function") window.reloadAppData(); } catch (_) {}
    // 2. Nudge notifications + reviews via the same channel sync.applied uses.
    try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_added" } })); } catch (_) {}
    try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "review_changed" } })); } catch (_) {}
    // 3. Trigger a server-side sync push so changes round-trip to the peer.
    //    `/api/sync/trigger` is fire-and-forget; if the local server
    //    isn't connected to a peer it just noops.
    const fetcher = window.authFetch || fetch;
    fetcher("/api/sync/trigger", { method: "POST" }).catch(() => {});
    // Reset the spin after a beat so the icon stops twirling.
    setTimeout(() => setSpinning(false), 900);
  }, [spinning]);
  return (
    <button
      className={"ph-iconbtn" + (spinning ? " is-spinning" : "") + (className ? " " + className : "")}
      aria-label="Refresh now"
      title="Refresh now"
      onClick={onClick}
      type="button"
    >
      {HIcon.refresh}
    </button>
  );
}
window.RefreshButton = RefreshButton;

// v07w — NotificationToaster. Lives outside the header DOM (renders
// fixed-positioned via the same modal-root portal pattern) but
// shares the bell's notification fetcher. Whenever a new
// notification arrives (id > the highest id seen on previous
// fetches), pops a toast for ~4.5 s below the bell. Clicking the
// toast routes to the entity using the same logic as the bell rows.
// Auto-dismiss + manual close, multi-stacked if many arrive close
// together.
const NOTIF_ICON_COLOR = {
  new_note:           "var(--archive)",
  note_reply:         "var(--archive)",
  note_resolved:      "var(--leaf)",
  shot_status_change: "var(--amber)",
  new_asset_version:  "var(--teal)",
  asset_created:      "var(--teal)",
  phase_started:      "var(--leaf)",
  new_episode:        "var(--leaf)",
};
const TOAST_DURATION = 4500;

function NotificationToaster() {
  const [toasts, setToasts] = React.useState([]);
  // v07x — Track seen ids in a Set, not a max-int. The old code
  // double-fired the same toast when the 12s poll and the SSE
  // event-driven check both ran inside the same React batch:
  // both read `seenMaxIdRef.current` BEFORE either updated it,
  // so both decided "this id is new". A Set is read-after-write
  // safe — once a toast fires we add the id and subsequent
  // checks find it.
  const seenIdsRef = React.useRef(null);
  const initialMaxIdRef = React.useRef(null);
  // v07zz35 — Per-author rate limit ACROSS check() calls. The in-call
  // 60 s grouping below already collapses N events from the same
  // author into one toast within a single fetch, but successive
  // fetches each produced their own grouped toast — so a tester
  // doing one thing every 10 s still spawned a new card every 10 s.
  // Track the last toast-fired time per author and refuse to issue a
  // new card from the same author within the same 60 s window. The
  // bell badge + dropdown still update on every notification; only
  // the slide-in fanfare card is silenced.
  const lastToastByAuthorRef = React.useRef(new Map());
  const fetcher = window.authFetch || fetch;

  const navigateToast = React.useCallback((t) => {
    const nav = window.__nav || {};
    const p = t.payload || {};
    // v722 — a note notification knows WHICH version it was written on; carry it into
    // openShot so you land on that exact one instead of the shot's latest.
    const _tgt = (window.__noteVersionTarget && window.__noteVersionTarget(p.version_label)) || null;
    const _openShotAtVersion = (id) => nav.openShot(id, _tgt && _tgt.version, _tgt && _tgt.family);
    if (t.link) {
      const m = t.link.match(/\/review\/(\d+)/);
      if (m && typeof nav.setView === "function") {
        window.__pendingReviewId = parseInt(m[1], 10);
        nav.setView("review");
        return;
      }
      const sm = t.link.match(/\/shots\/(\S+)/);
      if (sm && typeof nav.openShot === "function") { _openShotAtVersion(sm[1]); return; }
      // v07zz279 — /notes/<id> deep-link → Notes page, focused on that note.
      const nm = t.link.match(/\/notes\/(\d+)/);
      if (nm && typeof nav.setView === "function" && (!window.hasPerm || window.hasPerm("nav_notes"))) {
        const nid = parseInt(nm[1], 10);
        window.__pendingNoteId = nid;
        try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: nid } })); } catch (_) {}
        nav.setView("notes");
        return;
      }
    }
    const shotId = p.shot_id
      || (p.entity_type === "shot" && p.entity_id)
      || (typeof p.entity_id === "string" && /^SH\d+$/i.test(p.entity_id) && p.entity_id);
    if (shotId && typeof nav.openShot === "function") { _openShotAtVersion(shotId); return; }
    // v07zz279 — project-level note toasts (older rows without the link)
    // land on the Notes page too. Other project events keep their default.
    if (String(p.entity_type || "") === "project"
      && (t.event === "new_note" || t.event === "note_resolved" || t.event === "note_reply")
      && typeof nav.setView === "function"
      && (!window.hasPerm || window.hasPerm("nav_notes"))) {
      if (p.note_id != null) {
        window.__pendingNoteId = p.note_id;
        try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: p.note_id } })); } catch (_) {}
      }
      nav.setView("notes");
      return;
    }
    if (typeof p.entity_id === "number" || /^\d+$/.test(String(p.entity_id || ""))) {
      window.__pendingReviewId = parseInt(p.entity_id, 10);
      if (typeof nav.setView === "function") nav.setView("review");
    }
  }, []);

  const dismissToast = React.useCallback((id) => {
    setToasts(curr => curr.map(t => t.id === id ? { ...t, leaving: true } : t));
    setTimeout(() => setToasts(curr => curr.filter(t => t.id !== id)), 220);
  }, []);

  const check = React.useCallback(() => {
    fetcher("/api/notifications/me?limit=10")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!d || !Array.isArray(d.notifications)) return;
        // First mount: snapshot every current id into the seen set so
        // we don't toast pre-existing notifications. After this, only
        // ids new to the set get a toast.
        if (seenIdsRef.current === null) {
          seenIdsRef.current = new Set(d.notifications.map(n => n.id));
          initialMaxIdRef.current = d.notifications.length ? Math.max(...d.notifications.map(n => n.id)) : 0;
          return;
        }
        // Only consider rows that are (a) unread AND (b) not in the
        // seen set AND (c) id > the initial max (so historic unread
        // rows that became visible later don't pop unprompted).
        const fresh = d.notifications.filter(n =>
          !n.read_at
          && !seenIdsRef.current.has(n.id)
          && n.id > initialMaxIdRef.current
        );
        // Mark them ALL as seen immediately so a parallel check
        // (the SSE-triggered re-fetch racing the 12s poll) can't
        // also toast them.
        for (const n of fresh) seenIdsRef.current.add(n.id);
        if (!fresh.length) return;
        // v07zz16 — Skip notifications authored by the CURRENT USER.
        // Hugo doesn't want a toast every time he himself bumps a
        // shot status — only when someone else does.
        const me = (window.__currentUser && (window.__currentUser.name || window.__currentUser.email)) || "";
        const meId = (window.__currentUser && window.__currentUser.id) || null;
        const newToasts = fresh.map(n => {
          let payload = {};
          try { payload = n.payload ? JSON.parse(n.payload) : {}; } catch (_) {}
          return {
            id: n.id,
            event: n.event_type,
            title: _prettyNotifTitle(n.title, payload),
            sub: n.body || "",
            author: (payload && (payload.author || payload.actor_name)) || "",
            authorId: (payload && (payload.actor_id || payload.author_id)) || null,
            link: n.link || null,
            payload,
            leaving: false,
            ts: n.created_at,
          };
        }).filter(t => {
          // Skip if the actor IS the current user.
          if (me && t.author && String(t.author).toLowerCase() === String(me).toLowerCase()) return false;
          if (meId && t.authorId && String(t.authorId) === String(meId)) return false;
          return true;
        });

        // v07zz16 — Group by author within a 60s window. Multiple
        // events from the same person in quick succession collapse
        // into one toast with a "did 3 things" subtitle so Hugo
        // isn't spammed when Mike batch-approves five shots in a row.
        const GROUP_WINDOW_MS = 60_000;
        const grouped = [];
        const toMs = (iso) => {
          const s = String(iso || "").includes("T") ? iso : (iso || "").replace(" ", "T") + "Z";
          const t = new Date(s).getTime();
          return Number.isFinite(t) ? t : 0;
        };
        // newToasts is newest-first (server orders DESC). Walk and
        // bucket: if author + window match an existing group, merge;
        // else start a new group.
        for (const t of newToasts) {
          const tms = toMs(t.ts);
          const slot = grouped.find(g =>
            (g.author || "").toLowerCase() === (t.author || "").toLowerCase()
            && Math.abs(tms - toMs(g.ts)) <= GROUP_WINDOW_MS
          );
          if (slot) {
            slot.count++;
            slot.events.add(t.event);
            // Keep the most recent timestamp (smallest age).
            if (tms > toMs(slot.ts)) slot.ts = t.ts;
          } else {
            grouped.push({ ...t, count: 1, events: new Set([t.event]) });
          }
        }
        // Decorate grouped toasts so the user sees "Mike · 3 actions"
        // instead of N separate cards.
        const finalToasts = grouped.slice(0, 3).map(g => {
          if (g.count > 1) {
            const evs = Array.from(g.events);
            const summary = evs.length === 1
              ? `${g.count} × ${labelForEvent(evs[0])}`
              : `${g.count} actions`;
            return { ...g, title: `${g.author || "Someone"} · ${summary}`, sub: "" };
          }
          return g;
        });

        // v07zz14 — Respect the "Notification fanfare" toggle in
        // Settings. When off, skip the slide-in toast cards entirely
        // (the bell dot + dropdown still update so notifications
        // aren't lost — only the attention-grabbing card is muted).
        const fanfare = (function () { try { return localStorage.getItem("filmtracker.notif-fanfare") !== "0"; } catch (_) { return true; } })();
        if (!fanfare || finalToasts.length === 0) return;

        // v07zz35 — Per-author cross-poll rate limit. Skip a toast if
        // we already showed one from the same author within
        // GROUP_WINDOW_MS. The dropdown + unread count still update —
        // we're just muting the slide-in fanfare card for the duration
        // of the window.
        const tNow = Date.now();
        const rateLimited = finalToasts.filter(t => {
          const authorKey = (t.author || "").toLowerCase();
          if (!authorKey) return true;
          const last = lastToastByAuthorRef.current.get(authorKey) || 0;
          if (tNow - last < GROUP_WINDOW_MS) return false;
          lastToastByAuthorRef.current.set(authorKey, tNow);
          return true;
        });
        if (rateLimited.length === 0) return;
        setToasts(curr => {
          const haveIds = new Set(curr.map(t => t.id));
          const add = rateLimited.filter(t => !haveIds.has(t.id));
          if (!add.length) return curr;
          return [...add, ...curr].slice(0, 4);
        });
        for (const t of rateLimited) {
          setTimeout(() => dismissToast(t.id), TOAST_DURATION);
        }
      })
      .catch(() => {});
  }, [fetcher, dismissToast]);

  React.useEffect(() => {
    check();
    const id = setInterval(check, 12000);
    const onSse = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (!msg) return;
        if (msg.type === "note_added" || msg.type === "shot_status_change" || msg.type === "new_asset_version") {
          check();
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", onSse);
    return () => { clearInterval(id); window.removeEventListener("paradise-sse", onSse); };
  }, [check]);

  if (!toasts.length) return null;
  return ReactDOM.createPortal((
    <div className="ph-notif-toast-wrap">
      {toasts.map(t => {
        const color = NOTIF_ICON_COLOR[t.event] || "var(--archive)";
        const icon = (ICON_BY_EVENT_TOAST[t.event] || HIcon.nNote);
        return (
          <div
            key={t.id}
            className={"ph-notif-toast" + (t.leaving ? " is-leaving" : "")}
            onClick={() => { navigateToast(t); dismissToast(t.id); }}
            role="button"
          >
            <span className="ph-notif-toast-icon" style={{
              color, borderColor: `color-mix(in oklab, ${color} 60%, transparent)`,
              background: `color-mix(in oklab, ${color} 18%, transparent)`,
            }}>{icon}</span>
            <div className="ph-notif-toast-text">
              <div className="ph-notif-toast-title">{t.title}</div>
              {t.sub && <div className="ph-notif-toast-sub">{String(t.sub).slice(0, 110)}</div>}
              {t.author && <div className="ph-notif-toast-author">— {t.author}</div>}
            </div>
            <button
              type="button"
              className="ph-notif-toast-close"
              onClick={(e) => { e.stopPropagation(); dismissToast(t.id); }}
              aria-label="Dismiss"
            >×</button>
          </div>
        );
      })}
    </div>
  ), document.getElementById("modal-root") || document.body);
}
// v07zz16 — Human-friendly label for grouped notification toasts.
function labelForEvent(ev) {
  switch (ev) {
    case "new_note":           return "notes";
    case "note_reply":         return "replies";
    case "note_resolved":      return "notes resolved";
    case "shot_status_change": return "status updates";
    case "phase_started":      return "phase changes";
    case "new_asset_version":  return "new versions";
    case "asset_created":      return "assets created";
    case "new_episode":        return "episodes added";
    default:                   return "events";
  }
}
const ICON_BY_EVENT_TOAST = {
  new_note:           HIcon.nNote,
  note_reply:         HIcon.nNote,
  note_resolved:      HIcon.nDone,
  shot_status_change: HIcon.nLoop,
  new_asset_version:  HIcon.nPlay,
  asset_created:      HIcon.nStar,
  phase_started:      HIcon.nStar,
  new_episode:        HIcon.nStar,
};
window.NotificationToaster = NotificationToaster;

// v06p — Short relative time formatter for notification rows.
// SQLite timestamps come back as "YYYY-MM-DD HH:MM:SS" UTC, so
// we coerce to ISO before parsing.
function fmtRelativeTime(iso) {
  if (!iso) return "";
  const isoFixed = String(iso).includes("T") ? iso : iso.replace(" ", "T") + "Z";
  const t = new Date(isoFixed).getTime();
  if (!Number.isFinite(t)) return "";
  const diff = (Date.now() - t) / 1000;
  if (diff < 60)    return "just now";
  if (diff < 3600)  return `${Math.floor(diff / 60)} min ago`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
  if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
  return new Date(isoFixed).toLocaleDateString();
}

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

function getProjects() {
  return (window.__projects && window.__projects.projects) || [];
}

// 17 Sep 2026 - project logos, cached for the whole page (Hugo: "its pretty slow and buggy switching
// project"). A switch used to fetch the logo again, show the project's name as TEXT until it came
// back and then swap in the <img>, so the title changed twice and its entrance animation ran twice.
// Now each project's logo is fetched once (when the project list loads and when the picker opens),
// decoded before it is used, and kept per project id. A blob is freed only when that project gets a
// new logo_v. The logo's width/height ratio is remembered too, so the invisible stand-in that holds
// the title's place until the image is ready has the logo's exact size.
const _phLogoCache = new Map();   // project id -> { v, url, img, ready, failed, busy, aspect }
const _phLogoSubs = new Set();    // re-render callbacks of the mounted switchers
const _PH_LOGO_ASPECT_KEY = "ph-logo-aspect";   // localStorage { "<id>|<logo_v>": width / height }
function _phReadLogoAspects() {
  try { return JSON.parse(localStorage.getItem(_PH_LOGO_ASPECT_KEY) || "{}") || {}; } catch (_) { return {}; }
}
function _phSaveLogoAspect(id, v, aspect) {
  try {
    const all = _phReadLogoAspects();
    Object.keys(all).forEach((k) => { if (k.slice(0, k.lastIndexOf("|")) === id) delete all[k]; });
    all[id + "|" + v] = aspect;
    localStorage.setItem(_PH_LOGO_ASPECT_KEY, JSON.stringify(all));
  } catch (_) {}
}
// 17 Sep 2026 - a small logo is also kept across page loads (as a data URL, per project, for its
// logo_v), so after a reload or a login the title shows the logo at once instead of an invisible
// stand-in while it downloads (~225 ms). A logo too big for that is cached for the page only.
const _PH_LOGO_DATA_KEY = "ph-logo-data:";   // localStorage "<key><id>" -> { v, url }
const _PH_LOGO_DATA_MAX = 200000;            // characters of data URL
function _phSaveLogoData(id, v, blob) {
  if (!blob || typeof FileReader === "undefined" || Math.ceil(blob.size / 3) * 4 + 64 > _PH_LOGO_DATA_MAX) return;
  try {
    const fr = new FileReader();
    fr.onload = () => {
      try {
        if (typeof fr.result === "string" && fr.result.indexOf("data:image/") === 0) localStorage.setItem(_PH_LOGO_DATA_KEY + id, JSON.stringify({ v, url: fr.result }));
      } catch (_) {}
    };
    fr.readAsDataURL(blob);
  } catch (_) {}
}
// the kept logo for the project's CURRENT logo_v, put in the page cache (ready) - or null
function _phSeedLogo(p) {
  if (!_phHasLogo(p)) return null;
  const v = p.logo_v || 0;
  const prev = _phLogoCache.get(p.id);
  if (prev && prev.v === v) return prev;
  let saved = null;
  try { saved = JSON.parse(localStorage.getItem(_PH_LOGO_DATA_KEY + p.id) || "null"); } catch (_) {}
  if (!saved || saved.v !== v || typeof saved.url !== "string" || saved.url.indexOf("data:image/") !== 0) return null;
  if (prev && prev.url) { try { URL.revokeObjectURL(prev.url); } catch (_) {} }
  const img = new Image();
  img.decoding = "async";
  img.src = saved.url;
  const entry = { v, url: saved.url, img, ready: true, failed: false, busy: false, aspect: _phReadLogoAspects()[p.id + "|" + v] || null };
  _phLogoCache.set(p.id, entry);
  return entry;
}
function _phLogoNotify() { _phLogoSubs.forEach((fn) => { try { fn(); } catch (_) {} }); }
function _phHasLogo(p) { return !!(p && p.logo && p.id && p.id !== "loading"); }
// the cache entry for the project's CURRENT logo version, or null
function _phLogoEntry(p) {
  if (!_phHasLogo(p)) return null;
  const e = _phLogoCache.get(p.id);
  return e && e.v === (p.logo_v || 0) ? e : null;
}
function _phEnsureLogo(p) {
  if (!_phHasLogo(p)) return;
  _phSeedLogo(p);   // kept from an earlier page load: nothing to fetch
  const id = p.id, v = p.logo_v || 0;
  const prev = _phLogoCache.get(id);
  if (prev && prev.v === v && (prev.ready || prev.busy)) return;
  // a failed fetch retries in place (the title keeps showing the name meanwhile); a new logo_v gets a
  // new entry, and the old version's blob is freed once the new one has settled
  const old = prev && prev.v !== v ? prev : null;
  let entry = prev && prev.v === v ? prev : null;
  if (!entry) {
    entry = { v, url: null, img: null, ready: false, failed: false, busy: false, aspect: _phReadLogoAspects()[id + "|" + v] || null };
    _phLogoCache.set(id, entry);
  }
  entry.busy = true;
  const current = () => _phLogoCache.get(id) === entry;
  const freeOld = () => { if (old && old.url) { try { URL.revokeObjectURL(old.url); } catch (_) {} old.url = null; } };
  let url = null, blob = null;
  const fetcher = window.authFetch || fetch;
  // the server serves a logo only to a request made AS that project (X-Active-Project), and
  // authFetch leaves a header that is already set alone
  fetcher("/api/projects/" + encodeURIComponent(id) + "/logo?v=" + v, { headers: { "X-Active-Project": id } })
    .then((r) => (r.ok ? r.blob() : null))
    .then((b) => {
      if (!b) throw new Error("no logo");
      blob = b;
      url = URL.createObjectURL(b);
      if (!current()) throw new Error("superseded");
      const img = new Image();
      img.src = url;
      const decoded = img.decode ? img.decode() : new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
      return decoded.then(() => img);
    })
    .then((img) => {
      if (!current()) throw new Error("superseded");
      entry.url = url; entry.img = img; entry.ready = true; entry.failed = false; entry.busy = false;
      if (img.naturalWidth > 0 && img.naturalHeight > 0) {
        entry.aspect = img.naturalWidth / img.naturalHeight;
        _phSaveLogoAspect(id, v, entry.aspect);
      }
      _phSaveLogoData(id, v, blob);
      freeOld();
      _phLogoNotify();
    })
    .catch(() => {
      if (url && entry.url !== url) { try { URL.revokeObjectURL(url); } catch (_) {} }
      if (!current()) return;
      entry.busy = false; entry.failed = !entry.ready;
      freeOld();
      _phLogoNotify();
    });
}
function _phEnsureAllLogos() { getProjects().forEach(_phEnsureLogo); }
// a transparent image with the logo's proportions: an <img> with a fixed height takes the logo's width
function _phLogoStandIn(aspect) {
  const w = aspect > 0 ? Math.max(1, Math.round(aspect * 1000)) : 1000;
  return "data:image/svg+xml," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + ' 1000"/>');
}

function ProjectSwitcher({
  open, onToggle, onClose, activeId, onSelect, onNewProject, onImportShotlist,
  // t06c — episode switcher now lives inside the project subtitle line.
  // The episode dropdown gets its own openMenu key managed by the parent
  // ProjectHeader; ProjectSwitcher renders <EpisodeSwitcher/> compact-mode
  // inside its .ph-meta so the subtitle reads "Project Type · Episode ▾".
  episodeOpen = false, onEpisodeToggle = () => {}, onEpisodeClose = () => {},
  episodes = [], activeEpisodeId = null,
  onSwitchEpisode = () => {}, onCreateEpisode = () => {},
  // 16 Sep 2026 — CONTAINER GROUPS. An empty list means this project has no
  // group level, and nothing extra renders — which is every project but Trope.
  groupOpen = false, onGroupToggle = () => {}, onGroupClose = () => {},
  groups = [], activeGroupId = null,
  onSwitchGroup = () => {}, onCreateGroup = () => {},
  currentView = "overview",
  metaPending = false,
}) {
  const ref = React.useRef(null);
  useClickOutside(ref, onClose, open);

  const projects = getProjects();
  const active = projects.find(p => p.id === activeId) || projects[0] || {
    id: "loading", name: "LOADING…", subtitle: "", status: "—", statusColor: "var(--ink-soft)"
  };
  const others = projects.filter(p => p.id !== active.id);
  // 17 Sep 2026 — picking a project switches it AND closes the picker in the same render, so the
  // cards would swap (the picked one gone, the old one back) while they fade out. A closed picker
  // keeps showing the cards it had when it was last open; an open one (or one never opened yet)
  // shows the live list.
  const listedRef = React.useRef(null);
  if (open) listedRef.current = others;
  const listed = open || !listedRef.current ? others : listedRef.current;

  // 15 Sep 2026 — the project's wordmark instead of its name (Hugo: "put the trope logo at the
  // top instead of the TROPE text"). Fetched through the API as a blob (the JWT cannot ride on
  // an <img src>); the <img> takes the h1's exact line box, so nothing below moves.
  // 17 Sep 2026 — the logos come from the page-wide cache above (_phLogoCache): a cached logo shows
  // at once, a logo that is not decoded yet keeps an invisible stand-in of its size (no text flash),
  // and the switcher re-renders when a logo becomes ready.
  const [, bumpLogos] = React.useReducer((n) => n + 1, 0);
  React.useEffect(() => {
    _phLogoSubs.add(bumpLogos);
    return () => { _phLogoSubs.delete(bumpLogos); };
  }, []);
  // every project's logo is fetched as soon as the project list is known (or changes)
  const logoSig = projects.map((p) => p.id + (p.logo ? ":" + (p.logo_v || 0) : "")).join(",");
  React.useEffect(() => { _phEnsureAllLogos(); }, [logoSig]);
  React.useEffect(() => { _phEnsureLogo(active); }, [active && active.id, active && active.logo, active && active.logo_v]);
  // a logo kept from an earlier page load is used in this very render
  const logoEntry = _phLogoEntry(active) || _phSeedLogo(active);
  const logoReady = !!(logoEntry && logoEntry.ready);
  const showLogo = _phHasLogo(active) && !(logoEntry && logoEntry.failed && !logoReady);
  const standInAspect = logoEntry ? logoEntry.aspect : _phReadLogoAspects()[active.id + "|" + (active.logo_v || 0)];
  // a logo never seen in this browser has no known size yet: the caret next to it waits too, so it
  // does not jump sideways when the logo arrives
  const sizing = showLogo && !logoReady && !standInAspect;
  // opening the picker re-checks every logo and tells the page which projects are listed, so their
  // photos can be loaded before one is picked
  React.useEffect(() => {
    if (!open) return;
    _phEnsureAllLogos();
    try { window.dispatchEvent(new CustomEvent("filmtracker:picker-open", { detail: { ids: others.map((p) => p.id) } })); } catch (_) {}
  }, [open]);

  // 17 Sep 2026 — the projects are ONE sideways strip of cards (Hugo: "im gonna have so many
  // projects, it will be massive"). A mouse wheel scrolls up/down, so the strip turns a vertical
  // wheel into a sideways scroll while it has somewhere to go. React's onWheel is passive and
  // cannot stop the page scrolling, hence the native listener.
  const stripRef = React.useRef(null);
  React.useEffect(() => {
    const el = stripRef.current;
    if (!el) return undefined;
    const onWheel = (e) => {
      if (e.ctrlKey || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
      const max = el.scrollWidth - el.clientWidth;
      if (max <= 0) return;
      const next = Math.max(0, Math.min(max, el.scrollLeft + e.deltaY));
      if (next === el.scrollLeft) return;
      e.preventDefault();
      el.scrollLeft = next;
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, []);

  // 17 Sep 2026 — the picker closes FIRST, then the switch runs as a transition (the new project's
  // render no longer blocks the close). It used to wait two painted frames as well; measured, that
  // made no frame smoother and only showed the new title 15-40 ms later (the switch render is cheap
  // now: the previous project's rows are dropped, not re-rendered), so it runs at once. A closing
  // picker takes no clicks, so a double-click still switches once.
  const handlePick = (id) => {
    onClose();
    const run = () => { if (onSelect) onSelect(id); };
    if (typeof React.startTransition === "function") React.startTransition(run);
    else setTimeout(run, 0);
  };

  return (
    <div className={"ph-project" + (open ? " is-open" : "")} ref={ref}>
      <div className="ph-eyebrow">Project</div>
      <div
        className={"ph-title-row" + (sizing ? " is-sizing" : "")}
        role="button"
        tabIndex={0}
        aria-expanded={open}
        aria-label={open ? "Close project switcher" : "Open project switcher"}
        onClick={onToggle}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } }}
      >
        {/* keyed by the project only, so the entrance animation runs once per switch; the stand-in
            is the same <img>, invisible and not animating until the logo is ready */}
        {showLogo
          ? <img
              className={"ph-title ph-title--logo" + (logoReady ? "" : " is-pending")}
              key={active.id}
              src={logoReady ? logoEntry.url : _phLogoStandIn(standInAspect)}
              alt={active.name}
            />
          : <h1 className="ph-title" key={active.id}>{active.name}</h1>}
        <span className={"ph-caret" + (open ? " is-open" : "")} aria-hidden="true">{HIcon.caret}</span>
      </div>
      {/* 17 Sep 2026 - invisible (same height) until this project's episode list is known, so the
          line appears once, complete, instead of growing its episode label 16-1100 ms later */}
      <div className={"ph-meta" + (metaPending ? " is-pending" : "")} key={"meta-" + active.id}>
        <span className="ph-subtitle">{active.subtitle}</span>
        {/* t06c — episode dropdown integrated into the subtitle line.
            Compact variant: text + small chevron, no surrounding pill.
            v01b — when no episodes have loaded yet, EpisodeSwitcher
            renders nothing; hide the leading separator dot too so the
            subtitle doesn't end with a dangling "·". */}
        {/* The ALBUM pill sits left of the video pill, so the line reads
            "Music · Visuals · Trøpé ▾ · Cold Champagne ▾". It reuses
            EpisodeSwitcher rather than adding a second component: a group and a
            container present identically (a title, an order, a create form), and
            one component means one set of classes, one click-outside and one
            keyboard behaviour to keep right. */}
        {(groups && groups.length > 0) && (
          <>
            <span className="ph-meta-sep" aria-hidden="true">·</span>
            <EpisodeSwitcher
              compact
              open={groupOpen}
              onToggle={onGroupToggle}
              onClose={onGroupClose}
              episodes={groups}
              activeId={activeGroupId}
              onSelect={onSwitchGroup}
              onCreate={onCreateGroup}
              noun={(active.group && active.group.label) || "Album"}
              nounPlural={(active.group && active.group.label_plural) || "Albums"}
              showTitle
            />
          </>
        )}
        {(episodes && episodes.length > 0) && (
          <span className="ph-meta-sep" aria-hidden="true">·</span>
        )}
        <EpisodeSwitcher
          compact
          open={episodeOpen}
          onToggle={onEpisodeToggle}
          onClose={onEpisodeClose}
          episodes={episodes}
          activeId={activeEpisodeId}
          onSelect={onSwitchEpisode}
          onCreate={onCreateEpisode}
          noun={(active.container && active.container.label) || "Episode"}
          nounPlural={(active.container && active.container.label_plural) || "Episodes"}
          kind={(active.container && active.container.kind) || null}
          /* Inside an album the container's NAME is what identifies it; the
             number means nothing. A project with no groups keeps the numbered
             label it has always had. */
          showTitle={groups && groups.length > 0}
        />
        <span className="ph-status-pill" style={_phRowAccent(active) ? { "--project-accent": _phRowAccent(active) } : undefined}>
          <span className="ph-pill-dot"/>
          {active.status}
        </span>
        {/* v07zz175 — online pill sits right after the status pill. Fades in on
            every page EXCEPT Overview (where the TopBar already shows it), and
            slides out avatars on hover like the TopBar one. */}
        <HeaderOnlinePill visible={currentView !== "overview"}/>
        {/* v07w — second refresh button removed per Hugo's request.
            The top-bar RefreshButton (between bell + avatar) is
            enough. */}
      </div>

      {/* 17 Sep 2026 — SWITCH TO + one row of fixed-size cards that scrolls sideways, so the
          panel is the same height for 2 projects or 50 (header.css .ph-project-strip). */}
      <div className="ph-project-others" aria-hidden={!open}>
        <div className="ph-project-others-inner">
          <div className="ph-project-others-head">SWITCH TO</div>
          <div className="ph-project-strip" ref={stripRef} role="group" aria-label="Switch to project">
            {listed.map(p => (
              <button key={p.id} className="ph-project-other" onClick={() => handlePick(p.id)}>
                <div className="ph-project-other-text">
                  <div className="ph-project-other-name">{p.name}</div>
                  <div className="ph-project-other-sub">{p.subtitle}</div>
                </div>
                <span className="ph-project-other-status" style={_phRowAccent(p) ? { "--project-accent": _phRowAccent(p) } : undefined}>
                  <span className="ph-pill-dot"/>
                  <span className="ph-project-other-status-text">{p.status}</span>
                </span>
              </button>
            ))}
            <button className="ph-project-other ph-project-new" onClick={(e) => { e.stopPropagation(); onClose(); onNewProject && onNewProject(); }}>
              <span className="ph-menu-ico">{HIcon.plus}</span>
              <span className="ph-project-new-label">New project</span>
            </button>
            {/* 23 Sep 2026 — HIDDEN TOOLS: null while Settings ▸ HIDDEN TOOLS ▸ Import shotlist is off. */}
            {onImportShotlist && (
              <button className="ph-project-other ph-project-new" onClick={(e) => { e.stopPropagation(); onClose(); onImportShotlist(); }}>
                <span className="ph-menu-ico">📋</span>
                <span className="ph-project-new-label">Import shotlist into current project</span>
              </button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

function SearchPill({ open, onToggle, onClose }) {
  const ref = React.useRef(null);
  const inputRef = React.useRef(null);
  const [q, setQ] = React.useState("");
  useClickOutside(ref, () => { onClose(); setQ(""); }, open);
  // Wait for the width transition (~220ms) so the input doesn't focus before it's visible.
  React.useEffect(() => {
    if (open && inputRef.current) {
      const t = setTimeout(() => inputRef.current && inputRef.current.focus(), 240);
      return () => clearTimeout(t);
    }
  }, [open]);

  const data = window.__appData || {};
  const shots = data.shots || [];
  const sequences = data.sequences || [];
  const assets = data.assets || {};
  const crew = data.crew || [];
  const ql = q.trim().toLowerCase();

  const shotMatches = ql ? shots.filter(s =>
    s.id.toLowerCase().includes(ql) ||
    (s.frame_title || "").toLowerCase().includes(ql) ||
    (s.landscape || "").toLowerCase().includes(ql) ||
    (s.species || "").toLowerCase().includes(ql) ||
    (s.action || "").toLowerCase().includes(ql)
  ).slice(0, 8) : [];
  const seqMatches = ql ? sequences.filter(s =>
    s.slug.toLowerCase().includes(ql) ||
    String(s.number).padStart(2,"0").includes(ql) ||
    `seq ${s.number}`.includes(ql) ||
    `sequence ${s.number}`.includes(ql)
  ).slice(0, 4) : [];
  // v04l — extend search to assets and crew (client-side; no new
  // endpoint needed since /api/data already returns these).
  // 15 Sep 2026 — a templated project searches ITS categories (type = the category id, so the
  // result opens the right tab); Paradise Found keeps the four film-doc lists.
  const assetMatches = ql ? ((!window.__isDefaultProject || window.__isDefaultProject()) ? [
    ...(assets.characters || []).map(a => ({ ...a, type: "character" })),
    ...(assets.animals    || []).map(a => ({ ...a, type: "animal" })),
    ...(assets.locations  || []).map(a => ({ ...a, type: "location" })),
    ...(assets.props      || []).map(a => ({ ...a, type: "prop" })),
  ] : (window.__projectCategories ? window.__projectCategories() : []).flatMap(c => (assets[c.id] || []).map(a => ({ ...a, type: c.id })))).filter(a =>
    (a.name || "").toLowerCase().includes(ql) ||
    (a.slug || "").toLowerCase().includes(ql)
  ).slice(0, 5) : [];
  const crewMatches = ql ? crew.filter(c =>
    (c.name || "").toLowerCase().includes(ql) ||
    (c.role || "").toLowerCase().includes(ql) ||
    (c.email || "").toLowerCase().includes(ql)
  ).slice(0, 4) : [];

  const handleShot = (id) => { window.__nav && window.__nav.openShot(id); onClose(); setQ(""); };
  const handleSeq = (n)  => { window.__nav && window.__nav.openSequence(n); onClose(); setQ(""); };
  // v1091 — the Assets page's view id is "characters" ("assets" opened a blank page).
  const handleAsset = () => { onClose(); setQ(""); window.__nav && window.__nav.setView && window.__nav.setView("characters"); };
  const handleCrew  = () => { onClose(); setQ(""); window.__nav && window.__nav.setView && window.__nav.setView("crew"); };

  const focusInput = () => { if (inputRef.current) inputRef.current.focus(); };
  const handleIconClick = (e) => {
    if (open) { e.stopPropagation(); focusInput(); }
    else onToggle();
  };
  const handlePillClick = () => {
    if (open) focusInput();
  };

  return (
    <div className={"ph-search-anchor" + (open ? " is-open" : "")} ref={ref}>
      <div className={"ph-search-pill" + (open ? " is-open" : "")} onClick={handlePillClick}>
        {open && (
          <input
            ref={inputRef}
            className="ph-search-input-inline"
            placeholder="Search shots, sequences, locations…"
            value={q}
            onChange={(e) => setQ(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); onClose(); } }}
          />
        )}
        <button className="ph-search-iconbtn" onClick={handleIconClick} aria-label="Search">
          {HIcon.search}
        </button>
      </div>
      {open && ql && (
        <div className="ph-search-results-pop">
          {shotMatches.length === 0 && seqMatches.length === 0 && assetMatches.length === 0 && crewMatches.length === 0 && (
            <div className="ph-search-empty">No matches for &ldquo;{q}&rdquo;</div>
          )}
          {shotMatches.length > 0 && (
            <>
              <div className="ph-menu-eyebrow">SHOTS</div>
              {shotMatches.map(s => (
                <button key={s.id} className="ph-menu-row" onClick={() => handleShot(s.id)}>
                  <div className="ph-menu-row-text">
                    <div className="ph-menu-row-title">
                      <span className="ph-result-id">{s.id}</span> · {s.frame_title}
                    </div>
                    <div className="ph-menu-row-sub">Seq {String(s.seq).padStart(2,"0")} · {s.landscape}</div>
                  </div>
                </button>
              ))}
            </>
          )}
          {seqMatches.length > 0 && (
            <>
              <div className="ph-menu-sep"/>
              <div className="ph-menu-eyebrow">SEQUENCES</div>
              {seqMatches.map(s => (
                <button key={s.number} className="ph-menu-row" onClick={() => handleSeq(s.number)}>
                  <div className="ph-menu-row-text">
                    <div className="ph-menu-row-title">
                      <span className="ph-result-id">SEQ {String(s.number).padStart(2,"0")}</span> · {s.slug}
                    </div>
                    <div className="ph-menu-row-sub">{s.shot_count} shots</div>
                  </div>
                </button>
              ))}
            </>
          )}
          {/* v04l — assets and crew sections. */}
          {assetMatches.length > 0 && (
            <>
              <div className="ph-menu-sep"/>
              <div className="ph-menu-eyebrow">ASSETS</div>
              {assetMatches.map((a, i) => (
                <button key={`a-${a.type}-${a.slug || a.id || i}`} className="ph-menu-row" onClick={handleAsset}>
                  <div className="ph-menu-row-text">
                    <div className="ph-menu-row-title">
                      <span className="ph-result-id">{a.type.toUpperCase()}</span> · {a.name || a.slug}
                    </div>
                    <div className="ph-menu-row-sub">{a.role || a.species_latin || a.type}</div>
                  </div>
                </button>
              ))}
            </>
          )}
          {crewMatches.length > 0 && (
            <>
              <div className="ph-menu-sep"/>
              <div className="ph-menu-eyebrow">CREW</div>
              {crewMatches.map(c => (
                <button key={c.id || c.email} className="ph-menu-row" onClick={handleCrew}>
                  <div className="ph-menu-row-text">
                    <div className="ph-menu-row-title">
                      <span className="ph-result-id">{c.role || "Crew"}</span> · {c.name}
                    </div>
                    <div className="ph-menu-row-sub">{c.email}</div>
                  </div>
                </button>
              ))}
            </>
          )}
        </div>
      )}
    </div>
  );
}

// v07zz283 — ScalePill: always-visible App-scale control in the header.
// Shows the CURRENT effective scale as a mono percentage; clicking opens
// a small cream popover with Auto + the fixed increments (same set as
// Settings → App scale — both call window.__applyUiScale, the single
// entry point in index.html, so they can never disagree).
function ScalePill({ open, onToggle, onClose }) {
  const OPTIONS = [
    ["auto", "Auto"], ["0.7", "70%"], ["0.8", "80%"], ["0.85", "85%"],
    ["0.9", "90%"], ["1", "100%"], ["1.1", "110%"],
  ];
  const ref = React.useRef(null);
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const fn = () => force();
    window.addEventListener("paradise-ui-scale", fn);
    return () => window.removeEventListener("paradise-ui-scale", fn);
  }, []);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open, onClose]);
  const mode = window.__uiScaleMode || "auto";
  const eff = Math.round((window.__uiScaleEffective || 1) * 100);
  const autoPct = Math.round((window.__uiScaleAuto || 1) * 100);
  return (
    <div className="ph-anchor ph-scale-anchor" ref={ref}>
      <button
        className={"ph-iconbtn ph-scale-btn" + (open ? " is-open" : "")}
        aria-label="App scale"
        title={"App scale — " + (mode === "auto" ? `Auto (${eff}%)` : `${eff}%`)}
        onClick={onToggle}
      >
        <span className="ph-scale-val">{eff}<i>%</i></span>
      </button>
      {open && (
        <div className="ph-scale-pop" role="menu">
          <div className="ph-scale-pop-head">APP SCALE</div>
          {OPTIONS.map(([id, label]) => {
            const active = mode === id;
            return (
              <button
                key={id}
                type="button"
                role="menuitemradio"
                aria-checked={active}
                className={"ph-scale-opt" + (active ? " is-active" : "")}
                onClick={() => { if (window.__applyUiScale) window.__applyUiScale(id); }}
              >
                <span className="ph-scale-opt-label">
                  {label}
                  {id === "auto" && <em className="ph-scale-opt-hint"> · fits to {autoPct}%</em>}
                </span>
                <span className="ph-scale-tick" aria-hidden="true">{active ? "✓" : ""}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// 17 Sep 2026 — StylePill: switch the project's skin from the header (Hugo: "I need a style
// switcher on the overview page, at the top"; later: "make it visible on all pages"). Same list and the same save as Settings →
// APPEARANCE · SKIN (window.__setThemeForProject, App.jsx), so the choice is saved on the project
// and a skin's own photo comes with it. Only admins and producers may save a project's look (the
// server refuses anyone else), so only they see the button.
function StylePill({ open, onToggle, onClose }) {
  const ref = React.useRef(null);
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const fn = () => force();
    window.addEventListener("filmtracker:skin-changed", fn);
    return () => window.removeEventListener("filmtracker:skin-changed", fn);
  }, []);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [open, onClose]);
  const skins = (window.__themePresetsFor ? window.__themePresetsFor(window.__activeProjectId) : window.THEME_PRESETS) || [];
  const current = document.body.getAttribute("data-theme") || "forest";
  const pick = (id) => {
    if (id !== current && window.__setThemeForProject) window.__setThemeForProject(id);
    onClose();
  };
  return (
    <div className="ph-anchor ph-scale-anchor ph-style-anchor" ref={ref}>
      <button
        type="button"
        className={"ph-iconbtn ph-style-btn" + (open ? " is-open" : "")}
        aria-label="Style"
        aria-haspopup="menu"
        aria-expanded={open}
        onClick={onToggle}
      >
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.8-.8 1.8-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.5-.7-.5-1.2 0-.9.8-1.7 1.7-1.7H16a5 5 0 0 0 5-5c0-4-4-7.2-9-7.2z"/>
          <circle cx="7.5" cy="11.5" r="1.2"/><circle cx="10.5" cy="7.5" r="1.2"/><circle cx="15" cy="7.8" r="1.2"/>
        </svg>
      </button>
      {open && (
        <div className="ph-scale-pop ph-style-pop" role="menu" aria-label="Style">
          <div className="ph-scale-pop-head">STYLE</div>
          {skins.map((t) => {
            const active = current === t.id;
            return (
              <button
                key={t.id}
                type="button"
                role="menuitemradio"
                aria-checked={active}
                className={"ph-scale-opt ph-style-opt" + (active ? " is-active" : "")}
                onClick={() => pick(t.id)}
              >
                <span className="ph-style-swatches" aria-hidden="true">
                  {(t.swatches || []).map((s, i) => <span key={i} className="theme-swatch ph-style-swatch" style={{ background: s }}/>)}
                </span>
                <span className="ph-style-name">{t.name}</span>
                <span className="ph-scale-tick" aria-hidden="true">{active ? "✓" : ""}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

function NotificationsPopover({ open, onToggle, onClose }) {
  const ref = React.useRef(null);
  useClickOutside(ref, onClose, open);

  // v06p — Wired to the real /api/notifications/me endpoint.
  // Polls every 30s while the user has the app open so new
  // notes / status changes surface in the bell without a manual
  // refresh. SSE could replace polling later for instant.
  const fetcher = window.authFetch || fetch;
  const [items, setItems] = React.useState([]);
  const [unreadCount, setUnreadCount] = React.useState(0);
  // v07zz14 — Bell flash state. Toggles to true for ~2 s whenever a
  // notification id appears in the response that we hadn't seen
  // before. Skipped on the very first load (otherwise every page
  // refresh would fanfare). Respects the "Notification fanfare"
  // preference in Settings (localStorage key
  // filmtracker.notif-fanfare, default 1).
  const [bellFlash, setBellFlash] = React.useState(false);
  const _seenIdsRef = React.useRef(null);   // null = first load
  // v07zz35 — Hugo: "getting spammed with repetitive action from my
  // tester. Looks like bundling of notifications isn't working."
  // The toaster already collapses N events from the same author into
  // one card within a 60 s window, but the BELL FLASH was firing on
  // every fresh-from-others notification id — so a tester rattling
  // off 10 status changes produced 10 separate flash animations.
  // Debounce: don't re-fire the flash within FLASH_COOLDOWN_MS of the
  // last one. New notifications still increment the unread count + the
  // dropdown updates instantly; only the visual fanfare is rate-limited.
  const _lastFlashAtRef = React.useRef(0);
  const FLASH_COOLDOWN_MS = 8000;
  const load = React.useCallback(() => {
    fetcher("/api/notifications/me?limit=20")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!d) return;
        const list = (d.notifications || []).map(n => {
          // Parse the payload for entity_id hints used by the
          // click handler.
          let payload = {};
          try { payload = n.payload ? JSON.parse(n.payload) : {}; } catch (_) {}
          return {
            id: n.id,
            event: n.event_type,
            title: _prettyNotifTitle(n.title, payload),
            sub: n.body || "",
            // v07w — surface the author when the payload carries it.
            // Server-side new_note/new_asset_version/etc. send
            // `author` in the payload — see emailService.sendNotification
            // call sites. Falls back to "" (won't render the chip).
            author: (payload && (payload.author || payload.actor_name)) || "",
            time: fmtRelativeTime(n.created_at),
            unread: !n.read_at,
            link: n.link || null,
            payload,
          };
        });
        setItems(list);
        setUnreadCount(d.unread || 0);
        // v07zz14 — Detect brand-new notifications (ids we haven't
        // seen in any prior poll) and trigger the bell fanfare
        // unless the user opted out in Settings.
        try {
          const currentIds = new Set(list.map(x => x.id));
          if (_seenIdsRef.current === null) {
            _seenIdsRef.current = currentIds;
          } else {
            // v07zz16 — Only flash for notifications authored by
            // SOMEONE ELSE. Self-authored events shouldn't fanfare.
            const me = (window.__currentUser && (window.__currentUser.name || window.__currentUser.email)) || "";
            const meId = (window.__currentUser && window.__currentUser.id) || null;
            const hasNewFromOthers = list.some(x => {
              if (_seenIdsRef.current.has(x.id) || !x.unread) return false;
              const p = x.payload || {};
              const author = p.author || p.actor_name || "";
              const authorId = p.actor_id || p.author_id || null;
              if (me && author && String(author).toLowerCase() === String(me).toLowerCase()) return false;
              if (meId && authorId && String(authorId) === String(meId)) return false;
              return true;
            });
            if (hasNewFromOthers) {
              const enabled = (function () { try { return localStorage.getItem("filmtracker.notif-fanfare") !== "0"; } catch (_) { return true; } })();
              // v07zz35 — Rate-limit the flash to once per
              // FLASH_COOLDOWN_MS so a burst of activity from the same
              // author doesn't strobe the bell.
              const sinceLast = Date.now() - _lastFlashAtRef.current;
              if (enabled && sinceLast >= FLASH_COOLDOWN_MS) {
                _lastFlashAtRef.current = Date.now();
                setBellFlash(false);
                requestAnimationFrame(() => {
                  setBellFlash(true);
                  setTimeout(() => setBellFlash(false), 900);
                });
              }
            }
            _seenIdsRef.current = currentIds;
          }
        } catch (_) { /* non-fatal */ }
      })
      .catch(() => {});
  }, [fetcher]);
  React.useEffect(() => {
    load();
    const id = setInterval(load, 30000);
    return () => clearInterval(id);
  }, [load]);
  // Also refresh on open so the user always sees the latest list.
  React.useEffect(() => { if (open) load(); }, [open, load]);
  // Listen to SSE for instant updates.
  React.useEffect(() => {
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (msg && (msg.type === "new_note" || msg.type === "note_added" || msg.type === "shot_status_change" || msg.type === "new_asset_version")) {
          load();
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [load]);

  const markAllRead = () => {
    setItems(prev => prev.map(it => ({ ...it, unread: false })));
    setUnreadCount(0);
    fetcher("/api/notifications/read-all", { method: "POST" }).catch(() => {});
  };
  const markOneRead = (id) => {
    fetcher(`/api/notifications/${id}/read`, { method: "PATCH" }).catch(() => {});
    setItems(prev => prev.map(it => it.id === id ? { ...it, unread: false } : it));
    setUnreadCount(c => Math.max(0, c - 1));
  };
  const openLinkedShot = (shotId) => {
    onClose && onClose();
    if (shotId && window.__nav && window.__nav.openShot) {
      window.__nav.openShot(shotId);
    }
  };
  // v06p — Resolve a notification's destination: explicit `link`
  // wins; falls back to shot id parsed from the payload; review
  // events route to the Review page.
  const openNotification = (n) => {
    if (n.unread) markOneRead(n.id);
    onClose && onClose();
    const nav = window.__nav || {};
    const p = n.payload || {};
    // v722 — same as navigateToast: land on the version the note was written on. This
    // handler is a near-duplicate of that one (bell list vs slide-in card) — if you touch
    // the routing in one, touch both.
    const _tgt = (window.__noteVersionTarget && window.__noteVersionTarget(p.version_label)) || null;
    const _openShotAtVersion = (id) => nav.openShot(id, _tgt && _tgt.version, _tgt && _tgt.family);
    // Explicit link wins. /review/N → open the review page.
    if (n.link && typeof nav.setView === "function") {
      if (n.link.startsWith("/review")) {
        const m = n.link.match(/\/review\/(\d+)/);
        if (m) window.__pendingReviewId = parseInt(m[1], 10);
        nav.setView("review");
        return;
      }
      // v07u — `/shots/SHxxxx` is a shot deep-link.
      const sm = n.link.match(/\/shots\/(\S+)/);
      if (sm && typeof nav.openShot === "function") {
        _openShotAtVersion(sm[1]);
        return;
      }
      // v07zz279 — `/notes/<id>` → Notes page, focused on that note.
      const nm = n.link.match(/\/notes\/(\d+)/);
      if (nm && (!window.hasPerm || window.hasPerm("nav_notes"))) {
        const nid = parseInt(nm[1], 10);
        window.__pendingNoteId = nid;
        try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: nid } })); } catch (_) {}
        nav.setView("notes");
        return;
      }
    }
    // Payload-based routing (legacy + fallback).
    const shotId = p.shot_id
      || (p.entity_type === "shot" && p.entity_id)
      // v07u — many shot-note payloads only had entity_id without
      // entity_type. Recognise the SH#### naming convention so the
      // click navigates to the correct shot even without explicit
      // typing.
      || (typeof p.entity_id === "string" && /^SH\d+$/i.test(p.entity_id) && p.entity_id)
      || null;
    if (shotId && typeof nav.openShot === "function") {
      _openShotAtVersion(shotId);
      return;
    }
    // v07zd — Asset-typed notifications (entity_type starts with
    // "asset_") deep-link into the Assets view and request that
    // the asset's modal open. The Assets view consumes
    // window.__pendingAsset on mount/update.
    const et = String(p.entity_type || "");
    if (et === "asset" || et.startsWith("asset_")) {
      const kind = et === "asset" ? "asset" : et.replace(/^asset_/, "");
      const id = p.entity_id || null;
      // v07ze — Open the asset modal as an OVERLAY on whatever view
      // is currently open — same UX as shot notifications. The
      // GlobalAssetModal mounted at App root listens for this
      // event and renders the appropriate modal. Does NOT change
      // the current view.
      if (id) {
        if (typeof window.__openAssetItem === "function") {
          window.__openAssetItem(kind, id);
        } else {
          try {
            window.dispatchEvent(new CustomEvent("paradise-open-asset-modal", {
              detail: { kind, id },
            }));
          } catch (_) {}
        }
      }
      return;
    }
    // Project-level / overall. v07zz279 — NOTE events land on the
    // dedicated Notes page focused on the note (older rows without a
    // /notes/<id> link included). Other project events (phase changes
    // etc.) keep opening the activity slide.
    if (et === "project") {
      const isNoteEvent = n.event === "new_note" || n.event === "note_resolved" || n.event === "note_reply";
      if (isNoteEvent && typeof nav.setView === "function" && (!window.hasPerm || window.hasPerm("nav_notes"))) {
        if (p.note_id != null) {
          window.__pendingNoteId = p.note_id;
          try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: p.note_id } })); } catch (_) {}
        }
        nav.setView("notes");
        return;
      }
      if (typeof window.__openActivitySlide === "function") window.__openActivitySlide();
      return;
    }
    // Review payloads sometimes only have a numeric entity_id.
    if (typeof p.entity_id === "number" || /^\d+$/.test(String(p.entity_id || ""))) {
      const rid = parseInt(p.entity_id, 10);
      if (rid && typeof nav.setView === "function") {
        window.__pendingReviewId = rid;
        nav.setView("review");
      }
    }
  };
  // v06p — Pick an icon + colour by event type so the list is
  // scannable.
  const ICON_BY_EVENT = {
    new_note:           { icon: HIcon.nNote, color: "var(--archive)" },
    note_reply:         { icon: HIcon.nNote, color: "var(--archive)" },
    note_resolved:      { icon: HIcon.nDone, color: "var(--leaf)"    },
    shot_status_change: { icon: HIcon.nLoop, color: "var(--amber)"   },
    new_asset_version:  { icon: HIcon.nPlay, color: "var(--teal)"    },
    asset_created:      { icon: HIcon.nStar, color: "var(--teal)"    },
    phase_started:      { icon: HIcon.nStar, color: "var(--leaf)"    },
    new_episode:        { icon: HIcon.nStar, color: "var(--leaf)"    },
  };
  const openActivityLog = () => {
    onClose && onClose();
    // v06p — Slide is now mounted at the App root via
    // GlobalActivitySlide, so we can open it from any page
    // without forcing a navigation to Overview first.
    if (typeof window.__openActivitySlide === "function") {
      window.__openActivitySlide();
    }
  };
  const unread = unreadCount;

  return (
    <div className="ph-anchor" ref={ref}>
      <button
        className={"ph-iconbtn" + (open ? " is-open" : "") + (bellFlash ? " ph-iconbtn--notif-flash" : "")}
        aria-label="Notifications"
        onClick={onToggle}
      >
        {HIcon.bell}
        {unread > 0 && <span className="ph-bell-dot"/>}
      </button>
      {open && (
        <div className="ph-menu ph-menu--notif" role="menu">
          <div className="ph-menu-head">
            <span className="ph-menu-eyebrow">NOTIFICATIONS{unread > 0 ? ` · ${unread} UNREAD` : ""}</span>
            <button
              type="button"
              className="ph-menu-link"
              onClick={markAllRead}
              disabled={unread === 0}
            >Mark all read</button>
          </div>
          <div className="ph-notif-list">
            {items.length === 0 && (
              <div className="ph-notif-empty" style={{ padding: "18px 14px", textAlign: "center", color: "var(--ink-on-dark-muted, color-mix(in srgb, var(--ink-cream-3) 55%, transparent))", fontSize: "var(--fs-12)", fontStyle: "italic" }}>
                You're all caught up — no notifications yet.
              </div>
            )}
            {items.map((n) => {
              const meta = ICON_BY_EVENT[n.event] || { icon: HIcon.nNote, color: "var(--archive)" };
              return (
              <button
                key={n.id}
                className={"ph-menu-row ph-notif-row" + (n.unread ? " is-unread" : "")}
                role="menuitem"
                onClick={() => openNotification(n)}
              >
                <span className="ph-notif-icon" style={{
                  color: meta.color,
                  borderColor: `color-mix(in oklab, ${meta.color} 50%, transparent)`,
                  background: `color-mix(in oklab, ${meta.color} 16%, transparent)`,
                }}>{meta.icon}</span>
                <div className="ph-menu-row-text">
                  <div className="ph-menu-row-title">{n.title}</div>
                  {n.sub && <div className="ph-menu-row-sub">{n.sub}</div>}
                  <div className="ph-notif-meta">
                    {n.author && <span className="ph-notif-author">{n.author}</span>}
                    {n.author && <span className="ph-notif-dot-sep">·</span>}
                    <span className="ph-notif-time">{n.time}</span>
                  </div>
                </div>
                {n.unread && <span className="ph-notif-dot" style={{background: meta.color}}/>}
              </button>
              );
            })}
          </div>
          <div className="ph-menu-sep"/>
          <button
            className="ph-menu-row ph-menu-cta"
            role="menuitem"
            onClick={openActivityLog}
          >
            <span className="ph-menu-row-title">View all activity →</span>
          </button>
        </div>
      )}
    </div>
  );
}

// v01a — modal shell. Reuses the existing .modal-backdrop + .modal-card
// classes from styles/modal.css so we don't add new CSS for the chrome.
// Closes on backdrop click + Esc key.
function AuthModalShell({ children, onClose, width = 460 }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  const stop = (e) => e.stopPropagation();
  // v07zz136 — Only dismiss when the press AND the release both land on
  // the backdrop itself. Previously a bare onClick={onClose} meant that
  // selecting text inside a field and releasing the mouse outside the
  // card fired the backdrop click and kicked the user out mid-edit.
  const downOnBackdrop = React.useRef(false);
  const onBackdropMouseDown = (e) => { downOnBackdrop.current = (e.target === e.currentTarget); };
  const onBackdropClick = (e) => {
    if (downOnBackdrop.current && e.target === e.currentTarget) onClose();
    downOnBackdrop.current = false;
  };
  return (
    <div className="modal-backdrop" onMouseDown={onBackdropMouseDown} onClick={onBackdropClick}>
      <div className="modal-card glass auth-modal-card" style={{ maxWidth: width }} onMouseDown={stop} onClick={stop} role="dialog" aria-modal="true">
        <button className="modal-close-btn" aria-label="Close" onClick={onClose}>{HIcon.x}</button>
        {children}
      </div>
    </div>
  );
}

// v07zz128 — Avatar processing for the Profile modal. Mirrors the
// first-login CompleteProfileModal pipeline: a scaled portrait (max
// 800px wide → the 3:4 crew-card image) + a square cropped from the
// user-chosen region (chips / circles). The two helpers below operate
// on a dataURL so the modal can show a live draggable crop first, then
// render the final blobs from the chosen rect on save.
function _scalePortraitBlob(src, maxW) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onerror = () => reject(new Error("image load failed"));
    img.onload = () => {
      try {
        const scale = Math.min(1, maxW / img.naturalWidth);
        const w = Math.round(img.naturalWidth  * scale);
        const h = Math.round(img.naturalHeight * scale);
        const c = document.createElement("canvas");
        c.width = w; c.height = h;
        c.getContext("2d").drawImage(img, 0, 0, w, h);
        c.toBlob(b => b ? resolve(b) : reject(new Error("portrait encode failed")), "image/png", 0.92);
      } catch (e) { reject(e); }
    };
    img.src = src;
  });
}
function _cropSquareBlob(src, rect, outSize) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onerror = () => reject(new Error("image load failed"));
    img.onload = () => {
      try {
        const c = document.createElement("canvas");
        c.width = outSize; c.height = outSize;
        c.getContext("2d").drawImage(img, rect.x, rect.y, rect.size, rect.size, 0, 0, outSize, outSize);
        c.toBlob(b => b ? resolve(b) : reject(new Error("square encode failed")), "image/png", 0.92);
      } catch (e) { reject(e); }
    };
    img.src = src;
  });
}
// v07zz130 — IANA timezone list for the Profile modal's timezone select.
// Tries Intl.supportedValuesOf (modern browsers, ~400 zones), falls back
// to a short hand-picked list.
function _tzList() {
  try {
    if (typeof Intl !== "undefined" && typeof Intl.supportedValuesOf === "function") {
      const all = Intl.supportedValuesOf("timeZone");
      if (Array.isArray(all) && all.length) return all;
    }
  } catch (_) {}
  return [
    "UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Vienna",
    "Europe/Madrid", "Europe/Rome", "Europe/Amsterdam", "Europe/Athens",
    "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
    "America/Toronto", "America/Sao_Paulo", "Asia/Dubai", "Asia/Kolkata",
    "Asia/Singapore", "Asia/Hong_Kong", "Asia/Tokyo", "Australia/Sydney",
    "Pacific/Auckland",
  ];
}

// v01a — Profile / Account settings modal. Read-only name + email, plus
// a change-password form that POSTs to /api/auth/change-password. The
// password form is gracefully disabled (with a hint) when running under
// BYPASS_AUTH so dev mode doesn't try to rotate credentials it doesn't
// own.
// v07zz127 — Adds a profile-photo control so a user can set/replace
// their avatar any time (previously only possible at first login).
function ProfileModal({ user, onClose }) {
  const [cur, setCur]   = React.useState("");
  const [next, setNext] = React.useState("");
  const [conf, setConf] = React.useState("");
  const [submitting, setSubmitting] = React.useState(false);
  const [err, setErr]   = React.useState(null);
  const [ok, setOk]     = React.useState(false);

  // v07zz128 — Profile photo upload state, now with a draggable square
  // crop (parity with the first-login CompleteProfileModal).
  const photoRef = React.useRef(null);
  const [photoBusy, setPhotoBusy] = React.useState(false);
  const [photoErr, setPhotoErr]   = React.useState(null);
  const [photoOk,  setPhotoOk]    = React.useState(false);
  const [square,   setSquare]     = React.useState(
    (user && (user.avatar_square_url || user.avatar_portrait_url)) || null
  );
  // Crop state — set once a file is chosen; drives the live overlay.
  const [imageSrc,  setImageSrc]  = React.useState(null);   // dataURL
  const [imageDims, setImageDims] = React.useState(null);   // {w,h} in image px
  const [cropRect,  setCropRect]  = React.useState(null);   // {x,y,size} in image px
  const previewWrapRef = React.useRef(null);
  const cropDragRef    = React.useRef(null);

  const resetCrop = () => { setImageSrc(null); setImageDims(null); setCropRect(null); };

  const onPickFile = (file) => {
    if (!file) return;
    if (!/^image\//.test(file.type)) { setPhotoErr("File must be an image."); return; }
    setPhotoErr(null); setPhotoOk(false);
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        setImageSrc(reader.result);
        setImageDims({ w: img.naturalWidth, h: img.naturalHeight });
        const size = Math.min(img.naturalWidth, img.naturalHeight);
        setCropRect({
          x: Math.floor((img.naturalWidth  - size) / 2),
          y: Math.floor((img.naturalHeight - size) / 2),
          size,
        });
      };
      img.src = reader.result;
    };
    reader.readAsDataURL(file);
  };

  // Drag the square crop region (image-pixel coords, rendered as % overlay).
  const onCropMouseDown = (e) => {
    if (!cropRect || !imageDims || !previewWrapRef.current) return;
    e.preventDefault();
    const rect = previewWrapRef.current.getBoundingClientRect();
    cropDragRef.current = {
      startX: e.clientX, startY: e.clientY, origX: cropRect.x, origY: cropRect.y,
      pxPerImgX: rect.width / imageDims.w, pxPerImgY: rect.height / imageDims.h,
    };
    const onMove = (ev) => {
      const d = cropDragRef.current; if (!d) return;
      const dxImg = (ev.clientX - d.startX) / d.pxPerImgX;
      const dyImg = (ev.clientY - d.startY) / d.pxPerImgY;
      const nx = Math.max(0, Math.min(imageDims.w - cropRect.size, Math.round(d.origX + dxImg)));
      const ny = Math.max(0, Math.min(imageDims.h - cropRect.size, Math.round(d.origY + dyImg)));
      setCropRect(prev => ({ ...prev, x: nx, y: ny }));
    };
    const onUp = () => {
      cropDragRef.current = null;
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
    };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  };
  const onResizeCropSize = (newSize) => {
    if (!cropRect || !imageDims) return;
    const capped = Math.max(40, Math.min(Math.min(imageDims.w, imageDims.h), Math.round(newSize)));
    const x = Math.max(0, Math.min(imageDims.w - capped, cropRect.x));
    const y = Math.max(0, Math.min(imageDims.h - capped, cropRect.y));
    setCropRect({ x, y, size: capped });
  };

  const savePhoto = async () => {
    if (!imageSrc || !cropRect || photoBusy) return;
    setPhotoErr(null); setPhotoOk(false); setPhotoBusy(true);
    try {
      const portraitBlob = await _scalePortraitBlob(imageSrc, 800);
      const squareBlob   = await _cropSquareBlob(imageSrc, cropRect, 512);
      // v07zz129 — POST both blobs to the dedicated avatar endpoint, which
      // uploads straight to R2 (sync) and returns the updated user. This
      // guarantees a public cloud URL that renders on BOTH local and
      // Railway — the old /api/upload path returned a /local/ URL on local
      // that Railway couldn't serve.
      const fd = new FormData();
      fd.append("portrait", portraitBlob, "portrait.png");
      fd.append("square",   squareBlob,   "square.png");
      const fetcher = window.authFetch || fetch;
      const r = await fetcher("/api/users/me/avatar", { method: "POST", body: fd });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      const u = j.user || {};
      setSquare(u.avatar_square_url || u.avatar_portrait_url || null);
      setPhotoOk(true);
      resetCrop();
      if (j.user && typeof window.__setCurrentUser === "function") window.__setCurrentUser(j.user);
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e) {
      setPhotoErr(e.message || "Could not update photo.");
    } finally {
      setPhotoBusy(false);
      if (photoRef.current) photoRef.current.value = "";
    }
  };

  const cropPctStyle = (cropRect && imageDims) ? {
    left:   `${(cropRect.x / imageDims.w) * 100}%`,
    top:    `${(cropRect.y / imageDims.h) * 100}%`,
    width:  `${(cropRect.size / imageDims.w) * 100}%`,
    height: `${(cropRect.size / imageDims.h) * 100}%`,
  } : null;

  // v07zz130 — Editable profile details (name / company / timezone / bio).
  // Previously these were read-only after first-login setup; Hugo wants to
  // tweak them any time. Saves through PATCH /api/users/me/profile.
  const tzList = React.useMemo(_tzList, []);
  const [pName,    setPName]    = React.useState((user && user.name) || "");
  const [pCompany, setPCompany] = React.useState((user && user.company) || "");
  const [pBio,     setPBio]     = React.useState((user && user.bio) || "");
  const [pTz,      setPTz]      = React.useState((user && user.tz) || "");
  const [pBusy,    setPBusy]    = React.useState(false);
  const [pErr,     setPErr]     = React.useState(null);
  const [pOk,      setPOk]      = React.useState(false);
  // v07zz275 — dev BYPASS_AUTH: there's no real session to rotate a password for,
  // so the server rejects change-password. Detect it up front (App.jsx sets the
  // token to "bypass" when /api/auth/me succeeds tokenless) and render the form as
  // cleanly disabled with a muted note — instead of letting the browser autofill
  // the fields and surfacing a red error only after the user hits Update.
  const authBypassed = (typeof window !== "undefined" && window.__authToken === "bypass");

  const saveProfile = async (e) => {
    if (e) e.preventDefault();
    if (pBusy) return;
    setPErr(null); setPOk(false);
    if (!pName.trim()) { setPErr("Name can't be empty."); return; }
    setPBusy(true);
    try {
      const fetcher = window.authFetch || fetch;
      const r = await fetcher("/api/users/me/profile", {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: pName.trim(),
          company: pCompany.trim(),
          bio: pBio.trim(),
          tz: pTz || null,
        }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      setPOk(true);
      if (j.user && typeof window.__setCurrentUser === "function") window.__setCurrentUser(j.user);
      if (typeof window.reloadAppData === "function") window.reloadAppData();
    } catch (e2) {
      setPErr(e2.message || "Could not save changes.");
    } finally {
      setPBusy(false);
    }
  };

  const submit = (e) => {
    e.preventDefault();
    if (submitting) return;
    setErr(null); setOk(false);
    if (!cur || !next || !conf) { setErr("All three fields are required."); return; }
    if (next !== conf)         { setErr("New passwords do not match.");    return; }
    if (next.length < 6)       { setErr("New password must be at least 6 characters."); return; }
    setSubmitting(true);
    const fetcher = window.authFetch || fetch;
    fetcher("/api/auth/change-password", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ current_password: cur, new_password: next }),
    })
      .then(async r => {
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
        return body;
      })
      .then(() => {
        setOk(true);
        setCur(""); setNext(""); setConf("");
      })
      .catch(e2 => setErr(e2.message || "Could not change password."))
      .finally(() => setSubmitting(false));
  };

  return (
    <AuthModalShell onClose={onClose} width={460}>
      <div className="auth-modal-head">
        <div className="auth-modal-eyebrow">PROFILE</div>
        <div className="auth-modal-title">Account</div>
      </div>
      <div className="auth-modal-body">
        <div className="auth-field">
          <span className="auth-field-label">Profile photo</span>
          {!imageSrc && (
            <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
              <div style={{
                width: 64, height: 64, borderRadius: "50%", flex: "0 0 auto", overflow: "hidden",
                backgroundImage: square ? `url(${window.thumbUrl ? window.thumbUrl(square, 160) : square})` : "none",
                backgroundSize: "cover", backgroundPosition: "center",
                background: square ? undefined : "linear-gradient(160deg, var(--leaf), var(--shade-61))",
                display: "flex", alignItems: "center", justifyContent: "center",
                color: "var(--white)", fontWeight: "var(--fw-bold)", fontSize: "var(--fs-20)", letterSpacing: 0.5,
                border: "1px solid color-mix(in srgb, var(--card-border) 55%, transparent)",
              }}>
                {!square && initialsFrom((user && user.name) || "U")}
              </div>
              <div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-start" }}>
                <button type="button" className="auth-submit" style={{ padding: "7px 14px", width: "auto" }}
                  disabled={photoBusy} onClick={() => photoRef.current && photoRef.current.click()}>
                  {square ? "Change photo" : "Upload photo"}
                </button>
                <span style={{ fontSize: "var(--fs-11)", opacity: 0.7 }}>JPG / PNG · you'll be able to crop it.</span>
              </div>
            </div>
          )}
          {imageSrc && imageDims && cropRect && (
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              <span style={{ fontSize: "var(--fs-11)", opacity: 0.7 }}>Drag the box to reframe · use the slider to zoom.</span>
              <div ref={previewWrapRef} style={{
                position: "relative", width: "100%", maxWidth: 280, alignSelf: "center",
                borderRadius: "var(--r-sm)", overflow: "hidden", userSelect: "none",
                border: "1px solid color-mix(in srgb, var(--card-border) 55%, transparent)",
              }}>
                <img src={imageSrc} alt="" draggable={false} style={{ display: "block", width: "100%" }}/>
                <div onMouseDown={onCropMouseDown} style={{
                  position: "absolute", ...cropPctStyle, boxSizing: "border-box",
                  border: "2px solid var(--white)", boxShadow: "0 0 0 9999px rgba(0,0,0,0.45)", cursor: "move",
                }}/>
              </div>
              <label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
                <span style={{ fontSize: "var(--fs-10)", opacity: 0.7 }}>Zoom</span>
                <input type="range"
                  min={Math.max(40, Math.round(Math.min(imageDims.w, imageDims.h) * 0.2))}
                  max={Math.min(imageDims.w, imageDims.h)}
                  value={cropRect.size}
                  onChange={e => onResizeCropSize(parseInt(e.target.value, 10))}/>
              </label>
              <div style={{ display: "flex", gap: 8 }}>
                <button type="button" className="auth-submit" style={{ padding: "7px 14px", width: "auto" }}
                  disabled={photoBusy} onClick={savePhoto}>
                  {photoBusy ? "Saving…" : "Save photo"}
                </button>
                <button type="button" style={{
                  padding: "7px 14px", borderRadius: "var(--r-7)", cursor: "pointer",
                  border: "1px solid color-mix(in srgb, var(--card-border) 55%, transparent)", background: "transparent", color: "inherit", font: "inherit",
                }} disabled={photoBusy} onClick={resetCrop}>
                  {square ? "Cancel" : "Pick different image"}
                </button>
              </div>
            </div>
          )}
          <input ref={photoRef} type="file" accept="image/*" style={{ display: "none" }}
            onChange={e => onPickFile(e.target.files && e.target.files[0])}/>
          {photoErr && <div className="auth-msg auth-msg--err" role="alert">{photoErr}</div>}
          {photoOk  && <div className="auth-msg auth-msg--ok">Photo updated.</div>}
        </div>
        <div className="auth-modal-sep"/>
        <div className="auth-modal-subhead">Profile details</div>
        <form className="auth-form" onSubmit={saveProfile} autoComplete="off">
          <label className="auth-field">
            <span className="auth-field-label">Name</span>
            <input className="auth-input" type="text" value={pName}
              onChange={(e) => setPName(e.target.value)} disabled={pBusy} placeholder="Your name"/>
          </label>
          <label className="auth-field">
            <span className="auth-field-label">Company</span>
            <input className="auth-input" type="text" value={pCompany}
              onChange={(e) => setPCompany(e.target.value)} disabled={pBusy} placeholder="Company / studio"/>
          </label>
          <label className="auth-field">
            <span className="auth-field-label">Timezone</span>
            <select className="auth-input" value={pTz} onChange={(e) => setPTz(e.target.value)} disabled={pBusy}>
              <option value="">— select —</option>
              {tzList.map(z => <option key={z} value={z}>{z.replace(/_/g, " ")}</option>)}
            </select>
          </label>
          <label className="auth-field">
            <span className="auth-field-label">Bio</span>
            <textarea className="auth-input" rows={2} value={pBio}
              onChange={(e) => setPBio(e.target.value.slice(0, 200))} disabled={pBusy}
              placeholder="One-liner shown on your crew card."
              style={{ resize: "vertical", font: "inherit" }}/>
            <span style={{ fontSize: "var(--fs-11)", opacity: 0.6 }}>{200 - (pBio || "").length} characters left.</span>
          </label>
          <div className="auth-field">
            <span className="auth-field-label">Email</span>
            <div className="auth-field-readonly">{(user && user.email) || "—"}</div>
          </div>
          {user && user.role && (
            <div className="auth-field">
              <span className="auth-field-label">Role</span>
              <div className="auth-field-readonly">{user.role}</div>
            </div>
          )}
          {pErr && <div className="auth-msg auth-msg--err" role="alert">{pErr}</div>}
          {pOk  && <div className="auth-msg auth-msg--ok">Profile saved.</div>}
          <button className="auth-submit" type="submit" disabled={pBusy || !pName.trim()}>
            {pBusy ? "Saving…" : "Save changes"}
          </button>
        </form>

        <div className="auth-modal-sep"/>
        <div className="auth-modal-subhead">Change password</div>
        {authBypassed ? (
          <div className="auth-msg auth-msg--info" role="note">
            Password change is unavailable in local dev mode (BYPASS_AUTH is on — you're
            signed in without a real session). It works normally on the live site.
          </div>
        ) : (
        <form className="auth-form" onSubmit={submit} autoComplete="off">
          <label className="auth-field">
            <span className="auth-field-label">Current password</span>
            <input className="auth-input" type="password" autoComplete="current-password"
              value={cur} onChange={(e) => setCur(e.target.value)} disabled={submitting}/>
          </label>
          <label className="auth-field">
            <span className="auth-field-label">New password</span>
            <input className="auth-input" type="password" autoComplete="new-password"
              value={next} onChange={(e) => setNext(e.target.value)} disabled={submitting}/>
          </label>
          <label className="auth-field">
            <span className="auth-field-label">Confirm new password</span>
            <input className="auth-input" type="password" autoComplete="new-password"
              value={conf} onChange={(e) => setConf(e.target.value)} disabled={submitting}/>
          </label>
          {err && <div className="auth-msg auth-msg--err" role="alert">{err}</div>}
          {ok  && <div className="auth-msg auth-msg--ok">Password updated.</div>}
          <button className="auth-submit" type="submit" disabled={submitting || !cur || !next || !conf}>
            {submitting ? "Updating…" : "Update password"}
          </button>
        </form>
        )}
      </div>
    </AuthModalShell>
  );
}

// v01a — Plan modal. Static info per the v01a prompt — plan name +
// features + 'Upgrade coming soon' note. Future tasks can wire this
// to live billing data.
function PlanModal({ onClose }) {
  const features = [
    "Single project workspace",
    "Unlimited shots, sequences and episodes",
    "Cloudflare R2 cloud storage for renders",
    "Live multi-user updates with online presence",
    "Folder watcher + Claude webhook ingestion",
    "Rize time-tracking + Google Calendar integration",
  ];
  // v04cp — prominent Upgrade CTA. Stub action since billing isn't wired.
  const [showSoon, setShowSoon] = React.useState(false);
  return (
    <AuthModalShell onClose={onClose} width={460}>
      <div className="auth-modal-head">
        <div className="auth-modal-eyebrow">CURRENT PLAN</div>
        <div className="auth-modal-title">Solo — Free Beta</div>
        {/* 15 Sep 2026 — the active project's name + the real project count, not a Paradise Found literal */}
        <div className="auth-modal-sub">{(() => { const row = window.__activeProjectRow ? window.__activeProjectRow() : null; const n = ((window.__projects && window.__projects.projects) || []).length; return `${(row && row.name) || "This project"} / ${n > 1 ? `${n} projects` : "Single project"} · 1 user · local storage.`; })()}</div>
      </div>
      <div className="auth-modal-body">
        <ul className="auth-feature-list">
          {features.map(f => (
            <li key={f}><span className="auth-feature-tick">{HIcon.check}</span><span>{f}</span></li>
          ))}
        </ul>
        <div className="auth-modal-sep"/>
        <button
          type="button"
          className="plan-upgrade-cta"
          onClick={() => setShowSoon(true)}
        >Upgrade Plan</button>
        {showSoon && (
          <div className="auth-msg auth-msg--info" style={{marginTop: 10}}>
            Upgrade coming soon — multi-project plans, team seats, and longer asset retention will be available in v1.0.
          </div>
        )}
      </div>
    </AuthModalShell>
  );
}

// v01a — initials helper: pulls 1–2 uppercase letters from the user's
// display name. Falls back to "HJ" only when no user info is available
// (i.e. the very first render before /api/auth/me resolves).
function initialsFrom(name) {
  if (!name) return "??";
  const parts = String(name).trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "??";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function AvatarMenu({ open, onToggle, onClose }) {
  const ref = React.useRef(null);
  useClickOutside(ref, onClose, open);

  // v01a — pull the live user from UserContext (set by App.jsx). Falls
  // through to a permissive empty-context if the provider isn't mounted
  // yet (mid-bootstrap render).
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const user = (userCtx && userCtx.user) || null;
  const logout = (userCtx && userCtx.logout) || (() => {});
  const displayName  = (user && user.name)  || "User";
  const displayEmail = (user && user.email) || "";
  const inits        = initialsFrom(displayName);
  // v07zz137 — Show the user's profile photo in the top-right avatar (and
  // the dropdown account card) when they have one, falling back to the
  // initials circle. Reuses the existing .ph-initials circle — just swaps
  // its gradient fill for the photo via an inline background-image.
  const avatarUrl    = (user && (user.avatar_square_url || user.avatar_portrait_url)) || null;
  const avatarThumb  = avatarUrl ? (window.thumbUrl ? window.thumbUrl(avatarUrl, 160) : avatarUrl) : null;
  const avatarStyle  = avatarThumb ? { backgroundImage: `url(${avatarThumb})`, backgroundSize: "cover", backgroundPosition: "center" } : undefined;

  // v06p — "See as" persona switcher. Only the REAL admin (i.e. the
  // logged-in user before any impersonation) gets this affordance.
  // While impersonating, `user.role` is the persona's role but
  // `user.__realRole` carries the truth — that's what we gate on.
  const realRole = (user && user.__realRole) || (user && user.role) || null;
  const isRealAdmin = realRole === "admin";
  const seeAsRole = window.__seeAs || null;
  const ROLE_OPTIONS = (window.ROLE_OPTIONS || []).filter(r => r.id !== "admin");
  const setSeeAs = (role) => {
    if (typeof window.__setSeeAs === "function") window.__setSeeAs(role || null);
    onClose();
  };

  // v01a — modals controlled here (small, scoped, no global state).
  const [showProfile, setShowProfile] = React.useState(false);
  const [showPlan,    setShowPlan]    = React.useState(false);

  // v04pd / v04j — dedupe: Profile and Account settings were two
  // entries pointing at the same modal. Kept only one ('Profile &
  // Account'). Plan renamed → 'Current Plan' (v04cp).
  const items = [
    { label: "Profile & Account",  icon: HIcon.user,  onClick: () => setShowProfile(true) },
    { label: "Current Plan",       icon: HIcon.plan,  onClick: () => setShowPlan(true) },
    { label: "Workspace settings", icon: HIcon.build, onClick: () => window.__nav && window.__nav.setView && window.__nav.setView("settings") },
    { label: "Help & docs",        icon: HIcon.help },
  ];

  const go = (it) => { if (it.onClick) it.onClick(); onClose(); };

  // v01a — Sign out: clear the in-memory token via App's handleLogout
  // (exposed through UserContext.logout). React re-renders into
  // <LoginPage/>, so no manual redirect is needed. If a future task
  // (v01n) wires localStorage persistence the same call will need to
  // also clear that key — handle it there, not here.
  const handleSignOut = () => {
    onClose();
    fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
    logout();
  };

  // v06p — Find the human-readable label for the currently-active
  // persona so we can drop a tiny "Viewing as X" pill under the
  // avatar (replaces the giant gold sidebar banner that wrecked
  // the layout). Click the pill to revert.
  const ROLE_LABEL = window.ROLE_LABEL || ((id) => id);

  return (
    <div className="ph-anchor ph-anchor--avatar" ref={ref}>
      <button className={"ph-avatar" + (open ? " is-open" : "") + (seeAsRole ? " is-impersonating" : "")} aria-label="Account" onClick={onToggle}>
        <span className="ph-initials" style={avatarStyle}>{avatarThumb ? "" : inits}</span>
        <span className="ph-avatar-caret">{HIcon.caret}</span>
      </button>
      {seeAsRole && (
        <button type="button"
          className="ph-seeing-as-pill"
          onClick={(e) => { e.stopPropagation(); window.__setSeeAs(null); }}
          title="Stop impersonating">
          <span className="ph-seeing-as-eye">👁</span>
          <span className="ph-seeing-as-label">as {ROLE_LABEL(seeAsRole)}</span>
          <span className="ph-seeing-as-x" aria-hidden="true">×</span>
        </button>
      )}
      {open && (
        <div className="ph-menu ph-menu--avatar" role="menu">
          <div className="ph-account-card">
            <span className="ph-initials ph-initials-lg" style={avatarStyle}>{avatarThumb ? "" : inits}</span>
            <div className="ph-account-text">
              <div className="ph-account-name">{displayName}</div>
              <div className="ph-account-mail">{displayEmail}</div>
            </div>
          </div>
          <div className="ph-menu-sep"/>
          {items.map(it => (
            <button key={it.label} className="ph-menu-row" onClick={() => go(it)} role="menuitem">
              <span className="ph-menu-ico">{it.icon}</span>
              <span className="ph-menu-row-title">{it.label}</span>
            </button>
          ))}
          {isRealAdmin && (
            <>
              <div className="ph-menu-sep"/>
              <div className="ph-menu-eyebrow">SEE AS</div>
              <button
                className={"ph-menu-row ph-menu-see-as" + (!seeAsRole ? " is-active" : "")}
                onClick={() => setSeeAs("")}
                role="menuitem">
                <span className="ph-menu-ico">{!seeAsRole ? "●" : "○"}</span>
                <span className="ph-menu-row-title">Admin (yourself)</span>
              </button>
              {ROLE_OPTIONS.map(r => (
                <button
                  key={r.id}
                  className={"ph-menu-row ph-menu-see-as" + (seeAsRole === r.id ? " is-active" : "")}
                  onClick={() => setSeeAs(r.id)}
                  role="menuitem">
                  <span className="ph-menu-ico">{seeAsRole === r.id ? "●" : "○"}</span>
                  <span className="ph-menu-row-title">{r.label}</span>
                </button>
              ))}
            </>
          )}
          <div className="ph-menu-sep"/>
          <button className="ph-menu-row ph-menu-signout" role="menuitem" onClick={handleSignOut}>
            <span className="ph-menu-ico">{HIcon.out}</span>
            <span className="ph-menu-row-title">Sign out</span>
          </button>
        </div>
      )}
      {showProfile && (
        <ProfileModal user={user} onClose={() => setShowProfile(false)}/>
      )}
      {showPlan && (
        <PlanModal onClose={() => setShowPlan(false)}/>
      )}
    </div>
  );
}

// t07 — Episode switcher. Compact pill that sits next to the project
// title. Clicks open a small dropdown listing every episode for the
// current project plus a "New episode…" row that opens a 3-field form.
//
// v01b — When there is no active episode (typical during the brief
// /api/episodes fetch on first paint), render nothing rather than a
// dead "Loading…" button. The trigger label is formatted as
// "{title} (episode_{NN})" so it reads like the original handwritten
// subtitle Hugo had baked into projects.json.
function EpisodeSwitcher({ open, onToggle, onClose, episodes, activeId, onSelect, onCreate, compact = false, noun = "Episode", nounPlural = "Episodes", kind = null, showTitle = false }) {
  const ref = React.useRef(null);
  useClickOutside(ref, onClose, open);
  const [creating, setCreating] = React.useState(false);
  // v01b — three-field new-episode form (title, episode number, description).
  // The previous version only captured title and called onCreate(title).
  // onCreate now accepts an object so the App.jsx → POST /api/episodes path
  // can forward all three.
  const [draftTitle, setDraftTitle] = React.useState("");
  const [draftNum,   setDraftNum]   = React.useState("");
  const [draftDesc,  setDraftDesc]  = React.useState("");
  const list = (episodes && episodes.length) ? episodes : [];
  const active = list.find(e => e.id === activeId) || list[0];
  // v01b — suggest next episode number when the user opens the new form.
  const suggestNum = () => {
    if (!list.length) return 1;
    return (Math.max(...list.map(e => Number(e.episode_number) || 0)) || 0) + 1;
  };
  const beginCreate = () => {
    setDraftNum(String(suggestNum()));
    setCreating(true);
  };
  const cancelCreate = () => {
    setCreating(false);
    setDraftTitle(""); setDraftNum(""); setDraftDesc("");
  };
  const submitNew = (e) => {
    e && e.preventDefault();
    const title = (draftTitle || "").trim();
    if (!title) return;
    const numParsed = parseInt(draftNum, 10);
    const payload = {
      title,
      episode_number: Number.isFinite(numParsed) ? numParsed : undefined,
      description: (draftDesc || "").trim() || undefined,
      kind: kind || undefined,          // v86 — the template's container kind (music video …)
    };
    onCreate && onCreate(payload);
    cancelCreate();
  };

  // v01b — kill the "Loading…" placeholder. When there's no active
  // episode (list empty / still fetching), render nothing. The
  // surrounding ph-meta-sep is now also conditionally rendered upstream
  // when compact + active is null.
  // v86 — a project with no container yet still gets the switcher, as a "+ New music video…"
  // (Hugo: "where is the Episode or Music video creator/switcher gone?"). Only once the
  // project's data has loaded and truly holds none, so Paradise Found's boot never flashes it.
  const trulyEmpty = !list.length && window.__episodesLoadedFor === window.__activeProjectId;
  if (compact && !active && !trulyEmpty) return null;

  // Hugo update — compact label is just the episode number, never the
  // project name. Whatever active.title contains (which often duplicates
  // "Paradise Found" since the seed data is "Paradise Found - Pilot"),
  // we drop it entirely and render "Episode NN".
  // 16 Sep 2026 — showTitle. The numbered label exists because Paradise Found's
  // episode titles repeat the project name ("Paradise Found - Pilot"), so the
  // number reads better. That reasoning does not hold for a CONTAINER GROUP: an
  // album has no number at all, so it rendered as "Album -". And it does not
  // hold for a container inside a group either, where "Cold Champagne" is the
  // whole point and "Music video 01" tells you nothing.
  const compactLabel = active
    ? (showTitle
        ? String(active.title || active.id || "")
        : `${noun} ${window.epNumLabel ? window.epNumLabel(active) : String(active.episode_number || 0).padStart(2, "0")}`)
    : `No ${nounPlural.toLowerCase()} yet`;

  return (
    <div className={"ph-episode" + (compact ? " ph-episode--compact" : "") + (open ? " is-open" : "")} ref={ref}>
      <button
        type="button"
        className={"ph-episode-pill" + (compact ? " ph-episode-pill--compact" : "") + (open ? " is-open" : "")}
        onClick={onToggle}
        aria-haspopup="listbox"
        aria-expanded={open}
      >
        {/* t06c compact mode hides the EP NN eyebrow chip — the episode
            number is implicit in the title. Full mode keeps it for
            backward compatibility with any other call site. */}
        {!compact && (
          <span className="ph-episode-eyebrow">EP {window.epNumLabel ? window.epNumLabel(active) : (active ? String(active.episode_number).padStart(2, "0") : "—")}</span>
        )}
        <span className="ph-episode-name">{compact ? compactLabel : (active ? active.title : "—")}</span>
        <span className={"ph-episode-caret" + (open ? " is-open" : "")} aria-hidden="true">{HIcon.caret}</span>
      </button>
      {open && (
        <div className="ph-episode-menu" role="listbox">
          <div className="ph-menu-eyebrow">{nounPlural.toUpperCase()}</div>
          {list.map(e => (
            <button
              key={e.id}
              role="option"
              aria-selected={active && e.id === active.id}
              className={"ph-menu-row ph-episode-row" + (active && e.id === active.id ? " is-active" : "")}
              onClick={() => { onSelect && onSelect(e.id); onClose(); }}
            >
              {/* A container group has no number, so the chip rendered "EP -".
                  The counter is only meaningful for numbered rows. */}
              {e.episode_number != null && (
                <span className="ph-episode-num">EP {window.epNumLabel ? window.epNumLabel(e) : String(e.episode_number).padStart(2, "0")}</span>
              )}
              <div className="ph-menu-row-text">
                <div className="ph-menu-row-title">{e.title}</div>
                <div className="ph-menu-row-sub">{e.description || e.status}</div>
              </div>
              {active && e.id === active.id && <span className="ph-episode-check">{HIcon.check}</span>}
            </button>
          ))}
          <div className="ph-menu-sep"/>
          {creating ? (
            <form className="ph-episode-new-form" onSubmit={submitNew}>
              <label className="ph-episode-new-field">
                <span className="ph-episode-new-label">Title</span>
                <input
                  className="ph-episode-new-input"
                  placeholder={noun + " title"}
                  value={draftTitle}
                  onChange={(e) => setDraftTitle(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Escape") cancelCreate(); }}
                  autoFocus
                />
              </label>
              <label className="ph-episode-new-field">
                <span className="ph-episode-new-label">{noun} #</span>
                <input
                  className="ph-episode-new-input ph-episode-new-num"
                  type="number"
                  min="0"
                  step="1"
                  value={draftNum}
                  onChange={(e) => setDraftNum(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Escape") cancelCreate(); }}
                />
              </label>
              <label className="ph-episode-new-field">
                <span className="ph-episode-new-label">Description</span>
                <textarea
                  className="ph-episode-new-input ph-episode-new-desc"
                  rows={2}
                  placeholder="Optional one-liner"
                  value={draftDesc}
                  onChange={(e) => setDraftDesc(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Escape") cancelCreate(); }}
                />
              </label>
              <div className="ph-episode-new-actions">
                <button type="button" className="ph-episode-new-cancel" onClick={cancelCreate}>Cancel</button>
                <button type="submit" className="ph-episode-new-submit" disabled={!draftTitle.trim()}>Create</button>
              </div>
            </form>
          ) : (
            // v06p — Hidden when the user lacks manage_episodes
            // permission (so non-admins / non-producers don't see
            // the "+ New episode" affordance at all).
            (!window.hasPerm || window.hasPerm("manage_episodes")) && (
              <button className="ph-menu-row ph-episode-new" onClick={(e) => { e.stopPropagation(); beginCreate(); }}>
                <span className="ph-menu-ico">{HIcon.plus}</span>
                <span>New {noun.toLowerCase()}…</span>
              </button>
            )
          )}
        </div>
      )}
    </div>
  );
}

// v07zz172 — Online-count pill mirrored up next to the search bar. The TopBar's
// "N ONLINE" pill only renders on the Overview dashboard; this clone keeps the
// online presence visible everywhere else. It fades in (opacity) so it doesn't
// pop, and is fully hidden (no layout, no pointer) on Overview so there are never
// two pills at once. Reads the same window.__onlineUsers the TopBar uses.
function HeaderOnlinePill({ visible }) {
  const [online, setOnline] = React.useState(() => window.__onlineUsers || []);
  const [status, setStatus] = React.useState(() => window.__sseStatus || "connecting");
  React.useEffect(() => {
    const id = setInterval(() => {
      const o = window.__onlineUsers || [];
      setOnline(prev => prev.length === o.length ? prev : o);
      const s = window.__sseStatus || "connecting";
      setStatus(prev => prev === s ? prev : s);
    }, 500);
    return () => clearInterval(id);
  }, []);
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const me = userCtx && userCtx.user;
  const otherCount = online.length;
  const totalCount = otherCount + 1; // include self
  const label = otherCount === 0 ? "JUST YOU" : `${totalCount} ONLINE`;
  const live = status === "live";

  // v07zz175 — hover slides out avatars (initials or Crew image), mirroring the
  // TopBar online pill so this clone behaves identically.
  const crewList = (window.__appData && window.__appData.crew) || [];
  const initialsOf = (name) => {
    if (!name) return "??";
    const parts = String(name).trim().split(/\s+/).filter(Boolean);
    if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
    return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
  };
  const crewImageOf = (name) => {
    if (!name) return null;
    const hit = crewList.find(c => (c.name || "").toLowerCase() === String(name).toLowerCase());
    return (hit && hit.image) || null;
  };
  const avatars = [
    { key: "self", name: (me && me.name) || "You", initials: initialsOf((me && me.name) || "You"), image: (me && me.image) || crewImageOf(me && me.name), isMe: true },
    ...online.map((u, i) => ({ key: u.id || `u${i}`, name: u.name || `User ${u.id || i + 1}`, initials: initialsOf(u.name), image: crewImageOf(u.name), isMe: false })),
  ];
  return (
    <span
      className={"ph-online" + (visible && live ? " is-visible" : "")}
      aria-hidden={!(visible && live)}
      title={otherCount === 0 ? "Only you are online" : `${totalCount} people online`}
    >
      <span className="ph-online-pill">
        <span className="ph-online-dot"/>
        <span className="ph-online-label">{label}</span>
      </span>
      <span className="ph-online-avatars" aria-hidden="true">
        {avatars.map((a, i) => (
          <span
            key={a.key}
            className={"ph-online-avatar" + (a.isMe ? " is-me" : "")}
            style={{ ['--ai']: i, ...(a.image ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(a.image, 160) : a.image})`, backgroundSize: "cover", backgroundPosition: "center" } : {}) }}
            title={a.name + (a.isMe ? " (you)" : "")}
          >
            {!a.image && a.initials}
          </span>
        ))}
      </span>
    </span>
  );
}

function ProjectHeader({ activeProjectId, onSwitchProject, onCreateProject, currentView = "overview", episodes = [], activeEpisodeId, onSwitchEpisode, onCreateEpisode,
  // 16 Sep 2026 — container groups (albums). Empty for every project without them.
  groups = [], activeGroupId = null, onSwitchGroup = () => {}, onCreateGroup = () => {},
  // 17 Sep 2026 - the project's episode list has not arrived yet (its subtitle line waits for it)
  metaPending = false,
}) {
  const [openMenu, setOpenMenu] = React.useState(null);
  // (17 Sep 2026 review) an album that wears a skin of its own takes the style switcher's pick
  // (App.jsx setThemeForProject saves it there, a manage_episodes route), so the switcher is offered
  // only to someone that route allows
  const albumOwnsSkin = React.useMemo(() => {
    const g = (groups || []).find((x) => x.id === activeGroupId);
    if (!g) return false;
    let base = null;
    try { base = g.theme_json ? (typeof g.theme_json === "object" ? g.theme_json : JSON.parse(g.theme_json)).base : null; } catch (_) {}
    const known = (id) => !!id && (window.__knownSkin ? window.__knownSkin(id) : true);
    return known(base) || known(g.theme);
  }, [groups, activeGroupId]);
  const [showNew, setShowNew] = React.useState(false);
  const [showImport, setShowImport] = React.useState(false);
  // 23 Sep 2026 — the Agent reads a shotlist now; the window comes back with one switch in Settings.
  const importHidden = window.useUiHidden("import_shotlist");
  const toggle = (k) => setOpenMenu(prev => prev === k ? null : k);
  const close = () => setOpenMenu(null);

  // Fall back to global if used without prop (legacy)
  const effectiveActive = activeProjectId || (window.__projects && window.__projects.active) || "paradise-found";

  const setProject = (id) => {
    if (onSwitchProject) onSwitchProject(id);
    else if (window.__nav && window.__nav.switchProject) window.__nav.switchProject(id);
  };

  // Code review fix: this used to close the modal BEFORE the create request
  // even ran, so a server-side rejection ({ ok: false, error }) — e.g. a
  // relative watch_path — was thrown away and the modal vanished with no
  // feedback. Now it awaits the create, returns the result back to the
  // modal (which shows result.error inline and stays open on failure), and
  // only closes on a truthy `ok`.
  const onCreate = async (data) => {
    // [mp] P2.4.2 — the intake modal owns the create now (it holds the folder
    // manifest, which App.createProject's formData can't carry). When it has
    // already created and switched to the project it calls back with
    // { created: true, id } — there is nothing left to post, just close.
    if (data && data.created) { setShowNew(false); return { ok: true, id: data.id }; }
    const result = onCreateProject
      ? await onCreateProject(data)
      : (window.__nav && window.__nav.createProject
          ? await window.__nav.createProject(data)
          : { ok: false, error: "No project-create handler is available." });
    if (result && result.ok) setShowNew(false);
    return result;
  };

  return (
    <header className={"project-header" + (currentView && currentView !== "overview" ? " phm-collapsed" : "")}>
      {/* v07zz286/288 — MOBILE top bar: hamburger + FrameFlow wordmark on
          the left; bell + avatar (.ph-right) align on the same line via
          CSS. Leaf icon dropped per Hugo. Renders only on tier "s". */}
      <div className="phm-bar">
        <button
          type="button"
          className="phm-burger"
          aria-label="Open menu"
          onClick={() => { try { window.dispatchEvent(new CustomEvent("ff-mobile-nav-toggle")); } catch (_) {} }}
        >
          <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
        </button>
        <span className="phm-word">TrueNorths</span>
      </div>
      <div className="ph-left">
        <ProjectSwitcher
          open={openMenu === "project"}
          onToggle={() => toggle("project")}
          onClose={close}
          activeId={effectiveActive}
          onSelect={setProject}
          onNewProject={() => setShowNew(true)}
          onImportShotlist={importHidden ? null : () => setShowImport(true)}
          /* t06c — episode switcher integrated into the subtitle line */
          episodeOpen={openMenu === "episode"}
          onEpisodeToggle={() => toggle("episode")}
          onEpisodeClose={close}
          episodes={episodes}
          activeEpisodeId={activeEpisodeId}
          onSwitchEpisode={onSwitchEpisode}
          onCreateEpisode={onCreateEpisode}
          groupOpen={openMenu === "group"}
          onGroupToggle={() => toggle("group")}
          onGroupClose={close}
          groups={groups}
          activeGroupId={activeGroupId}
          onSwitchGroup={onSwitchGroup}
          onCreateGroup={onCreateGroup}
          currentView={currentView}
          metaPending={metaPending}
        />
      </div>
      {/* 23 Sep 2026 — the music player (src/MusicPlayer.jsx): only in projects with a songs
          library, on the status-pill line, position:absolute so nothing here moves. Hidden while
          the project picker is open (its card strip opens right under that line). */}
      {window.MusicPlayer && (
        <window.MusicPlayer projectId={effectiveActive} activeGroupId={activeGroupId} hidden={openMenu === "project"}/>
      )}
      <div className="ph-right">
        {/* the cluster is right-aligned, so this button adds room on its left and moves nothing else.
            On every page (Hugo, 17 Sep 2026: "make it visible on all pages"); phones hide it with the
            scale button (mobile.css .ph-scale-anchor) */}
        {["admin", "producer"].includes(window.__effectiveRole || (window.__currentUser && window.__currentUser.role))
          && (!albumOwnsSkin || !window.hasPerm || window.hasPerm("manage_episodes")) && (
          <StylePill          open={openMenu === "style"}  onToggle={() => toggle("style")}  onClose={close}/>
        )}
        <ScalePill            open={openMenu === "scale"}  onToggle={() => toggle("scale")}  onClose={close}/>
        <SearchPill           open={openMenu === "search"} onToggle={() => toggle("search")} onClose={close}/>
        <NotificationsPopover open={openMenu === "notif"}  onToggle={() => toggle("notif")}  onClose={close}/>
        <RefreshButton/>
        <AvatarMenu           open={openMenu === "avatar"} onToggle={() => toggle("avatar")} onClose={close}/>
      </div>
      {showNew && window.NewProjectModal && (
        <window.NewProjectModal onClose={() => setShowNew(false)} onCreate={onCreate}/>
      )}
      {showImport && window.ImportShotlistModal && (
        <window.ImportShotlistModal onClose={() => setShowImport(false)}/>
      )}
    </header>
  );
}

Object.assign(window, { ProjectHeader });
