/* ============================================================================
   cand-record.jsx — the candidate record SHELL: the route (id / ?q queue / the
   /activity sub-route), the tombstone + not-found guards, every piece of state
   the panes share, and every store write, Undo and toast on this record. It
   composes cand-header.jsx, cand-tabs.jsx, cand-overview.jsx and cand-rail.jsx
   and owns the Experience / Skills / Messages / Files tab bodies, the stage
   chip, the Needs-you card, the duplicate banner, the queue review bar and the
   dialogs.

   R143 rebuilt six of those pieces - the next-action card, a NEW fact strip and
   the Experience / Skills / Messages bodies - as module-level components at the
   top of this file. `CandidateMessagesTab` (screens-core.jsx) is no longer
   called; `CandidateFilesSection` still is, for both the Files tab and the
   files overlay.

   Split out of screens-core.jsx (it was 1,283 lines of a 5,633-line shared
   file). Loaded AFTER screens-core.jsx - the helpers it still calls
   (LockdownStrip, PrescreenPanel, SubmissionPacketDialog, DebriefDialog,
   CandidateFilesSection, CandidatePresentOverlay,
   MergedTombstone, timelineFor, profileOf, resolveQueue, e8TopMatch,
   e8NoteToEvent, e8MergeEvents, e8AgoLabel, e8FreshFieldLabel, e8ReasonWeight,
   e8CanReassignCandidate, CandidateReassignDialog, AddToListPopover, Timeline,
   SourceLabel, RunMatchingCard, RiskFirstReasons, OvDisclose, FreshnessMenu,
   TechStackChips, candidateLinkedInUrl) stay there - and BEFORE main.jsx,
   which routes #/candidate here.

   The `e8-hooks-safe:` waiver below travels with the component; it is what
   scripts/check-hooks.mjs reads to allow ~30 hooks after the guard returns.

   No inline `style` for a property a primitive covers: a new .jsx carries an
   inline-style budget of 0. The two that remain are the ones the linter does
   not count and should not - `paddingBottom` on the page, and the swipe
   transform + the derived grid-template-columns, both of which are DATA.
   ============================================================================ */

/* ============================================================================
   R143 candidate-record rebuild — the next-action card, the fact strip and the
   Experience / Skills / Messages tab bodies. Everything below is module-level
   and hook-free: they are presentational, the screen keeps every store write.

   Why they live HERE and not in screens-core.jsx: this batch owns
   cand-record.jsx and cand-record.css only. `CandidateFilesSection` and the old
   `CandidateMessagesTab` stay in screens-core.jsx untouched - the Files tab
   still renders the shared section (see the note at its call site), and the
   Messages tab renders the local one below.
   ============================================================================ */

/* ---- dates: an absolute month index (year*12 + month) is the whole arithmetic ---- */
const E8_CAND_MON = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };

function candRecNowIdx() { const d = new Date(); return d.getFullYear() * 12 + d.getMonth(); }

/* One end of a span. Handles 'present'/'current'/'now', 'YYYY' and 'Mon YYYY'.
   Anything else returns null, and a null end makes the WHOLE span unparsed - a
   duration we cannot compute is left blank rather than approximated, because a
   wrong tenure on a candidate record is worse than an absent one. */
function candRecPoint(s) {
  const t = String(s || '').trim().toLowerCase().replace(/[.,]/g, '');
  if (!t) return null;
  if (/^(present|current|now|today|ongoing)$/.test(t)) return { idx: candRecNowIdx(), open: true };
  let m = t.match(/^([a-z]{3})[a-z]*\s+(\d{4})$/);
  if (m && E8_CAND_MON[m[1]] != null) return { idx: parseInt(m[2], 10) * 12 + E8_CAND_MON[m[1]], open: false };
  m = t.match(/^(\d{4})$/);
  if (m) return { idx: parseInt(m[1], 10) * 12, open: false };
  return null;
}

/* '2021 - present' -> { from, to, months, open }. Both ends must parse. */
function candRecSpan(span) {
  const raw = String(span || '').trim();
  if (!raw) return null;
  const parts = raw.split(/\s*(?:[–—-]|\bto\b)\s*/i).filter((p) => p !== '');
  if (parts.length !== 2) return null;
  const from = candRecPoint(parts[0]);
  const to = candRecPoint(parts[1]);
  if (!from || !to || from.open) return null;
  const months = to.idx - from.idx;
  if (months < 0) return null;
  return { from: from.idx, to: to.idx, months, open: !!to.open };
}

/* 33 -> '2y 9m'. Sub-month spans read '1m' rather than '0m'. */
function candRecDur(months) {
  if (months == null || months < 0) return null;
  const y = Math.floor(months / 12);
  const mo = months % 12;
  if (y && mo) return y + 'y ' + mo + 'm';
  if (y) return y + 'y';
  return Math.max(mo, 1) + 'm';
}

/* The role rows the Experience timeline renders: newest first, each with its own
   duration, plus the GAP to the role below it. A gap is only claimed when BOTH
   neighbours parsed - an unparsed span produces no gap rather than a fake one -
   and only from 3 months, below which it is a normal notice period, not a gap. */
function candRecRoles(experience) {
  const rows = (experience || []).map((x) => ({ x, sp: candRecSpan(x.span) }));
  const allParsed = rows.length > 0 && rows.every((r) => r.sp);
  const ordered = allParsed ? rows.slice().sort((a, b) => b.sp.from - a.sp.from) : rows;
  return ordered.map((r, i, arr) => {
    const next = arr[i + 1];
    const gap = (r.sp && next && next.sp) ? r.sp.from - next.sp.to : null;
    return {
      x: r.x,
      months: r.sp ? r.sp.months : null,
      open: !!(r.sp && r.sp.open),
      gapMonths: gap != null && gap >= 3 ? gap : null,
    };
  });
}

/* The three tenure stats, DERIVED. They were hardcoded strings ('2y 8m',
   '4y 11m') that did not move when the résumé did. Total experience keeps
   reading c.years - it is the record's own field and the list column agrees
   with it - and falls back to the parsed span only when years is absent. */
function candRecTenure(roles, c) {
  const dur = roles.map((r) => r.months).filter((m) => m != null);
  const avg = dur.length ? Math.round(dur.reduce((a, b) => a + b, 0) / dur.length) : null;
  const cur = roles.find((r) => r.open) || roles[0];
  const spanMonths = dur.length ? dur.reduce((a, b) => a + b, 0) : null;
  /* The profile's own `years` and the résumé's spans are two different claims, and on this
     dataset they disagree by two and a half years. Total experience keeps reading c.years -
     it is the field the candidate list, the header and the AI summary all quote, and making
     this one screen derive its own number would put the record at odds with every other -
     but a disagreement of a year or more is a real finding on a candidate record, so it is
     STATED rather than smoothed over. Threshold is 12 months: a résumé rounded to whole
     years cannot disagree by less than that without it being an artefact of rounding. */
  const conflict = (c.years && spanMonths != null && Math.abs(spanMonths - c.years * 12) >= 12)
    ? candRecDur(spanMonths) : null;
  return {
    avg: candRecDur(avg) || '—',
    current: (cur && candRecDur(cur.months)) || '—',
    total: c.years ? c.years + 'y' : (candRecDur(spanMonths) || '—'),
    conflict,
  };
}

/* ---- the next-action card (piece 3) ---------------------------------------
   Same rows as the Work queue (tkOpenTasksFor); the screen still resolves the
   destination through tkTaskPath. Three changes from the card it replaces:
   the title and reason WRAP instead of clipping to one nowrap line (both
   truncated mid-word at 390), the task's urgency is stated rather than implied,
   and the action button is a real touch target rather than a 26px inline control.

   R143 REVIEW - two things reversed here, both against measurements:

   (a) THE BUTTON IS NOW PRIMARY. The R138 P2 note said it should stay secondary
   because the card mirrors the Work queue and because three filled accent
   buttons were competing in one view. Measured at 1440 after the R143 rebuild,
   that is no longer the situation: the only other filled button above the fold
   is the topbar's routine `Submit to job` (114x24), so the loudest control on
   the screen was a stage advance while the card flagged "Right now / candidate
   replied 11 min ago" got the quiet tier. §2.4 calls this card the strongest
   thing on the screen; its button now agrees.

   (b) THE ACTION SITS UNDER THE REASON AT EVERY WIDTH, not floated to the card's
   right edge. Measured at 1440: the title started at x=262 and `Send reply` sat
   at x=952 - a 690px separation inside an 820px card, so the action and the
   sentence justifying it were nowhere near each other.

   `same items as Today` is gone from the head. It named another screen instead
   of telling the recruiter anything about this candidate, and it was rendered in
   the tertiary ink that fails AA in light mode (2.72:1). The urgency chip beside
   it already says the only thing that mattered. */
function CandRecNextAction({ tasks, open, onOpen, onGo }) {
  if (!tasks || !tasks.length) return null;                 /* absent, not an empty shell */
  const urg = (typeof TK_URGENCY !== 'undefined' && TK_URGENCY) || {};
  const shown = open ? tasks : tasks.slice(0, 1);
  const lead = urg[tasks[0].urgency];
  return (
    <section className="e8-cand-rec-na" aria-label="Needs you">
      <div className="e8-cand-rec-na-head">
        {/* WAVE-3 SEAM FIX — SENTENCE CASE, BECAUSE THIS IS A CONTENT-COLUMN SECTION HEADING.
            Measured at 1440 on #/candidate/c-okafor, every top-level heading in this column
            reads 11.5 / 500 / --ui-text-secondary / sentence case through `.e8-sectionlabel`:
            About (y=361), Prescreen (489), Why we matched (490), Latest activity (772), and
            all four on the Skills tab. This one was the single exception - `.e8-eyebrow` at
            11.5 / 600 / .05em / UPPERCASE (y=143) - so the first heading a recruiter meets
            announced itself in the RAIL's grammar. The record's two label systems now split
            cleanly by REGION rather than by author: sentence case in the content column,
            uppercase for `.e8-rrail-sec-title` in the rail and for sub-labels INSIDE a card
            ("STILL NEEDED"). Nothing is lost: this card's emphasis comes from its accent
            inset bar, its border, the urgency chip and its primary button - the same argument
            cand-overview.css used to take `Prescreen` down from an h3.
            The ink repair that used to live here goes with it. `.e8-cand-rec-na .e8-eyebrow`
            existed only to lift `.e8-eyebrow`'s --ui-text-tertiary (2.71:1 on this card's
            white surface) to --ui-text-secondary; `.e8-sectionlabel` IS secondary already
            (measured rgb(111,110,119), 5.03:1), so the override was deleted rather than
            restated. The tertiary-ink fault on the REMAINING eyebrows is unchanged and
            still belongs to product.css - see the note in cand-record.css. */}
        <span className="e8-sectionlabel">Needs you</span>
        {lead ? <span className={'e8-cand-rec-na-urg is-' + (lead.tone || 'neutral')}>{lead.label}</span> : null}
      </div>
      {shown.map((t) => {
        const path = window.tkTaskPath ? window.tkTaskPath(t) : null;
        return (
          <div key={t.id} className="e8-cand-rec-na-item">
            <span className="material-symbols-outlined e8-cand-rec-na-ic" aria-hidden="true">
              {t.icon || (typeof TK_KIND_ICON !== 'undefined' && TK_KIND_ICON[t.kind]) || 'task_alt'}
            </span>
            <div className="e8-cand-rec-na-body">
              <p className="e8-cand-rec-na-t">{t.title}</p>
              {t.sub ? <p className="e8-cand-rec-na-s">{t.aiAssisted ? 'Agent-assisted · ' : ''}{t.sub}</p> : null}
            </div>
            {path ? (
              <div className="e8-cand-rec-na-act">
                <DSc.Button variant="primary" size="sm" onClick={() => onGo(path)}>{t.actionLabel || 'Open'}</DSc.Button>
              </div>
            ) : null}
          </div>
        );
      })}
      {tasks.length > 1 && !open ? (
        <button type="button" className="e8-cand-rec-na-more" onClick={onOpen}>
          <span className="material-symbols-outlined" aria-hidden="true">expand_more</span>
          <span className="e8-cand-rec-na-more-t">{tasks.length - 1} more — {tasks[1].title}</span>
        </button>
      ) : null}
    </section>
  );
}

/* ---- the fact strip (piece 4, NEW) ----------------------------------------
   The qualifying facts a recruiter reads in the first second, each of which used
   to cost a rail open. Unknowns are RENDERED, not dropped: "Work auth — Unknown"
   is the fact that sends you to ask, and a strip whose columns move per
   candidate is unscannable. `title` carries the longer authored context (comp
   history, residence detail) where the record has it.

   R143 REVIEW - THE STRIP IS THREE FACTS, NOT SIX. Location, Stage and
   Experience were struck because THE HEADER ALREADY RENDERS ALL THREE, ~200px
   above: `.e8-cand-header-meta` reads "Sr. Software Engineer at Optum · Memphis,
   TN · 8y experience" and `.e8-cand-header-stage` reads "Screening". Measured on
   c-okafor at 390: the strip was 204px of a 658px content viewport and 102px of
   that said the same three things a second time, which is how the whole viewport
   came to be consumed by chrome with 0px of tab content above the fold. §2.3
   defines density as more answered QUESTIONS per screen; a fact answered twice
   is one answered question at twice the price. Reproduced on c-mills
   (Sourced / Memphis, TN / 3y all appeared twice).

   WHAT SURVIVES IS WHAT THE HEADER CANNOT SAY.

   R143 REVIEW 2 - AND WHAT THE RAIL IS NOT ALREADY SAYING, WHICH IS NOT THE SAME
   THREE FACTS AT EVERY WIDTH. Measured at 1440: `Rate  $92/hr W2` and
   `Availability  Immediate` were rendering here AND, verbatim, in the rail's
   "At a glance" 300px to the right, in one viewport - so the 820x35 band bought
   one fact the reader did not already have. The previous note said this "cannot
   be fixed here" because At a glance lives in cand-rail.jsx. It can: the fix is
   not to change the rail, it is to stop repeating it.
   And it has to be width-aware, because THE RAIL IS ONLY A RAIL ON A DESKTOP.
   At 390 and 834 it is a dismissed bottom sheet, so there the strip is the only
   place rate and availability appear at all, and dropping them would take the
   two facts a recruiter reads first away from the width with the least room to
   go looking. So five facts are rendered and CSS shows the three that are not
   already on screen, keyed on `.e8-recordbody.is-narrow` - the shell's own
   verdict about whether the rail is inline. Rail open: work auth, work model,
   last contact. Rail collapsed: rate, availability, work auth. Three cells
   either way; see cand-record.css for the rules.
   `railAway` closes the third case: the rail has TWO panes and only Details
   renders At a glance, so with the rail on Activity - where every activity toast
   and #/candidate/:id/activity lands it - rate and availability are on nobody's
   screen. The strip takes those two back for as long as that lasts, and still
   renders three cells.
   Unknowns are still RENDERED, not dropped: "Work auth - Unknown" is the fact
   that sends you to ask, and a strip whose columns move per candidate is
   unscannable. `title` carries the longer authored context where we have it. */

/* The residence note is authored prose ("Memphis, TN - local; hybrid OK, 3 days
   on-site max"); the strip needs one word of it. Nothing is inferred that the
   note does not say - no match means no claim, and the cell reads Unknown. */
function candRecWorkModel(cmeta) {
  const r = String(cmeta.residence || '').toLowerCase();
  if (!r) return null;
  if (/\bremote\b/.test(r)) return 'Remote';
  if (/hybrid/.test(r)) return 'Hybrid';
  if (/on-?site/.test(r)) return 'On-site';
  if (/\blocal\b/.test(r)) return 'Local';
  return null;
}

function CandRecFactStrip({ c, cmeta, listMeta, railAway }) {
  const lm = listMeta || {};
  const facts = [
    /* `when` = the shell state this cell is FOR. 'railsheet' cells are the ones the open rail
       would duplicate; 'railopen' cells are the ones no rail pane carries above its fold. */
    { k: 'Rate', v: c.rate ? c.rate + ' W2' : null, when: 'railsheet', title: cmeta.compHistory ? 'Current: ' + cmeta.compHistory : null },
    { k: 'Availability', v: cmeta.availability || null, when: 'railsheet' },
    { k: 'Work auth', v: cmeta.workAuth ? cmeta.workAuth + (cmeta.workAuthNoSponsorship === 'No' ? ' · visa on file' : '') : null },
    { k: 'Work model', v: candRecWorkModel(cmeta), when: 'railopen', title: cmeta.residence || null },
    { k: 'Last contact', v: lm.last || null, when: 'railopen', title: lm.lastNote || null },
  ];
  if (!facts.some((f) => f.v)) return null;
  return (
    <dl className={'e8-cand-rec-facts' + (railAway ? ' is-railaway' : '')}>
      {facts.map((f) => (
        <div key={f.k} className={'e8-cand-rec-fact' + (f.when ? ' is-' + f.when : '')} title={f.v && f.title ? f.title : undefined}>
          <dt className="e8-cand-rec-fact-k">{f.k}</dt>
          <dd className={'e8-cand-rec-fact-v' + (f.v ? '' : ' is-unknown')}>{f.v || 'Unknown'}</dd>
        </div>
      ))}
    </dl>
  );
}

/* ---- Experience (piece 11) ------------------------------------------------ */
function CandRecExperienceTab({ c, prof, cvActions, matchJob, showToast, onViewResume }) {
  const roles = candRecRoles(prof.experience);
  const tenure = candRecTenure(roles, c);
  const stats = prof.experienceParsed
    ? [['Average tenure', tenure.avg], ['Current tenure', tenure.current], ['Total experience', tenure.total]]
    : [['Total experience', tenure.total]];
  return (
    <div className="e8-cand-rec-exp">
      {/* `.e8-cand-rec-cvbar` is this batch's ANCHOR on the shared `.e8-cv-toolbar` (app.css:1682):
          a bare override of that class would reach every other record type that uses it. Measured
          at 390, the toolbar kept all three controls on one row and left the filename 76px of a
          255px string - 70% of "Okafor_Daniel_CV.pdf · updated 2…" hidden. The wrap rule is in
          cand-record.css, keyed on the anchor. */}
      <div className="e8-cv-toolbar e8-cand-rec-cvbar">
        <window.Row as="span" gap={8} className="e8-cand-rec-inline e8-cand-rec-cvfile e8-ui-shrink">
          <span className="material-symbols-outlined e8-cand-rec-cvic">description</span>
          <span className="e8-cand-rec-cvname">
            {/* WAVE-3 SEAM FIX — THE DOCUMENT IS A "RÉSUMÉ" EVERYWHERE IT IS NAMED AS A THING.
                This row said "No CV on file yet" beside a button that says "View résumé", about
                a file this same screen tags `'Résumé'` (cand-record.jsx:1060) using the app's
                own tag vocabulary (screens-core.jsx:3230, `['Résumé', 'Formatted CV', …]`), and
                app-wide `grep -oh "résumé\|Résumé" app/*.jsx | wc -l` is 79 against 19 for CV.
                The tool LABELS ("CV tools", "Generate CV") deliberately keep the word: they name
                the FORMATTED CV, which is a different artefact in that same tag list, and "CV
                tools" is also rendered verbatim by screens-core.jsx:3598 - renaming this copy
                would put the Experience tab and the Files tab out of step. Noun unified, verbs
                left alone; the residue is reported. */}
            {prof.cv.updated === '-'
              ? <window.Text tone="secondary">No résumé on file yet</window.Text>
              : <React.Fragment>{prof.cv.file} <window.Text tone="secondary">· updated {prof.cv.updated}</window.Text></React.Fragment>}
          </span>
        </window.Row>
        <window.Row as="span" gap={6} align="stretch" className="e8-cand-rec-inline e8-cand-rec-cvacts e8-ui-static e8-ui-push">
          {c.resumeText ? <DSc.Button variant="secondary" size="sm" icon="visibility" onClick={onViewResume}>View résumé</DSc.Button> : null}
          <DSc.MenuButton label="CV tools" icon="more_horiz" size="sm" variant="ghost" items={cvActions} />
          <DSc.Button variant="primary" size="sm" icon="auto_awesome" onClick={() => showToast('Tailored CV for ' + (matchJob ? matchJob.title : 'the best-fit role') + ' - live CV drafting is a prototype stub')}>Generate CV</DSc.Button>
        </window.Row>
      </div>

      {/* R143 REVIEW - THE INLINE grid-template-columns IS GONE, DELIBERATELY, AND THAT IS
          THE FIX. It read `repeat(3|1, 1fr)`, and app.css:3543 matches
          `.e8-content [style*="repeat(3, 1fr)"]` to force TWO columns below 840px. Three
          tenure tiles in two columns orphans the third and leaves a whole empty cell:
          measured 165x64px empty at 390 and 355x64px empty at 834, on a block whose entire
          content is three numbers. That app.css rule is a viewport `@media` reaching into a
          record, which is the thing CLAUDE.md forbids twice over - it fires on the VIEWPORT
          while this block lives in the container-queried content column.
          Carrying the columns on a class instead takes this block out of that selector's
          reach (it matches an inline `style` ATTRIBUTE, and there no longer is one) and lets
          `.e8-cand-rec-expstats` state its own responsive behaviour in cand-record.css, by
          `@container`, like every other block in this file. Nothing else on any screen
          changes: the app.css rule is untouched and still collapses every other
          `repeat(3, 1fr)` grid it was written for. */}
      {/* R143 REVIEW 2 - TWO COLUMNS ONCE THERE IS ROOM FOR TWO. Measured at 1440 the tab was
          720x408 at x=224 against a rail starting at 1140: 196px of window with nothing in it,
          on the one tab that holds a whole career. The wrappers are what make the split
          expressible - the summary (tenure numbers + the résumé-vs-profile conflict, which
          describe the timeline rather than continue it) becomes a reference column beside the
          timeline at wide widths, and stays a stack above it at narrow ones, in this DOM order.
          The conditional lives in cand-record.css and measures the CONTENT COLUMN. */}
      <div className="e8-cand-rec-expcols">
        <div className="e8-cand-rec-expside">
          <div className={'e8-cand-rec-expstats' + (prof.experienceParsed ? ' is-3' : ' is-1')}>
            {stats.map(([k, v]) => (
              <div key={k} className="e8-cand-rec-expstat">
                <window.Text as="div" size="sm" tone="secondary">{k}</window.Text>
                <window.Text as="div" size="md" weight="medium" mt={2} className="tnum">{v}</window.Text>
              </div>
            ))}
          </div>

          {tenure.conflict ? (
            <p className="e8-cand-rec-expnote">
              <span className="material-symbols-outlined e8-cand-rec-expnote-ic" aria-hidden="true">info</span>
              The profile says {c.years} years; the roles span {tenure.conflict}. Worth confirming which is right before the packet goes out.
            </p>
          ) : null}
        </div>

        <div className="e8-cand-rec-expmain">
          <ol className="e8-cand-rec-tl">
            {roles.map((r, i) => {
              const dur = candRecDur(r.months);
              return (
                <React.Fragment key={i}>
                  <li className={'e8-cand-rec-tlrow' + (r.open ? ' is-current' : '')}>
                    <span className="e8-cand-rec-tldot" aria-hidden="true"></span>
                    <div className="e8-cand-rec-tlmain">
                      <p className="e8-cand-rec-tlrole">{r.x.role}</p>
                      <p className="e8-cand-rec-tlco">{r.x.co}</p>
                      {r.x.sub ? <p className="e8-cand-rec-tlsub">{r.x.sub}</p> : null}
                    </div>
                    <p className="e8-cand-rec-tlmeta">
                      <span className="e8-cand-rec-tlspan tnum">{r.x.span}</span>
                      {dur ? <span className="e8-cand-rec-tldur tnum">{dur}</span> : null}
                    </p>
                  </li>
                  {r.gapMonths ? (
                    <li className="e8-cand-rec-tlgap">
                      <span className="material-symbols-outlined e8-cand-rec-tlgapic" aria-hidden="true">more_vert</span>
                      {candRecDur(r.gapMonths)} gap — nothing on the résumé between these two roles
                    </li>
                  ) : null}
                </React.Fragment>
              );
            })}
          </ol>

          {!prof.experienceParsed ? (
            <window.Row gap={6} size="sm" tone="secondary" mt={10}>
              <span className="material-symbols-outlined" style={{ fontSize: 'var(--ui-icon-xs)' }}>info</span>
              Earlier roles parse from the résumé once it's uploaded - use "Update with new CV" above.
            </window.Row>
          ) : null}
        </div>
      </div>
    </div>
  );
}

/* ---- Skills (piece 12) -----------------------------------------------------
   "Evidence where we have it" is taken literally: a skill is evidenced when the
   RECORD ITSELF says so somewhere - a match reason, a role description, the AI
   summary, the profile bio or the résumé text - and the row shows the sentence
   and where it came from. Nothing is invented; a skill with no hit falls into
   the second group, which is the useful half of the answer ("we are claiming
   Terraform on his behalf"). */
function candRecSnippet(text, skill) {
  const hay = String(text || '');
  const at = hay.toLowerCase().indexOf(String(skill).toLowerCase());
  if (at < 0) return null;
  let s = at;
  while (s > 0 && !/[.;]/.test(hay[s - 1])) s -= 1;
  let e = at;
  while (e < hay.length && !/[.;]/.test(hay[e])) e += 1;
  let out = hay.slice(s, e).trim();
  if (out.length > 150) {
    out = out.slice(0, 150);
    out = out.slice(0, Math.max(out.lastIndexOf(' '), 60)).trim() + '…';
  }
  return out || null;
}

function candRecSkillGroups(c, prof, dm, extracted) {
  const sources = [];
  (((dm && dm.reasons) || [])).forEach((r) => {
    if (r && (r.weight | 0) > 0) sources.push({ where: dm.job ? 'Match to ' + dm.job.id : 'Match reason', text: r.detail || r.label });
  });
  (prof.experience || []).forEach((x) => {
    if (x.sub) sources.push({ where: x.role + ' · ' + x.co, text: x.sub });
  });
  if (c.aiSummary) sources.push({ where: 'AI summary', text: c.aiSummary });
  if (prof.about) sources.push({ where: 'Profile', text: prof.about });
  if (c.resumeText) sources.push({ where: 'Résumé', text: c.resumeText });

  const seen = {};
  const all = (prof.techStack || []).concat(c.skills || []).filter((s) => {
    const k = String(s).toLowerCase();
    if (!s || seen[k]) return false;
    seen[k] = 1;
    return true;
  });
  /* A skill the résumé parser pulled out IS evidenced - it was read off the document.
     Without this the same skill appeared twice under contradictory headings, once as
     "no evidence yet" and once as "also extracted from the résumé". Measured on
     c-okafor: Docker. Only the extracted skills that are NOT on the profile stay in
     their own group, which is the genuinely different thing that list says. */
  const exLower = {};
  (extracted || []).forEach((s) => { exLower[String(s).toLowerCase()] = s; });

  /* ONE SENTENCE, ONE ROW. A single résumé line legitimately backs several skills - measured on
     c-okafor, "Six years of Spring Boot in production, currently on Boot 3 and Java 17 - led the
     migration at Optum" is the evidence for BOTH `Java 17` and `Spring Boot`. Printed twice
     verbatim it stops reading as proof and starts reading as filler, and it cost a 143px row to
     say nothing new. Rows are therefore keyed by (source + sentence): the second skill joins the
     row the first opened, in first-appearance order, and the row names both skills. Nothing is
     dropped and no skill loses its evidence - the same fact is asserted once. */
  const evidenced = [];
  const byQuote = {};
  const listed = [];
  all.forEach((s) => {
    let hit = null;
    for (let i = 0; i < sources.length && !hit; i += 1) {
      const snip = candRecSnippet(sources[i].text, s);
      if (snip) hit = { where: sources[i].where, snippet: snip };
    }
    if (!hit && exLower[String(s).toLowerCase()]) {
      hit = { where: 'Résumé', snippet: 'Named in the parsed résumé.' };
    }
    if (!hit) { listed.push(s); return; }
    /* '\u0000', written as an ESCAPE. It was a literal NUL byte in the source, which made
       every `grep` in this repo classify the record's largest file as binary and skip it
       silently - and "grep the prefix before adding it" is the rule this project runs on.
       Same string at runtime; the file is now text. */
    const key = hit.where + '\u0000' + hit.snippet;
    if (byQuote[key]) { byQuote[key].skills.push(s); return; }
    const row = { skills: [s], where: hit.where, snippet: hit.snippet };
    byQuote[key] = row;
    evidenced.push(row);
  });
  const extraOnly = (extracted || []).filter((s) => !seen[String(s).toLowerCase()]);
  return { evidenced, listed, extraOnly };
}

function CandRecSkillsTab({ c, prof, dm, extracted, showToast }) {
  const { evidenced, listed, extraOnly } = candRecSkillGroups(c, prof, dm, extracted);
  /* The count is SKILLS, not rows - one row can now carry two skills that share a quote. */
  const evidencedCount = evidenced.reduce((n, e) => n + e.skills.length, 0);
  return (
    <div className="e8-cand-rec-skills">
      <section>
        <div className="e8-cand-rec-skhead">
          <span className="e8-sectionlabel">Evidenced on this record</span>
          <span className="e8-cand-rec-skhead-n tnum">{evidencedCount}</span>
        </div>
        {evidenced.length ? (
          <ul className="e8-cand-rec-sklist">
            {evidenced.map((e) => (
              <li key={e.skills.join('|')} className="e8-cand-rec-skrow">
                <span className="e8-cand-rec-sknames">
                  {e.skills.map((s) => <span key={s} className="e8-cand-rec-skname">{s}</span>)}
                </span>
                <span className="e8-cand-rec-skev">
                  <span className="e8-cand-rec-skwhere">{e.where}</span>
                  <span className="e8-cand-rec-sksnip">{e.snippet}</span>
                </span>
              </li>
            ))}
          </ul>
        ) : (
          <p className="e8-cand-rec-skempty">Nothing on the record backs a skill yet — a résumé, a screening note or a match reason is what turns a claim into evidence.</p>
        )}
      </section>

      {listed.length ? (
        <section>
          <div className="e8-cand-rec-skhead">
            <span className="e8-sectionlabel">Listed, no evidence yet</span>
            <span className="e8-cand-rec-skhead-n tnum">{listed.length}</span>
          </div>
          <window.Row gap={6} align="stretch" wrap>
            {listed.map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
          </window.Row>
          <p className="e8-cand-rec-skempty">On the profile, but no role description, match reason or résumé line mentions them.</p>
        </section>
      ) : null}

      {extraOnly && extraOnly.length ? (
        <section>
          <SourceLabel label="On the résumé, not on the profile" source="resume" />
          <window.Row gap={6} align="stretch" wrap>
            {extraOnly.map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
          </window.Row>
          {/* "off the résumé", not "off the CV" - this sentence sits directly under the heading
              "On the résumé, not on the profile", so the two words for one document were four
              lines apart inside one section. */}
          <p className="e8-cand-rec-skempty">The parser read these off the résumé; nobody has added them to the profile, so they do not score in matching yet.</p>
        </section>
      ) : null}

      <section>
        <window.Text as="div" mb={8} className="e8-sectionlabel">Add a skill</window.Text>
        <DSc.Button variant="secondary" size="sm" icon="add" onClick={() => showToast('Skill editor opens here')}>Add skill</DSc.Button>
      </section>
    </div>
  );
}

/* ---- Messages (piece 13) ---------------------------------------------------
   The shared CandidateMessagesTab in screens-core.jsx showed the last THREE
   messages with no day grouping, no direction and no unread state, so the tab
   answered neither "what was said" nor "does this need me". This one renders
   the whole thread (capped, with a link out), groups it by day, distinguishes
   inbound from outbound, and marks the trailing inbound run unread when the
   conversation is - which is the same flag the Inbox list reads. */
const CAND_REC_MSG_CAP = 12;

function candRecDayLabel(ts) {
  const d = new Date(ts);
  const now = new Date();
  const day = (x) => x.getFullYear() * 400 + x.getMonth() * 32 + x.getDate();
  if (day(d) === day(now)) return 'Today';
  const y = new Date(now.getTime() - 864e5);
  if (day(d) === day(y)) return 'Yesterday';
  return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}

/* 'Yesterday · 4:31 pm' inside a group already headed "Yesterday" is noise. */
function candRecClock(when, dayLabel) {
  const w = String(when || '');
  const m = w.match(/^\s*[A-Za-z]+\s*·\s*(.+)$/);
  if (m && w.toLowerCase().indexOf(dayLabel.toLowerCase()) === 0) return m[1];
  return w;
}

function CandRecMessagesTab({ convo, name }) {
  if (!convo) {
    return (
      <div className="e8-ui-center">
        <DSc.EmptyState
          icon="forum"
          title="No messages yet"
          body={'Outreach to ' + name + ' starts as a draft in Approvals - once it sends, the whole thread lives here and in your inbox.'}
          cta={<DSc.Button variant="secondary" size="sm" className="e8-empty-cta" onClick={() => navigate('approvals')}>Open approvals</DSc.Button>}
        />
      </div>
    );
  }
  const thread = convo.thread || [];
  const hidden = Math.max(0, thread.length - CAND_REC_MSG_CAP);
  const shown = hidden ? thread.slice(-CAND_REC_MSG_CAP) : thread;
  /* Unread = the trailing run of inbound messages, when the conversation carries
     the flag. Anything the recruiter sent has by definition been read by them. */
  let firstUnread = -1;
  if (convo.unread) {
    for (let i = thread.length - 1; i >= 0 && thread[i].who !== 'me'; i -= 1) firstUnread = i;
  }
  const counts = {};
  thread.forEach((m) => { counts[m.ch] = (counts[m.ch] || 0) + 1; });
  const offset = thread.length - shown.length;
  let lastDay = null;
  return (
    <div className="e8-cand-rec-msgs">
      <div className="e8-cand-rec-msghead">
        <span className="e8-sectionlabel">One thread, every channel</span>
        {firstUnread > -1 ? <span className="e8-cand-rec-msgunread">{thread.length - firstUnread} unread</span> : null}
        <window.Row as="span" gap={8} wrap className="e8-cand-rec-inline e8-cand-rec-msgchans">
          {Object.entries(counts).map(([ch, n]) => (
            <window.Row as="span" gap={5} className="e8-cand-rec-inline" key={ch}>
              <ChannelChip ch={ch} />
              <window.Text size="xs" tone="secondary" className="tnum">{n}</window.Text>
            </window.Row>
          ))}
        </window.Row>
        <span className="e8-cand-rec-msghead-act">
          <DSc.Button variant="secondary" size="sm" icon="forum" onClick={() => navigate('inbox/' + convo.id)}>Open in Inbox</DSc.Button>
        </span>
      </div>

      {hidden ? (
        <button type="button" className="e8-cand-rec-msgearlier" onClick={() => navigate('inbox/' + convo.id)}>
          <span className="material-symbols-outlined" aria-hidden="true">history</span>
          {hidden} earlier message{hidden > 1 ? 's' : ''} — read the full thread in the Inbox
        </button>
      ) : null}

      <ol className="e8-cand-rec-msglist">
        {shown.map((m, i) => {
          const idx = offset + i;
          const ts = window.e8WhenToTs ? window.e8WhenToTs(m.when) : Date.now();
          const day = candRecDayLabel(ts);
          const newDay = day !== lastDay;
          lastDay = day;
          const mine = m.who === 'me';
          const who = mine ? (m.name || 'You') : (m.agent ? m.name : name);
          return (
            <React.Fragment key={idx}>
              {newDay ? <li className="e8-cand-rec-msgday"><span>{day}</span></li> : null}
              {idx === firstUnread ? <li className="e8-cand-rec-msgnew"><span>Unread</span></li> : null}
              <li className={'e8-cand-rec-msg' + (mine ? ' is-mine' : ' is-theirs') + (firstUnread > -1 && idx >= firstUnread ? ' is-unread' : '')}>
                <span className="e8-cand-rec-msgav">
                  {m.agent ? <DSc.Avatar agent size="sm" /> : <DSc.Avatar name={mine ? (m.name || 'Sarah Kim') : name} size="sm" />}
                  <ChannelDot ch={m.ch} size={12} />
                </span>
                <div className="e8-cand-rec-msgbody">
                  <p className="e8-cand-rec-msgmeta">
                    <span className="e8-cand-rec-msgwho">{who}</span>
                    <ChannelChip ch={m.ch} />
                    {m.ai ? <span className="e8-cand-rec-msgai">AI-drafted</span> : null}
                    <span className="e8-cand-rec-msgwhen tnum">{candRecClock(m.when, day)}</span>
                  </p>
                  <p className="e8-cand-rec-msgtext">{m.text}</p>
                </div>
              </li>
            </React.Fragment>
          );
        })}
      </ol>

      {convo.draftReply ? (
        <div className="e8-cand-rec-msgdraft">
          <span className="material-symbols-outlined e8-cand-rec-msgdraft-ic" aria-hidden="true">edit_note</span>
          <div className="e8-cand-rec-msgdraft-body">
            <p className="e8-cand-rec-msgdraft-k">Draft reply waiting</p>
            <p className="e8-cand-rec-msgdraft-t">{convo.draftReply}</p>
          </div>
          <div className="e8-cand-rec-msgdraft-act">
            <DSc.Button variant="secondary" size="sm" icon="send" onClick={() => navigate('inbox/' + convo.id)}>Review &amp; send</DSc.Button>
          </div>
        </div>
      ) : null}

      <window.Text as="div" size="sm" tone="secondary" mt={12}>
        Replies route through whichever channel you pick in the composer - email, LinkedIn, WhatsApp, or text via RingCentral.
      </window.Text>
    </div>
  );
}

function CandidateDetailScreen({ id, q, sub }) {
  const D = window.E8DATA;
  const c = D.matches.find((m) => m.id === id);
  /* e8-hooks-safe: main.jsx mounts this with key={'cand-' + route.id}, so a different candidate is
     a different mount and the hook count cannot change between two renders of the same one. This
     guard also cannot START firing mid-mount - the store never REMOVES a candidate row. Hoisting
     ~30 hooks to satisfy the linter would be a larger and riskier change than documenting why it
     is safe.
     THE WAIVER COVERS THIS GUARD AND NOTHING ELSE. The R109 tombstone redirect used to sit here
     under the same waiver, and for that one the argument was false: merging is the one thing that
     mutates this row IN PLACE, under the same id and the same key. It now runs after the last
     hook - see the guard below `useIsMobile`. */
  if (!c) {
    return (
      <React.Fragment>
        <Topbar crumbs={[{ label: 'Candidates', to: 'candidates' }, { label: 'Not found' }]} />
        <div className="e8-content"><div className="e8-page"><div className="e8-ui-center" style={{ '--e8-ui-my': '48px' }}>
          <DSc.EmptyState icon="person_off" title="Candidate not found" body="This candidate does not exist, or the link is out of date." cta={<DSc.Button variant="primary" icon="group" onClick={() => navigate('candidates')}>Back to candidates</DSc.Button>} />
        </div></div></div>
      </React.Fragment>
    );
  }
  const { showToast } = React.useContext(E8Ctx);
  const cap = React.useContext(CaptureCtx);
  const { confirm, dialog: reverifyConfirm } = window.useConfirm(); // R109 T4: ineligible single-record re-verify warning
  const listMeta = D.candidates.find((x) => x.id === c.id) || {};
  const ownershipRow = { ...c, ...listMeta };
  /* R104 nit C: a placed candidate carries a back-link to its engagement/consultant (stamped by the
     placement executor). `placement` makes the journey navigable in the UI, and `headerStage` reflects
     the placement so a placed candidate never shows a stale 'Sourced'. */
  const placement = listMeta.engagementId
    ? { cid: listMeta.cid, engagementId: listMeta.engagementId, eng: (D.engagements || []).find((e) => e.id === listMeta.engagementId || (listMeta.cid && e.cid === listMeta.cid)) || null }
    : null;
  const placementCid = placement ? (placement.cid || (placement.eng && placement.eng.cid)) : null;
  const headerStage = placement ? 'Engaged' : (listMeta.status || 'Screening');
  const [tab, setTab] = React.useState('overview');
  const [actSeg, setActSeg] = React.useState('all'); /* R124: rail Activity segments - all | movement | notes */
  /* R124 record shell: the rail owns Details/Activity. #/candidate/:id/activity (the live deep
     link every activity-routed toast uses) initializes the rail on Activity. */
  const [railTab, setRailTab] = React.useState(() => (sub === 'activity' ? 'activity' : 'details'));
  const [railSheetOpen, setRailSheetOpen] = React.useState(false); /* bottom sheet, once collapsed */
  /* R138: true when the RECORD ROW is too narrow for an inline rail - which happens on a 1000px
     desktop window as readily as on a phone. RecordBody measures its own box and reports back;
     asking the viewport here is the bug CLAUDE.md warns about. Seeded from the viewport so the
     first paint is right before the observer fires. */
  const [railCollapsed, setRailCollapsed] = React.useState(window.railCollapseSeed);
  const railTrigRef = React.useRef(null);
  /* Navigating #/candidate/:id <-> #/candidate/:id/activity does NOT remount (same key), so
     the rail follows the sub-route here, not only in the initializer. Manual rail toggles are
     untouched - this runs only when `sub` changes. */
  React.useEffect(() => {
    setRailTab(sub === 'activity' ? 'activity' : 'details');
  }, [sub]);
  const filesTrigRef = React.useRef(null);
  const [filesMode, setFilesMode] = React.useState(null); /* null | 'list' | 'add' - 'add' lands the dialog on the add-file form */
  const [stageOpen, setStageOpen] = React.useState(false); /* compact stage chip -> full strip */
  const [nyOpen, setNyOpen] = React.useState(false);       /* Needs-you "N more" expander */
  const [present, setPresent] = React.useState(false);
  const [reviewReject, setReviewReject] = React.useState(false);
  const [showResume, setShowResume] = React.useState(false);
  const [listOpen, setListOpen] = React.useState(false);
  /* R111 W2: prescreen / packet / debrief surfaces */
  const [psOpen, setPsOpen] = React.useState(false);
  const [pktOpen, setPktOpen] = React.useState(false);
  const [debriefFor, setDebriefFor] = React.useState(null);
  /* Ownership is changeable from the record itself, not only from the list row. Ops/admin can move
     any record; the current owner can hand off their own candidate. The persona is re-read on
     persona:changed so the affordance appears/disappears without a reload. */
  const [ownPersona, setOwnPersona] = React.useState(() => (window.e8ActivePersona ? window.e8ActivePersona() : null));
  const canReassign = e8CanReassignCandidate(ownPersona, ownershipRow);
  const [reassignOpen, setReassignOpen] = React.useState(false);
  React.useEffect(() => (window.E8Events
    ? window.E8Events.subscribe(['persona:changed'], () => setOwnPersona(window.e8ActivePersona ? window.e8ActivePersona() : null))
    : undefined), []);
  /* R106 T2: data-freshness lifecycle (Task 1 data layer). The chip reads the derived state; verifyState
     'requested' overlays a pending indicator until a verify-back lands. */
  const cmeta = (D.candidateMeta || {})[c.id] || {};
  const linkedInUrl = candidateLinkedInUrl(c);
  const fresh = window.candidateFreshness ? window.candidateFreshness(c) : null;
  const reqRequested = cmeta.verifyState === 'requested';
  /* Request re-verification: replayable verifyState op + a provenance-stamped timeline event + a toast
     simulating the tokenized portal link. Undo restores the prior verifyState and drops the event.
     R109 T4: the timeline event carries the concrete touchpoint (from candidateReverifyEligible) so the
     Stand8-branded ask is not cold, and always mentions the one-tap opt-out. */
  const doRequestReverify = () => {
    const prev = cmeta.verifyState || 'idle';
    const noteId = 'n-rv-' + Date.now().toString(36);
    const elig = window.candidateReverifyEligible ? window.candidateReverifyEligible(c.id) : { eligible: true, touchpoint: null };
    const ctx = elig.touchpoint ? elig.touchpoint.charAt(0).toUpperCase() + elig.touchpoint.slice(1) : null;
    if (window.E8Store) {
      window.E8Store.setMeta(c.id, { verifyState: 'requested' });
      window.E8Store.add('notes', {
        type: 'verification',
        id: noteId, title: 'Re-verification requested',
        points: [
          'Stand8 re-verify link sent to ' + c.name + ' via the candidate self-service portal',
          elig.eligible && ctx ? ctx + ' - context referenced so the ask is not cold' : 'No prior touchpoint on file - sent as a clearly context-light Stand8 introduction',
          'Includes a one-tap "this is not me / stop" opt-out',
        ],
        refType: 'candidate', refId: c.id, ts: Date.now(),
        author: (D.user || {}).name || 'You', prov: 'human', when: 'Just now',
      });
    }
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Requested re-verification' + (elig.eligible ? '' : ' (no prior touchpoint)'), target: c.name, route: 'candidate/' + c.id });
    showToast('Re-verification link sent to ' + c.name, 'Undo', () => {
      if (!window.E8Store) return;
      window.E8Store.setMeta(c.id, { verifyState: prev });
      window.E8Store.remove('notes', noteId);
    });
  };
  /* Gate the single-record ask: if the candidate has only an AI screen / no prior touchpoint, warn that a
     re-verify may feel unexpected - but still allow an explicit send (a conscious choice). */
  const requestReverify = () => {
    const elig = window.candidateReverifyEligible ? window.candidateReverifyEligible(c.id) : { eligible: true };
    if (!elig.eligible) {
      confirm({
        title: c.name + (elig.reason === 'ai-screen' ? ' has only an AI screen on file' : ' has no prior touchpoint on file'),
        body: 'They have no application or human contact yet, so a "re-verify your profile" ask may feel unexpected. Send it anyway as a clearly Stand8-branded, context-light note with an easy opt-out?',
        confirmLabel: 'Send anyway',
        cancelLabel: 'Cancel',
        tone: 'primary',
        icon: 'info',
        onConfirm: doRequestReverify,
      });
      return;
    }
    doRequestReverify();
  };
  /* R107 T3: resolve a persisted pending conflict (from a portal submit) right on the record. Delegates
     to the replayable window.resolveCandidateConflict - 'accept' applies the candidate's new value +
     stamps provenance, 'keep' leaves the current value; both drop the item from pendingConflicts and are
     undoable. The record re-renders live via the store:changed subscription above. */
  const resolveConflict = (field, decision) => {
    if (!window.resolveCandidateConflict) return;
    const label = e8FreshFieldLabel(field);
    const res = window.resolveCandidateConflict(c.id, field, decision);
    if (decision === 'accept') showToast(label + ' updated from candidate', 'Undo', res && res.undo);
    else showToast('Kept current ' + label.toLowerCase(), 'Undo', res && res.undo);
  };
  /* R104 T2: re-render when a note (or any store op) lands so a just-saved note shows on this
     record's own timeline + rail immediately, not only after a navigation. */
  const [, setActTick] = React.useState(0);
  React.useEffect(() => (window.E8Events ? window.E8Events.subscribe(['store:changed', 'persona:changed'], () => setActTick((t) => t + 1)) : undefined), []);
  const prof = profileOf(c, listMeta);
  const liveLists = window.listsForCandidate ? window.listsForCandidate(c.id, ownPersona) : [];
  const convo = D.conversations.find((x) => x.candId === c.id);
  /* R104 T5: every match surface (score badge, why-matched panel, match rail, submit prefill, timeline)
     is driven by this candidate's REAL best-fit job, not a hardcoded flagship. */
  const tm = e8TopMatch(c);
  const jScore = tm.score;
  const matchTier = tm.tier;
  const matchReasons = tm.reasons;
  const matchJob = tm.job;
  const matchJobLabel = matchJob ? (matchJob.id + ' · ' + matchJob.title) : (tm.top ? tm.top.jobId : null);
  const fitTier = (DSc.MATCH_TIERS || []).find((t) => t.id === matchTier) || {};
  /* Open the real, deduped submission dialog (NewSubmissionDialog / e8AcceptDecision) prefilled to
     this candidate and their best-fit job - never a fake toast, never a literal JO-44219. */
  const openSubmit = () => {
    /* R111 W2.1: submit always goes through the packet preview - it carries its own job
       selector, so multi-job submits work even without a resolved best match. */
    if ((D.jobs || []).length) { setPktOpen(true); return; }
    if (window.e8OpenCreate) window.e8OpenCreate('submission', { prefillCand: c.id, prefillJob: tm.top ? tm.top.jobId : '' });
    else showToast('Submission dialog is unavailable in this build');
  };

  /* Queue context: use ?q if it contains this candidate, else fall back to the full roster. */
  const queue = resolveQueue(q);
  const inQueue = queue && queue.ids.indexOf(c.id) > -1;
  const queueIds = inQueue ? queue.ids : D.matches.map((m) => m.id);
  const queueLabel = inQueue ? queue.label : 'All candidates';
  const queueBack = inQueue ? queue.backTo : 'candidates';
  const qPos = Math.max(0, queueIds.indexOf(c.id));
  const qSuffix = (q && inQueue) ? '?q=' + q : '';

  const go = (d) => {
    const n = (qPos + d + queueIds.length) % queueIds.length;
    navigate('candidate/' + queueIds[n] + qSuffix);
  };
  /* Opened from a job's matching queue (?q=review-<jobId>): accept/reject use the SAME
     decision path as the matching-tab rows (E8Match decision + real submission + working
     undo, via e8AcceptDecision/e8RejectDecision from screens-job.jsx). Job-agnostic entries
     (roster browse, status queues) keep the light toast - there is no job to decide against. */
  const reviewJob = inQueue && queue.jobId
    ? (D.jobs.find((x) => x.id === queue.jobId) || (D.job && D.job.id === queue.jobId ? D.job : null))
    : null;
  /* R111 match-state model: at book scale most candidates have never been scored, so every match
     surface gates on hasMatch and the record stands on its own without pretending. When opened from
     a job's review queue, the decision panel scores THAT job (the decision being made), falling back
     to the generic best fit only when the queue job has no ranking row. */
  const hasMatch = !!(tm.top && tm.job);
  const decision = (() => {
    if (!reviewJob) return null;
    const ranked = window.E8Match ? window.E8Match.candidateJobs(c.id) : ((D.candidateJobs && D.candidateJobs[c.id]) || []);
    const row = ranked.find((rj) => rj.jobId === reviewJob.id);
    if (!row) return null;
    return { job: reviewJob, score: row.score, tier: row.tier, reasons: (row.reasons || []).map((label) => ({ label, weight: e8ReasonWeight(label, row.tier) })) };
  })();
  const dm = decision || { job: matchJob, score: jScore, tier: matchTier, reasons: matchReasons };
  const dmLabel = dm.job ? (dm.job.id + ' · ' + dm.job.title) : matchJobLabel;
  const dmTier = (DSc.MATCH_TIERS || []).find((t) => t.id === dm.tier) || fitTier;
  const accept = () => {
    if (reviewJob && window.e8AcceptDecision && window.E8Match) {
      const res = window.e8AcceptDecision(reviewJob, c);
      showToast(c.name + (res.existed ? ' accepted' : ' accepted - submission created'), 'Undo', res.undo);
    } else {
      showToast(c.name + ' accepted - draft submittal ready', 'Undo');
    }
    go(1);
  };

  /* Keyboard triage: J/K move, A accept, R reject - ignored while typing or in a dialog.
     R111: only active in queue context (the review bar advertises the keys), and plain
     ArrowUp/Down no longer hijack scroll-reading into record navigation. */
  React.useEffect(() => {
    if (!inQueue) return;
    const onKey = (e) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      /* Same blocking-overlay rule as the 1-5 tab handler below: accepting or paging records
         BEHIND the files dialog, prescreen, packet, debrief, resume viewer or reassign dialog
         would be an invisible decision. */
      if (reviewReject || present || filesMode || psOpen || pktOpen || debriefFor || showResume || reassignOpen) return;
      if (document.querySelector('.e8-ndoc-scrim, .e8-tkc-scrim')) return;
      const k = e.key.toLowerCase();
      if (k === 'j') { e.preventDefault(); go(1); }
      else if (k === 'k') { e.preventDefault(); go(-1); }
      /* A/R only where Accept is REAL (a decision job) - cohort browsing has no job to
         decide against, so the keys stay free for the global quick-add chord. */
      else if (k === 'a' && reviewJob) { e.preventDefault(); accept(); }
      else if (k === 'r' && reviewJob) { e.preventDefault(); setReviewReject(true); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [inQueue, qPos, queueIds.length, reviewReject, present, reviewJob, filesMode, psOpen, pktOpen, debriefFor, showResume, reassignOpen]);

  /* R124: while REAL triage is active (a decision job in play), unmodified A means Accept -
     the global A-then-X quick-add chord (main.jsx) checks this flag and stands down. Cohort
     browsing sets no flag: there, A is the quick-add starter again. */
  React.useEffect(() => {
    window.__e8TriageActive = !!(inQueue && reviewJob);
    return () => { window.__e8TriageActive = false; };
  }, [inQueue, reviewJob]);

  /* R124: 1-5 switch the record tabs; the handler itself lives in cand-tabs.jsx beside the
     strip it drives, so the digit map and the tab list cannot drift apart again. */
  useCandTabKeys(setTab, !!(filesMode || present || psOpen || pktOpen || debriefFor || reviewReject || showResume || reassignOpen));

  /* R124 rail edits. RailFact stays presentational; these callbacks own the store write, the
     prior-value snapshot, the Undo, and the VISIBLE activity event. timelineFor() reads notes
     (e8NoteEvents) - not E8Audit - so the note IS how an edit "lands in Activity"; the audit
     log gets a parallel immutable entry, and Undo APPENDS a reversal entry rather than ever
     deleting audit history. A field that was absent before the edit is restored to absent
     (E8Store.unset), not to an empty string. */
  /* Three destinations, three different questions. The note is what Activity renders; the audit is
     the global prose recorder; E8EditLog is the STRUCTURED per-field history that can answer "every
     change to work authorization on this candidate, and who made each one". Only the third survives
     as evidence - the first two are sentences. */
  const logFactEdit = (label, fromVal, toVal, field) => {
    const noteId = 'n-edit-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
    window.E8Store.add('notes', {
      type: 'field-edit',
      id: noteId, title: label + ' updated',
      points: [(fromVal == null || fromVal === '' ? 'Was empty' : 'Was ' + fromVal) + ' → ' + toVal],
      refType: 'candidate', refId: c.id, ts: Date.now(),
      author: (D.user || {}).name || 'You', prov: 'human', when: 'Just now',
    });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Edited ' + label.toLowerCase(), target: c.name, route: 'candidate/' + c.id });
    let entryId = null;
    if (window.E8EditLog && field) {
      const rec = window.E8EditLog.record({
        recordType: 'candidate', recordId: c.id, field: field, label: label,
        from: fromVal, to: toVal,
        by: (D.user || {}).name || 'You', actorType: 'human', source: 'rail',
      });
      entryId = rec ? rec.id : null;
    }
    return { noteId, entryId };
  };
  const commitRecordField = (field, label) => (next) => {
    if (!window.E8Store) return;
    const had = c[field] != null && c[field] !== '';
    const prev = c[field];
    window.E8Store.set('matches', c.id, { [field]: next });
    const { noteId, entryId } = logFactEdit(label, prev, next, field);
    showToast(label + ' updated', 'Undo', () => {
      if (had) window.E8Store.set('matches', c.id, { [field]: prev });
      else window.E8Store.unset('matches', c.id, [field]);
      window.E8Store.remove('notes', noteId);
      if (window.E8EditLog && entryId) window.E8EditLog.revert('candidate', c.id, entryId);
      if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Undid ' + label.toLowerCase() + ' edit', target: c.name, route: 'candidate/' + c.id });
    });
  };
  const commitAvailability = (next) => {
    if (!window.E8Store) return;
    const prev = cmeta.availability;
    window.E8Store.setMeta(c.id, { availability: next });
    const { noteId, entryId } = logFactEdit('Availability', prev, next, 'availability');
    showToast('Availability updated', 'Undo', () => {
      window.E8Store.setMeta(c.id, { availability: prev == null ? null : prev });
      window.E8Store.remove('notes', noteId);
      if (window.E8EditLog && entryId) window.E8EditLog.revert('candidate', c.id, entryId);
      if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Undid availability edit', target: c.name, route: 'candidate/' + c.id });
    });
  };

  /* Mobile rail sheet + files overlay both hand focus back to their trigger on close. */
  const closeRailSheet = () => {
    setRailSheetOpen(false);
    window.setTimeout(() => { if (railTrigRef.current) railTrigRef.current.focus(); }, 0);
  };
  const closeFiles = () => {
    setFilesMode(null);
    window.setTimeout(() => { if (filesTrigRef.current) filesTrigRef.current.focus(); }, 0);
  };
  /* An activity deep link opens the sheet whenever the rail has collapsed - the rail is the only
     Activity home. Depends on `sub`: in-place navigation to /activity must open it, not just
     mount.

     R138 fix: `railCollapsed` was read but not depended on, which broke the case the P1 rework
     created. The seed only knows the VIEWPORT; on a 1000px-wide window the row is narrow but the
     viewport is not, so at mount railCollapsed is false, the effect declines to open, and the
     observer's true verdict arrives one render later with nothing listening. The deep link
     silently landed on a record with no Activity anywhere. `firedFor` keeps it one-shot per
     visit so a manual close is not immediately undone by an unrelated re-render or a resize. */
  const activityAutoOpen = React.useRef(null);
  React.useEffect(() => {
    if (sub !== 'activity') { activityAutoOpen.current = null; return; }
    if (!railCollapsed || activityAutoOpen.current === sub) return;
    activityAutoOpen.current = sub;
    setRailSheetOpen(true);
  }, [sub, railCollapsed]);

  /* Screening-call facts exist only for Daniel in the demo data - don't show his facts on other profiles. */
  const facts = c.id === 'c-okafor' ? ((D.callResults.find((r) => r.id === 'r-okafor') || {}).facts || []) : [];
  const tl = timelineFor(c, listMeta, convo);
  const swt = useCandSwipeTabs(tab, setTab);
  const isMobile = useIsMobile();

  /* R109 T3: tombstone redirect. A merged record's row carries mergedInto -> the survivor; land on
     the survivor instead of showing a stale duplicate.
     IT RUNS HERE, AFTER THE LAST HOOK, AND THAT PLACEMENT IS THE POINT. This record's own
     duplicate banner opens the merge dialog with THIS candidate as one side (the "Review" button
     below), and the survivor default can resolve to the other side - or the recruiter can swap it
     - so `mergeCandidates` tombstones the record that is currently mounted. It writes through the
     store, the `store:changed` subscription above re-renders this same mount (same key, same id,
     synchronously, before the navigate that follows it), and returning from the top of the body
     would skip every hook below it: React throws "Rendered fewer hooks than expected" and tears
     the tree down before the redirect ever happens. Every hook has run by this line, so the early
     return costs nothing but the derivations underneath it - which a tombstone does not render. */
  if (c.mergedInto && D.matches.some((m) => m.id === c.mergedInto)) {
    return <MergedTombstone mergedId={id} survivorId={c.mergedInto} />;
  }

  /* CV toolkit - the recruiter résumé workflow Spott centers the profile around. */
  const cvActions = [
    { icon: 'note_add', label: 'Add note', onClick: () => cap.open({ target: 'note', entityLabel: c.name, refType: 'candidate', refId: c.id }) },
    { icon: 'sync', label: 'Check for updates', onClick: () => showToast('Checking LinkedIn for profile changes…') },
    { icon: 'upload_file', label: 'Update with new CV', onClick: () => setFilesMode('add') },
    { icon: 'outgoing_mail', label: 'Request updated CV', onClick: () => showToast('CV request drafted to ' + c.name + ' - review in Approvals', 'Open approvals', () => navigate('approvals')) },
  ];
  /* R125.2: the prompt-based link-file flow retired - adding files (upload, URL, pasted
     resume text) lives in the files dialog's add view now. */

  /* ---- R124: shared models for the compact stage chip, the Needs-you card and the rail ---- */
  /* One pipeline model - the chip and the rail Pipeline section read the same rows. */
  const pipeSubs = (D.submissions || []).filter((s) => s.candId === c.id);
  const pipeSubJobIds = new Set(pipeSubs.map((s) => s.jobId));
  const pipeApps = prof.applications.filter((a) => !pipeSubJobIds.has(a.jobId));
  const pipeTotal = pipeSubs.length + pipeApps.length;
  /* Chip subject precedence: the queue-context process when opened from a review queue,
     otherwise the furthest active stage, tie broken by most recent stage movement. */
  const subLastAt = (s) => {
    const h = Array.isArray(s.stageHistory) && s.stageHistory.length ? s.stageHistory[s.stageHistory.length - 1] : null;
    return h && h.at != null ? h.at : 0;
  };
  const stageSubject = (() => {
    const openSubs = pipeSubs.filter((s) => s.open !== false);
    if (!openSubs.length) return null;
    if (reviewJob) {
      const hit = openSubs.find((s) => s.jobId === reviewJob.id);
      if (hit) return hit;
    }
    return openSubs.slice().sort((a, b) => ((b.stage | 0) - (a.stage | 0)) || (subLastAt(b) - subLastAt(a)))[0];
  })();
  /* "+N in play" counts exactly the rows the expanded strip renders: OPEN submissions
     (minus the subject) plus applications - closed submissions are neither. */
  const stageOthersCount = pipeSubs.filter((s) => s !== stageSubject && s.open !== false).length + pipeApps.length;

  /* Needs-you: the SAME selector RecordTasksRail uses (open, not snoozed, urgency-ranked),
     destinations resolved through tkTaskPath - the card claims "same items as your Work
     queue", so it must read the same model, not a lookalike. */
  const nyTasks = window.tkOpenTasksFor ? window.tkOpenTasksFor('candidate', c.id) : [];

  /* Files: rail summary + overlay share one list (was computed inside the old Activity tab). */
  const seedFiles = (D.candidateFiles || {})[c.id]
    || (prof.cv && prof.cv.updated !== '-'
      ? [{ id: 'cf-' + c.id, name: prof.cv.file, kind: 'description', size: '—', by: c.name, when: 'updated ' + prof.cv.updated, tag: 'Résumé', resume: !!c.resumeText }]
      : []);
  const candFiles = seedFiles.concat(Array.isArray(cmeta.attachments) ? cmeta.attachments : []);

  /* Activity segments (all | movement | notes) - files moved to the Details Files section.
     R142 batch 4: "Notes" now means notes a PERSON wrote. Every inline fact edit writes a note so
     the product can show the edit on the timeline, and those outnumber real notes badly - before
     this, opening Notes on an edited record showed "Rate updated / Availability updated / Phone
     updated" and the prescreen was somewhere underneath. System-typed notes fall through to
     Movement, which is where an audit-shaped event belongs. Untyped legacy rows resolve through
     E8NotePolicy.normalize, so nothing has to be backfilled for this to work. */
  const NPc = window.E8NotePolicy;
  /* Filter the NOTE ROWS, then convert - not the other way round. e8NoteToEvent rewrites the id to
     'note-<id>', so matching events back to their note by id needs the prefix stripped, and doing
     it here keeps the classification reading the real record rather than a reconstructed stub. */
  const humanNoteRows = (D.notes || [])
    .filter((n) => n.refType === 'candidate' && n.refId === c.id)
    .filter((n) => !NPc || NPc.isHuman(NPc.normalize(n)));
  const railNoteEvs = e8MergeEvents(humanNoteRows.map((n) => e8NoteToEvent(n)));
  /* Movement keeps everything that is not a HUMAN note - so system notes still appear there
     rather than vanishing from the record entirely. */
  const railNoteIds = new Set(railNoteEvs.map((e) => e.id));
  const railMovement = tl.filter((e) => !railNoteIds.has(e.id));

  /* `seg` is optional: callers that know which Activity segment they mean pass it, and the rail
     opens on that one instead of on whatever was last selected. The `typeof` test is load-bearing,
     not defensive noise - two call sites pass this function STRAIGHT to onClick
     (cand-overview.jsx "View full activity", cand-rail.jsx), so an untested first argument is a
     SyntheticEvent, which is truthy and would set the segment to an event object. */
  const openRailActivity = (seg) => {
    setRailTab('activity');
    if (typeof seg === 'string') setActSeg(seg);
    if (railCollapsed) setRailSheetOpen(true);
  };

  return (
    <React.Fragment>
      <Topbar
        crumbs={[{ label: 'Candidates', to: 'candidates' }, { label: c.name }]}
        after={<CandHeaderNav c={c} inQueue={inQueue} isMobile={isMobile} go={go} showToast={showToast} />}
        actions={
          <CandHeaderActions
            c={c} D={D} listMeta={listMeta} convo={convo} matchJob={matchJob} linkedInUrl={linkedInUrl}
            canReassign={canReassign} placement={placement} placementCid={placementCid}
            listOpen={listOpen} setListOpen={setListOpen} cap={cap} showToast={showToast}
            setPresent={setPresent} setPsOpen={setPsOpen} setFilesMode={setFilesMode}
            setReassignOpen={setReassignOpen} setDebriefFor={setDebriefFor} openSubmit={openSubmit}
          />
        }
      />
      <RecordBody
        railLabel={c.name}
        railOpen={railSheetOpen}
        onRailClose={closeRailSheet}
        onCollapse={setRailCollapsed}
        rail={
          <RecordRail active={railTab} onTab={setRailTab}>
            {railTab === 'activity' ? (
              <CandRailActivity c={c} cap={cap} tl={tl} railMovement={railMovement} railNoteEvs={railNoteEvs} actSeg={actSeg} setActSeg={setActSeg} />
            ) : (
              <CandRailDetails
                c={c} cmeta={cmeta} listMeta={listMeta} prof={prof} D={D} fresh={fresh}
                reqRequested={reqRequested} headerStage={headerStage} linkedInUrl={linkedInUrl}
                liveLists={liveLists} placement={placement} placementCid={placementCid}
                humanNoteRows={humanNoteRows} pipeSubs={pipeSubs} pipeApps={pipeApps} pipeTotal={pipeTotal}
                candFiles={candFiles} filesTrigRef={filesTrigRef} cap={cap}
                requestReverify={requestReverify} resolveConflict={resolveConflict}
                commitRecordField={commitRecordField} commitAvailability={commitAvailability}
                openRailActivity={openRailActivity} setFilesMode={setFilesMode} setDebriefFor={setDebriefFor}
              />
            )}
          </RecordRail>
        }
      >
        <div className="e8-page" style={{ paddingBottom: 96 }}>
          {/* Header */}
          {/* R124 header zones: assessment (one scored chip) + pin beside the name; ownership at
              the right edge (owner appears ONCE - the handoff's subline owner segment is
              dropped); STATE leads the subline as quiet text (what it governs is the primary
              button); freshness + availability moved to the rail Details pane. */}
          <CandIdentityHeader c={c} listMeta={listMeta} headerStage={headerStage} hasMatch={hasMatch}
            dm={dm} dmTier={dmTier} canReassign={canReassign} setReassignOpen={setReassignOpen} />

          {/* R143 piece 3: the next-action card is now the FIRST thing under the header at
              every width - it was below the stage chip, so the one block that says what to do
              sat behind a block that says where things stand. It renders nothing at all when
              tkOpenTasksFor returns nothing. */}
          <CandRecNextAction tasks={nyTasks} open={nyOpen} onOpen={() => setNyOpen(true)} onGo={navigate} />

          {/* R143 piece 4 (NEW): the qualifying facts nothing else on screen is carrying.
              Location / stage / experience were struck because the header renders all three
              ~200px above; rate and availability now yield to the rail whenever the rail is
              actually open, and work model / last contact take their place. See the note on
              CandRecFactStrip. */}
          <CandRecFactStrip c={c} cmeta={cmeta} listMeta={listMeta} railAway={railTab === 'activity'} />

          {/* R124 compact stage chip row - replaces the always-open full-width stage strip.
              Subject: queue-context process first, else furthest active stage, tie -> most
              recently moved. "+N in play" expands the full pipeline inline. */}
          {stageSubject ? (
            <div className="e8-stagechip-row">
              <button type="button" className="e8-stagechip" aria-expanded={stageOpen}
                title={stageSubject.job + ' · click to ' + (stageOpen ? 'collapse' : 'expand') + ' the stage strip'}
                onClick={() => setStageOpen((v) => !v)}>
                <span className="material-symbols-outlined" aria-hidden="true">clock_loader_40</span>
                <span className="e8-stagechip-label">
                  {stageSubject.jobId} · {(D.submissionStages || [])[stageSubject.stage | 0] || 'In process'}
                  {stageSubject.owner ? ' · with ' + String(stageSubject.owner).split(' ')[0] : ''}
                </span>
                <span className="material-symbols-outlined e8-stagechip-car" aria-hidden="true">{stageOpen ? 'expand_less' : 'expand_more'}</span>
              </button>
              {stageOthersCount > 0 ? (
                <button type="button" className="e8-stagechip-more" onClick={() => setStageOpen((v) => !v)}>+{stageOthersCount} in play</button>
              ) : null}
            </div>
          ) : null}
          {stageSubject && stageOpen ? (
            <div className="e8-stagechip-open">
              <DSc.StageCell stages={D.submissionStages} current={stageSubject.stage} />
              {pipeSubs.filter((s) => s !== stageSubject && s.open !== false).map((s) => (
                <button key={s.id} type="button" className="e8-applrow" onClick={() => navigate('job/' + s.jobId + '/submissions')}>
                  <span className="e8-cand-rec-stagerow">{s.jobId} · {s.job} · {s.client}</span>
                  <span className="e8-ui-static e8-ui-push"><DSc.Badge tone="neutral">{(D.submissionStages || [])[s.stage | 0] || 'In process'}</DSc.Badge></span>
                </button>
              ))}
              {pipeApps.map((a, i) => (
                <button key={'sa' + i} type="button" className="e8-applrow" onClick={() => navigate('job/' + a.jobId + '/overview')}>
                  <span className="e8-cand-rec-stagerow">{a.jobId} · {a.job} · {a.client}</span>
                  <span className="e8-ui-static e8-ui-push"><DSc.Badge tone="neutral">{a.stage}</DSc.Badge></span>
                </button>
              ))}
            </div>
          ) : null}

          {/* R143: the Needs-you card moved above the stage chip and became CandRecNextAction.
              The R138 P2 action-hierarchy note travels with it: the button stays SECONDARY
              because this card mirrors the Work queue rather than owning the record's decision,
              and it was one of three filled accent buttons competing in a single view. */}

          {/* R111: the possible-duplicate flag lives in the UPPER section now - a prominent, flat warning-tint
              banner right under the header, spanning the main content width (moved off the lower right rail).
              The matcher (or an explicit possibleDuplicateOf seed) thinks another record is the same person;
              dismissed pairs are filtered out. A human decides (Review -> the R109 merge modal); nothing is
              ever auto-merged. Multiple dups collapse to the top one + a link to the dedicated page. */}
          {(() => {
            if (!window.possibleDuplicateOf) return null;
            const dismissedSet = {};
            (D.dupDismissed || []).forEach((p) => { if (p) dismissedSet[window.dupPairKey(p.a, p.b)] = 1; });
            const dupIds = window.possibleDuplicateOf(c.id).filter((bId) => !dismissedSet[window.dupPairKey(c.id, bId)]);
            if (!dupIds.length) return null;
            // Rank by matcher score so the banner leads with the strongest suspected match; flagged-only seeds fall to the end.
            const rec = D.matches.find((m) => m.id === c.id) || { id: c.id };
            const mById = {};
            (window.candidateMatches ? (window.candidateMatches(rec, { excludeId: c.id }) || []) : []).forEach((m) => { mById[m.id] = m; });
            const ordered = dupIds.slice().sort((x, y) => ((mById[y] ? mById[y].score : 0) - (mById[x] ? mById[x].score : 0)));
            const topId = ordered[0];
            const top = D.matches.find((m) => m.id === topId) || { id: topId, name: topId };
            const topM = mById[topId];
            const reason = topM && (topM.signals || []).length
              ? topM.signals.map((s) => s.label).slice(0, 3).join(' · ')
              : 'flagged as a possible duplicate';
            const dismissTop = () => {
              const key = window.dupPairKey(c.id, topId);
              window.E8Store.add('dupDismissed', { id: key, a: c.id, b: topId });
              if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Marked not a duplicate', target: c.name, route: 'candidate/' + c.id });
              showToast('Marked not a duplicate', 'Undo', () => window.E8Store.remove('dupDismissed', key));
            };
            const reviewTop = () => {
              if (window.e8OpenMerge) { window.e8OpenMerge(c.id, topId); return; }
              showToast('Merge review is unavailable in this build', 'Open', () => navigate('candidate/' + topId));
            };
            return (
              <window.InlineBanner tone="warning" emphasis="stripe" icon="content_copy" label="Possible duplicate"
                title={<React.Fragment>
                  Possible duplicate of{' '}
                  <a className="e8-link e8-dup-banner-name" tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('candidate/' + topId)}>{top.name}</a>
                  {topM ? <span className="e8-dup-banner-score tnum">{topM.band === 'high' ? 'High' : 'Review'} · {topM.score}</span> : null}
                </React.Fragment>}
                sub={<React.Fragment>
                  {reason}
                  {ordered.length > 1 ? <React.Fragment>{' · '}<a className="e8-link" tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('duplicates')}>and {ordered.length - 1} more</a></React.Fragment> : null}
                </React.Fragment>}
                actions={<React.Fragment>
                  <DSc.Button variant="secondary" size="sm" icon="merge" onClick={reviewTop}>Review</DSc.Button>
                  <DSc.Button variant="ghost" size="sm" icon="close" onClick={dismissTop}>Not a duplicate</DSc.Button>
                </React.Fragment>}
              />
            );
          })()}

          <div>
            <div className="e8-ui-grow">
              {/* R124: Activity lives EXCLUSIVELY in the rail now - five content tabs, 1-5
                  keyboard switching, ` flips the rail. Mobile gets a compact Details trigger
                  beside the tabs that opens the rail bottom sheet. */}
              <CandTabStrip
                tab={tab} onTab={setTab}
                counts={{ experience: prof.experience.length, messages: convo ? convo.thread.length : undefined, files: candFiles.length || undefined }}
                railCollapsed={railCollapsed} railTrigRef={railTrigRef} onOpenRail={() => setRailSheetOpen(true)}
              />
              <div className="e8-cand-rec-tabbody" style={{ paddingTop: 18, ...(swt.style || {}) }} {...swt.bind}>
                {tab === 'overview' ? (
                  <CandOverviewTab
                    c={c} cmeta={cmeta} prof={prof} facts={facts} hasMatch={hasMatch} dm={dm}
                    decision={decision} placement={placement} headerStage={headerStage} tl={tl}
                    requestReverify={requestReverify} setPsOpen={setPsOpen} setActTick={setActTick}
                    openRailActivity={openRailActivity}
                  />
                ) : null}
                {tab === 'experience' ? (
                  <CandRecExperienceTab c={c} prof={prof} cvActions={cvActions} matchJob={matchJob}
                    showToast={showToast} onViewResume={() => setShowResume(true)} />
                ) : null}
                {tab === 'skills' ? (
                  <CandRecSkillsTab c={c} prof={prof} dm={dm} showToast={showToast}
                    extracted={c.id === 'c-okafor' ? ['Docker', 'Jenkins', 'GraphQL', 'JUnit 5'] : null} />
                ) : null}
                {tab === 'messages' ? <CandRecMessagesTab convo={convo} name={c.name} /> : null}
                {/* Files keeps the SHARED CandidateFilesSection. It already answers piece 14 -
                    kind icon, tag, size, author, date, and a row that opens an inline preview -
                    plus search and type/date filters this batch would have had to reimplement to
                    replace it. Two measured defects are repaired from OUT HERE rather than by
                    forking it, both scoped by `.e8-cand-rec-files` so nothing reaches the same
                    component in the files overlay or on any other record:

                    - the filename head-truncated behind the type chip at 390 (4 of 4 names
                      ellipsised; `RTR_JO-10864_signed.pdf` lost its JO number at 49% shown).
                      Fixed by letting the name wrap - see cand-record.css. The name span carries
                      an INLINE white-space/overflow style written in screens-core.jsx, so the
                      override needs `!important` to reach it; middle-truncation and moving the
                      chip onto the metadata line are markup changes and belong in that file.
                    - filter chrome sized for a large library sitting above four rows (search +
                      type chips + date select = 115px above 239px of content at 1440). `is-few`
                      hides the search and type chips under 10 files; `Add file` and the row list
                      always stay. The threshold is a display decision about THIS surface, which
                      is why it is expressed here and not by editing the shared component. */}
                {tab === 'files' ? (
                  <div className={'e8-cand-rec-files' + (candFiles.length < 10 ? ' is-few' : '')}>
                    <CandidateFilesSection c={c} files={candFiles} prof={prof} cvActions={cvActions} initialView="list" />
                  </div>
                ) : null}
                {/* R124: Activity moved exclusively into the rail (railActivity). */}
              </div>
            </div>

            {/* R124: the in-page rail is retired - its groups live in the RecordRail Details pane. */}
          </div>
        </div>
      </RecordBody>

      {/* R124 files overlay - the FULL files workflow (CV toolkit, link file, view resume);
          the rail keeps only a summary. Closing returns focus to the "All files" trigger. */}
      {filesMode ? (
        <DSc.Modal title="Files" icon="folder_open" ariaLabel={'Files - ' + c.name} closeTitle="Close" onClose={closeFiles} className="e8-candfiles-dlg">
          <CandidateFilesSection key={filesMode} c={c} files={candFiles} prof={prof} cvActions={cvActions} initialView={filesMode} />
        </DSc.Modal>
      ) : null}

      {/* Contextual decision bar - queue context only: back to queue, position, accept,
          reject. R124: prev/next moved to the record header (no duplicate navigator here);
          J/K/A/R keyboard triage unchanged. */}
      {inQueue ? (
      <div className="e8-reviewbar-wrap">
        <div className="e8-reviewbar">
          <button type="button" className="e8-rb-back" title={'Back to ' + queueLabel} onClick={() => navigate(queueBack)}>
            <span className="material-symbols-outlined">format_list_bulleted</span>
            <span className="e8-rb-qlabel">{queueLabel}</span>
          </button>
          <span className="e8-rb-div"></span>
          <span className="tnum e8-cand-rec-qpos" title={'Position in ' + queueLabel + ' - J/K move'}>{qPos + 1} of {queueIds.length}</span>
          {reviewJob ? (
            <React.Fragment>
              <span className="e8-rb-div"></span>
              <window.ActionBar ariaLabel="Review decision" actions={[
                { key: 'accept', label: 'Accept', icon: 'check', emphasis: 'primary', variant: 'accept', kbd: 'A', onClick: accept },
                { key: 'reject', label: 'Reject', icon: 'close', destructive: true, kbd: 'R', onClick: () => setReviewReject(true) },
              ]} />
            </React.Fragment>
          ) : null}
        </div>
      </div>
      ) : null}

      {reviewReject ? (
        <window.RejectReasonDialog
          name={c.name}
          onConfirm={(payload) => {
            setReviewReject(false);
            if (reviewJob && window.e8RejectDecision && window.E8Match) {
              const res = window.e8RejectDecision(reviewJob, c, payload);
              showToast(c.name + ' rejected · ' + res.decision.reason, 'Undo', res.undo);
            } else {
              const decision = window.E8RejectPolicy.normalize(payload);
              showToast(c.name + ' rejected · ' + decision.reason, 'Undo');
            }
            go(1);
          }}
          onClose={() => setReviewReject(false)}
        />
      ) : null}
      {present ? <CandidatePresentOverlay c={c} prof={prof} onClose={() => setPresent(false)} /> : null}
      {showResume && window.ResumeDocModal ? <window.ResumeDocModal a={c} onClose={() => setShowResume(false)} /> : null}
      {psOpen ? <PrescreenPanel c={c} cmeta={cmeta} prof={prof} job={dm && dm.job ? dm.job : null} onClose={() => setPsOpen(false)} /> : null}
      {pktOpen ? <SubmissionPacketDialog c={c} cmeta={cmeta} prof={prof} dm={dm} onClose={() => setPktOpen(false)} onPrescreen={() => { setPktOpen(false); setPsOpen(true); }} /> : null}
      {debriefFor ? <DebriefDialog c={c} sub={debriefFor.sub || debriefFor} round={debriefFor.round} onClose={() => setDebriefFor(null)} /> : null}
      {/* The same dialog the list row opens - one reassign flow, one audit shape, one Undo. */}
      {reassignOpen ? <CandidateReassignDialog cand={ownershipRow} onClose={() => setReassignOpen(false)} showToast={showToast} /> : null}
      {reverifyConfirm}
    </React.Fragment>
  );
}

Object.assign(window, { CandidateDetailScreen });
