/* ELEV8 ATS - R125 composers: TaskComposer + NoteDoc (from the shell handoff's task modal +
   note window, extended with the dock/minimize/notify/suggestions controls).

   TaskComposer - a task is never just a line of text: the modal carries the title (@ links a
   person or job), a label (maps onto the task `kind` the queue already renders), a due chip
   (maps onto the canonical urgency bands) and an assignee. Creates REAL rows in the tasks
   collection via E8Store, with audit + undo.

   NoteDoc - the rich note surface: editable title + body (the note's real points[]), link
   chips with a suggestions-first picker ("mentioned in this note" is a live text scan), copy
   link, notify a colleague, generate next-step suggestions, delete with undo. On DESKTOP the
   window docks to the corner (and minimizes to its header) so a note can stay open while you
   work the record - mobile keeps the plain modal. Namespaces .e8-tkc-* / .e8-ndoc-*. */

/* ---- shared: search people/jobs/contacts for @-linking and the note picker ----
   Results interleave by TYPE (candidate, job, contact round-robin) so a broad or empty query
   surfaces every kind of record instead of the first-scanned type consuming the whole limit. */
/* R142 batch 4: five buckets, not three.
   The owner's ask was that a note "can tag a person, contact, recruiter, company, job, etc" -
   recruiters (teammates) and companies (clients) were the two that did not exist. They are added
   here rather than in a note-only helper because the TASK composer reads the same function, so
   @-mentioning a colleague on a task starts working at the same time.
   Round-robin below keeps one bucket from crowding the list out, so a query matching a candidate
   and a client shows both rather than six candidates. */
function e8EntitySearch(query, limit) {
  const D = window.E8DATA || {};
  const ql = String(query || '').toLowerCase();
  const cap = limit || 6;
  const buckets = [[], [], [], [], []];
  (D.matches || []).forEach((c) => {
    if (buckets[0].length < cap && !c.mergedInto && (!ql || c.name.toLowerCase().includes(ql))) {
      buckets[0].push({ type: 'candidate', id: c.id, label: c.name, sub: c.title, icon: 'person' });
    }
  });
  (D.jobs || []).forEach((j) => {
    if (buckets[1].length < cap && (!ql || (j.id + ' ' + j.title + ' ' + (j.client || '')).toLowerCase().includes(ql))) {
      buckets[1].push({ type: 'job', id: j.id, label: j.id + ' · ' + j.title, sub: j.client, icon: 'work' });
    }
  });
  (D.contacts || []).forEach((ct) => {
    if (buckets[2].length < cap && (!ql || ct.name.toLowerCase().includes(ql))) {
      buckets[2].push({ type: 'contact', id: ct.id, label: ct.name, sub: ct.client, icon: 'contacts' });
    }
  });
  /* Teammates. Union of the commission roster (D.reps) and the switchable personas, deduped by
     name - the two overlap but neither is complete on its own, and a recruiter thinks in names
     rather than in which table someone happens to live in. `type: 'rep'` is new; E8Workspace's
     resolve() does not know it, so consumers fall back to the stored label, which is the person's
     name and is exactly what should render. */
  const seenRep = {};
  const repRows = (D.reps || []).concat(window.e8Personas ? window.e8Personas() : []);
  repRows.forEach((r) => {
    const name = r && r.name;
    if (!name || seenRep[name]) return;
    if (buckets[3].length >= cap) return;
    if (ql && name.toLowerCase().indexOf(ql) === -1) return;
    seenRep[name] = 1;
    buckets[3].push({ type: 'rep', id: r.id || r.repId || name, label: name, sub: r.role || r.label || 'Teammate', icon: 'badge' });
  });
  (D.clients || []).forEach((cl) => {
    if (buckets[4].length < cap && (!ql || String(cl.name || '').toLowerCase().includes(ql))) {
      buckets[4].push({ type: 'client', id: cl.id, label: cl.name, sub: cl.industry || 'Client', icon: 'apartment' });
    }
  });
  /* Interleave for VARIETY, then sort by how well the query actually matched.
     Round-robin alone put a job whose Spanish title contains "ren" above the teammate named
     Renee - which is what typing "@Ren" produced, and it is the difference between a mention
     field that feels like a product and one that feels like a filter. Rank 0 the labels that
     START with the query, 1 those where it starts a word (so "okafor" finds "Daniel Okafor",
     and "JO-107" finds the job), 2 the rest. Ties keep the interleaved order, so a query that
     matches everything equally still shows a spread of record kinds rather than six candidates. */
  const out = [];
  for (let i = 0; buckets.some((b) => i < b.length); i += 1) {
    buckets.forEach((b) => { if (i < b.length) out.push(b[i]); });
  }
  if (!ql) return out.slice(0, cap);
  const rank = (row) => {
    const l = String(row.label || '').toLowerCase();
    if (l.indexOf(ql) === 0) return 0;
    if (new RegExp('(^|[^a-z0-9])' + ql.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(l)) return 1;
    return 2;
  };
  return out
    .map((row, i) => ({ row: row, r: rank(row), i: i }))
    .sort((a, b) => (a.r - b.r) || (a.i - b.i))
    .slice(0, cap)
    .map((x) => x.row);
}

/* Record label for a {type, id} ref - used for "linked to @X" and the note hub line. */
function e8RefLabel(type, id) {
  if (!window.E8Workspace) return id;
  const row = window.E8Workspace.resolve({ type, id });
  return row ? row.label : id;
}

/* ============================== TaskComposer ============================== */
const TKC_LABELS = [
  { kind: 'general', label: 'General', icon: 'task_alt' },
  { kind: 'send', label: 'Outreach', icon: 'send' },
  { kind: 'match', label: 'Matching', icon: 'join_inner' },
  { kind: 'alert', label: 'Urgent flag', icon: 'priority_high' },
  { kind: 'note', label: 'Note-keeping', icon: 'sticky_note_2' },
];
const TKC_DUE = [
  { key: 'none', label: 'No due date', urgency: 'week' },
  { key: 'now', label: 'Right now', urgency: 'now' },
  { key: 'today', label: 'Today', urgency: 'today' },
  { key: 'week', label: 'This week', urgency: 'week' },
];

function TaskComposer({ refType, refId, entityLabel, prefillTitle, onClose }) {
  const { showToast } = React.useContext(E8Ctx);
  const D = window.E8DATA || {};
  const persona = window.e8ActivePersona ? window.e8ActivePersona() : D.user;
  const me = (persona || {}).name || 'You';
  const [title, setTitle] = React.useState(prefillTitle || '');
  const [link, setLink] = React.useState(refType && refId
    ? { type: refType, id: refId, label: entityLabel || e8RefLabel(refType, refId), fromRecord: true }
    : null);
  const [kind, setKind] = React.useState(null);   /* null -> chip reads "Label" */
  const [due, setDue] = React.useState('none');
  const [owner, setOwner] = React.useState(me);
  const [menu, setMenu] = React.useState(null);   /* 'label' | 'due' | 'assign' */
  const [atSel, setAtSel] = React.useState(0);
  const inputRef = React.useRef(null);
  React.useEffect(() => { if (inputRef.current) inputRef.current.focus(); }, []);

  /* @-linking: an @ anywhere in the draft opens live person/job/contact suggestions for the
     text after it; picking sets the task's ref and strips the @query from the title. */
  const atMatch = /@([^@]*)$/.exec(title);
  const atSugs = atMatch ? e8EntitySearch(atMatch[1], 6) : [];
  React.useEffect(() => { setAtSel(0); }, [title]);
  const pickAt = (s) => {
    setLink({ type: s.type, id: s.id, label: s.label });
    setTitle(title.replace(/@[^@]*$/, '').trimEnd() + (title.replace(/@[^@]*$/, '').trimEnd() ? ' ' : ''));
    if (inputRef.current) inputRef.current.focus();
  };

  const personas = window.e8Personas ? window.e8Personas() : [persona].filter(Boolean);
  const dueDef = TKC_DUE.find((d) => d.key === due) || TKC_DUE[0];
  const labelDef = TKC_LABELS.find((l) => l.kind === kind);

  const create = () => {
    const t = title.trim();
    if (!t) return;
    if (!window.E8Store) { showToast('Storage is unavailable in this build'); return; }
    const rec = {
      id: 'tk-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
      title: t, sub: '', kind: kind || 'general', urgency: dueDef.urgency,
      owner, state: 'open', snoozedUntil: null,
      ref: link ? { type: link.type, id: link.id } : null, route: null,
      aiAssisted: false, prov: null, createdAt: 'Just now', doneAt: null,
    };
    window.E8Store.add('tasks', rec);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Created task', target: t + (link ? ' → ' + link.label : ''), route: 'tasks' });
    showToast('Task created' + (owner !== me ? ' for ' + owner : ''), 'Undo', () => window.E8Store.remove('tasks', rec.id));
    onClose();
  };

  /* Escape lives on the DIALOG, not the title input, so it dismisses from every focused
     control (chips, menu items, the create button). stopPropagation: a docked NoteDoc listens
     for Escape at the document level - Esc here must close ONLY the composer. Enter is left
     alone here so buttons keep their native activation; the title input handles its own. */
  const onDialogKey = (e) => {
    if (e.key !== 'Escape') return;
    e.preventDefault(); e.stopPropagation();
    if (menu) setMenu(null); else onClose();
  };
  const onTitleKey = (e) => {
    if (e.key === 'Escape') return; /* bubbles to the dialog handler */
    if (atSugs.length) {
      if (e.key === 'ArrowDown') { e.preventDefault(); setAtSel((s) => Math.min(s + 1, atSugs.length - 1)); return; }
      if (e.key === 'ArrowUp') { e.preventDefault(); setAtSel((s) => Math.max(s - 1, 0)); return; }
      if (e.key === 'Enter') { e.preventDefault(); pickAt(atSugs[Math.min(atSel, atSugs.length - 1)]); return; }
    }
    if (e.key === 'Enter') { e.preventDefault(); create(); }
  };

  const chip = (id, icon, text, onClickChip) => (
    <span className="e8-tkc-chipwrap">
      <button type="button" className="e8-tkc-chip" aria-haspopup="menu" aria-expanded={menu === id} onClick={() => setMenu(menu === id ? null : id)}>
        <span className="material-symbols-outlined" aria-hidden="true">{icon}</span>{text}
      </button>
      {menu === id ? onClickChip : null}
    </span>
  );

  return (
    <div className="e8-tkc-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="e8-tkc" role="dialog" aria-modal="true" aria-label="New task" onKeyDown={onDialogKey}>
        <div className="e8-tkc-head">
          <span className="material-symbols-outlined" aria-hidden="true">task_alt</span>
          <b>Task</b>
          <button type="button" className="e8-tkc-x" aria-label="Close" onClick={onClose}><span className="material-symbols-outlined">close</span></button>
        </div>
        <div className="e8-tkc-body">
          <input ref={inputRef} className="e8-tkc-input" value={title} placeholder="Task title — @ to link a person or job"
            aria-label="Task title" onChange={(e) => setTitle(e.target.value)} onKeyDown={onTitleKey} />
          {atSugs.length ? (
            <div className="e8-tkc-sugs" role="listbox" aria-label="Link a record">
              {atSugs.map((s, i) => (
                <button key={s.type + s.id} type="button" role="option" aria-selected={i === atSel}
                  className={'e8-tkc-sug' + (i === atSel ? ' is-sel' : '')}
                  onMouseEnter={() => setAtSel(i)} onClick={() => pickAt(s)}>
                  <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                  <span className="e8-tkc-sug-label">{s.label}</span>
                  {s.sub ? <span className="e8-tkc-sug-sub">{s.sub}</span> : null}
                </button>
              ))}
            </div>
          ) : null}
          <span className="e8-tkc-linkline">
            {link
              ? <React.Fragment>linked to <b>@{link.label}</b>{link.fromRecord ? ' — created from this record' : ''}{' '}
                  <button type="button" className="e8-tkc-unlink" onClick={() => setLink(null)}>unlink</button>
                </React.Fragment>
              : 'not linked to a record — type @ to link a person or job'}
          </span>
        </div>
        <div className="e8-tkc-foot">
          {chip('label', (labelDef || { icon: 'sell' }).icon, labelDef ? labelDef.label : 'Label', (
            <div className="e8-tkc-menu" role="menu">
              {TKC_LABELS.map((l) => (
                <button key={l.kind} type="button" role="menuitem" onClick={() => { setKind(l.kind); setMenu(null); }}>
                  <span className="material-symbols-outlined" aria-hidden="true">{l.icon}</span>{l.label}
                  {kind === l.kind ? <span className="material-symbols-outlined e8-tkc-check" aria-hidden="true">check</span> : null}
                </button>
              ))}
            </div>
          ))}
          {chip('due', 'calendar_today', dueDef.label, (
            <div className="e8-tkc-menu" role="menu">
              {TKC_DUE.map((d) => (
                <button key={d.key} type="button" role="menuitem" onClick={() => { setDue(d.key); setMenu(null); }}>
                  <span className="material-symbols-outlined" aria-hidden="true">{d.key === 'none' ? 'event_busy' : 'calendar_today'}</span>{d.label}
                  {due === d.key ? <span className="material-symbols-outlined e8-tkc-check" aria-hidden="true">check</span> : null}
                </button>
              ))}
            </div>
          ))}
          {chip('assign', 'person', owner === me ? 'Assigned to you' : owner, (
            <div className="e8-tkc-menu" role="menu">
              {personas.map((p) => (
                <button key={p.name} type="button" role="menuitem" onClick={() => { setOwner(p.name); setMenu(null); }}>
                  <span className="material-symbols-outlined" aria-hidden="true">person</span>{p.name}{p.name === me ? ' (you)' : ''}
                  {owner === p.name ? <span className="material-symbols-outlined e8-tkc-check" aria-hidden="true">check</span> : null}
                </button>
              ))}
            </div>
          ))}
          <button type="button" className="e8-tkc-create" disabled={!title.trim()} onClick={create}>Create task</button>
        </div>
      </div>
    </div>
  );
}

function e8NoteMentions(note) {
  const D = window.E8DATA || {};
  const text = ((note.title || '') + ' ' + (note.points || []).join(' ')).toLowerCase();
  const hits = [];
  (D.matches || []).forEach((c) => {
    if (!c.mergedInto && c.id !== note.refId && text.includes(c.name.toLowerCase())) hits.push({ type: 'candidate', id: c.id, label: c.name, sub: c.title, icon: 'person' });
  });
  (D.jobs || []).forEach((j) => {
    if (j.id !== note.refId && text.includes(j.id.toLowerCase())) hits.push({ type: 'job', id: j.id, label: j.id + ' · ' + j.title, sub: j.client, icon: 'work' });
  });
  (D.contacts || []).forEach((ct) => {
    if (ct.id !== note.refId && text.includes(ct.name.toLowerCase())) hits.push({ type: 'contact', id: ct.id, label: ct.name, sub: ct.client, icon: 'contacts' });
  });
  return hits.slice(0, 5);
}

/* ============================== NoteDoc ============================== */
const NDOC_TYPE_ICON = { candidate: 'person', job: 'work', client: 'apartment', contact: 'contacts', submission: 'send' };

/* Split a transcript into up to 6 sentence bullets. Moved here from the capture sheet along with
   the recorder - the note document is now the only surface that dictates into a note. */
function ndocSentences(t) {
  const parts = String(t || '').split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
  return parts.length > 1 ? parts.slice(0, 6) : [String(t || '').trim()].filter(Boolean);
}
const ndocClock = (s) => {
  const n = Math.max(0, Math.floor(s || 0));
  return String(Math.floor(n / 60)).padStart(2, '0') + ':' + String(n % 60).padStart(2, '0');
};

/* ---- templates ---------------------------------------------------------------------------
   Only the PLAYBOOK templates. They are the only kind whose body is a note scaffold rather than a
   message to send - an email template dropped into a note is a message, not a note - and there are
   exactly three of them, which is why the palette below is a COMMAND palette with templates as one
   section rather than a template picker that opens onto three rows and reads as unfinished. */
const NDOC_TPL_TYPE = { 'tpl-play-prescreen': 'prescreen', 'tpl-play-debrief': 'debrief', 'tpl-play-packet': 'submission' };
function ndocTemplates() {
  return (window.E8DATA && window.E8DATA.templates ? window.E8DATA.templates : [])
    .filter((t) => t.type === 'playbook' && t.body);
}

/* Fill a template's {tokens} from the record the note is on.
   Every existing template body uses single braces, so this matches all of them. There was no
   record-based resolver in the app - fillTemplate in screens-comms.jsx fills from an open
   CONVERSATION and is screen-local - so this is new, and deliberately small: it resolves the four
   tokens a record can actually answer and leaves the rest alone.
   The ones it does NOT resolve are not failures, they are the recruiter's blanks: {topic} is
   "recent projects involving ___", {sell_paragraph} is the pitch she writes, {rate} is what she is
   about to negotiate. Those come back as [topic] - square brackets read as "fill me in" where a
   surviving {topic} reads as a broken merge field - and the insert reports how many are left. */
function e8ResolveNoteTokens(body, ref) {
  const D = window.E8DATA || {};
  const map = {};
  const cand = ref && ref.refType === 'candidate'
    ? (D.matches || []).find((c) => c.id === ref.refId) : null;
  const job = ref && ref.refType === 'job' ? (D.jobs || []).find((j) => j.id === ref.refId) : null;
  if (cand) {
    map.candidate = cand.name;
    map.name = cand.name;
    map.job_title = cand.title;
    map.location = cand.location;
    map.company = cand.company;
  }
  if (job) {
    map.job_title = job.title;
    map.client = job.client;
    map.location = job.location || map.location;
    map.job = job.id + ' · ' + job.title;
  }
  const persona = window.e8ActivePersona ? window.e8ActivePersona() : (D.user || {});
  if (persona && persona.name) map.recruiter = persona.name;
  let open = 0;
  const filled = String(body || '').replace(/\{(\w+)\}/g, (m, k) => {
    if (map[k]) return map[k];
    open += 1;
    return '[' + k.replace(/_/g, ' ') + ']';
  });
  return { text: filled, resolved: Object.keys(map).length, open: open };
}

/* Recordings, kept for the SESSION and keyed by note id.
   Spott's note model carries the recording as its own linked object (`audioRecording` with a
   `hasTranscript` flag) rather than as text pasted into the body - which is right: a transcript is
   a derivative, and the thing the recruiter actually wants to replay is the take. This prototype
   has no file storage, so a blob URL is the most it can honestly hold, and it dies on reload. It
   is kept HERE rather than on the note row for exactly that reason: writing an ephemeral URL into
   a persisted record would produce a note that claims an audio attachment it cannot play back.
   The note row stores what survives - the duration, and `hasTranscript` - and the window says
   plainly when the audio itself is gone. */
const ndocClips = {};

/* A brand-new note is NOT written to the store on open. It is held in component state until it
   says something - otherwise every abandoned "Add note" click would leave an empty row on the
   record, and every list, rail and timeline in the app would need a draft filter to hide it.
   `ndocHasContent` is the commit threshold; once crossed the row is added for real and every later
   edit is a normal store write, so the note is live on the record while you are still typing. */
function ndocHasContent(n) {
  return !!(String(n.title || '').trim() || (n.points || []).some((p) => String(p).trim()) || (n.links || []).length);
}

/* ============================== NoteDoc ==============================
   THE THESIS: a recruiter's note is not a document, it is the residue of a CONVERSATION. Spott's
   data model says the same thing - the note carries a `source` channel and nullable call / meeting
   / audioRecording objects - so this window is organised who / when / through what channel, then
   what was said, then what it is filed under. Not title / chips / textarea / buttons, which is
   what it was and which is why every element sat at the same weight in the same grey.

   THE SIGNATURE is the channel spine: a rule down the left of the masthead and body, coloured by
   the note's channel, with the channel's own glyph at its head. It encodes a real closed
   vocabulary rather than decorating one, it makes every note identifiable by its left edge before
   you read a word - and a note that was simply WRITTEN gets no spine at all, because the absence
   of a conversation is itself the fact. */
function NoteDoc({ noteId, draft, onClose }) {
  const { showToast } = React.useContext(E8Ctx);
  const isMobile = useIsMobile();
  const [, setTick] = React.useState(0);
  React.useEffect(() => (window.E8Events ? window.E8Events.subscribe(['store:changed'], () => setTick((t) => t + 1)) : undefined), []);
  const [mode, setMode] = React.useState('center'); /* center | dock | min (min = docked + collapsed) */
  const [picker, setPicker] = React.useState(false);
  const [pickQ, setPickQ] = React.useState('');
  const [menu, setMenu] = React.useState(null); /* 'kebab' | 'notify' | 'channel' | 'label' */
  const [sugs, setSugs] = React.useState(null);
  const [saveState, setSaveState] = React.useState('idle'); /* idle | dirty | saved */
  const winRef = React.useRef(null);
  const bodyRef = React.useRef(null);
  const D = window.E8DATA || {};
  const NP = window.E8NotePolicy;

  /* ---- the draft a NEW note starts as ---------------------------------------------------- */
  const [draftRow, setDraftRow] = React.useState(() => {
    if (!draft) return null;
    const me = (window.e8ActivePersona ? window.e8ActivePersona().name : null) || (D.user || {}).name || 'You';
    return {
      id: 'n-' + Date.now().toString(36), title: '', points: [], links: [], labels: [],
      refType: draft.refType || 'workspace', refId: draft.refId || null,
      entity: draft.entityLabel || 'Workspace', when: 'Just now', week: 'this',
      ts: Date.now(), ai: false, prov: 'human', author: me, by: me,
      source: null, channel: null, icon: 'sticky_note_2', modality: 'type',
      type: null, typeSource: 'default', editors: [],
    };
  });
  const committedRef = React.useRef(false);
  /* draftRow ALSO in a ref. Two write() calls can land in one tick - inserting a template writes
     the body and then the label - and the second one reads its starting point from the closure,
     which React has not re-rendered yet. Merging from the stale state dropped the text the first
     call had just written. The ref is the live copy; the state exists to trigger the render. */
  const draftRef = React.useRef(null);
  if (draftRef.current === null && draftRow) draftRef.current = draftRow;
  const stored = (D.notes || []).find((n) => n.id === (noteId || (draftRow && draftRow.id)));
  const note = stored || draftRow;
  const isDraft = !!draftRow && !committedRef.current;

  /* ---- command palette + @-mentions ------------------------------------------------------ */
  const [palette, setPalette] = React.useState(false);
  const [palQ, setPalQ] = React.useState('');
  const [palSel, setPalSel] = React.useState(0);
  const [atQ, setAtQ] = React.useState(null); /* null = no mention in progress */
  const [atSel, setAtSel] = React.useState(0);

  /* ---- recorder -------------------------------------------------------------------------- */
  const [recPhase, setRecPhase] = React.useState('idle'); /* idle | recording | recorded | transcribing */
  const [secs, setSecs] = React.useState(0);
  const [clipUrl, setClipUrl] = React.useState(null);
  const [asrDetail, setAsrDetail] = React.useState('');
  const recRef = React.useRef(null);
  const chunksRef = React.useRef([]);
  const streamRef = React.useRef(null);
  const blobRef = React.useRef(null);
  const timerRef = React.useRef(null);

  const stopTracks = () => { try { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } } catch (e) {} };
  const stopTimer = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } };
  /* Declared HERE, above the early return below, because the Escape effect calls it. Down with the
     other recorder handlers it would be in the temporal dead zone on any render where the note has
     gone (another surface deleted it), and Escape would throw instead of stopping the take. */
  const stopRec = () => {
    stopTimer();
    try { if (recRef.current && recRef.current.state === 'recording') recRef.current.stop(); }
    catch (e) {
      /* rec.onstop never fires when stop() throws, so the mic is released HERE or not at all. */
      stopTracks();
      showToast('Could not stop the recorder');
      setRecPhase('idle');
    }
  };
  React.useEffect(() => () => { stopTimer(); stopTracks(); try { if (recRef.current && recRef.current.state === 'recording') recRef.current.stop(); } catch (e) {} }, []);
  const keptUrls = React.useRef({});
  React.useEffect(() => () => { if (clipUrl && !keptUrls.current[clipUrl]) URL.revokeObjectURL(clipUrl); }, [clipUrl]);

  /* Click-away for the popovers. Escape alone is not a dismissal - a menu you can only leave by
     picking something or knowing a keystroke is a trap, and every one of these (labels, channel,
     notify, actions, the palette) is opened casually to look at what is in it. Mousedown rather
     than click so it fires before the thing underneath takes the press. */
  React.useEffect(() => {
    if (!menu && !palette) return undefined;
    const away = (e) => {
      if (menu && !(e.target.closest && e.target.closest('.e8-ndoc-menuwrap'))) setMenu(null);
      if (palette && !(e.target.closest && e.target.closest('.e8-ndoc-pal, .e8-ndoc-baract'))) { setPalette(false); setPalQ(''); }
    };
    document.addEventListener('mousedown', away);
    return () => document.removeEventListener('mousedown', away);
  }, [menu, palette]);

  /* Esc: innermost surface first, then the window. A TaskComposer above the doc owns Esc while it
     is open, so the doc stands down - a docked doc's document-level listener can still see the
     event first depending on registration order, so check explicitly. */
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key !== 'Escape') return;
      if (document.querySelector('.e8-tkc, .e8-cap-overlay.is-open')) return;
      if (atQ !== null) { setAtQ(null); return; }
      if (palette) { setPalette(false); setPalQ(''); return; }
      if (menu) { setMenu(null); return; }
      if (picker) { setPicker(false); return; }
      /* Never let Esc discard a take mid-recording - stop it instead, so the audio survives to the
         recorded state and the user decides what happens to it. */
      if (recPhase === 'recording') { stopRec(); return; }
      /* The body is a live textarea now, so Esc while writing must let go of the field rather than
         throw the window away mid-sentence. It closes on the SECOND press, from outside the body. */
      if (document.activeElement === bodyRef.current) { bodyRef.current.blur(); return; }
      onClose();
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [menu, picker, palette, atQ, recPhase, onClose]);

  /* Computed BEFORE the early return below, because the dock effect must run on every render.
     `note` disappears from under this component whenever another surface deletes it, and a hook
     placed after `if (!note)` would be skipped - React throws "Rendered fewer hooks than expected"
     and the tree unmounts. */
  const docked = !isMobile && (mode === 'dock' || mode === 'min');
  const minimized = !isMobile && mode === 'min';

  /* A docked note is a NON-modal floating window, so unlike a modal it must never make a control
     permanently unreachable. It is offset clear of the record rail (--e8-rail-w); publishing its
     height lets the scrolling content column add matching bottom clearance. A ResizeObserver
     rather than a dependency list: the body is a growing textarea now, so its height changes on
     keystrokes that no dep list can name. */
  React.useEffect(() => {
    const root = document.documentElement;
    const el = winRef.current;
    if (!note || !docked || !el) { root.removeAttribute('data-e8-dock'); root.style.removeProperty('--e8-dock-h'); return undefined; }
    root.setAttribute('data-e8-dock', '1');
    const publish = () => root.style.setProperty('--e8-dock-h', Math.round(el.getBoundingClientRect().height) + 'px');
    publish();
    const ro = window.ResizeObserver ? new window.ResizeObserver(publish) : null;
    if (ro) ro.observe(el);
    return () => {
      if (ro) ro.disconnect();
      root.removeAttribute('data-e8-dock');
      root.style.removeProperty('--e8-dock-h');
    };
  }, [note, docked, minimized]);

  /* Autosave. The body is UNCONTROLLED (defaultValue + ref) so the store:changed pump above cannot
     clobber keystrokes mid-word, which is exactly what a controlled value re-rendered from the
     store would do. Writes land on a 900ms idle and on blur, so the op log gets one entry per
     editing pause rather than one per keystroke - and "Saved" means it. */
  const idleRef = React.useRef(null);
  React.useEffect(() => () => { if (idleRef.current) clearTimeout(idleRef.current); }, []);

  if (!note) return null;
  const links = Array.isArray(note.links) ? note.links : [];
  const refLabel = note.entity || (note.refType && note.refId ? e8RefLabel(note.refType, note.refId) : null);
  const bodyText = (note.points || []).join('\n');

  /* THE ONE WRITE PATH. A draft edits in memory until it has content, then becomes a real row and
     every later edit is an ordinary store write - so a note is live on its record the moment it
     says anything, and an abandoned one never existed. */
  const write = (fields) => {
    /* committedRef, not the render's `isDraft`: a second write in the same tick as the one that
       committed the draft would otherwise take the draft branch and re-add a stale row. */
    if (!(draftRef.current && !committedRef.current)) {
      /* editors[] is Spott's answer to "altered by multiple admins without coordination", at the
         note level: append-only, one entry per person per editing session. Written FORWARD ONLY -
         notes that predate it simply have none, rather than being backfilled with a lie. */
      const me = (window.e8ActivePersona ? window.e8ActivePersona().name : null) || (D.user || {}).name || 'You';
      const prior = Array.isArray(note.editors) ? note.editors : [];
      const mine = prior.filter((e) => e.by === me);
      const next = mine.length && mine[mine.length - 1] === prior[prior.length - 1]
        ? prior
        : prior.concat([{ by: me, at: Date.now() }]);
      window.E8Store.set('notes', note.id, next === prior ? fields : { ...fields, editors: next });
      setSaveState('saved');
      return true;
    }
    const merged = { ...draftRef.current, ...fields };
    if (ndocHasContent(merged)) {
      if (!String(merged.title || '').trim()) {
        /* Typed from what is being committed, not from the render's effectiveType - that value was
           derived from the note BEFORE this edit, so on the very first line it is always null. */
        const t = (merged.labels || [])[0] || (NP ? NP.guess((merged.points || []).join(' ')) : null);
        const label = t && NP ? NP.labelFor(t) : null;
        const first = (merged.points || []).map((p) => String(p).trim()).find(Boolean) || '';
        merged.title = label
          ? label + (refLabel ? ' - ' + refLabel : '')
          : (first.length > 60 ? first.slice(0, 57).trimEnd() + '…' : first) || 'Note';
      }
      committedRef.current = true;
      draftRef.current = merged;
      setDraftRow(merged);
      if (window.E8Store) window.E8Store.add('notes', merged);
      if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Saved note', target: merged.title, route: 'notes' });
      setSaveState('saved');
      return true;
    }
    draftRef.current = merged;
    setDraftRow(merged);
    return false;
  };
  const saveField = (fields, label, prevFields) => {
    const wasDraft = isDraft;
    const landed = write(fields);
    if (!landed) return;
    if (wasDraft) { showToast('Note saved' + (refLabel ? ' to ' + refLabel : '')); return; }
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Edited note ' + label, target: note.title, route: 'notes' });
    showToast('Note ' + label + ' updated', 'Undo', () => window.E8Store.set('notes', note.id, prevFields));
  };

  /* ---- body: uncontrolled, autosaving --------------------------------------------------- */
  const linesOf = (v) => String(v || '').split('\n').map((s) => s.replace(/\s+$/, '')).filter((s, i, a) => s || (i > 0 && i < a.length - 1));
  const commitBody = (value) => {
    const pts = linesOf(value);
    if (JSON.stringify(pts) === JSON.stringify(note.points || [])) { setSaveState('saved'); return; }
    write({ points: pts });
  };
  const grow = (el) => { if (el) { el.style.height = 'auto'; el.style.height = Math.max(96, el.scrollHeight) + 'px'; } };
  const onBodyInput = (e) => {
    const el = e.target;
    grow(el);
    setSaveState('dirty');
    if (idleRef.current) clearTimeout(idleRef.current);
    const v = el.value;
    idleRef.current = setTimeout(() => commitBody(v), 900);
    /* @-mention: the token being typed right before the caret. Anything with a space in it is not
       a mention any more, so the list closes rather than searching the rest of the sentence. */
    const upto = el.value.slice(0, el.selectionStart);
    const m = /@([^@\s]*)$/.exec(upto);
    setAtQ(m ? m[1] : null);
    if (m) setAtSel(0);
    /* "/" alone on a line opens the command palette and takes the slash back out with it. */
    if (/(^|\n)\/$/.test(upto)) {
      el.value = el.value.slice(0, el.selectionStart - 1) + el.value.slice(el.selectionStart);
      setPalette(true); setPalQ(''); setPalSel(0);
    }
  };
  const insertAtCaret = (text) => {
    const el = bodyRef.current;
    if (!el) return;
    const at = el.selectionStart;
    const before = el.value.slice(0, at);
    const after = el.value.slice(el.selectionEnd);
    const pad = before && !/\n$/.test(before) ? '\n' : '';
    el.value = before + pad + text + after;
    const caret = (before + pad + text).length;
    el.focus();
    el.setSelectionRange(caret, caret);
    grow(el);
    commitBody(el.value);
  };

  const atRows = atQ === null ? [] : e8EntitySearch(atQ, 6);
  const pickMention = (s) => {
    const el = bodyRef.current;
    if (!el) return;
    const at = el.selectionStart;
    const before = el.value.slice(0, at).replace(/@[^@\s]*$/, '');
    const after = el.value.slice(at);
    el.value = before + s.label + ' ' + after;
    const caret = (before + s.label + ' ').length;
    el.focus(); el.setSelectionRange(caret, caret);
    setAtQ(null);
    grow(el);
    /* A mention IS a link. One concept, one write path - which is also Spott's model, where links[]
       is the whole story and there is no separate mention table. */
    const already = links.some((l) => l.type === s.type && l.id === s.id);
    write({ points: linesOf(el.value), links: already ? links : links.concat([{ type: s.type, id: s.id, label: s.label }]) });
  };

  const linkRecord = (s) => {
    write({ links: links.concat([{ type: s.type, id: s.id, label: s.label }]) });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Linked ' + s.label + ' to note', target: note.title, route: 'notes' });
  };
  const unlinkRecord = (l) => write({ links: links.filter((x) => x.type !== l.type || x.id !== l.id) });
  const isLinked = (s) => links.some((l) => l.type === s.type && l.id === s.id);

  /* ---- labels: a SET, not a value ------------------------------------------------------- */
  const labels = NP ? NP.labelsOf(note).filter((id) => NP.isHuman(id)) : [];
  const explicit = Array.isArray(note.labels) && note.labels.length;
  const guessed = !explicit && NP ? NP.guess(bodyText) : null;
  const shownLabels = explicit ? labels : (guessed ? [guessed] : []);
  const toggleLabel = (id) => {
    const cur = explicit ? labels.slice() : (guessed ? [guessed] : []);
    const next = cur.includes(id) ? cur.filter((x) => x !== id) : cur.concat([id]);
    write({ labels: next, type: next[0] || null, typeSource: next.length ? 'human' : 'default' });
  };

  /* ---- channel: how the conversation happened ------------------------------------------- */
  const channel = NP ? NP.sourceOf(note) : null;
  const chan = channel && NP ? NP.getSource(channel) : null;
  const setChannel = (id) => {
    setMenu(null);
    write({ channel: id, source: id ? (NP.getSource(id) || {}).label : null });
  };

  const copyLink = () => {
    const route = note.refType === 'candidate' ? 'candidate/' + note.refId + '/activity'
      : note.refType === 'job' ? 'job/' + note.refId + '/activity' : 'notes';
    const href = location.origin + location.pathname + '#/' + route;
    try {
      navigator.clipboard.writeText(href)
        .then(() => showToast('Link copied — opens the record this note lives on'))
        .catch(() => showToast(href));
    } catch (e) { showToast(href); }
  };
  const togglePin = () => {
    if (isDraft) { showToast('Write something first - there is no note to pin yet'); return; }
    write({ pinned: !note.pinned });
    showToast(note.pinned ? 'Unpinned' : 'Pinned to the top of this record');
  };
  /* R142: this reported a delivery it had never attempted - E8Notify.show() hard-gates on its
     CATALOG, which had no note channel. The toast now reports what actually happened. */
  const notify = (name) => {
    setMenu(null);
    if (isDraft) { showToast('Write something first - there is no note to point them at yet'); return; }
    const me = (window.e8ActivePersona ? window.e8ActivePersona().name : (D.user || {}).name) || 'You';
    const route = note.refType === 'candidate' ? 'candidate/' + note.refId + '/activity'
      : note.refType === 'job' ? 'job/' + note.refId + '/activity' : 'notes';
    const sent = window.E8Notify
      ? window.E8Notify.show('note', { id: note.id, to: name, from: me, title: note.title, route: route })
      : false;
    Promise.resolve(sent).then((ok) => {
      if (window.E8Audit) {
        window.E8Audit.log({
          agent: 'You', prov: 'human',
          action: ok ? 'Notified ' + name + ' about a note'
            : 'Could not notify ' + name + ' about a note (notifications off or blocked)',
          target: note.title, route: 'notes',
        });
      }
      if (ok) { showToast('Notified ' + name + ' about this note'); return; }
      showToast("Couldn't notify " + name + ' - notifications are off in this browser', 'Turn on', () => {
        if (!window.E8Notify) return;
        window.E8Notify.enable().then((res) => {
          showToast(res && res.ok ? 'Notifications on - try that again' : 'Your browser blocked notifications');
        });
      });
    });
  };
  const del = () => {
    setMenu(null);
    if (isDraft) { onClose(); return; }
    const snapshot = { ...note };
    window.E8Store.remove('notes', note.id);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Deleted note', target: snapshot.title, route: 'notes' });
    onClose();
    showToast('Note deleted', 'Undo', () => window.E8Store.add('notes', snapshot));
  };
  const generate = () => {
    setMenu(null);
    const list = [{
      icon: 'add_task', label: 'Create a follow-up task',
      sub: 'Opens the task composer, linked to this record',
      run: () => { if (window.e8OpenTask) window.e8OpenTask({ refType: note.refType, refId: note.refId, entityLabel: refLabel, prefillTitle: 'Follow up — ' + note.title }); },
    }];
    if (note.refType === 'candidate') {
      list.push({ icon: 'mail', label: 'Draft a recap message', sub: 'Send a summary of this conversation', run: () => { onClose(); navigate('inbox'); } });
    }
    list.push({ icon: 'campaign', label: 'Share with the pod', sub: 'Post this note to your team channel', run: () => showToast('Shared with the Memphis pod') });
    setSugs(list);
  };

  /* ---- recorder handlers ---------------------------------------------------------------- */
  const discardClip = () => {
    if (clipUrl && !keptUrls.current[clipUrl]) URL.revokeObjectURL(clipUrl);
    setClipUrl(null); blobRef.current = null; setSecs(0); setRecPhase('idle'); setAsrDetail('');
  };
  const startRec = async () => {
    discardClip();
    if (!window.E8AI || !window.E8AI.asrSupported || !window.E8AI.asrSupported()) { showToast('Recording is not supported in this browser'); return; }
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
    catch (e) { showToast('Microphone blocked - allow it in your browser to record'); return; }
    streamRef.current = stream;
    chunksRef.current = [];
    let rec;
    try { rec = new MediaRecorder(stream); } catch (e) { stopTracks(); showToast('Recording is not supported in this browser'); return; }
    recRef.current = rec;
    rec.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
    rec.onstop = () => {
      /* Not only stopRec() gets here - a track ending on its own (permission revoked mid-take, a
         USB mic unplugged) fires onstop too, and the clock would keep counting. */
      stopTimer();
      stopTracks();
      const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
      if (!blob.size) { showToast('No audio was captured'); setRecPhase('idle'); return; }
      blobRef.current = blob;
      setClipUrl(URL.createObjectURL(blob));
      setRecPhase('recorded');
    };
    rec.start();
    setRecPhase('recording');
    setSecs(0);
    timerRef.current = setInterval(() => setSecs((s) => s + 1), 1000);
  };
  /* Committing a take does two things, and they are different things: the transcript goes into the
     BODY (a note nobody can read back is a file, not a note), and the RECORDING stays on the note
     so the take itself can be replayed. */
  const commitClip = async () => {
    if (!blobRef.current) return;
    setRecPhase('transcribing'); setAsrDetail('');
    let txt = '';
    try { txt = await window.E8AI.transcribe(blobRef.current, (s) => setAsrDetail((s && s.detail) || '')); }
    catch (e) { showToast('On-device transcription could not run here'); setRecPhase('recorded'); return; }
    if (!String(txt || '').trim()) { showToast('No speech was detected in that take'); setRecPhase('recorded'); return; }
    const took = secs;
    const el = bodyRef.current;
    const kept = el ? linesOf(el.value) : (note.points || []).filter((p) => String(p).trim());
    const nextPoints = kept.concat(ndocSentences(txt));
    if (el) { el.value = nextPoints.join('\n'); grow(el); }
    write({
      points: nextPoints, modality: 'voice',
      channel: channel || 'phone', source: (chan && chan.label) || 'Call',
      audio: { secs: took, hasTranscript: true },
    });
    keptUrls.current[clipUrl] = 1;
    ndocClips[note.id] = { url: clipUrl, secs: took };
    blobRef.current = null;
    setClipUrl(null); setSecs(0); setRecPhase('idle'); setAsrDetail('');
    showToast('Transcribed on your device and added to the note');
  };

  /* ---- the command palette ---------------------------------------------------------------
     Not a template picker. There are three templates, and a palette that opens onto three rows
     reads as unfinished - so templates are ONE section of the things you can do from the caret,
     all of which the window already does. Nothing here is a placeholder. */
  const commands = [];
  ndocTemplates().forEach((t) => {
    commands.push({
      id: t.id, group: 'Start from a script', icon: 'article', label: t.name, sub: t.preview,
      preview: e8ResolveNoteTokens(t.body, note).text,
      run: () => {
        const res = e8ResolveNoteTokens(t.body, note);
        insertAtCaret(res.text);
        /* Read the labels FRESH: insertAtCaret above may have just committed the draft, so the
           render's `explicit` is a tick out of date. A template proposes a label only when the
           note has none - it never overwrites one you chose. */
        const mapped = NDOC_TPL_TYPE[t.id];
        const now = draftRef.current && !committedRef.current ? draftRef.current
          : ((window.E8DATA.notes || []).find((n) => n.id === note.id) || note);
        const hasLabels = Array.isArray(now.labels) && now.labels.length;
        if (mapped && !hasLabels) write({ labels: [mapped], type: mapped, typeSource: 'template' });
        showToast(res.open
          ? t.name + ' inserted — ' + res.open + ' blank' + (res.open === 1 ? '' : 's') + ' to fill in'
          : t.name + ' inserted');
      },
    });
  });
  (NP ? NP.humanTypes() : []).slice(0, 4).forEach((t) => {
    commands.push({
      id: 'label-' + t.id, group: 'Label this note', icon: t.icon, label: t.label,
      sub: shownLabels.includes(t.id) ? 'Applied' : 'Add this label',
      run: () => toggleLabel(t.id),
    });
  });
  commands.push(
    { id: 'cmd-mention', group: 'Insert', icon: 'alternate_email', label: 'Mention a record',
      sub: 'Person, job, client or teammate', run: () => insertAtCaret('@') },
    { id: 'cmd-date', group: 'Insert', icon: 'today', label: "Today's date",
      sub: new Date().toLocaleDateString(undefined, { month: 'long', day: 'numeric' }),
      run: () => insertAtCaret(new Date().toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })) },
    { id: 'cmd-record', group: 'Capture', icon: 'mic', label: 'Record this call',
      sub: 'Transcribed on your device', run: () => startRec() },
    { id: 'cmd-task', group: 'Capture', icon: 'add_task', label: 'Create a follow-up task',
      sub: 'Linked to ' + (refLabel || 'this note'),
      run: () => { if (window.e8OpenTask) window.e8OpenTask({ refType: note.refType, refId: note.refId, entityLabel: refLabel, prefillTitle: 'Follow up — ' + (note.title || 'note') }); } },
  );
  const palRows = (() => {
    const q = palQ.trim().toLowerCase();
    const hits = q ? commands.filter((c) => (c.label + ' ' + c.sub + ' ' + c.group).toLowerCase().indexOf(q) > -1) : commands;
    return hits;
  })();
  const runCommand = (c) => { setPalette(false); setPalQ(''); c.run(); };
  const onPalKey = (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); setPalSel((i) => (i + 1) % Math.max(1, palRows.length)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setPalSel((i) => (i - 1 + palRows.length) % Math.max(1, palRows.length)); }
    else if (e.key === 'Enter') { e.preventDefault(); if (palRows[palSel]) runCommand(palRows[palSel]); }
  };
  const onBodyKey = (e) => {
    if (atQ !== null && atRows.length) {
      if (e.key === 'ArrowDown') { e.preventDefault(); setAtSel((i) => (i + 1) % atRows.length); return; }
      if (e.key === 'ArrowUp') { e.preventDefault(); setAtSel((i) => (i - 1 + atRows.length) % atRows.length); return; }
      if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pickMention(atRows[atSel] || atRows[0]); return; }
    }
    if (e.key === '/' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setPalette(true); setPalQ(''); setPalSel(0); }
  };

  /* ---- people --------------------------------------------------------------------------- */
  const author = note.author || note.by || 'you';
  const editors = (Array.isArray(note.editors) ? note.editors : []).filter((e) => e.by && e.by !== author);
  const editorNames = Array.from(new Set(editors.map((e) => e.by)));
  const mentions = e8NoteMentions(note);
  const searchRows = pickQ.trim() ? e8EntitySearch(pickQ, 5) : [];
  const personas = window.e8Personas ? window.e8Personas() : [];
  const meName = (window.e8ActivePersona ? window.e8ActivePersona().name : null) || (D.user || {}).name;
  const spineStyle = chan ? { '--e8-ndoc-chan': 'var(--ui-cat-' + chan.cat + '-text)' } : undefined;

  const win = (
    <div ref={winRef} className={'e8-ndoc' + (docked ? ' is-dock' : '') + (minimized ? ' is-min' : '')}
      role="dialog" aria-modal={!docked} aria-label={'Note - ' + (String(note.title || '').trim() || 'Untitled')}>
      <div className="e8-ndoc-head" onDoubleClick={() => { if (minimized) setMode('dock'); }}>
        <span className="material-symbols-outlined" aria-hidden="true">description</span>
        <b>Note</b>
        {minimized ? <span className="e8-ndoc-min-title">· {String(note.title || '').trim() || 'Untitled note'}</span> : null}
        <span className="e8-ndoc-tools">
          <button type="button" className={'e8-ndoc-tool' + (note.pinned ? ' is-on' : '')} disabled={isDraft}
            title={isDraft ? 'Write something first - there is no note to pin yet' : (note.pinned ? 'Unpin from this record' : 'Pin to the top of this record')}
            aria-label={note.pinned ? 'Unpin note' : 'Pin note'} aria-pressed={!!note.pinned} onClick={togglePin}>
            <span className="material-symbols-outlined">{note.pinned ? 'keep' : 'keep_off'}</span>
          </button>
          {!isMobile && !minimized ? (
            <button type="button" className="e8-ndoc-tool" title="Minimize" aria-label="Minimize note" onClick={() => setMode('min')}>
              <span className="material-symbols-outlined">remove</span>
            </button>
          ) : null}
          {!isMobile && minimized ? (
            <button type="button" className="e8-ndoc-tool" title="Expand" aria-label="Expand note" onClick={() => setMode('dock')}>
              <span className="material-symbols-outlined">expand_content</span>
            </button>
          ) : null}
          {!isMobile ? (
            <button type="button" className={'e8-ndoc-tool' + (docked ? ' is-on' : '')} title={docked ? 'Undock note' : 'Dock note'} aria-label={docked ? 'Undock note' : 'Dock note'}
              onClick={() => setMode(docked ? 'center' : 'dock')}>
              <span className="material-symbols-outlined">picture_in_picture_alt</span>
            </button>
          ) : null}
          <button type="button" className="e8-ndoc-tool" title="Copy link" aria-label="Copy a link to this note" onClick={copyLink}>
            <span className="material-symbols-outlined">link</span>
          </button>
          <span className="e8-ndoc-menuwrap">
            <button type="button" className="e8-ndoc-tool" disabled={isDraft}
              title={isDraft ? 'Write something first - there is no note to point them at yet' : 'Send notification to colleague about this note'}
              aria-label="Notify a colleague"
              aria-haspopup="menu" aria-expanded={menu === 'notify'} onClick={() => setMenu(menu === 'notify' ? null : 'notify')}>
              <span className="material-symbols-outlined">notifications</span>
            </button>
            {menu === 'notify' ? (
              <div className="e8-ndoc-menu" role="menu">
                <span className="e8-ndoc-menu-cap">Notify about this note</span>
                {personas.filter((p) => p.name !== meName).map((p) => (
                  <button key={p.name} type="button" role="menuitem" onClick={() => notify(p.name)}>
                    <span className="material-symbols-outlined" aria-hidden="true">person</span>{p.name}
                  </button>
                ))}
              </div>
            ) : null}
          </span>
          <span className="e8-ndoc-menuwrap">
            <button type="button" className="e8-ndoc-tool" title="More" aria-label="Note actions" aria-haspopup="menu" aria-expanded={menu === 'kebab'}
              onClick={() => setMenu(menu === 'kebab' ? null : 'kebab')}>
              <span className="material-symbols-outlined">more_vert</span>
            </button>
            {menu === 'kebab' ? (
              <div className="e8-ndoc-menu" role="menu">
                <button type="button" role="menuitem" onClick={generate}>
                  <span className="material-symbols-outlined" aria-hidden="true">auto_awesome</span>Generate suggestions
                </button>
                <button type="button" role="menuitem" className="is-danger" onClick={del}>
                  <span className="material-symbols-outlined" aria-hidden="true">delete</span>Delete note
                </button>
              </div>
            ) : null}
          </span>
          <button type="button" className="e8-ndoc-tool" title="Close" aria-label="Close note" onClick={onClose}>
            <span className="material-symbols-outlined">close</span>
          </button>
        </span>
      </div>

      {!minimized ? (
        <div className="e8-ndoc-body">
          {/* The title is a real input, not a button that swaps to one. The old click-to-edit made
              the two most-used fields feel like read-only text you had to unlock. */}
          <input className="e8-ndoc-title" defaultValue={note.title} placeholder="Untitled note" aria-label="Note title"
            onKeyDown={(e) => {
              if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }
              else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.target.value = note.title || ''; e.target.blur(); }
            }}
            onChange={() => setSaveState('dirty')}
            onBlur={(e) => {
              const v = e.target.value.trim();
              if (v === String(note.title || '').trim()) { setSaveState('saved'); return; }
              saveField({ title: v }, 'title', { title: note.title });
            }} />

          {/* THE MASTHEAD + SPINE. Channel, who, when - the three facts about the conversation,
              in that order, on one line. The spine below inherits its colour. */}
          <div className={'e8-ndoc-conv' + (chan ? ' has-chan' : '')} style={spineStyle}>
            <div className="e8-ndoc-conv-head">
              {chan ? (
                <span className="e8-ndoc-chan">
                  <span className="material-symbols-outlined" aria-hidden="true">{chan.icon}</span>
                  {chan.label}{chan.qualifier ? <i>{chan.qualifier}</i> : null}
                </span>
              ) : <span className="e8-ndoc-chan is-none">Written</span>}
              <span className="e8-ndoc-conv-by">{author}</span>
              <span className="e8-ndoc-conv-when">{note.when || 'just now'}</span>
              {note.ai ? <span className="e8-ndoc-conv-ai">AI-drafted{note.by && note.by !== author ? ' · reviewed by ' + note.by : ''}</span> : null}
              {editorNames.length ? (
                <span className="e8-ndoc-conv-ed" title={'Also edited by ' + editorNames.join(', ')}>
                  · edited by {editorNames.length === 1 ? editorNames[0] : editorNames.length + ' others'}
                </span>
              ) : null}
            </div>

            <textarea className="e8-ndoc-ta" defaultValue={bodyText}
              placeholder={'Write what was said. Type / for templates and actions, @ to mention a record.'}
              aria-label="Note" rows={3}
              onInput={onBodyInput} onKeyDown={onBodyKey}
              onBlur={(e) => { if (idleRef.current) clearTimeout(idleRef.current); commitBody(e.target.value); }}
              /* A callback ref, not just bodyRef: the textarea has to be sized to its content the
                 first time it mounts, and there is no render-time height to give it. */
              ref={(el) => { bodyRef.current = el; if (el && !el.dataset.grown) { el.dataset.grown = '1'; grow(el); } }} />

            {/* IN FLOW, deliberately: .e8-ndoc-body is overflow-y:auto and clips an absolutely
                positioned list. The palette below escapes that by anchoring to the WINDOW, which
                is not a scroll container; a six-row mention list does not need to. */}
            {atQ !== null && atRows.length ? (
              <div className="e8-ntp-sugs" role="listbox" aria-label="Mention a record">
                {atRows.map((s, i) => (
                  <button key={s.type + s.id} type="button" role="option" aria-selected={i === atSel}
                    className={'e8-ntp-sug' + (i === atSel ? ' is-sel' : '')}
                    onMouseEnter={() => setAtSel(i)} onMouseDown={(e) => { e.preventDefault(); pickMention(s); }}>
                    <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                    <span className="e8-ntp-sug-l">{s.label}</span>
                    <span className="e8-ntp-sug-s">{s.sub}</span>
                  </button>
                ))}
              </div>
            ) : null}

            {note.audio && recPhase === 'idle' ? (
              <div className="e8-ndoc-rec">
                <span className="material-symbols-outlined e8-ndoc-rec-ic" aria-hidden="true">graphic_eq</span>
                {ndocClips[note.id] ? (
                  <React.Fragment>
                    <audio className="e8-ndoc-rec-audio" src={ndocClips[note.id].url} controls preload="metadata" aria-label="The recording on this note" />
                    <span className="e8-ndoc-rec-busy">Transcribed into this note</span>
                  </React.Fragment>
                ) : (
                  <span className="e8-ndoc-rec-busy">
                    Recording · {ndocClock(note.audio.secs)} — captured in an earlier session and no longer
                    available to play. The transcript above is what was said.
                  </span>
                )}
              </div>
            ) : null}
            {recPhase !== 'idle' ? (
              <div className="e8-ndoc-rec">
                {recPhase === 'recording' ? (
                  <React.Fragment>
                    <span className="e8-ndoc-rec-dot" aria-hidden="true"></span>
                    <span className="e8-ndoc-rec-time tnum" role="timer" aria-live="off">{ndocClock(secs)}</span>
                    <button type="button" className="e8-ndoc-rec-stop" onClick={stopRec}>
                      <span className="material-symbols-outlined" aria-hidden="true">stop_circle</span>Stop recording
                    </button>
                  </React.Fragment>
                ) : null}
                {recPhase === 'recorded' ? (
                  <React.Fragment>
                    <audio className="e8-ndoc-rec-audio" src={clipUrl} controls preload="metadata" aria-label="Your recording" />
                    <button type="button" className="e8-ndoc-rec-commit" onClick={commitClip}>
                      <span className="material-symbols-outlined" aria-hidden="true">graphic_eq</span>Transcribe &amp; add
                    </button>
                    <button type="button" className="e8-ndoc-rec-bin" title="Discard this take" aria-label="Discard this recording" onClick={discardClip}>
                      <span className="material-symbols-outlined" aria-hidden="true">delete</span>
                    </button>
                  </React.Fragment>
                ) : null}
                {recPhase === 'transcribing' ? (
                  <span className="e8-ndoc-rec-busy">{asrDetail || 'Transcribing on your device'}…<span className="e8-cap-pulse" /></span>
                ) : null}
              </div>
            ) : null}
          </div>

          {/* LABELS: a set. Coloured from the app's categorical palette, so two labels on one note
              are told apart at a glance rather than by reading them. */}
          <div className="e8-ndoc-labels">
            {shownLabels.map((id) => (
              <button key={id} type="button" className={'e8-ndoc-label' + (explicit ? '' : ' is-guess')}
                style={{ '--e8-lab': 'var(--ui-cat-' + NP.catFor(id) + '-text)', '--e8-lab-bg': 'var(--ui-cat-' + NP.catFor(id) + '-bg)' }}
                title={explicit ? 'Remove this label' : 'Guessed from what you wrote — click to keep it'}
                onClick={() => (explicit ? toggleLabel(id) : write({ labels: [id], type: id, typeSource: 'human' }))}>
                <span className="material-symbols-outlined" aria-hidden="true">{NP.iconFor(id)}</span>
                {NP.labelFor(id)}
                {explicit ? <span className="e8-ndoc-label-x" aria-hidden="true">×</span> : <span className="e8-ntp-guess" aria-label="guessed">✦</span>}
              </button>
            ))}
            <span className="e8-ndoc-menuwrap">
              <button type="button" className="e8-ndoc-label is-add" aria-haspopup="menu" aria-expanded={menu === 'label'}
                onClick={() => setMenu(menu === 'label' ? null : 'label')}>
                <span className="material-symbols-outlined" aria-hidden="true">add</span>
                {shownLabels.length ? 'Label' : 'Add a label'}
              </button>
              {menu === 'label' ? (
                <div className="e8-ndoc-menu is-up" role="menu">
                  <span className="e8-ndoc-menu-cap">Label this note</span>
                  {(NP ? NP.humanTypes() : []).map((t) => (
                    <button key={t.id} type="button" role="menuitemcheckbox" aria-checked={shownLabels.includes(t.id)}
                      className={shownLabels.includes(t.id) ? 'is-on' : ''} onClick={() => toggleLabel(t.id)}>
                      <span className="material-symbols-outlined" aria-hidden="true">{t.icon}</span>{t.label}
                    </button>
                  ))}
                </div>
              ) : null}
            </span>
          </div>

          {/* LINKS: where this note appears. Typed, so "Daniel Okafor" and "JO-10864" do not read
              as the same kind of thing, and removable. */}
          <div className="e8-ndoc-links">
            {refLabel ? (
              <span className="e8-ndoc-link is-home" title="The record this note was written on">
                <span className="material-symbols-outlined" aria-hidden="true">{NDOC_TYPE_ICON[note.refType] || 'description'}</span>
                {refLabel}
              </span>
            ) : null}
            {links.map((l) => (
              <span key={l.type + l.id} className="e8-ndoc-link">
                <span className="material-symbols-outlined" aria-hidden="true">{NDOC_TYPE_ICON[l.type] || 'description'}</span>
                {l.label}
                <button type="button" className="e8-ndoc-link-x" aria-label={'Unlink ' + l.label} title="Unlink" onClick={() => unlinkRecord(l)}>
                  <span className="material-symbols-outlined">close</span>
                </button>
              </span>
            ))}
            <button type="button" className="e8-ndoc-link is-add" aria-expanded={picker} onClick={() => setPicker(!picker)}>
              <span className="material-symbols-outlined" aria-hidden="true">add</span>Link a record
            </button>
          </div>

          {picker ? (
            <div className="e8-ndoc-picker">
              <div className="e8-ndoc-picker-search">
                <span className="material-symbols-outlined" aria-hidden="true">search</span>
                <input autoFocus value={pickQ} placeholder="Link a person, job, or contact…" aria-label="Search records to link"
                  onChange={(e) => setPickQ(e.target.value)} />
              </div>
              {!pickQ.trim() && mentions.length ? (
                <React.Fragment>
                  <span className="e8-ndoc-picker-cap">Mentioned in this note</span>
                  {mentions.map((s) => (
                    <div key={s.type + s.id} className="e8-ndoc-picker-row">
                      <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                      <span className="e8-ndoc-picker-label">{s.label}</span>
                      {s.sub ? <span className="e8-ndoc-picker-sub">{s.sub}</span> : null}
                      {isLinked(s) ? <span className="e8-ndoc-picker-done">linked ✓</span>
                        : <button type="button" className="e8-ndoc-picker-link" onClick={() => linkRecord(s)}>Link</button>}
                    </div>
                  ))}
                </React.Fragment>
              ) : null}
              {pickQ.trim() ? (
                <React.Fragment>
                  <span className="e8-ndoc-picker-cap">Results</span>
                  {searchRows.length ? searchRows.map((s) => (
                    <div key={s.type + s.id} className="e8-ndoc-picker-row">
                      <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                      <span className="e8-ndoc-picker-label">{s.label}</span>
                      {s.sub ? <span className="e8-ndoc-picker-sub">{s.sub}</span> : null}
                      {isLinked(s) ? <span className="e8-ndoc-picker-done">linked ✓</span>
                        : <button type="button" className="e8-ndoc-picker-link" onClick={() => linkRecord(s)}>Link</button>}
                    </div>
                  )) : <span className="e8-ndoc-picker-sub" style={{ padding: '4px 0' }}>Nothing matches.</span>}
                </React.Fragment>
              ) : null}
              <span className="e8-ndoc-picker-hint">Suggestions first, search second — the picker leads with what the note mentions.</span>
            </div>
          ) : null}

          {sugs ? (
            <div className="e8-ndoc-sugs">
              <span className="e8-ndoc-picker-cap">Suggested next steps</span>
              {sugs.map((s) => (
                <button key={s.label} type="button" className="e8-ndoc-sug" onClick={s.run}>
                  <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                  <span className="e8-ndoc-sug-main"><b>{s.label}</b><small>{s.sub}</small></span>
                  <span className="material-symbols-outlined e8-ndoc-sug-go" aria-hidden="true">chevron_right</span>
                </button>
              ))}
            </div>
          ) : null}
        </div>
      ) : null}

      {/* THE BAR. Two properties of the note and one state - not three identical outline chips for
          three different kinds of thing, which is what it was. Content actions moved into the body,
          where content is authored. */}
      {!minimized ? (
        <div className="e8-ndoc-bar">
          <span className="e8-ndoc-menuwrap">
            <button type="button" className={'e8-ndoc-chanpick' + (chan ? ' is-set' : '')} aria-haspopup="menu" aria-expanded={menu === 'channel'}
              style={chan ? { '--e8-ndoc-chan': 'var(--ui-cat-' + chan.cat + '-text)' } : undefined}
              title="How this conversation happened" onClick={() => setMenu(menu === 'channel' ? null : 'channel')}>
              <span className="material-symbols-outlined" aria-hidden="true">{chan ? chan.icon : 'edit_note'}</span>
              {chan ? chan.label + (chan.qualifier ? ' · ' + chan.qualifier : '') : 'Written'}
            </button>
            {menu === 'channel' ? (
              <div className="e8-ndoc-menu is-up" role="menu">
                <span className="e8-ndoc-menu-cap">How did this happen?</span>
                {(NP ? NP.sources : []).filter((s) => s.id !== 'phone').map((s) => (
                  <button key={s.id} type="button" role="menuitemradio" aria-checked={channel === s.id}
                    className={channel === s.id ? 'is-on' : ''} onClick={() => setChannel(s.id)}>
                    <span className="material-symbols-outlined" aria-hidden="true">{s.icon}</span>
                    {s.label}{s.qualifier ? ' · ' + s.qualifier : ''}
                  </button>
                ))}
                <button type="button" role="menuitemradio" aria-checked={!channel} className={!channel ? 'is-on' : ''} onClick={() => setChannel(null)}>
                  <span className="material-symbols-outlined" aria-hidden="true">edit_note</span>Written — no conversation
                </button>
              </div>
            ) : null}
          </span>
          <button type="button" className="e8-ndoc-baract" onClick={() => { setPalette(true); setPalQ(''); setPalSel(0); }}>
            <span className="material-symbols-outlined" aria-hidden="true">bolt</span>Templates &amp; actions
            <span className="e8-kbd">/</span>
          </button>
          <span className={'e8-ndoc-state is-' + (isDraft ? 'draft' : saveState)}>
            {isDraft ? 'Saves as you write' : saveState === 'dirty' ? 'Saving…' : 'Saved'}
          </span>
        </div>
      ) : null}

      {/* The palette anchors to the WINDOW, not the scrolling body - .e8-ndoc-body is
          overflow-y:auto and would clip it, and .e8-ndoc is not a scroll container. */}
      {palette && !minimized ? (
        <div className="e8-ndoc-pal" role="dialog" aria-label="Templates and actions">
          <div className="e8-ndoc-pal-search">
            <span className="material-symbols-outlined" aria-hidden="true">bolt</span>
            <input autoFocus value={palQ} placeholder="Insert a script, add a label, capture…" aria-label="Search templates and actions"
              onChange={(e) => { setPalQ(e.target.value); setPalSel(0); }} onKeyDown={onPalKey} />
            <button type="button" className="e8-ndoc-pal-x" aria-label="Close" onClick={() => { setPalette(false); setPalQ(''); }}>
              <span className="material-symbols-outlined">close</span>
            </button>
          </div>
          <div className="e8-ndoc-pal-cols">
            <div className="e8-ndoc-pal-list" role="listbox" aria-label="Actions">
              {palRows.length ? palRows.map((c, i) => (
                <React.Fragment key={c.id}>
                  {i === 0 || palRows[i - 1].group !== c.group ? <span className="e8-eyebrow e8-ndoc-pal-cap">{c.group}</span> : null}
                  <button type="button" role="option" aria-selected={i === palSel}
                    className={'e8-ndoc-pal-row' + (i === palSel ? ' is-sel' : '')}
                    onMouseEnter={() => setPalSel(i)} onClick={() => runCommand(c)}>
                    <span className="material-symbols-outlined" aria-hidden="true">{c.icon}</span>
                    <span className="e8-ndoc-pal-main"><b>{c.label}</b><small>{c.sub}</small></span>
                  </button>
                </React.Fragment>
              )) : <span className="e8-ndoc-pal-none">Nothing matches “{palQ}”.</span>}
            </div>
            {/* The preview is the point. The old picker showed a two-line arrow-soup summary and
                then pasted something else; this is the actual text, with this record's details
                already filled in. */}
            <div className="e8-ndoc-pal-prev" aria-hidden="true">
              {palRows[palSel] && palRows[palSel].preview ? (
                <React.Fragment>
                  <span className="e8-eyebrow e8-ndoc-pal-cap">Inserts</span>
                  <pre>{palRows[palSel].preview}</pre>
                </React.Fragment>
              ) : (
                <span className="e8-ndoc-pal-none">
                  {palRows[palSel] ? palRows[palSel].sub : 'Pick an action.'}
                </span>
              )}
            </div>
          </div>
          <div className="e8-ndoc-pal-foot">
            <span><span className="e8-kbd">↑↓</span> move</span>
            <span><span className="e8-kbd">↵</span> insert</span>
            <span><span className="e8-kbd">esc</span> close</span>
          </div>
        </div>
      ) : null}
    </div>
  );

  if (docked) return win;
  return (
    <div className="e8-ndoc-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      {win}
    </div>
  );
}

Object.assign(window, { TaskComposer, NoteDoc, e8EntitySearch, e8RefLabel, e8NoteMentions });
