// screens-notifications.jsx — the Notification Center. Vault's default view.
//
// Built to the Claude Design "Vault Notification Center.dc.html". Where the mock
// worked off hypotheticals, the copy is unified against Vault's real sources —
// see the CARVE-OUTS block below for every place this deliberately differs from
// the mock, and why.
//
// ARCHITECTURE. This screen renders; it does not aggregate. All eleven sources
// are normalized by lib-actions-feed.js, which also derives the six categories
// and four sections. Nothing here is stored: completing or re-dating an item
// writes to the SOURCE row and the next load reflects it. The only persistence
// is read-state (notification_reads) and preferences (notification_prefs).
//
// CARVE-OUTS FROM THE MOCK
//  1. The mock's ROUTE map sent Team -> "Action Dashboard". That screen is being
//     retired, so Team items route to the screen owning the underlying record;
//     a personal to-do has no owning screen and opens in place.
//  2. The mock showed the completion tick only on In Progress rows. Brian asked
//     for reflexive completion, so the tick shows wherever the SOURCE supports
//     it — and is absent, rather than fake, where it does not (meetings, call
//     batches, RPM cards).
//  3. The mock's "push" is an in-app toast layer on a 1-minute-plays-as-1-second
//     clock. Kept as a real clock. OS-level push (tab closed) is a later goal.
//  4. Added, not in the mock: a date-move control, a timezone selector, and a
//     meeting lead-time selector. The first is Brian's write-back ruling; the
//     other two are what make meeting times correct in Pacific.
//  5. The mock's Recent Activity card was hardcoded. It now reads the real
//     completed tail.

// These were the root tokens as they stood BEFORE the 8/20 theme work, copied
// inline. Repointing them moves the Center onto the current neutrals (a slightly
// cooler page, darker ink) and makes it follow dark mode. Not a no-op -- the
// default view will look marginally different, by design.
const NC = {
  canvas: "var(--bg)", ink: "var(--ink)", sec: "var(--muted)", muted: "var(--muted-2)",
  line: "var(--line)", hair: "var(--line-2)", chip: "var(--surface-2)",
  accent: "var(--accent)", accentDeep: "var(--accent-2)", accentSoft: "var(--accent-soft)",
  good: "var(--ok)", risk: "var(--risk)", warn: "var(--warn)",
  mono: "var(--f-mono)",
  panel: {
    background: "var(--surface)", border: "1px solid var(--line)",
    borderRadius: "var(--r-card)", boxShadow: "var(--shadow-none)",
  },
};
const NC_KICKER = {
  fontSize: 11, fontWeight: 600, letterSpacing: ".04em",
  textTransform: "uppercase", color: NC.muted,
};

// [LABEL, VALUE] — the same order as NC_POSITIONS/NC_FREQS/NC_LEADS above.
// I wrote this pair backwards first: NCSegGroup destructures ([label, val]), so
// the buttons read "light"/"dark" AND patched theme:"Light", which the CHECK in
// sql_87 rejected with a 400. The constraint did its job.
const NC_THEMES = [["Light", "light"], ["Dark", "dark"]];

// Timezones offered in Settings. Deliberately short: these are the zones Harvey
// actually operates in. A free-text field would let a typo through, and while
// sql_84's trigger would reject it, a dropdown never gets there.
const NC_ZONES = [
  ["Pacific",  "America/Los_Angeles"],
  ["Mountain", "America/Denver"],
  ["Central",  "America/Chicago"],
  ["Eastern",  "America/New_York"],
  ["UTC",      "UTC"],
];
// RETIRED 2026-09-01. reminder_freq was a single integer per person driving one
// setInterval, priority-blind and category-blind. Kept only so an older saved
// value still renders something if this file is ever rolled back.
const NC_FREQS = [["Off", 0], ["15 min", 15], ["30 min", 30], ["60 min", 60]];

// The five presets Brian approved, 2026-08-31. Deliberately NOT a free number:
// a cadence anyone can set to anything drifts into uselessness, and the grid
// stops being readable at a glance.
const NC_PRESETS = [
  ["Off", "off"], ["30 Min", "30min"], ["Hourly", "hourly"],
  ["Morning + Afternoon", "morning_afternoon"], ["Once Daily", "daily"],
];
// Meetings are ABSENT on purpose: they have their own lead-time reminder
// (meeting_lead_min, below), and adding them here would notify them twice.
const NC_REMIND_CATS = [
  ["Pipeline", "lead"], ["Client", "client"], ["Research", "research"],
  ["Outreach", "outreach"], ["Team", "team"],
];
const NC_PRIOS = [["High", "high"], ["Medium", "medium"], ["Low", "low"]];
// Must match list_due_reminder_slots(). One fact in two places, so it is named
// as such: if these move, sql_191 moves with them.
const NC_PRESET_DEFAULT = { high: "hourly", medium: "morning_afternoon", low: "daily" };
const NC_LEADS = [["At start", 0], ["5 min", 5], ["10 min", 10], ["15 min", 15], ["30 min", 30], ["60 min", 60]];
const NC_POSITIONS = [["Top right", "top-right"], ["Bottom right", "bottom-right"], ["Top center", "top-center"]];

// Delegates to VaultAlpha. This parsed hex text, so once its inputs became
// tokens in the 8/20 sweep it returned "rgba(NaN,NaN,NaN,a)" -- an invalid
// colour the browser drops silently, which is how the aging ramp on Weekly
// Research Progression went grey without an error.
function ncHexA(color, a) {
  return window.VaultAlpha ? window.VaultAlpha(color, a)
    : `color-mix(in srgb, ${color} ${Math.round((a || 0) * 100)}%, transparent)`;
}

// Segmented-control button. Hoisted to module scope on purpose — a component
// defined inside a render closure is a NEW type on every keystroke, which
// unmounts and remounts its subtree and steals focus from any input inside it.
function NCSeg({ label, active, onClick }) {
  return (
    <button onClick={onClick}
      style={{
        padding: "5px 12px", fontSize: 12, fontWeight: active ? 600 : 500,
        color: active ? NC.ink : NC.sec, background: active ? "var(--surface)" : "transparent",
        borderRadius: "var(--r-ctl)", border: "none", cursor: "pointer", whiteSpace: "nowrap",
        fontFamily: "inherit",
        boxShadow: active ? "0 1px 2px var(--line), 0 0 0 1px var(--line-2)" : "none",
      }}>{label}</button>
  );
}
function NCSegGroup({ options, value, onChange }) {
  return (
    <div style={{ display: "inline-flex", padding: 3, background: NC.chip, border: "1px solid " + NC.line, borderRadius: "var(--r-ctl)", gap: 2, flexWrap: "wrap" }}>
      {options.map(([label, val]) => (
        <NCSeg key={String(val)} label={label} active={value === val} onClick={() => onChange(val)}/>
      ))}
    </div>
  );
}
function NCToggle({ on, onClick }) {
  return (
    <span onClick={onClick} role="switch" aria-checked={!!on}
      style={{ width: 34, height: 19, borderRadius: "var(--r-card)", background: on ? NC.accent : "var(--line-strong)",
        position: "relative", cursor: "pointer", flex: "none", transition: "background .15s" }}>
      <span style={{ position: "absolute", top: 2, left: on ? 17 : 2, width: 15, height: 15, borderRadius: "50%",
        background: "var(--surface)", boxShadow: "var(--shadow-none)", transition: "left .15s" }}/>
    </span>
  );
}
// The cadence grid: category down, priority across. SPARSE — a cell only writes
// a value when it differs from the priority default, so changing the defaults
// later does not have to rewrite everyone's saved rules. Storing the resolved
// value in every cell is the "derived value stored as if authoritative" defect
// this codebase keeps paying for.
function NCReminderGrid({ rules, onChange }) {
  const set = (cat, prio, val) => {
    const next = JSON.parse(JSON.stringify(rules || {}));
    if (val === NC_PRESET_DEFAULT[prio]) {
      // back to the default: REMOVE the override rather than writing the same
      // value, so the row stays sparse and keeps following the default.
      if (next[cat]) { delete next[cat][prio]; if (!Object.keys(next[cat]).length) delete next[cat]; }
    } else {
      next[cat] = next[cat] || {};
      next[cat][prio] = val;
    }
    onChange(next);
  };
  const valueOf = (cat, prio) =>
    (rules && rules[cat] && rules[cat][prio]) || NC_PRESET_DEFAULT[prio];

  return (
    <div style={{ width: "100%" }}>
      <div style={{ display: "grid", gridTemplateColumns: "5.5rem repeat(3, minmax(0,1fr))",
        gap: "6px 8px", alignItems: "center" }}>
        <span/>
        {NC_PRIOS.map(([label]) => (
          <span key={label} style={{ ...NC_KICKER, margin: 0 }}>{label}</span>
        ))}
        {NC_REMIND_CATS.map(([catLabel, cat]) => (
          <React.Fragment key={cat}>
            <span style={{ fontSize: "var(--t-micro)", color: "var(--ink-2)" }}>{catLabel}</span>
            {NC_PRIOS.map(([, prio]) => {
              const v = valueOf(cat, prio);
              const overridden = !!(rules && rules[cat] && rules[cat][prio]);
              return (
                <select key={prio} value={v} onChange={e => set(cat, prio, e.target.value)}
                  aria-label={catLabel + " " + prio + " priority reminder"}
                  style={{ width: "100%", fontSize: "var(--t-micro)", fontFamily: "inherit",
                    padding: "3px 5px", borderRadius: "var(--r-ctl)",
                    border: "1px solid var(--line)", background: "var(--surface)",
                    color: overridden ? "var(--ink)" : "var(--muted)" }}>
                  {NC_PRESETS.map(([pl, pv]) => <option key={pv} value={pv}>{pl}</option>)}
                </select>
              );
            })}
          </React.Fragment>
        ))}
      </div>
      <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginTop: 8 }}>
        Grey is the default for that priority. A reminder only counts actions due today or overdue.
      </div>
    </div>
  );
}

function NCSettingRow({ title, sub, children, stack }) {
  return (
    <div style={{ display: stack ? "block" : "flex", alignItems: "center", gap: 12, padding: "11px 0", borderBottom: "1px solid " + NC.hair }}>
      <span style={{ fontSize: 13, flex: 1, color: NC.ink }}>
        {title}
        {sub ? <span style={{ display: "block", fontSize: 11.5, color: NC.muted, marginTop: 1 }}>{sub}</span> : null}
      </span>
      <div style={{ marginTop: stack ? 8 : 0 }}>{children}</div>
    </div>
  );
}

// ---------------------------------------------------------------- one row
function NCRow({ item, viewerTz, onReview, onComplete, onMoveDate, busy }) {
  const [dateOpen, setDateOpen] = React.useState(false);
  // The date popover had no way out except committing a date: clicking away or
  // pressing Escape did nothing, so "changed my mind" meant moving the item.
  const dateRef = React.useRef(null);
  React.useEffect(() => {
    if (!dateOpen) return;
    const onDoc = (e) => { if (dateRef.current && !dateRef.current.contains(e.target)) setDateOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setDateOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [dateOpen]);
  const emphasise = item.unread;
  return (
    <div style={{ display: "grid", gridTemplateColumns: "8px 1fr auto auto auto", gap: 13, alignItems: "center",
      padding: "12px 18px", borderBottom: "1px solid " + NC.hair, position: "relative",
      opacity: busy ? .5 : 1, transition: "opacity .12s" }}>
      <span style={{ width: 8, height: 8, borderRadius: "50%", background: item.tint }}/>

      <span style={{ minWidth: 0, lineHeight: 1.4 }}>
        <span style={{ display: "flex", alignItems: "center", gap: 7 }}>
          <span style={{ fontSize: 13.5, fontWeight: 600, color: NC.ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
            {item.headline || item.description}
          </span>
          {/* NEW is green: it means "arrived", not "wrong". Red here competed with
              the overdue treatment and made an ordinary new item look urgent. */}
          {item.unread && (
            <span style={{ fontSize: 9, fontWeight: 800, letterSpacing: ".06em", background: "var(--line-2)",
              color: NC.good, borderRadius: "var(--r-chip)", padding: "1px 5px", flexShrink: 0 }}>NEW</span>
          )}
          {/* EVERY priority renders, not just High. Showing only High made the
              board look like it contained nothing else — the team read the
              absence of a badge as an absence of priority. */}
          {item.priority_ && (
            <span title={item.priority_ + " priority"}
              style={{ fontSize: 9, fontWeight: 800, letterSpacing: ".06em", background: ncHexA(item.prioColor, .12),
                color: item.prioColor, borderRadius: "var(--r-chip)", padding: "1px 5px", flexShrink: 0 }}>
              {item.priority_.toUpperCase()}
            </span>
          )}
        </span>
        <span style={{ display: "block", fontSize: 12.5, color: NC.sec, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
          {item.projectName}
          {item.assignedBy ? <span>{" \u2014 assigned by " + item.assignedBy}</span> : null}
          {!item.assignedBy && item.type !== "meeting" && item.description !== item.headline
            ? <span>{" \u2014 " + item.description}</span> : null}
          {item.type === "meeting" && item.location ? <span>{" \u2014 " + item.location}</span> : null}
        </span>
      </span>

      <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11, fontWeight: 600,
        letterSpacing: ".02em", padding: "4px 10px", borderRadius: "var(--r-pill)", background: item.catBg,
        color: item.tint, whiteSpace: "nowrap" }}>
        <span style={{ width: 8, height: 8, borderRadius: "50%", background: item.tint }}/>
        {item.catLabel}
      </span>

      <span style={{ fontFamily: NC.mono, fontSize: 11, color: item.late ? NC.risk : NC.muted,
        whiteSpace: "nowrap", minWidth: 62, textAlign: "right", fontWeight: item.late ? 700 : 400 }}>
        {item.timeLabel}
      </span>

      <span style={{ minWidth: 150, display: "flex", gap: 6, justifyContent: "flex-end", alignItems: "center" }}>
        <button onClick={() => onReview(item)} disabled={busy}
          style={{ background: emphasise ? NC.accent : "var(--surface)", color: emphasise ? "var(--surface)" : NC.ink,
            border: "1px solid " + (emphasise ? NC.accent : NC.line), padding: "5px 12px", borderRadius: "var(--r-ctl)",
            fontSize: 12, fontWeight: 500, whiteSpace: "nowrap", cursor: busy ? "default" : "pointer", fontFamily: "inherit" }}>
          {item.actionLabel}
        </button>

        {/* Date move — only where the source can actually accept a new date. */}
        {item.canMoveDate && (
          <span ref={dateRef} style={{ position: "relative" }}>
            <button onClick={() => setDateOpen(v => !v)} disabled={busy}
              title={dateOpen ? "Close" : "Move the date"}
              style={{ width: 26, height: 26, borderRadius: "var(--r-ctl)", border: "1px solid " + NC.line, background: "var(--surface)",
                color: NC.muted, fontSize: 12, cursor: busy ? "default" : "pointer", lineHeight: 1, fontFamily: "inherit" }}>
              {"\u21BB"}
            </button>
            {dateOpen && (
              <span style={{ position: "absolute", right: 0, top: 30, zIndex: 30, background: "var(--surface)",
                border: "1px solid " + NC.line, borderRadius: "var(--r-card)", boxShadow: "var(--shadow-pop)",
                padding: 10, display: "flex", flexDirection: "column", gap: 6, minWidth: 168 }}>
                <span style={{ ...NC_KICKER, marginBottom: 2 }}>Move to</span>
                {/* Anchored on the VIEWER'S zone, not the browser's. todayStr() is
                    the browser date; between midnight and 8am UTC it is a day
                    ahead of Pacific, so "Tomorrow" would move the item two days. */}
                {[["Today", 0], ["Tomorrow", 1], ["Next week", 7]].map(([lbl, d]) => (
                  <button key={lbl} onClick={() => { setDateOpen(false); onMoveDate(item,
                    window.VaultActionsFeed.addDaysStr(
                      window.VaultActionsFeed.dateInTz(Date.now(), viewerTz || window.VaultActionsFeed.DEFAULT_TZ), d)); }}
                    style={{ textAlign: "left", background: "var(--surface)", border: "1px solid " + NC.line, borderRadius: "var(--r-ctl)",
                      padding: "5px 9px", fontSize: 12, color: NC.ink, cursor: "pointer", fontFamily: "inherit" }}>{lbl}</button>
                ))}
                <input type="date" defaultValue={window.VaultActionsFeed.d10(item.dueDate)}
                  onChange={e => { if (e.target.value) { setDateOpen(false); onMoveDate(item, e.target.value); } }}
                  style={{ border: "1px solid " + NC.line, borderRadius: "var(--r-ctl)", padding: "5px 7px",
                    fontFamily: NC.mono, fontSize: 11, color: NC.ink }}/>
                <button onClick={() => setDateOpen(false)}
                  style={{ textAlign: "center", background: "transparent", border: "none", borderTop: "1px solid " + NC.hair,
                    marginTop: 2, paddingTop: 7, fontSize: 11.5, color: NC.sec, cursor: "pointer", fontFamily: "inherit" }}>
                  Cancel
                </button>
              </span>
            )}
          </span>
        )}

        {/* Completion — absent, not fake, where the source owns it. */}
        {item.canComplete && (
          <button onClick={() => onComplete(item)} disabled={busy} title={item.doneLabel}
            style={{ width: 26, height: 26, borderRadius: "var(--r-ctl)", border: "1px solid " + NC.line, background: "var(--surface)",
              color: NC.muted, fontSize: 12, cursor: busy ? "default" : "pointer", lineHeight: 1, fontFamily: "inherit" }}>
            {"\u2713"}
          </button>
        )}
      </span>
    </div>
  );
}

// ---------------------------------------------------------------- one section
function NCSection({ title, headColor, rows, emptyText, viewerTz, onReview, onComplete, onMoveDate, busyKeys }) {
  return (
    <div>
      <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 7 }}>
        <span style={{ ...NC_KICKER, color: headColor }}>{title}</span>
        <span style={{ fontFamily: NC.mono, fontSize: 10.5, color: NC.muted }}>{rows.length}</span>
      </div>
      <div style={{ ...NC.panel, overflow: "visible" }}>
        {rows.length === 0
          ? <div style={{ padding: "16px 18px", fontSize: 12.5, color: NC.muted }}>{emptyText}</div>
          : rows.map(r => (
              <NCRow key={r.notifKey} item={r} viewerTz={viewerTz} busy={busyKeys.has(r.notifKey)}
                onReview={onReview} onComplete={onComplete} onMoveDate={onMoveDate}/>
            ))}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------- loader
// NCLoading moved to shell.jsx as window.VaultLoader. The Calendar screen needs
// the same 25-second-wait treatment, and two copies of a loading animation is
// exactly the kind of thing that drifts.

// ---------------------------------------------------------------- toasts
// NCToastLayer lived here and is gone. The toast layer is the shell's
// (VaultPushLayer) so it renders on every screen and, critically, ABOVE the
// settings overlay. Leaving a second copy here would be dead code that a later
// change could re-wire at the wrong z-index and reintroduce the invisible-toast
// bug.

// ---------------------------------------------------------------- add action
// PORTED from the deleted Action Dashboard. Removing that screen took the ONLY
// way to create a personal or project action with it — nothing else in Vault
// calls createUserAction or createProjectAction. The Notification Center owns
// the Team and Client categories those rows feed, so the entry point belongs
// here.
//
// Module scope, not declared inside the screen: a component defined in a render
// closure is a new type every keystroke, which remounts the inputs and steals
// focus after each character.
const NC_ADD_CTL = {
  width: "100%", fontSize: 15, color: NC.ink, background: "var(--surface)",
  border: "1px solid " + NC.line, borderRadius: "var(--r-ctl)", padding: "11px 13px",
  outline: "none", boxSizing: "border-box", fontFamily: "inherit",
};
const NC_ADD_LAB = { fontSize: 11, marginBottom: 3, color: NC.muted };

function NCAddActionModal({ viewer, peoplePickerList, onClose, onCreated }) {
  const people = peoplePickerList || [];
  const [ownerId, setOwnerId] = React.useState((viewer && viewer.id) || (people[0] && people[0].id) || null);
  const [kind, setKind] = React.useState("personal"); // personal -> user_actions, project -> project_actions
  const [projectPick, setProjectPick] = React.useState("");
  const [description, setDescription] = React.useState("");
  const [dueDate, setDueDate] = React.useState(() => window.VaultActionsFeed.todayStr());
  const [projectName, setProjectName] = React.useState("");
  const [priority, setPriority] = React.useState("normal");
  const [notes, setNotes] = React.useState("");
  const [saving, setSaving] = React.useState(false);

  const submit = async () => {
    if (!description.trim()) { window.VaultUI.toast("error", "Description is required."); return; }
    if (!dueDate) { window.VaultUI.toast("error", "Due date is required."); return; }
    if (!ownerId) {
      window.VaultUI.toast("error", "Assigned-to is required. If you don't see yourself in the dropdown, your account may not be linked to a Vault person \u2014 flag for admin.");
      return;
    }
    if (kind === "project" && !projectPick && !projectName.trim()) {
      window.VaultUI.toast("error", "Pick a project (or type a project name) for a Project action.");
      return;
    }
    setSaving(true);
    try {
      const moduleHandle = viewer.team || (viewer.subteam ? String(viewer.subteam).replace(/^st-/, "") : null);
      if (kind === "project") {
        const proj = (window.HARVEY_CLIENTS || []).find(p => String(p.id) === String(projectPick)) || null;
        await window.VaultAPI.createProjectAction({
          ownerId: ownerId,
          projectId: proj ? proj.id : null,
          projectName: proj ? proj.client : (projectName.trim() || null),
          module: moduleHandle,
          nextAction: description.trim(),
          dueDate: dueDate,
          notes: notes.trim() || null,
        });
      } else {
        await window.VaultAPI.createUserAction({
          ownerId: ownerId,
          module: moduleHandle,
          projectName: projectName.trim() || null,
          description: description.trim(),
          dueDate: dueDate,
          priority: priority,
          notes: notes.trim() || null,
        });
      }
      onCreated();
    } catch (err) {
      setSaving(false);
      window.VaultUI.toast("error", "Failed to create action: " + (err.message || err));
    }
  };

  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      display: "flex", alignItems: "center", justifyContent: "center", padding: 16, zIndex: 1200 }}>
      <div onClick={e => e.stopPropagation()} style={{ background: "var(--surface)", borderRadius: "var(--r-card)", width: 440,
        maxWidth: "92vw", maxHeight: "90vh", overflowY: "auto",
        boxShadow: "var(--shadow-modal)", border: "1px solid " + NC.line, overflow: "hidden" }}>
        <div style={{ height: 4, background: NC.accent }}/>
        <div style={{ padding: "22px 26px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
          <div style={{ fontSize: 21, fontWeight: 700, letterSpacing: "-0.01em", lineHeight: 1.2, color: NC.ink }}>Add Action</div>
          <div style={{ display: "flex", gap: 8 }}>
            {[["personal", "Personal"], ["project", "Project"]].map(([k, lbl]) => (
              <button key={k} onClick={() => setKind(k)}
                style={{ flex: 1, padding: "10px 0", fontSize: 14, cursor: "pointer", borderRadius: "var(--r-ctl)", fontFamily: "inherit",
                  border: "1px solid " + (kind === k ? "var(--line-strong)" : NC.line),
                  background: kind === k ? NC.accentSoft : "var(--surface)",
                  color: kind === k ? NC.accent : "var(--prio-med)", fontWeight: kind === k ? 600 : 500 }}>
                {lbl}
              </button>
            ))}
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 11 }}>
            <div>
              <div style={NC_ADD_LAB}>Description *</div>
              <input type="text" value={description} onChange={e => setDescription(e.target.value)}
                placeholder="What needs to happen?" autoFocus style={NC_ADD_CTL}/>
            </div>
            {people.length > 1 && (
              <div>
                <div style={NC_ADD_LAB}>Assigned to</div>
                <select value={ownerId || ""} onChange={e => setOwnerId(e.target.value)} style={NC_ADD_CTL}>
                  {people.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
                </select>
              </div>
            )}
            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
              <div>
                <div style={NC_ADD_LAB}>Due date *</div>
                <input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} style={NC_ADD_CTL}/>
              </div>
              <div>
                <div style={NC_ADD_LAB}>Priority</div>
                <select value={priority} onChange={e => setPriority(e.target.value)} style={NC_ADD_CTL}>
                  <option value="low">Low</option>
                  <option value="normal">Medium</option>
                  <option value="high">High</option>
                </select>
              </div>
            </div>
            {kind === "project" ? (
              <div>
                <div style={NC_ADD_LAB}>Project *</div>
                <select value={projectPick} onChange={e => setProjectPick(e.target.value)} style={NC_ADD_CTL}>
                  <option value="">{"Select a project\u2026"}</option>
                  {((window.HARVEY_CLIENTS || []).slice().sort((a, b) => String(a.client).localeCompare(String(b.client)))).map(p => (
                    <option key={p.id} value={p.id}>{p.client}{p.type ? " \u00B7 " + p.type : ""}</option>
                  ))}
                </select>
              </div>
            ) : (
              <div>
                <div style={NC_ADD_LAB}>Project (optional)</div>
                <input type="text" value={projectName} onChange={e => setProjectName(e.target.value)}
                  placeholder="Project / client name" style={NC_ADD_CTL}/>
              </div>
            )}
            <div>
              <div style={NC_ADD_LAB}>Notes (optional)</div>
              <textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
                style={{ ...NC_ADD_CTL, resize: "vertical" }}/>
            </div>
          </div>
          <button onClick={submit} disabled={saving}
            style={{ width: "100%", fontSize: 15, fontWeight: 600, padding: "12px 0", borderRadius: "var(--r-ctl)",
              background: NC.accent, color: "var(--accent-ink)", border: "none", textAlign: "center",
              cursor: saving ? "default" : "pointer", fontFamily: "inherit", opacity: saving ? .7 : 1 }}>
            {saving ? "Saving\u2026" : "Add action"}</button>
          <button onClick={onClose} disabled={saving}
            style={{ alignSelf: "center", fontSize: 13, color: NC.muted, background: "none", border: "none",
              cursor: "pointer", fontFamily: "inherit" }}>Cancel</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ---------------------------------------------------------------- settings
function NCSettings({ prefs, onPatch, onClose, onTestPush, saving }) {
  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "var(--scrim)", zIndex: 1000, display: "flex", justifyContent: "flex-end" }}>
      <div onClick={e => e.stopPropagation()}
        style={{ width: 400, maxWidth: "100%", background: "var(--surface)", height: "100%",
          boxShadow: "-8px 0 32px color-mix(in srgb, var(--ink) 12%, transparent)", padding: "24px 26px", overflowY: "auto" }}>
        <div style={{ display: "flex", alignItems: "center", marginBottom: 4 }}>
          <span style={{ fontSize: 17, fontWeight: 600, letterSpacing: "-.015em", color: NC.ink }}>Notification Settings</span>
          <span onClick={onClose} style={{ marginLeft: "auto", color: NC.muted, fontSize: 15, cursor: "pointer", padding: 4 }}>{"\u2715"}</span>
        </div>
        <div style={{ fontSize: 12.5, color: NC.sec, marginBottom: 20 }}>
          Controls how and where Vault notifies you.{saving ? " Saving\u2026" : ""}
        </div>

        <div style={{ ...NC_KICKER, margin: "18px 0 10px" }}>Push notifications</div>
        <NCSettingRow title="Enable push notifications" sub="Appears on any Vault screen when items are assigned or upcoming">
          <NCToggle on={prefs.pushEnabled} onClick={() => onPatch({ pushEnabled: !prefs.pushEnabled })}/>
        </NCSettingRow>
        <NCSettingRow title="Position on screen" stack>
          <NCSegGroup options={NC_POSITIONS} value={prefs.toastPosition} onChange={v => onPatch({ toastPosition: v })}/>
        </NCSettingRow>

        {/* Theme lives here because this modal already owns `tz` -- both decide
            how Vault LOOKS to one person rather than what the data says. Until
            2026-08-20 the only way to switch was a console command: the value
            came from the in-memory tweaks object, whose panel has no launcher in
            Vault, so dark mode shipped unreachable. */}
        <div style={{ ...NC_KICKER, margin: "18px 0 10px" }}>Appearance</div>
        <NCSettingRow title="Theme" sub="Applies everywhere except the sign-in page, which is dark by design" stack>
          <NCSegGroup options={NC_THEMES} value={prefs.theme || "light"} onChange={v => onPatch({ theme: v })}/>
        </NCSettingRow>

        <div style={{ ...NC_KICKER, margin: "18px 0 10px" }}>Reminders</div>
        <NCSettingRow title="How often to remind you"
          sub="Weekdays, 8am to 6pm, in your time zone. Meetings are reminded separately, below." stack>
          <NCReminderGrid rules={prefs.reminderRules || {}}
            onChange={rules => onPatch({ reminderRules: rules })}/>
        </NCSettingRow>

        <div style={{ ...NC_KICKER, margin: "18px 0 10px" }}>Meetings</div>
        {/* Not in the mock. Without a stated zone a meeting time is resolved
            against UTC and reads seven or eight hours wrong — the same failure
            that hit Days Since. Naming the zone also handles DST by itself. */}
        <NCSettingRow title="Your time zone" sub="Meeting times are shown in this zone, not your browser's" stack>
          <NCSegGroup options={NC_ZONES} value={prefs.tz} onChange={v => onPatch({ tz: v })}/>
        </NCSettingRow>
        <NCSettingRow title="Remind me before a meeting" sub="Also decides when a meeting moves from Upcoming into Needs Action" stack>
          <NCSegGroup options={NC_LEADS} value={prefs.meetingLeadMin} onChange={v => onPatch({ meetingLeadMin: v })}/>
        </NCSettingRow>
        <NCSettingRow title="Mute calendar notifications" sub="Hides meeting items and meeting reminders">
          <NCToggle on={prefs.muteCalendar} onClick={() => onPatch({ muteCalendar: !prefs.muteCalendar })}/>
        </NCSettingRow>

        <div style={{ ...NC_KICKER, margin: "18px 0 10px" }}>Categories</div>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingBottom: 4 }}>
          {window.VaultActionsFeed.CATEGORIES.map(c => {
            const muted = (prefs.mutedCats || []).indexOf(c.key) !== -1;
            return (
              <span key={c.key}
                onClick={() => onPatch({ mutedCats: muted
                  ? (prefs.mutedCats || []).filter(x => x !== c.key)
                  : (prefs.mutedCats || []).concat([c.key]) })}
                style={{ fontSize: 11.5, fontWeight: 600, padding: "4px 11px", borderRadius: "var(--r-card)", cursor: "pointer",
                  border: "1px solid " + (muted ? NC.line : ncHexA(c.tint, .45)),
                  color: muted ? NC.muted : c.tint, background: muted ? "var(--surface)" : c.bg,
                  textDecoration: muted ? "line-through" : "none" }}>
                {c.label}
              </span>
            );
          })}
        </div>
        <div style={{ fontSize: 11, color: NC.muted, marginTop: 8 }}>Struck-through categories are muted.</div>

        <button onClick={onTestPush}
          style={{ marginTop: 22, width: "100%", background: "var(--surface)", color: NC.accent, border: "1px solid " + NC.accent,
            padding: "9px 14px", borderRadius: "var(--r-ctl)", fontSize: 13, fontWeight: 600, cursor: "pointer", fontFamily: "inherit" }}>
          Send a test push
        </button>
      </div>
    </div>
  );
}

// ================================================================== main
function NotificationCenterScreen({ user }) {
  // THIS SCREEN NO LONGER OWNS ANY OF THIS.
  //
  // Polling, the digest timer, meeting reminders, read state and the unread
  // count all live in lib-notif-store.js, started once by the shell. app.jsx
  // keys ScreenBoundary on the view, so anything owned here dies the moment you
  // navigate away — which is exactly why push only worked on this screen.
  //
  // What remains here is rendering and local view state (which tab, which chip,
  // is the archive open). Everything else is read from the store and mutated
  // through it.
  const FEED = window.VaultActionsFeed;
  const STORE = window.VaultNotifications;

  // Subscribe: the store bumps a version, we re-read getState(). Deliberately
  // not useSyncExternalStore — this ships through in-browser Babel with no
  // build step, and a version counter behaves identically here.
  const [, forceTick] = React.useState(0);
  React.useEffect(() => STORE.subscribe(() => forceTick(n => n + 1)), []);
  const st = STORE.getState();

  const prefs = st.prefs || window.VaultAPI.defaultNotificationPrefs(null);
  const notif = st.notif || { rows: [], needsAction: [], inProgress: [], upcoming: [], done: [], unreadCount: 0, byCategory: {} };
  const loading = st.loading;
  const error = st.error;
  const partialFail = st.partialFail || [];
  const busyKeys = st.busyKeys || new Set();
  const viewer = st.viewer;

  // Local view state only.
  const [tab, setTab] = React.useState("All");
  // 2026-08-31 (Brian): filter the feed by priority. No new data — the feed has
  // attached `priority_` to every row since the priority badge was added, so
  // this is a predicate and a strip of chips, nothing more.
  const [prio, setPrio] = React.useState("all");
  // DAY WINDOW. "Today" is the default and is what the sections were built
  // around. The team's complaint was that getting ahead for tomorrow meant
  // scrolling to Upcoming and reading dates off individual rows — there was no
  // way to ASK "what does tomorrow look like". Picking a day re-anchors the
  // whole board on that date: Needs Action becomes "due that day or earlier",
  // Upcoming becomes "after it".
  const [dayWindow, setDayWindow] = React.useState("today"); // today | tomorrow | week
  const [completedOpen, setCompletedOpen] = React.useState(false);
  const [addOpen, setAddOpen] = React.useState(false);

  // Who a new action can be assigned to. Team lead and above may assign across
  // their own module; everyone else gets themselves only. Default is always
  // yourself -- see NCAddActionModal's ownerId seed.
  //
  // GATED ON THE DERIVED TIER, NOT ON THE JOB TITLE (Brian, 2026-08-31).
  // This used to test the role STRING -- role.includes("director"), plus a
  // regex of abbreviations. That is a fifth place where "who leads" was
  // written down, and it disagreed with the org chart: Grant Burton, Jordan
  // Scheftz and Cody Swan are all team_lead by lib-org-scope's rule and all
  // read "Associate", so none of them could assign an action to anyone. Two
  // people passed on the word "Director" alone. VaultOrg.atLeast() reads the
  // same tier the RLS policies enforce, so the control and the database now
  // agree about who a lead is.
  //
  // Module still comes from `viewer.team` with the subteam fallback, matching
  // what NCAddActionModal stamps into `module` on submit. Those two must not
  // drift: the sql_178 write policy compares that stamped module against the
  // writer's own, so a picker offering someone the policy would refuse is a
  // silent failure at save time rather than a disabled option.
  //
  // A firm-level person (no team -- Riegler, Gandhi) still gets themselves
  // only. They have firm-wide VISIBILITY, which is a different axis from
  // owning a module's work, and there is no module here to scope a list to.
  const peoplePickerList = React.useMemo(() => {
    if (!viewer) return [];
    const me = [{ id: viewer.id, name: viewer.name || viewer.id }];
    const myTeam = viewer.team || (viewer.subteam ? String(viewer.subteam).replace(/^st-/, "") : null);
    const ORG = window.VaultOrg;
    const leads = !!(ORG && ORG.atLeast && ORG.atLeast(viewer, "team_lead"));
    if (!leads || !myTeam) return me;
    // roster() is the single person-list source: module is `team` never
    // subteam, inactive people are out, and `include` keeps the viewer present
    // even if the org chart has not caught up with them yet.
    const list = (ORG.roster ? ORG.roster(myTeam, { include: viewer.id }) : [])
      .map(p => ({ id: p.id, name: p.name }));
    return list.length ? list : me;
  }, [viewer]);

  const reload = React.useCallback(() => STORE.reload(), []);
  const savePrefs = React.useCallback((patch) => STORE.savePrefs(patch), []);
  const onReview = React.useCallback((item) => STORE.review(item), []);
  const onComplete = React.useCallback((item) => STORE.complete(item), []);
  const onMoveDate = React.useCallback((item, iso) => STORE.moveDate(item, iso), []);
  const markAllRead = React.useCallback(() => STORE.markAllRead(), []);
  const onReopen = React.useCallback((item) => STORE.reopen(item), []);

  // ---- tab filtering
  const inTab = React.useCallback((r) => tab === "All" || r.cat === tab, [tab]);
  // Values are "High" / "Medium" / "Low" — capitalised, from PRIO_NORM in
  // lib-actions-feed.js. The first version of this used lowercase keys and would
  // have matched nothing at all.
  //
  // NO "Untriaged" OPTION. prioOf() never returns empty: an action with no
  // stored priority falls through to a type-derived one and, failing that, to
  // "Low". So a filter for "no priority" could never match a single row — a
  // control that can never be true is the same defect as a condition that can
  // never fire, and this screen would have shipped one.
  const inPrio = React.useCallback((r) => prio === "all" || r.priority_ === prio, [prio]);

  // THE DAY WINDOW IS A SCOPE, NOT A RE-ANCHOR.
  //
  // First version moved the Needs Action / Upcoming split point while leaving
  // the row set alone, so the donut, the tab counts and the "N need action"
  // pill never moved — and worse, they were derived from a DIFFERENT set than
  // the list beneath them, so they would disagree the moment something fell due
  // tomorrow. Two numbers describing one board.
  //
  // Now the window decides which rows exist at all, and EVERYTHING below is
  // derived from that one set: sections, tabs, pill, donut, priority card,
  // archive. Picking "Tomorrow" answers "what does tomorrow look like".
  //
  // OVERDUE IS ALWAYS IN SCOPE. An item you already owe does not stop being
  // owed because you are looking at tomorrow — hiding it would make the window
  // a way to lose work.
  const FEEDX = window.VaultActionsFeed;
  const anchorDate = React.useMemo(() => {
    const t = FEEDX.dateInTz(Date.now(), prefs.tz || FEEDX.DEFAULT_TZ);
    if (dayWindow === "tomorrow") return FEEDX.addDaysStr(t, 1);
    if (dayWindow === "week") return FEEDX.addDaysStr(t, 6);
    return t;
  }, [dayWindow, prefs.tz]);
  const todayDate = React.useMemo(
    () => FEEDX.dateInTz(Date.now(), prefs.tz || FEEDX.DEFAULT_TZ), [prefs.tz]);

  const inWindow = React.useCallback(
    (r) => FEEDX.d10(r.dueDate) <= anchorDate, [anchorDate]);

  // The one scoped set. Everything on the screen counts THIS.
  const windowRows = React.useMemo(
    () => notif.rows.filter(inWindow), [notif.rows, inWindow]);

  const live = windowRows.filter(r => r.status !== "done");
  const needs = live.filter(r => r.status === "new").filter(inTab).filter(inPrio);
  const prog  = live.filter(r => r.status === "progress").filter(inTab).filter(inPrio);
  const upc   = live.filter(r => r.status === "upcoming").filter(inTab).filter(inPrio);
  const done  = windowRows.filter(r => r.status === "done").filter(r => tab === "All" || r.cat === tab);

  // ---- right rail figures, all from the same scoped set
  const catCounts = FEED.CATEGORIES.map(c => [c, live.filter(r => r.cat === c.key).length]).filter(x => x[1] > 0);
  const total = catCounts.reduce((a, x) => a + x[1], 0);
  const donutBg = (() => {
    if (!total) return NC.hair;
    let acc = 0;
    const segs = catCounts.map(([c, n]) => {
      const a0 = acc / total * 360, a1 = (acc + n) / total * 360; acc += n;
      return c.tint + " " + a0 + "deg " + a1 + "deg";
    });
    return "conic-gradient(" + segs.join(", ") + ")";
  })();
  const prioCounts = ["High", "Medium", "Low"].map(p => [p, live.filter(r => r.unread && r.priority_ === p).length]);
  const recent = windowRows.filter(r => r.status === "done").slice(0, 4);

  const tabDefs = [["All", "All", live.length]].concat(
    FEED.CATEGORIES.map(c => [c.key, c.tab, live.filter(r => r.cat === c.key).length])
  );
  const needsCount = live.filter(r => r.status === "new").length;

  const tabMeta = tab === "All" ? null : FEED.CAT_BY_KEY[tab];

  // NOTE: an escape written as JSX TEXT ships as the literal characters
  // "\u2026" -- it must be a JS expression. This is the rule I broke twice here.
  if (!viewer) return <div style={{ padding: 40, color: NC.muted }}>{"Loading\u2026"}</div>;

  return (
    <div style={{ padding: "22px 28px 40px", minHeight: "100vh", background: NC.canvas, color: NC.ink }}>

      {/* Header */}
      <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        <span className="v-h1">Notification Center</span>
        <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11, fontWeight: 600,
          letterSpacing: ".02em", padding: "4px 10px", borderRadius: "var(--r-pill)", background: NC.accentSoft, color: NC.accent }}>
          <span style={{ width: 8, height: 8, borderRadius: "50%", background: NC.accent }}/>
          {needsCount} need action
        </span>
        <div style={{ flex: 1 }}/>
        <button onClick={() => savePrefs({ pushEnabled: !prefs.pushEnabled })}
          style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "var(--surface)", border: "1px solid " + NC.line,
            padding: "7px 14px", borderRadius: "var(--r-ctl)", fontSize: 13, fontWeight: 500, color: NC.ink, cursor: "pointer", fontFamily: "inherit" }}>
          <span style={{ width: 8, height: 8, borderRadius: "50%", background: prefs.pushEnabled ? NC.good : NC.muted }}/>
          Push: {prefs.pushEnabled ? "On" : "Off"}
        </button>
        <button onClick={() => STORE.openSettings()}
          style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "var(--surface)", border: "1px solid " + NC.line,
            padding: "7px 14px", borderRadius: "var(--r-ctl)", fontSize: 13, fontWeight: 500, color: NC.ink, cursor: "pointer", fontFamily: "inherit" }}>
          {"\u2699"} Settings
        </button>
        {/* The ONLY way to create a personal or project action since the Action
            Dashboard was retired — nothing else in Vault calls createUserAction
            or createProjectAction. */}
        <button onClick={() => setAddOpen(true)}
          style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "var(--surface)", border: "1px solid " + NC.line,
            padding: "7px 14px", borderRadius: "var(--r-ctl)", fontSize: 13, fontWeight: 500, color: NC.ink, cursor: "pointer", fontFamily: "inherit" }}>
          + Action
        </button>
        <button onClick={markAllRead} disabled={notif.unreadCount === 0}
          style={{ background: notif.unreadCount ? NC.accent : "var(--surface)", color: notif.unreadCount ? "var(--surface)" : NC.muted,
            border: "1px solid " + (notif.unreadCount ? NC.accent : NC.line), padding: "7px 14px", borderRadius: "var(--r-ctl)",
            fontSize: 13, fontWeight: 500, cursor: notif.unreadCount ? "pointer" : "default", fontFamily: "inherit", whiteSpace: "nowrap" }}>
          Mark all read
        </button>
      </div>

      {/* Controls row. Both segmented strips live in ONE flex container: they
          previously carried their own marginTop (16 vs 12) as separate inline
          blocks, which is why they sat a few pixels out of line. */}
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 16, flexWrap: "wrap" }}>
      <div style={{ display: "inline-flex", padding: 3, background: NC.chip, border: "1px solid " + NC.line,
        borderRadius: "var(--r-card)", gap: 2, flexWrap: "wrap" }}>
        {tabDefs.map(([key, label, n]) => {
          const active = tab === key;
          const isMutedCat = key !== "All" && ((prefs.mutedCats || []).indexOf(key) !== -1 || (key === "meetings" && prefs.muteCalendar));
          return (
            <span key={key} onClick={() => setTab(key)}
              style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 13px", fontSize: 12.5,
                fontWeight: active ? 600 : 500, color: active ? NC.ink : NC.sec,
                background: active ? "var(--surface)" : "transparent", borderRadius: "var(--r-ctl)", whiteSpace: "nowrap",
                cursor: "pointer", userSelect: "none",
                boxShadow: active ? "0 1px 2px var(--line), 0 0 0 1px var(--line-2)" : "none" }}>
              {label}
              {n > 0 && <span style={{ fontSize: 10.5, fontWeight: 700, fontFamily: NC.mono, color: active ? NC.accent : NC.muted }}>{n}</span>}
              {isMutedCat && <span style={{ fontSize: 10, color: NC.muted }}>muted</span>}
            </span>
          );
        })}
      </div>

      {/* Priority. Its own strip beside the categories rather than more chips in
          theirs: category and priority are independent questions, and merging
          them would make "Pipeline" and "High" look mutually exclusive. Counts
          come from the SAME `live` set the sections are built from — the day
          window already taught this screen that two numbers describing one board
          will disagree the moment anything moves. */}
      <div style={{ display: "inline-flex", padding: 3, background: NC.chip, border: "1px solid " + NC.line,
        borderRadius: "var(--r-card)", gap: 2, flexWrap: "wrap" }}>
        {[["all", "All Priorities"], ["High", "High"], ["Medium", "Medium"], ["Low", "Low"]]
          .map(([key, label]) => {
            const active = prio === key;
            const n = key === "all" ? 0 : live.filter(r => r.priority_ === key).length;
            return (
              <span key={key} onClick={() => setPrio(key)}
                style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 13px", fontSize: 12.5,
                  fontWeight: active ? 600 : 500, color: active ? NC.ink : NC.sec,
                  background: active ? "var(--surface)" : "transparent", borderRadius: "var(--r-ctl)",
                  whiteSpace: "nowrap", cursor: "pointer", userSelect: "none",
                  boxShadow: active ? "0 1px 2px var(--line), 0 0 0 1px var(--line-2)" : "none" }}>
                {label}
                {n > 0 && <span style={{ fontSize: 10.5, fontWeight: 700, fontFamily: NC.mono,
                  color: active ? NC.accent : NC.muted }}>{n}</span>}
              </span>
            );
          })}
      </div>

      {/* Day window — "what does tomorrow look like" without leaving the board. */}
      <div style={{ display: "inline-flex", padding: 3, background: NC.chip, border: "1px solid " + NC.line,
        borderRadius: "var(--r-card)", gap: 2, flexWrap: "wrap" }}>
        {[["today", "Today"], ["tomorrow", "Tomorrow"], ["week", "Next 7 Days"]].map(([k, lbl]) => {
          const active = dayWindow === k;
          return (
            <button key={k} onClick={() => setDayWindow(k)}
              style={{ padding: "5px 13px", fontSize: 12.5, fontWeight: active ? 600 : 500,
                color: active ? NC.ink : NC.sec, background: active ? "var(--surface)" : "transparent",
                borderRadius: "var(--r-ctl)", border: "none", cursor: "pointer", whiteSpace: "nowrap", fontFamily: "inherit",
                boxShadow: active ? "0 1px 2px var(--line), 0 0 0 1px var(--line-2)" : "none" }}>
              {lbl}
            </button>
          );
        })}
      </div>
      </div>

      {dayWindow !== "today" && (
        <div style={{ marginTop: 12, padding: "9px 16px", borderRadius: "var(--r-card)", background: NC.accentSoft,
          border: "1px solid var(--line-strong)", fontSize: 12.5, color: NC.accentDeep }}>
          Showing everything due through {window.VaultDate(anchorDate + "T00:00:00Z", { weekday: "long", month: "short", day: "numeric" })}
          {", including anything already overdue. Reminders and the sidebar badge still follow today."}
        </div>
      )}

      {/* Purpose line. The sub-type CHIP ROW that used to sit here is gone
          (Brian 8/19): clicking a Summary legend slice selected the category,
          which then revealed a second, unannounced filter layer — it read as a
          card appearing from nowhere. One filter level is enough. */}
      {tabMeta && (
        <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", marginTop: 14 }}>
          <span style={{ fontSize: 12.5, color: NC.sec }}>{tabMeta.purpose}</span>
          <div style={{ flex: 1 }}/>
          {tab === "meetings" && (
            <span onClick={() => savePrefs({ muteCalendar: !prefs.muteCalendar })}
              style={{ fontSize: 11.5, fontWeight: 600, padding: "4px 11px", borderRadius: "var(--r-card)",
                border: "1px solid " + NC.line, color: NC.sec, background: "var(--surface)", cursor: "pointer" }}>
              {prefs.muteCalendar ? "Unmute Calendar" : "Mute Calendar"}
            </span>
          )}
        </div>
      )}

      {tab === "meetings" && prefs.muteCalendar && (
        <div style={{ marginTop: 14, padding: "10px 16px", background: "var(--warn-soft)", border: "1px solid var(--gold-line)",
          borderRadius: "var(--r-card)", fontSize: 12.5, color: "var(--warn)", display: "flex", alignItems: "center", gap: 10 }}>
          Calendar notifications are muted — meeting items and reminders are hidden.
          <span onClick={() => savePrefs({ muteCalendar: false })} style={{ fontWeight: 700, cursor: "pointer", textDecoration: "underline" }}>Unmute</span>
        </div>
      )}

      {partialFail.length > 0 && (
        <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", marginTop: 14,
          borderRadius: "var(--r-card)", background: "var(--surface-2)", border: "1px solid var(--gold-line)" }}>
          <span style={{ fontSize: 13, color: "var(--gold-ink)", flex: 1 }}>
            Some sources didn't load ({partialFail.join(", ")}). Showing what's available — counts may be incomplete.
          </span>
          <button onClick={reload} style={{ background: "var(--surface)", border: "1px solid var(--gold-line)", color: "var(--gold-ink)",
            borderRadius: "var(--r-ctl)", padding: "6px 12px", fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit" }}>Retry</button>
        </div>
      )}

      {error && (
        <div style={{ ...NC.panel, padding: "22px 24px", marginTop: 16 }}>
          <div style={{ fontSize: 14, fontWeight: 600, color: NC.risk }}>Couldn't load your notifications.</div>
          <div style={{ fontFamily: NC.mono, fontSize: 11, color: NC.muted, marginTop: 6 }}>{error}</div>
          <button onClick={reload} style={{ marginTop: 12, background: "var(--surface)", border: "1px solid " + NC.line,
            color: NC.sec, borderRadius: "var(--r-ctl)", padding: "7px 14px", fontSize: 12, cursor: "pointer", fontFamily: "inherit" }}>Retry</button>
        </div>
      )}

      {/* Body */}
      <div style={{ display: "flex", gap: 18, marginTop: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
        <div style={{ flex: "1 1 620px", minWidth: 0, display: "flex", flexDirection: "column", gap: 14 }}>
          {loading
            ? <window.VaultLoader/>
            : (<>
                <NCSection title="Needs Action" headColor={NC.risk} rows={needs} viewerTz={prefs.tz} busyKeys={busyKeys}
                  emptyText="Nothing needs your attention right now." onReview={onReview} onComplete={onComplete} onMoveDate={onMoveDate}/>
                <NCSection title="In Progress" headColor="var(--prio-med)" rows={prog} viewerTz={prefs.tz} busyKeys={busyKeys}
                  emptyText="No items in progress." onReview={onReview} onComplete={onComplete} onMoveDate={onMoveDate}/>
                <div id="nc-upcoming">
                  <NCSection title="Upcoming" headColor="var(--prio-med)" rows={upc} viewerTz={prefs.tz} busyKeys={busyKeys}
                    emptyText="Nothing scheduled." onReview={onReview} onComplete={onComplete} onMoveDate={onMoveDate}/>
                </div>

                <div onClick={() => setCompletedOpen(v => !v)}
                  style={{ display: "flex", alignItems: "center", gap: 9, padding: "10px 18px", ...NC.panel,
                    cursor: "pointer", userSelect: "none" }}>
                  <span style={{ width: 8, height: 8, borderRadius: "50%", background: NC.good }}/>
                  <span style={{ fontSize: 12.5, fontWeight: 600, color: "var(--prio-med)" }}>Completed &amp; archived</span>
                  <span style={{ fontFamily: NC.mono, fontSize: 10.5, color: NC.muted }}>{done.length}</span>
                  <span style={{ marginLeft: "auto", fontSize: 12, fontWeight: 500, color: NC.accent }}>
                    {completedOpen ? "Hide" : "View archive"}
                  </span>
                </div>
                {completedOpen && (
                  <div style={{ ...NC.panel, overflow: "hidden", marginTop: -8 }}>
                    {done.length === 0
                      ? <div style={{ padding: "16px 18px", fontSize: 12.5, color: NC.muted }}>Nothing completed yet.</div>
                      : done.map(d => (
                          <div key={d.notifKey} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 18px",
                            borderBottom: "1px solid " + NC.hair }}>
                            <span style={{ color: NC.good, fontSize: 12 }}>{"\u2713"}</span>
                            <span style={{ fontSize: 12.5, fontWeight: 600, color: "var(--prio-med)", textDecoration: "line-through" }}>{d.description}</span>
                            <span style={{ fontSize: 12, color: NC.muted }}>{d.projectName}</span>
                            <span style={{ marginLeft: "auto", fontFamily: NC.mono, fontSize: 10.5, color: NC.muted }}>{d.timeLabel}</span>
                            {/* Ticking the wrong row was a one-way door. Reopen writes
                                back to the source, exactly as completing did. Absent
                                where completing DESTROYED the action (a pipeline pick's
                                next action, a new_clients date) — nothing remembers what
                                it was, so a reopen there would be a lie. */}
                            {d.canReopen ? (
                              <button onClick={() => onReopen(d)} disabled={busyKeys.has(d.notifKey)}
                                title="Mark this not done"
                                style={{ border: "1px solid " + NC.line, background: "var(--surface)", color: NC.sec,
                                  borderRadius: "var(--r-ctl)", padding: "3px 10px", fontSize: 11.5, cursor: "pointer",
                                  fontFamily: "inherit", flexShrink: 0,
                                  opacity: busyKeys.has(d.notifKey) ? .5 : 1 }}>Undo</button>
                            ) : (
                              <span title="Completing this replaced the action at its source, so it can't be reopened here."
                                style={{ fontSize: 10.5, color: NC.muted, flexShrink: 0 }}>at source</span>
                            )}
                          </div>
                        ))}
                  </div>
                )}
              </>)}
        </div>

        {/* Right rail */}
        <aside style={{ width: 284, flex: "none", display: "flex", flexDirection: "column", gap: 14 }}>
          <div style={{ ...NC.panel, padding: "16px 18px" }}>
            <div style={{ display: "flex", alignItems: "baseline" }}>
              <span style={{ fontSize: 13.5, fontWeight: 600 }}>Summary</span>
              <span style={{ marginLeft: "auto", fontFamily: NC.mono, fontSize: 11, color: NC.muted }}>{total}</span>
            </div>
            <div style={{ display: "flex", gap: 16, alignItems: "center", marginTop: 14 }}>
              <div style={{ width: 96, height: 96, borderRadius: "50%", background: donutBg, position: "relative", flex: "none" }}>
                <div style={{ position: "absolute", inset: 15, borderRadius: "50%", background: "var(--surface)",
                  display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
                  <span style={{ fontSize: 19, fontWeight: 600, fontFamily: NC.mono, lineHeight: 1 }}>{total}</span>
                  <span style={{ fontSize: 10, color: NC.sec, marginTop: 2 }}>Total</span>
                </div>
              </div>
              <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
                {catCounts.length === 0
                  ? <span style={{ fontSize: 11, color: NC.muted }}>All clear.</span>
                  : catCounts.map(([c, n]) => (
                      <div key={c.key} onClick={() => setTab(c.key)}
                        style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer" }}>
                        <span style={{ width: 8, height: 8, borderRadius: "50%", background: c.tint }}/>
                        <span style={{ fontSize: 10.5, color: "var(--prio-med)", flex: 1, whiteSpace: "nowrap" }}>{c.label}</span>
                        <span style={{ fontFamily: NC.mono, fontSize: 10, color: NC.muted }}>{n}</span>
                      </div>
                    ))}
              </div>
            </div>
          </div>

          <div style={{ ...NC.panel, padding: "16px 18px" }}>
            <div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 12 }}>Recently completed</div>
            {recent.length === 0
              ? <div style={{ fontSize: 12, color: NC.muted }}>Nothing completed yet.</div>
              : (
                <div style={{ display: "flex", flexDirection: "column", gap: 11 }}>
                  {recent.map(r => (
                    <div key={r.notifKey} style={{ display: "flex", gap: 10, alignItems: "center", paddingBottom: 11, borderBottom: "1px solid " + NC.hair }}>
                      {/* Green, not the category tint: every row in this card is a
                          COMPLETION, and that is the signal worth carrying. */}
                      <span style={{ width: 32, height: 32, borderRadius: "var(--r-ctl)", background: "var(--line-2)", display: "flex",
                        alignItems: "center", justifyContent: "center", flex: "none",
                        color: NC.good, fontSize: 14, fontWeight: 700 }}>{"\u2713"}</span>
                      <span style={{ minWidth: 0, flex: 1, lineHeight: 1.35 }}>
                        <span style={{ display: "block", fontSize: 12, fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.description}</span>
                        <span style={{ display: "block", fontSize: 11, color: NC.sec }}>{r.projectName}</span>
                      </span>
                    </div>
                  ))}
                </div>
              )}
          </div>

          <div style={{ ...NC.panel, padding: "16px 18px" }}>
            <div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 8 }}>Unread by priority</div>
            <div style={{ display: "flex", flexDirection: "column" }}>
              {prioCounts.map(([p, n]) => (
                <div key={p} style={{ display: "flex", alignItems: "center", gap: 9, padding: "9px 0", borderBottom: "1px solid " + NC.hair }}>
                  <span style={{ width: 8, height: 8, borderRadius: "50%", background: (FEED.PRIO_META[p] || {}).color }}/>
                  <span style={{ fontSize: 12.5, color: "var(--prio-med)", flex: 1 }}>{p}</span>
                  <span style={{ fontFamily: NC.mono, fontSize: 11, color: NC.muted }}>{n}</span>
                </div>
              ))}
            </div>
            <div style={{ fontSize: 10.5, color: NC.muted, marginTop: 10, lineHeight: 1.45 }}>
              Unmarked items count as Low.
            </div>
          </div>
        </aside>
      </div>

      {/* The toast layer is rendered by the SHELL (VaultPushLayer), not here --
          it has to appear on every screen, and rendering it in both places
          would double every toast while the Center is open. */}

      {addOpen && (
        <NCAddActionModal viewer={viewer} peoplePickerList={peoplePickerList}
          onClose={() => setAddOpen(false)}
          onCreated={() => { setAddOpen(false); window.VaultUI.toast("success", "Action added."); reload(); }}/>
      )}

      {/* The settings drawer is rendered by the SHELL (VaultPushLayer), not
          here. It has to be openable on any screen -- otherwise a test push can
          only ever be fired from the one screen you are already on, which was
          the whole complaint. Rendering it in both places would stack two
          drawers whenever the Center happened to be open. */}
    </div>
  );
}

window.NotificationCenterScreen = NotificationCenterScreen;
// Exported so the shell can render the drawer globally. It stays defined here
// because it depends on this file's design tokens (NC, NC_ZONES, NCSegGroup,
// NCToggle, NCSettingRow); duplicating it into shell.jsx would create two
// copies of the same panel that drift apart.
window.NCSettingsPanel = NCSettings;
