/* ELEV8 ATS - screens: Dashboard, Candidates list, Candidate detail. */

const DSc = window.Stand8DesignSystem_b5c975;

/* Record cells + timeline now come from the design system (components/ats-records,
   components/ats-timeline). Local aliases keep the screen code readable. */
const { ActorChip, NameCell, CellChip, ExpCell, WhyMatchedCards } = DSc;
function Timeline({ events }) {
  return <DSc.Timeline events={events.map((ev) => ({ ...ev, onLink: ev.link ? () => navigate(ev.linkTo || 'voice') : undefined }))} />;
}

/* ============================== DASHBOARD ============================== */
/* The radar is in the DEFAULT layout (fresh profiles see it under the KPIs, per the design
   hero). Saved layouts are untouched - useDashLayout only reads DASH_DEFAULT when no
   e8-dash-layout-v1 exists. */
const DASH_DEFAULT = ['morning-brief', 'kpis', 'redeployment-radar', 'submittals-trend', 'pipeline-funnel', 'agent-activity', 'priorities', 'activity-feed', 'pinned'];
// 'getting-started' (R98 Task 6) is a known widget everywhere but only default-visible in empty mode.
const DASH_ALL = ['getting-started'].concat(DASH_DEFAULT).concat(['engagement-health', 'renewals-due', 'csat-overview', 'top-templates', 'po-burn-watch', 'my-consultants']);
const DASH_SPAN = { sm: 2, md: 3, lg: 4, full: 6 };

function AddCardBar({ widgets, hiddenIds, onAdd }) {
  return (
    <div className="e8-addcard">
      <span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 600, color: 'var(--ui-text-secondary)', alignSelf: 'center', marginRight: 4 }}>Add a card</span>
      {hiddenIds.length === 0
        ? <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', alignSelf: 'center' }}>All cards are on the dashboard.</span>
        : hiddenIds.map((id) => <button key={id} type="button" className="e8-addcard-chip" onClick={() => onAdd(id)}>+ {widgets[id] ? widgets[id].title : id}</button>)}
    </div>
  );
}

/* R98 Task 6: first-run checklist (empty mode). Each item deep-links to the real creation
   flow built this round; its checkmark derives live from collection lengths. Dismissible. */
function GettingStartedCard({ onDismiss }) {
  const D = window.E8DATA;
  const cap = React.useContext(CaptureCtx);
  const [, setTick] = React.useState(0);
  React.useEffect(() => (window.E8Events ? window.E8Events.subscribe(['store:changed'], () => setTick((t) => t + 1)) : undefined), []);
  const steps = [
    { key: 'client', label: 'Add your first client', sub: 'The company you are staffing for', icon: 'apartment', done: (D.clients || []).length > 0, go: () => navigate('clients') },
    { key: 'job', label: 'Capture a job order with the agent', sub: 'Talk it through or paste a description', icon: 'graphic_eq', done: (D.jobs || []).length > 0, go: () => (cap ? cap.open({ target: 'req' }) : navigate('jobs')) },
    { key: 'candidate', label: 'Add a candidate from a résumé', sub: 'Upload or paste - fields extract on-device', icon: 'person_add', done: (D.candidates || []).length > 0, go: () => (cap ? cap.open({ target: 'candidate' }) : navigate('candidates')) },
    { key: 'submission', label: 'Create a submission', sub: 'Put a candidate in front of a client', icon: 'send', done: (D.submissions || []).length > 0, go: () => (window.e8OpenCreate ? window.e8OpenCreate('submission') : navigate('submissions')) },
    { key: 'engagement', label: 'Place someone into an engagement', sub: 'Turn a placement into billing', icon: 'badge', done: (D.engagements || []).length > 0, go: () => (window.e8OpenCreate ? window.e8OpenCreate('engagement') : navigate('engagements')) },
  ];
  const doneCount = steps.filter((s) => s.done).length;
  return (
    <div className="e8-card e8-gs">
      <div className="e8-gs-head">
        <div>
          <div className="e8-gs-title">Getting started</div>
          <div className="e8-gs-sub">Set up your desk. {doneCount} of {steps.length} done.</div>
        </div>
        <button type="button" className="e8-gs-dismiss" onClick={onDismiss} aria-label="Dismiss getting started">
          <span className="material-symbols-outlined">close</span>
        </button>
      </div>
      <div className="e8-gs-steps">
        {steps.map((s) => (
          <div key={s.key} className={'e8-gs-step' + (s.done ? ' done' : '')} role="button" tabIndex={0}
            onClick={s.done ? undefined : s.go}
            onKeyDown={(e) => { if (!s.done && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); s.go(); } }}>
            <span className="e8-gs-check"><span className="material-symbols-outlined">{s.done ? 'check_circle' : 'radio_button_unchecked'}</span></span>
            <span className="e8-gs-ic"><span className="material-symbols-outlined">{s.icon}</span></span>
            <span className="e8-gs-txt">
              <span className="e8-gs-l">{s.label}</span>
              <span className="e8-gs-d">{s.sub}</span>
            </span>
            {!s.done ? <span className="material-symbols-outlined e8-gs-chev">chevron_right</span> : null}
          </div>
        ))}
      </div>
    </div>
  );
}

/* R101: role-aware "My consultants" dashboard widget. Reads the active persona (shared
   localStorage['e8-persona']) and shows the persona's book count + up to 3 attention items
   (PO at risk / renewal due), each deep-linking to the Consultants "Mine" tab. Recomputes on
   persona/store/owner changes. Guarded empty for a persona with no book. */
function MyConsultantsCard() {
  const D = window.E8DATA;
  const [persona, setPersona] = React.useState(() => (window.e8ActivePersona ? window.e8ActivePersona() : null));
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    if (!window.E8Events) return undefined;
    return window.E8Events.subscribe(['persona:changed', 'store:changed', 'owner:changed'], () => {
      if (window.e8ActivePersona) setPersona(window.e8ActivePersona());
      setTick((t) => t + 1);
    });
  }, []);
  const mine = window.myConsultants ? window.myConsultants(persona) : (D.engagements || []);
  const attn = mine.map((e) => {
    const poRisk = e.po === 'expiring' || e.po === 'expired' || (e.sentiment && e.sentiment.tone === 'danger');
    const renewalDue = e.renewal && e.renewal !== 'Not due';
    if (!poRisk && !renewalDue) return null;
    const tone = (e.po === 'expired' || (e.sentiment && e.sentiment.tone === 'danger')) ? 'danger' : 'warning';
    const reason = poRisk ? (e.po === 'expired' ? 'PO expired' : e.po === 'expiring' ? 'PO expiring' : 'At risk') : e.renewal;
    return { e: e, tone: tone, reason: reason };
  }).filter(Boolean).slice(0, 3);
  return (
    <DSc.Card title="My consultants" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('consultants/mine')}>View all mine →</DSc.Button>}>
      {!mine.length ? (
        <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>No consultants assigned to you. Switch the view on the Consultants page to see the full desk.</div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          <div style={{ fontSize: 'var(--ui-text-3xl)', fontWeight: 650 }}>{mine.length}<span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 400, color: 'var(--ui-text-tertiary)' }}> on your desk</span></div>
          {attn.length ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {attn.map((a) => (
                <a key={a.e.id} className="e8-link" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('consultants/mine')}>
                  <DSc.Badge tone={a.tone} dot>{a.reason}</DSc.Badge>
                  <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.e.consultant}</span>
                  <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', whiteSpace: 'nowrap' }}>{a.e.client}</span>
                </a>
              ))}
            </div>
          ) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>All clear - no PO or renewal flags on your book.</div>}
        </div>
      )}
    </DSc.Card>
  );
}

function DashboardScreen() {
  const D = window.E8DATA;
  const { showToast } = React.useContext(E8Ctx);
  useWorkspaceVersion();
  const [runs, setRuns] = React.useState(D.agentRuns);
  const isMobile = useIsMobile();
  const [edit, setEdit] = React.useState(false);
  // R98 Task 6: the getting-started card is default-visible only in empty mode (first-run).
  const isEmptyMode = window.E8_DATA_MODE === 'empty';
  const dashDefault = isEmptyMode ? ['getting-started'].concat(DASH_DEFAULT) : DASH_DEFAULT;
  // Scope the saved dashboard layout per data mode so each mode keeps its own customization
  // (and empty mode gets the first-run default) - switching modes no longer resets the dashboard.
  const dashMode = window.E8_DATA_MODE && window.E8_DATA_MODE !== 'demo' ? ':' + window.E8_DATA_MODE : '';
  const lay = useDashLayout('e8-dash-layout-v1' + dashMode, DASH_ALL, dashDefault);
  const toggleRun = (id) =>
    setRuns(runs.map((r) => (r.id === id ? { ...r, state: r.state === 'paused' ? 'running' : 'paused' } : r)));
  const reviewMatches = D.matches.filter((m) => m.state === 'review').length;
  const approvalsDue = D.approvals.filter((i) => i.state === 'review').length;
  const persona = window.e8ActivePersona ? window.e8ActivePersona() : D.user;
  const pinned = window.E8Workspace && persona ? window.E8Workspace.listResolved(persona.name) : [];
  // Empty mode has no records to point at, so the hardcoded priorities are hidden there.
  const priorities = isEmptyMode ? [] : [
    { t: 'Review ' + reviewMatches + ' matches on JO-44219', to: 'job/JO-44219/matching' },
    { t: "Escalate Omar Haddad's expired PO", to: 'renewals' },
    { t: 'Clear ' + approvalsDue + ' items in Approvals', to: 'approvals' },
    { t: 'Rescue JO-44087 - 4 days to SLA', to: 'job/JO-44087/overview' },
  ];
  const agentsRunning = runs.filter((r) => r.state === 'running').length;

  const WIDGETS = {
    'getting-started': { title: 'Getting started', size: 'full', render: () => (
      <GettingStartedCard onDismiss={() => lay.remove('getting-started')} />
    ) },
    'morning-brief': { title: 'Morning brief', size: 'full', render: () => (
      <div className="e8-card e8-aibrief">
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
          <span className="e8-sectionlabel">Morning brief</span>
          <DSc.ProvenanceBadge kind="drafted" />
          <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', whiteSpace: 'nowrap' }}>Generated at 7:00 am</span>
        </div>
        <p style={{ margin: 0, fontSize: 'var(--ui-text-base)', lineHeight: 1.55, color: 'var(--ui-text)', maxWidth: 880, textWrap: 'pretty' }}>{D.brief}</p>
      </div>
    ) },
    'kpis': { title: 'KPIs', size: 'full', render: () => (
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 12 }}>
        {D.metrics.map((m) => (
          <window.KpiCard key={m.label} label={m.label} value={m.value} delta={m.delta + ' · ' + m.sub} dir={m.dir} spark={m.spark} />
        ))}
      </div>
    ) },
    'submittals-trend': { title: 'Submittals per week', size: 'md', render: () => (
      <DSc.Card title="Submittals per week" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('reports')}>Reports →</DSc.Button>}>
        {window.E8Charts ? <window.E8Charts.AreaTrend series={D.reports.submittals} labels={D.reports.weeks} color="var(--ui-accent)" height={150} /> : null}
      </DSc.Card>
    ) },
    'pipeline-funnel': { title: 'Pipeline this quarter', size: 'md', render: () => (
      <DSc.Card title="Pipeline this quarter" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('reports')}>Reports →</DSc.Button>}>
        {window.E8Charts ? <window.E8Charts.FunnelBars stages={D.reports.funnel} /> : null}
      </DSc.Card>
    ) },
    'agent-activity': { title: 'Live agent activity', size: 'lg', render: () => (
      <DSc.Card title="Live agent activity" flush action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('voice')}>Voice ops →</DSc.Button>}>
        {isMobile ? (
          <div style={{ display: 'flex', flexDirection: 'column' }}>
            {runs.map((r) => (
              <div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', borderTop: '1px solid var(--ui-border)' }}>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                    <span style={{ fontSize: 'var(--ui-text-base)', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name}</span>
                    <DSc.Badge tone={r.state === 'running' ? 'success' : 'neutral'} dot>{r.state === 'running' ? 'Running' : 'Paused'}</DSc.Badge>
                  </div>
                  <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', marginBottom: 6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.agent} · {r.kind}</div>
                  <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                    <DSc.Progress value={(r.done / r.total) * 100} soft={r.state === 'paused'} />
                    <span className="tnum" style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-secondary)', whiteSpace: 'nowrap' }}>{r.done}/{r.total}</span>
                  </span>
                </div>
                <DSc.Button variant="ghost" size="sm" icon={r.state === 'paused' ? 'play_arrow' : 'pause'} iconOnly title={r.state === 'paused' ? 'Resume' : 'Pause'} onClick={() => toggleRun(r.id)}></DSc.Button>
              </div>
            ))}
          </div>
        ) : (
        <ConfigurableTable listKey="dashboard-agent-activity" primary={false}
          flush
          density="compact"
          registry={[
            { key: 'name', label: 'Run', render: (r) => (
              <span>
                <span style={{ fontWeight: 500 }}>{r.name}</span>
                <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{r.agent}</span>
              </span>
            ) },
            { key: 'kind', label: 'Type', secondary: true, width: 90 },
            { key: 'progress', label: 'Progress', width: 220, render: (r) => (
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, width: 200 }}>
                <DSc.Progress value={(r.done / r.total) * 100} soft={r.state === 'paused'} />
                <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)', whiteSpace: 'nowrap' }}>{r.done}/{r.total}</span>
              </span>
            ) },
            { key: 'state', label: 'State', width: 100, render: (r) => (
              <DSc.Badge tone={r.state === 'running' ? 'success' : 'neutral'} dot>{r.state === 'running' ? 'Running' : 'Paused'}</DSc.Badge>
            ) },
          ]}
          rows={runs}
          rowActions={(r) => (
            <DSc.Button variant="ghost" size="sm" icon={r.state === 'paused' ? 'play_arrow' : 'pause'} onClick={() => toggleRun(r.id)}>
              {r.state === 'paused' ? 'Resume' : 'Pause'}
            </DSc.Button>
          )}
        />
        )}
      </DSc.Card>
    ) },
    'activity-feed': { title: 'Activity', size: 'lg', render: () => (
      <DSc.Card title="Activity" flush>
        <div style={{ padding: '4px 0' }}>
          {D.feed.map((f) => (
            <div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 16px', borderBottom: '1px solid var(--ui-border)' }}>
              <ActorChip name={f.actor} ai={f.ai} />
              <span style={{ fontSize: 'var(--ui-text-base)', color: 'var(--ui-text-secondary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.text}</span>
              <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', flex: 'none', whiteSpace: 'nowrap' }}>{f.when}</span>
            </div>
          ))}
        </div>
      </DSc.Card>
    ) },
    'priorities': { title: "Today's priorities", size: 'sm', render: () => (
      <DSc.Card title="Today's priorities">
        {!priorities.length ? <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>Nothing urgent yet. Add a client, job or candidate and priorities will surface here.</div> : null}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {priorities.map((p, i) => (
            <a key={i} className="e8-link" style={{ fontSize: 'var(--ui-text-base)', display: 'flex', gap: 8, alignItems: 'baseline', color: 'var(--ui-text)', lineHeight: 1.4 }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate(p.to)}>
              <span className="tnum" style={{ color: 'var(--ui-text-tertiary)', fontSize: 'var(--ui-text-sm)' }}>{i + 1}</span>
              <span style={{ borderBottom: '1px dashed var(--ui-border-strong)' }}>{p.t}</span>
            </a>
          ))}
        </div>
      </DSc.Card>
    ) },
    'pinned': { title: 'Pinned', size: 'sm', render: () => (
      <DSc.Card title="Pinned">
        {pinned.length ? (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {pinned.map((p) => (
              <button key={p.type + '-' + p.id} type="button" className="e8-cmdk-item" style={{ padding: '6px 8px' }} onClick={() => navigate(p.route)}>
                <span className="material-symbols-outlined">push_pin</span>
                <span style={{ minWidth: 0 }}>
                  <span style={{ display: 'block', fontSize: 'var(--ui-text-base)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.label}</span>
                  <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{p.sub}</span>
                </span>
              </button>
            ))}
          </div>
        ) : (
          <div className="e8-pinned-empty">
            <span>Pin active records to keep them here.</span>
            <span><button type="button" onClick={() => navigate('jobs')}>Job orders</button> or <button type="button" onClick={() => navigate('candidates')}>Candidates</button></span>
          </div>
        )}
      </DSc.Card>
    ) },
    'redeployment-radar': { title: 'Redeployment radar', size: 'full', render: () => (
      /* Shared with Today (screens-today.jsx) - loads later in the bundle, so resolve at render time. */
      window.RedeployRadarCard ? <window.RedeployRadarCard /> : null
    ) },
    'engagement-health': { title: 'Engagement health', size: 'md', render: () => (
      <DSc.Card title="Engagement health" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('engagements')}>All →</DSc.Button>}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {(D.managedEngagements || []).map((m) => (
            <a key={m.id} className="e8-link" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'var(--ui-text-base)', color: 'var(--ui-text)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('engagement/' + m.id)}>
              <DSc.Badge tone={m.health.tone} dot>{m.health.score}</DSc.Badge>
              <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.name}</span>
              <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{m.poBurn.pct}% PO</span>
            </a>
          ))}
        </div>
      </DSc.Card>
    ) },
    'renewals-due': { title: 'Renewals due', size: 'sm', render: () => {
      const r = D.engagements.filter((e) => e.renewal && e.renewal !== 'Not due' && !e.projectId);
      return (
        <DSc.Card title="Renewals due" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('renewals')}>View →</DSc.Button>}>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            <div style={{ fontSize: 'var(--ui-text-3xl)', fontWeight: 650 }}>{r.length}<span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 400, color: 'var(--ui-text-tertiary)' }}> in flight</span></div>
            {r.slice(0, 3).map((e) => (<a key={e.id} className="e8-link" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('consultant/' + e.cid)}>{e.consultant} · {e.renewal}</a>))}
          </div>
        </DSc.Card>
      );
    } },
    'csat-overview': { title: 'CSAT overview', size: 'sm', render: () => {
      const me = D.managedEngagements || [];
      const avg = me.length ? (me.reduce((n, m) => n + (m.csat ? m.csat.score : 0), 0) / me.length) : 0;
      return (
        <DSc.Card title="CSAT overview">
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            <div style={{ fontSize: 'var(--ui-text-3xl)', fontWeight: 650 }}>{avg.toFixed(1)}<span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 400, color: 'var(--ui-text-tertiary)' }}> /5 avg</span></div>
            {me.map((m) => (<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--ui-text-sm)' }}><span style={{ color: 'var(--ui-text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.client}</span><span style={{ fontWeight: 600 }}>{m.csat ? m.csat.score : '-'}</span></div>))}
          </div>
        </DSc.Card>
      );
    } },
    'top-templates': { title: 'Top templates', size: 'md', render: () => (
      <DSc.Card title="Top templates" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('templates')}>All →</DSc.Button>}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {(D.templates || []).slice().sort((a, b) => b.usage - a.usage).slice(0, 5).map((t) => (
            <a key={t.id} className="e8-link" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'var(--ui-text-base)', color: 'var(--ui-text)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('templates/' + t.id)}>
              <span className={'e8-tpl-type t-' + t.type} style={{ fontSize: 'var(--ui-text-xs)' }}>{t.type.toUpperCase()}</span>
              <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
              <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{t.usage}x</span>
            </a>
          ))}
        </div>
      </DSc.Card>
    ) },
    'my-consultants': { title: 'My consultants', size: 'md', render: () => <MyConsultantsCard /> },
    'po-burn-watch': { title: 'PO burn watch', size: 'md', render: () => {
      const items = [].concat(
        (D.managedEngagements || []).map((m) => ({ name: m.name, pct: m.poBurn.pct, to: 'engagement/' + m.id })),
        (D.engagements || []).filter((e) => !e.projectId).map((e) => ({ name: e.consultant, pct: e.poBurn, to: 'consultant/' + e.cid }))
      ).filter((x) => x.pct >= 80).sort((a, b) => b.pct - a.pct);
      return (
        <DSc.Card title="PO burn watch" action={<DSc.Button variant="ghost" size="sm" onClick={() => navigate('engagements')}>All →</DSc.Button>}>
          {items.length ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
              {items.map((x, i) => (<a key={i} className="e8-link" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'var(--ui-text-base)', color: 'var(--ui-text)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate(x.to)}>
                <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{x.name}</span>
                <span className="e8-eng-membar" style={{ width: 54 }}><span style={{ width: x.pct + '%', background: x.pct >= 90 ? 'var(--ui-danger)' : 'var(--ui-warning)' }} /></span>
                <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 600 }}>{x.pct}%</span>
              </a>))}
            </div>
          ) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>No POs above 80% burn.</div>}
        </DSc.Card>
      );
    } },
  };
  const hiddenIds = DASH_ALL.filter((id) => lay.hidden.has(id));

  return (
    <div className="e8-page">
      <div className="e8-pagehead">
        <div>
          <h1 className="e8-h1">Good morning, Sarah</h1>
          <div className="e8-pagehead-sub">Friday, June 12 · {priorities.length} priorities · {agentsRunning} agent{agentsRunning === 1 ? '' : 's'} running</div>
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          {edit ? <DSc.Button variant="ghost" size="sm" onClick={lay.reset}>Reset layout</DSc.Button> : null}
          <DSc.Button variant={edit ? 'primary' : 'secondary'} size="sm" icon="tune" onClick={() => setEdit(!edit)}>{edit ? 'Done' : 'Customize'}</DSc.Button>
        </div>
      </div>
      {edit ? <AddCardBar widgets={WIDGETS} hiddenIds={hiddenIds} onAdd={lay.add} /> : null}
      <div className="e8-dashgrid">
        {lay.visible.map((id) => {
          const w = WIDGETS[id];
          if (!w) return null;
          return (
            <div key={id} className={'e8-dashcell span-' + (DASH_SPAN[w.size] || 3) + (edit ? ' editing' : '')}>
              {edit ? (
                <div className="e8-dashcell-bar">
                  <span>{w.title}</span>
                  <span>
                    <button type="button" className="e8-iconbtn" onClick={() => lay.move(id, -1)} aria-label="Move up">↑</button>
                    <button type="button" className="e8-iconbtn" onClick={() => lay.move(id, 1)} aria-label="Move down">↓</button>
                    <button type="button" className="e8-iconbtn" onClick={() => lay.remove(id)} aria-label="Remove">×</button>
                  </span>
                </div>
              ) : null}
              {w.render()}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ============================== CANDIDATES BOARD ============================== */
/* Helper: group a candidate row array by status (excluding 'All'). */
function groupByStatus(rows) {
  const D = window.E8DATA;
  const statuses = D.candidateStatuses.filter((s) => s !== 'All');
  const out = {};
  statuses.forEach((s) => { out[s] = []; });
  rows.forEach((c) => { if (out[c.status]) out[c.status].push(c); });
  return out;
}

/* Individual draggable candidate card on the board. */
function CandidateBoardCard({ c, meta, dragging, onDragStart, onDragEnd }) {
  const handleClick = (e) => {
    // Only fire navigate if the mouse didn't move (i.e., not a drag)
    if (!dragging) navigate('candidate/' + c.id + '?q=status-All');
  };
  const matchTier = meta.bestMatch ? meta.bestMatch.tier : c.tier;
  const matchLabel = meta.bestMatch ? meta.bestMatch.job + ' · ' + meta.bestMatch.client : null;
  return (
    <div
      className={'e8-cboard-card' + (dragging ? ' is-dragging' : '')}
      draggable
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
      {...window.clickableProps(handleClick)}
    >
      <div className="e8-cboard-card-name">
        <DSc.Avatar name={c.name} size="sm" />
        <span className="e8-cboard-card-name-text">{c.name}</span>
      </div>
      <div className="e8-cboard-card-sub">{c.title + ' · ' + c.company}</div>
      {matchLabel ? (
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
          <DSc.MatchBadge tier={matchTier} dot />
          <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>{matchLabel}</span>
        </span>
      ) : null}
      {c.skills && c.skills.length ? <DSc.SkillChips skills={c.skills} max={2} /> : null}
      <div className="e8-cboard-card-footer">
        <ActorChip name={c.owner} />
        <span className="e8-cboard-card-last">{c.last}</span>
      </div>
    </div>
  );
}

/* The full Kanban board - one column per candidate status. */
const CAND_BOARD_CAP = 15; // R98 Task 6: cap cards per column at scale, with a "+N more" tail
function CandidatesBoard({ rows }) {
  const D = window.E8DATA;
  const { showToast } = React.useContext(E8Ctx);
  const statuses = D.candidateStatuses.filter((s) => s !== 'All');

  /* R104 Task 4: derive the columns from the FILTERED rows via memo so the board re-syncs whenever the
     list scope/search/facet filters change or a candidate is re-staged. A stage move writes through
     E8Store, which fires store:changed -> the parent bumps storeVer -> a fresh `rows` prop arrives ->
     this memo re-derives. Previously byStage was a one-time useState snapshot that ignored both, so the
     board drifted from the list (and never reflected the active filter). */
  const byStage = React.useMemo(() => groupByStatus(rows), [rows]);
  const [dragId, setDragId] = React.useState(null);
  const [overCol, setOverCol] = React.useState(null);

  const drop = (status, e) => {
    let id = dragId;
    try { id = (e && e.dataTransfer && e.dataTransfer.getData('text/plain')) || dragId; } catch (x) {}
    setOverCol(null);
    if (!id) return;
    // Find the candidate in the current derived columns.
    let found = null;
    let fromStatus = null;
    statuses.forEach((s) => {
      const idx = (byStage[s] || []).findIndex((c) => c.id === id);
      if (idx > -1) { found = byStage[s][idx]; fromStatus = s; }
    });
    if (!found || fromStatus === status) return;
    const name = found.name;
    /* R78: the move writes through E8Store so it survives reload - and Undo is real. The store write
       fires store:changed, which re-derives byStage above (no local optimistic copy to drift). */
    window.E8Store.set('candidates', id, { status });
    if (window.E8Events) window.E8Events.emit('candidate:staged', { id, from: fromStatus, to: status });
    showToast(name + ' moved to ' + status, 'Undo', () => {
      window.E8Store.set('candidates', id, { status: fromStatus });
    });
  };

  return (
    <div className="e8-cboard">
      {statuses.map((status) => {
        const cards = byStage[status] || [];
        return (
          <div
            key={status}
            className={'e8-cboard-col' + (overCol === status ? ' is-dragover' : '')}
            onDragOver={(e) => { e.preventDefault(); if (overCol !== status) setOverCol(status); }}
            onDragLeave={(e) => { if (e.currentTarget === e.target) setOverCol(null); }}
            onDrop={(e) => drop(status, e)}
          >
            <div className="e8-cboard-colhead">
              {status}
              <span className="e8-cboard-colhead-count">{cards.length}</span>
            </div>
            <div className="e8-cboard-body">
              {cards.slice(0, CAND_BOARD_CAP).map((c) => {
                const meta = (D.candidateMeta && D.candidateMeta[c.id]) || {};
                return (
                  <CandidateBoardCard
                    key={c.id}
                    c={c}
                    meta={meta}
                    dragging={dragId === c.id}
                    onDragStart={(e) => {
                      setDragId(c.id);
                      e.dataTransfer.effectAllowed = 'move';
                      try { e.dataTransfer.setData('text/plain', c.id); } catch (x) {}
                    }}
                    onDragEnd={() => { setDragId(null); setOverCol(null); }}
                  />
                );
              })}
              {cards.length > CAND_BOARD_CAP ? <button type="button" className="e8-cboard-more" onClick={() => navigate('candidates')}>+{cards.length - CAND_BOARD_CAP} more</button> : null}
              {!cards.length ? <div className="e8-cboard-empty">Drop here</div> : null}
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* ============================== CANDIDATES LIST ============================== */
/* ---- Mobile candidate cards ---- */
const CAND_STAGE_TONE = { Sourced: 'neutral', Engaged: 'accent', Screening: 'info', Submitted: 'success', Archived: 'neutral' };
// R104 Task 4: AI-screen verdict -> tier token (matches the applicants screen AP_VERDICT_TIER).
const CAND_VERDICT_TIER = { Strong: 1, Maybe: 3, Weak: 4 };
const CAND_RAIL = { Sourced: 'var(--ui-text-tertiary)', Engaged: 'var(--ui-accent, var(--ui-accent))', Screening: 'var(--ui-info)', Submitted: 'var(--ui-success)', Archived: 'var(--ui-text-tertiary)' };

function CandidateCard({ r }) {
  const D = window.E8DATA;
  const cm = (D.candidateMeta && D.candidateMeta[r.id]) || {};
  const bm = cm.bestMatch;
  const meta = [r.location, r.years ? r.years + ' yrs' : null, r.owner ? 'Owner ' + r.owner : null].filter(Boolean).join(' · ');
  return (
    <div className="e8-mcard">
      <span className="e8-mcard-rail" style={{ background: CAND_RAIL[r.status] || 'var(--ui-text-tertiary)' }}></span>
      <div className="e8-mcard-body">
        <div className="e8-mcard-top">
          <DSc.Avatar name={r.name} size="sm" />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="e8-mcard-name">{r.name}</div>
            <div className="e8-mcard-role">{r.title}</div>
          </div>
          <WorkspacePinButton type="candidate" id={r.id} compact />
          {bm ? <DSc.MatchBadge tier={bm.tier} dot /> : null}
        </div>
        {meta ? <div className="e8-mcard-meta">{meta}</div> : null}
        {r.skills && r.skills.length ? <div className="e8-mcard-skills"><DSc.SkillChips skills={r.skills} max={3} /></div> : null}
        {cm.tags && cm.tags.length ? <div className="e8-mcard-skills" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>{cm.tags.map((t) => <span key={t} style={{ fontSize: 'var(--ui-text-xs)', fontWeight: 600, color: 'var(--ui-daybreak-text)', background: 'var(--ui-daybreak-tint)', borderRadius: 5, padding: '1px 6px' }}>{t}</span>)}</div> : null}
        <div className="e8-mcard-foot" style={{ flexWrap: 'wrap', rowGap: 6 }}>
          <DSc.Badge tone={CAND_STAGE_TONE[r.status] || 'neutral'} dot>{r.status}</DSc.Badge>
          {/* R106 T3: compact freshness hint on the mobile card fallback (same derived tone as the list/record). */}
          {(() => { const f = window.candidateFreshness ? window.candidateFreshness(r) : null; return f ? <DSc.Badge tone={f.tone} dot>{f.label}</DSc.Badge> : null; })()}
          <span className="e8-mcard-when">{r.last}{r.lastBy === 'ai' ? ' · AI surfaced' : ''}</span>
        </div>
      </div>
    </div>
  );
}

/* Quick-view content for the candidate detent sheet: identity, stage, thumb-reachable
   contact actions (tel:/sms:/mailto: hand off to the OS), best match, skills. */
function CandidateQuickView({ r, status, onOpenFull, cap }) {
  const D = window.E8DATA;
  const cm = (D.candidateMeta && D.candidateMeta[r.id]) || {};
  const bm = cm.bestMatch;
  const tel = (r.phone || '').replace(/[^0-9+]/g, '');
  const meta = [r.location, r.years ? r.years + ' yrs' : null].filter(Boolean).join(' · ');
  return (
    <div>
      <div className="e8-qv-head">
        <DSc.Avatar name={r.name} size="lg" />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 'var(--ui-text-xl)', fontWeight: 650, color: 'var(--ui-text)', lineHeight: 1.2 }}>{r.name}</div>
          <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)' }}>{r.title}</div>
        </div>
        {bm ? <DSc.MatchBadge tier={bm.tier} /> : null}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
        <DSc.Badge tone={CAND_STAGE_TONE[r.status] || 'neutral'} dot>{r.status}</DSc.Badge>
        {meta ? <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{meta}</span> : null}
      </div>
      <div className="e8-qv-actions">
        <a className="e8-qa-btn" href={tel ? 'tel:' + tel : '#'}><span className="material-symbols-outlined">call</span>Call</a>
        <a className="e8-qa-btn" href={tel ? 'sms:' + tel : '#'}><span className="material-symbols-outlined">sms</span>Text</a>
        <a className="e8-qa-btn" href={r.email ? 'mailto:' + r.email : '#'}><span className="material-symbols-outlined">mail</span>Email</a>
        <button type="button" className="e8-qa-btn" onClick={() => cap && cap.open({ target: 'note', entityLabel: r.name, refType: 'candidate', refId: r.id })}><span className="material-symbols-outlined">note_add</span>Note</button>
      </div>
      {bm ? (
        <div style={{ marginTop: 16 }}>
          <div className="e8-sectionlabel" style={{ marginBottom: 6 }}>Best match</div>
          <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)' }}>{bm.job} at {bm.client}</div>
        </div>
      ) : null}
      {r.skills && r.skills.length ? (
        <div style={{ marginTop: 16 }}>
          <div className="e8-sectionlabel" style={{ marginBottom: 6 }}>Skills</div>
          <DSc.SkillChips skills={r.skills} max={10} />
        </div>
      ) : null}
      <button type="button" onClick={onOpenFull} style={{ marginTop: 18, width: '100%', height: 38, border: '1px solid var(--ui-border)', borderRadius: 9, background: 'var(--ui-surface)', color: 'var(--ui-text)', font: 'inherit', fontSize: 'var(--ui-text-base)', fontWeight: 500, cursor: 'pointer' }}>Open full profile</button>
    </div>
  );
}

function CandidateCardList({ rows, status }) {
  const { showToast } = React.useContext(E8Ctx);
  const cap = React.useContext(CaptureCtx);
  const [acted, setActed] = React.useState({});
  const [quick, setQuick] = React.useState(null);
  const [shown, setShown] = React.useState(null);
  const [peekFull, setPeekFull] = React.useState(false);
  const ptr = usePullToRefresh(() => new Promise((res) => { setTimeout(() => { showToast('Synced - 3 new candidates since you last looked'); res(); }, 1100); }));
  const order = ['Sourced', 'Engaged', 'Screening', 'Submitted'];
  const nextStage = (s) => order[Math.min(order.length - 1, order.indexOf(s) + 1)] || 'Submitted';
  const commit = (r, dir) => {
    setActed((a) => Object.assign({}, a, { [r.id]: dir }));
    const first = (r.name || '').split(' ')[0];
    const restore = () => setActed((a) => { const n = Object.assign({}, a); delete n[r.id]; return n; });
    if (dir === 'advance') showToast('Advanced ' + first + ' to ' + nextStage(r.status), 'Undo', restore);
    else showToast(first + ' rejected', 'Undo', restore);
  };
  if (!rows.length) return <div className="e8-listfoot">No candidates match this view.</div>;
  const visible = rows.filter((r) => !acted[r.id]);
  const qc = shown;
  return (
    <React.Fragment>
      <div className={'e8-ptr' + (ptr.refreshing ? ' refreshing' : '')} aria-hidden={!ptr.pull && !ptr.refreshing}>
        <div className="e8-ptr-pill" style={{ transform: 'translateY(' + Math.min(ptr.pull, 70) + 'px)', opacity: (ptr.pull > 6 || ptr.refreshing) ? 1 : 0 }}>
          <span className={'material-symbols-outlined' + (ptr.refreshing ? ' e8-ptr-spin' : '')} style={ptr.refreshing ? null : { transform: ptr.pull >= 72 ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>{ptr.refreshing ? 'progress_activity' : 'arrow_downward'}</span>
          <span>{ptr.refreshing ? 'Syncing…' : ptr.pull >= 72 ? 'Release to sync' : 'Pull to sync'}</span>
        </div>
      </div>
      <div className="e8-cards">
        {visible.map((r) => (
          <SwipeTriage key={r.id}
            left={{ label: 'Advance', icon: 'arrow_circle_up', bg: 'var(--ui-success)' }}
            right={{ label: 'Reject', icon: 'do_not_disturb_on', bg: 'var(--ui-danger)' }}
            onCommit={(dir) => commit(r, dir)}
            onTap={() => { setShown(r); setPeekFull(false); setQuick(r); }}
            onLongPress={() => { setShown(r); setPeekFull(true); setQuick(r); }}>
            <CandidateCard r={r} />
          </SwipeTriage>
        ))}
      </div>
      <div className="e8-listfoot"><span className="tnum">{visible.length}</span> candidate{visible.length === 1 ? '' : 's'}{visible.length !== rows.length ? ' · ' + (rows.length - visible.length) + ' actioned' : ''}</div>
      <BottomSheet open={!!quick} onClose={() => setQuick(null)} title="Candidate" initial={peekFull ? 'full' : 'half'}
        footer={qc ? (
          <div style={{ display: 'flex', gap: 8 }}>
            <button type="button" className="e8-bsheet-reject" onClick={() => { const r = qc; setQuick(null); commit(r, 'reject'); }}>
              <span className="material-symbols-outlined">do_not_disturb_on</span>Reject
            </button>
            <button type="button" className="e8-bsheet-primary" style={{ flex: 1 }} onClick={() => { const r = qc; setQuick(null); commit(r, 'advance'); }}>
              <span className="material-symbols-outlined">arrow_circle_up</span>Advance to {nextStage(qc.status)}
            </button>
          </div>
        ) : null}>
        {qc ? <CandidateQuickView r={qc} status={status} onOpenFull={() => { setQuick(null); navigate('candidate/' + qc.id + '?q=status-' + status); }} cap={cap} /> : null}
      </BottomSheet>
    </React.Fragment>
  );
}

/* R110 T2: the candidate facet buckets. skills + location JOIN the R104 source/avail/owner facets - all
   multi-select, all rendered in the rail + sheet with live counts. A factory (fresh arrays) so a reset
   never shares array refs across resets. Freshness + status keep their own SubTabs (single-select), so
   they are deliberately NOT facet buckets here (no double control). */
function candEmptyFilters() { return { source: [], avail: [], owner: [], skills: [], location: [] }; }

/* R110 T2: the shared multi-select facet groups - rendered in BOTH the always-visible desktop rail and
   the mobile filter sheet, so counts + selection stay identical across surfaces. `groups` is
   [{ key, label, values:[{value,count}] }] where `key` matches a `filters` bucket. Selecting a value is
   OR-within-dimension (toggleFilter appends/removes), AND-across-dimensions (matchesFilters). Buttons are
   natively keyboard-accessible (aria-pressed), so no clickableProps needed. */
function CandidateFacetGroups({ groups, filters, toggleFilter }) {
  return (
    <React.Fragment>
      {groups.map((g) => (
        g.values && g.values.length ? (
          <div key={g.key} className="e8-fac-group">
            <div className="e8-fac-k">{g.label}</div>
            <div className="e8-fac-vals">
              {g.values.map((v) => {
                const on = (filters[g.key] || []).indexOf(v.value) > -1;
                return (
                  <button key={v.value} type="button" className={'e8-fac-chip' + (on ? ' on' : '')} aria-pressed={on} onClick={() => toggleFilter(g.key, v.value)}>
                    <span className="e8-fac-chip-v">{v.value}</span>
                    <span className="e8-fac-chip-c tnum">{(v.count != null ? v.count : 0).toLocaleString()}</span>
                  </button>
                );
              })}
            </div>
          </div>
        ) : null
      ))}
    </React.Fragment>
  );
}

/* R110 T3: the talent-pools / saved-searches surface - rendered in BOTH the desktop facet rail and the
   mobile filter sheet. Each pool is a persistent, persona-scoped saved query (D.talentPools, replayable
   store ops). A row Runs the pool's full query on click; the kebab renames/deletes (both persisted, both
   undoable). The live count is memoized over the persona-scoped set by the caller. "Save current search"
   captures the active query. Owner chip = the pool owner, or "Shared" for a seeded/unowned pool. Buttons +
   the DS MenuButton are natively keyboard-accessible (Enter/Esc handled), so no clickableProps needed. */
function TalentPoolPanel({ pools, counts, onRun, onSave, onRename, onDelete, activeId }) {
  return (
    <div className="e8-pool">
      <div className="e8-pool-head">
        <span className="e8-pool-head-t">Saved searches</span>
        <button type="button" className="e8-pool-save" onClick={onSave} title="Save the current search and facets as a talent pool">
          <span className="material-symbols-outlined" aria-hidden="true">bookmark_add</span>
          Save current search
        </button>
      </div>
      {pools.length ? (
        <div className="e8-pool-list">
          {pools.map((p) => (
            <div key={p.id} className={'e8-pool-row' + (activeId === p.id ? ' on' : '')}>
              <button type="button" className="e8-pool-run" onClick={() => onRun(p)} title={'Run "' + p.name + '"'}>
                <span className="e8-pool-name">{p.name}</span>
                <span className="e8-pool-sub">
                  <span className="e8-pool-count tnum">{(counts[p.id] != null ? counts[p.id] : 0).toLocaleString()} matching</span>
                  <span className="e8-pool-owner">{p.ownerId ? (p.owner || 'Owned') : 'Shared'}</span>
                </span>
              </button>
              <DSc.MenuButton icon="more_horiz" size="sm" variant="ghost" align="right" title={'Actions for ' + p.name}
                items={[
                  { icon: 'edit', label: 'Rename…', onClick: () => onRename(p) },
                  { icon: 'delete', label: 'Delete', danger: true, onClick: () => onDelete(p) },
                ]} />
            </div>
          ))}
        </div>
      ) : (
        <div className="e8-pool-empty">No saved searches yet. Search, pick facets, then save this view.</div>
      )}
    </div>
  );
}

/* R110 T2: on mobile the always-visible desktop facet rail routes into this sheet - the SAME computed
   facetGroups (with live counts), plus the status chips. R110 T3: the talent-pools surface leads the sheet
   (Run closes the sheet so the results show). "Clear all" resets every facet bucket (all 5 keys). */
function CandidateFilterSheet({ open, onClose, D, status, setStatus, filters, setFilters, toggleFilter, facetGroups, poolProps }) {
  const drag = useSheetDrag(onClose);
  // R110 T2: Esc closes the sheet (hard rule) - alongside backdrop tap + drag-to-dismiss.
  React.useEffect(() => {
    if (!open) return undefined;
    const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);
  return (
    <React.Fragment>
      <div className={'e8-msheet-backdrop' + (open ? ' is-open' : '')} onClick={onClose}></div>
      <div className={'e8-createsheet' + (open ? ' is-open' : '')} role="dialog" aria-label="Filters" aria-hidden={!open} style={drag.style}>
        <div className="e8-msheet-grab" {...drag.bind} onClick={onClose}></div>
        <div className="e8-createsheet-title" {...drag.bind}>Filters</div>
        <div style={{ maxHeight: '58vh', overflowY: 'auto' }}>
          {poolProps ? (
            <div className="e8-fsheet-group">
              <TalentPoolPanel
                pools={poolProps.pools}
                counts={poolProps.counts}
                activeId={poolProps.activeId}
                onSave={poolProps.onSave}
                onRename={poolProps.onRename}
                onDelete={poolProps.onDelete}
                onRun={(p) => { poolProps.onRun(p); onClose(); }}
              />
            </div>
          ) : null}
          <div className="e8-fsheet-group">
            <div className="e8-fsheet-k">Status</div>
            <div className="e8-fsheet-chips">
              {D.candidateStatuses.map((s) => (
                <button key={s} type="button" className={'e8-reason-chip' + (status === s ? ' on' : '')} onClick={() => setStatus(s)}>{s}</button>
              ))}
            </div>
          </div>
          <CandidateFacetGroups groups={facetGroups} filters={filters} toggleFilter={toggleFilter} />
        </div>
        <div className="e8-candfilter-foot" style={{ marginTop: 8 }}>
          <DSc.Button variant="ghost" size="sm" onClick={() => setFilters(candEmptyFilters())}>Clear all</DSc.Button>
          <DSc.Button variant="primary" size="sm" onClick={onClose}>Done</DSc.Button>
        </div>
      </div>
    </React.Fragment>
  );
}

/* R98: Add candidate (basic) - the interim manual path (Task 5 adds resume/AI capture as primary).
   Persists BOTH rows the app expects, mirroring qualifyApplicantRecord: a profile row into
   `matches` and a status row into `candidates`. Undo removes both. No AI ran, so no aiSummary,
   no reasons and no tier claim beyond the unscored default. Dedup warns on a case-insensitive
   name match but lets you proceed (new id gets a suffix - never a silent double-write). */
function NewCandidateDialog({ onClose }) {
  const D = window.E8DATA;
  const { showToast } = React.useContext(E8Ctx);
  const Dialog = window.E8CreateDialog;
  if (!Dialog) return null;

  const dupOf = (name) => {
    const n = String(name || '').trim().toLowerCase();
    return n ? (D.matches || []).find((m) => String(m.name || '').toLowerCase() === n) : null;
  };

  return (
    <Dialog
      title="Add candidate"
      createLabel="Add candidate"
      createIcon="person_add"
      onClose={onClose}
      fields={[
        { key: 'name', label: 'Full name', placeholder: 'e.g. Jordan Ellis', required: true,
          note: (vals) => { const d = dupOf(vals.name); return d ? ('A candidate named ' + d.name + ' already exists - adding again creates a second record.') : null; } },
        { key: 'title', label: 'Title', placeholder: 'e.g. Data Engineer' },
        { key: 'skills', label: 'Skills', placeholder: 'Comma-separated, e.g. Python, dbt, Snowflake' },
        { key: 'rate', label: 'Rate', placeholder: 'e.g. $85/hr' },
        { key: 'location', label: 'Location', placeholder: 'e.g. Memphis, TN' },
      ]}
      onCreate={(v) => {
        onClose();
        const name = v.name.trim();
        const slug = 'c-' + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
        let cid = slug, n = 2;
        while ((D.matches || []).some((m) => m.id === cid) || (D.candidates || []).some((c) => c.id === cid)) cid = slug + '-' + n++;
        const skills = (v.skills || '').split(',').map((s) => s.trim()).filter(Boolean);
        window.E8Store.add('matches', {
          id: cid, state: 'review', stage: 'source', tier: 3,
          name, title: v.title.trim() || 'Candidate', company: 'Independent',
          location: v.location.trim(), email: name.toLowerCase().replace(/[^a-z]+/g, '.') + '@email.com',
          rate: v.rate.trim() || undefined, linkedin: false, skills, reasons: [],
        });
        window.E8Store.add('candidates', { id: cid, status: 'Sourced', source: 'Manual', availability: 'Unknown', owner: D.user.name, last: 'Just now', lastBy: 'human', lastNote: 'Added manually' });
        if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Added candidate', target: name, route: 'candidate/' + cid });
        if (window.E8Events) window.E8Events.emit('candidate:created', { candId: cid });
        showToast('Added ' + name + ' to candidates', 'Undo', () => {
          window.E8Store.remove('matches', cid);
          window.E8Store.remove('candidates', cid);
          if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Removed candidate (undo)', target: name, route: 'candidates' });
        });
      }}
    />
  );
}

/* R104 Task 4: role-aware default ops scope - the ops lead lands on the whole desk (All); a
   recruiter/CSM has no toggle (they always see their own book). */
function candDefaultScope() {
  const p = window.e8ActivePersona ? window.e8ActivePersona() : null;
  return p && p.role === 'ops' ? 'all' : 'mine';
}

/* R111: the dedicated Duplicates review PAGE (#/duplicates). R109 built the "possible duplicates" queue
   inline on the candidates list; at ~1M candidates that queue can be thousands of pairs, so it earns its
   own paged screen instead of dominating the list. Reuses R109's machinery verbatim: candidateDupPairs
   (the ranked, persona-scoped, dismissed-removed pair sweep), e8OpenMerge (the human-reviewed merge
   modal - the ONLY merge path), and the dupDismissed replayable op (Not a duplicate + Undo). Persona-
   scoped so a recruiter never sees another rep's pairs; re-derives live on store:changed / persona:changed
   so a merged / dismissed pair leaves the list. PAGED via a Show-more slice so it never renders thousands
   at once. */
const DUP_PAGE = 40; // pairs revealed per "Show more" click - scale-safe slice
function DuplicatesScreen() {
  const D = window.E8DATA;
  const { showToast } = React.useContext(E8Ctx);
  const byId = React.useMemo(() => Object.fromEntries((D.matches || []).map((m) => [m.id, m])), [D.matches]);
  const [persona, setPersona] = React.useState(() => (window.e8ActivePersona ? window.e8ActivePersona() : null));
  const personaKey = persona ? persona.name : '';
  const [storeVer, setStoreVer] = React.useState(0);
  const [shown, setShown] = React.useState(DUP_PAGE);
  React.useEffect(() => {
    if (!window.E8Events) return undefined;
    return window.E8Events.subscribe(['persona:changed', 'store:changed', 'owner:changed'], () => {
      if (window.e8ActivePersona) setPersona(window.e8ActivePersona());
      setStoreVer((v) => v + 1);
    });
  }, []);
  // A persona switch changes the whole set - reset the reveal slice so it never lands mid-page on a smaller book.
  React.useEffect(() => { setShown(DUP_PAGE); }, [personaKey]);
  const pairs = React.useMemo(
    () => (window.candidateDupPairs ? window.candidateDupPairs(persona) : []),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [personaKey, storeVer]
  );
  const matchName = (mid) => (byId[mid] && byId[mid].name) || (((D.candidates || []).find((c) => c.id === mid) || {}).id) || mid;
  const openMerge = (aId, bId) => {
    if (window.e8OpenMerge) { window.e8OpenMerge(aId, bId); return; }
    showToast('Merge review is unavailable in this build', 'Open', () => navigate('candidate/' + bId));
  };
  const dismissDup = (pair) => {
    const key = window.dupPairKey(pair.a, pair.b);
    window.E8Store.add('dupDismissed', { id: key, a: pair.a, b: pair.b });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Marked not a duplicate', target: matchName(pair.a) + ' / ' + matchName(pair.b), route: 'duplicates' });
    showToast('Marked not a duplicate', 'Undo', () => window.E8Store.remove('dupDismissed', key));
  };
  const paged = pairs.slice(0, shown);
  const n = pairs.length;

  return (
    <React.Fragment>
      <Topbar
        crumbs={[{ label: 'Duplicates' }]}
        actions={<window.PersonaViewAs />}
      />
      <div className="e8-content">
        <div className="e8-page">
          <div className="e8-dupscreen-head">
            <div className="e8-dupscreen-head-l">
              <h1 className="e8-h1">Duplicates</h1>
              <p className="e8-dupscreen-sub">
                {n
                  ? 'Suspected same person across records. Review to merge into one, or mark not a duplicate.'
                  : 'Every candidate in this book looks unique.'}
              </p>
            </div>
            {n ? <span className="e8-dupscreen-count tnum">{n.toLocaleString()}</span> : null}
          </div>

          {n ? (
            <React.Fragment>
              <div className="e8-dupscreen-list" role="list" aria-label="Possible duplicate pairs">
                {paged.map((p) => {
                  const an = matchName(p.a), bn = matchName(p.b);
                  return (
                    <div key={p.key} className="e8-dup-row" role="listitem">
                      <div className="e8-dup-people">
                        <span className="e8-dup-person">
                          <DSc.Avatar name={an} size="sm" />
                          <a className="e8-link" tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('candidate/' + p.a)}>{an}</a>
                        </span>
                        <span className="material-symbols-outlined e8-dup-vs" aria-hidden="true">sync_alt</span>
                        <span className="e8-dup-person">
                          <DSc.Avatar name={bn} size="sm" />
                          <a className="e8-link" tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('candidate/' + p.b)}>{bn}</a>
                        </span>
                      </div>
                      <div className="e8-dup-mid">
                        {p.score != null
                          ? <DSc.Badge tone={p.band === 'high' ? 'success' : 'warning'} dot>{p.band === 'high' ? 'High' : 'Review'} · {p.score}</DSc.Badge>
                          : <DSc.Badge tone="warning" dot>Flagged</DSc.Badge>}
                        <span className="e8-dup-sigs">
                          {(p.signals || []).map((s, i) => <span key={i} className="e8-dup-sig">{s.label}</span>)}
                        </span>
                      </div>
                      <div className="e8-dup-acts">
                        <DSc.Button variant="primary" size="sm" icon="merge" onClick={() => openMerge(p.a, p.b)}>Review</DSc.Button>
                        <DSc.Button variant="ghost" size="sm" icon="close" onClick={() => dismissDup(p)}>Not a duplicate</DSc.Button>
                      </div>
                    </div>
                  );
                })}
              </div>
              {n > shown ? (
                <div className="e8-showmore">
                  <button type="button" className="e8-showmore-btn" onClick={() => setShown(shown + DUP_PAGE)}>
                    Show more<span className="e8-showmore-n">{(n - shown).toLocaleString()} more</span>
                  </button>
                </div>
              ) : null}
            </React.Fragment>
          ) : (
            <div className="e8-dupscreen-empty">
              <span className="material-symbols-outlined e8-dupscreen-empty-ico" aria-hidden="true">verified_user</span>
              <div className="e8-dupscreen-empty-t">No possible duplicates</div>
              <div className="e8-dupscreen-empty-sub">Every candidate looks unique. New matches show up here as records are added or enriched.</div>
              <DSc.Button variant="secondary" size="sm" icon="group" onClick={() => navigate('candidates')}>Back to candidates</DSc.Button>
            </div>
          )}
        </div>
      </div>
    </React.Fragment>
  );
}

function CandidatesScreen() {
  const D = window.E8DATA;
  const { showToast, density } = React.useContext(E8Ctx);
  const cap = React.useContext(CaptureCtx);
  const byId = Object.fromEntries(D.matches.map((m) => [m.id, m]));
  const [status, setStatus] = React.useState('All');
  const [mode, setMode] = React.useState('list');
  const [sel, setSel] = React.useState([]);
  const [insights, setInsights] = React.useState(D.insights.candidates);
  // R104 Task 4: the headline box is a real live text filter (was a decorative NL string that never
  // filtered), so it starts empty - the whole scoped desk is visible until you type.
  const [query, setQuery] = React.useState('');
  // R106 T3: active data-freshness cohort ('all' | 'never' | 'stale' | 'aging' | 'verified') - ANDs on
  // top of persona scope + facet filters + text search + the status tab.
  const [freshCohort, setFreshCohort] = React.useState('all');

  const [filters, setFilters] = React.useState(candEmptyFilters);
  const [compareOpen, setCompareOpen] = React.useState(false);
  /* R105 Pass 2a: the Compare overlay keeps its bespoke side-by-side grid, but earns a focus trap
     (window.useDialog re-runs when compareOpen flips) + Esc-to-close like the Modal-shell dialogs. */
  const compareRef = React.useRef(null);
  window.useDialog(compareOpen, compareRef);
  React.useEffect(() => {
    if (!compareOpen) return undefined;
    const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); setCompareOpen(false); } };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [compareOpen]);
  const [filterSheetOpen, setFilterSheetOpen] = React.useState(false);
  const isMobile = useIsMobile();
  const mobileAct = React.useContext(MobileActionCtx);
  const openFilterSheet = React.useCallback(() => setFilterSheetOpen(true), []);
  const aiState = useOnDeviceAI();
  const [aiRank, setAiRank] = React.useState(null); // { [id]: score } when semantic ranking is on
  const [aiBusy, setAiBusy] = React.useState(false);

  // R104 Task 4: persona-scoped candidate desk, mirroring R101 consultants / R103 sequences. The
  // active persona (shared localStorage['e8-persona']) drives which rows are "mine", whether the ops
  // Mine|All|Unassigned scope + reassign appear, and the default scope. storeVer is a monotonic bump
  // threaded into the scoping memo deps: a reassignment is an in-place E8Store.set on the same
  // D.candidates array, so without a changing dep the memo would keep its stale scoping.
  const [persona, setPersona] = React.useState(() => (window.e8ActivePersona ? window.e8ActivePersona() : null));
  const isOps = !!persona && persona.role === 'ops';
  const personaKey = persona ? persona.name : '';
  const [scope, setScope] = React.useState(candDefaultScope); // ops-only: 'mine' | 'all' | 'unassigned'
  const [storeVer, setStoreVer] = React.useState(0);
  const [reassign, setReassign] = React.useState(null); // candidate row being reassigned (ops), or null
  React.useEffect(() => {
    if (!window.E8Events) return undefined;
    return window.E8Events.subscribe(['persona:changed', 'store:changed', 'owner:changed'], (ev) => {
      if (window.e8ActivePersona) setPersona(window.e8ActivePersona());
      if (ev && ev.type === 'persona:changed') { setScope(candDefaultScope()); setAiRank(null); }
      setStoreVer((v) => v + 1);
    });
  }, []);

  const cmeta = (id) => (D.candidateMeta && D.candidateMeta[id]) || {};
  /* R104 nit A: the list Submit controls open the SAME real, deduped submission dialog as the record CTA -
     prefilled to the candidate + their best-fit job (e8TopMatch) - never a fake JO-44219 toast. */
  const submitCand = (id) => {
    const m = byId[id] || (D.matches || []).find((x) => x.id === id) || { id };
    const tm = e8TopMatch(m);
    if (window.e8OpenCreate) window.e8OpenCreate('submission', { prefillCand: id, prefillJob: tm.top ? tm.top.jobId : '' });
    else showToast('Submission dialog is unavailable in this build');
  };
  /* R110 T2: facet predicate - AND across dimensions, OR within a dimension. skills matches if ANY of the
     candidate's skills is selected; the rest are single-valued exact-in-set. Every bucket is guarded with
     `|| []` so a saved segment that predates the skills/location buckets never throws. */
  const matchesFilters = (c, f) => {
    const m = cmeta(c.id);
    if ((f.source || []).length && f.source.indexOf(m.source) < 0) return false;
    if ((f.avail || []).length && f.avail.indexOf(m.availability) < 0) return false;
    if ((f.owner || []).length && f.owner.indexOf(c.owner) < 0) return false;
    if ((f.location || []).length && f.location.indexOf(c.location || m.location) < 0) return false;
    if ((f.skills || []).length && !(c.skills || []).some((s) => f.skills.indexOf(s) > -1)) return false;
    return true;
  };
  /* Persona-scoped base (before per-view status/facet/search): ops sees Mine|All|Unassigned via the
     scope toggle; recruiter/CSM always see only their own book, so ops "All" is the only way to see
     another owner's rows. Memoized + storeVer-keyed so an in-place reassign re-scopes without a new
     array. `enriched` merges the matches profile onto each scoped candidate; `filtered` applies the
     facet chips; `searched` applies the live text query (name/title/skills/company/location). */
  const q = (query || '').trim().toLowerCase();
  // R110 T2: multi-term search - whitespace-separated tokens are AND-combined (every term must match).
  const terms = q ? q.split(/\s+/).filter(Boolean) : [];
  const model = React.useMemo(() => {
    const all = D.candidates || [];
    const mine = window.myCandidates ? window.myCandidates(persona) : all;
    const opsOwned = all.filter((c) => persona && ((persona.repId && c.ownerId === persona.repId) || c.owner === persona.name));
    const orphaned = all.filter((c) => (window.candidateOwner ? window.candidateOwner(c.id).orphaned : false));
    const base = !isOps ? mine : (scope === 'mine' ? opsOwned : scope === 'unassigned' ? orphaned : all);
    const enriched = base.map((c) => ({ ...byId[c.id], ...c }));
    /* R110 T2: apply the multi-term text search BEFORE the facet chips, so `textMatched` (scoped + text,
       no facets) is the honest base the faceted rail counts over - each rail dimension counts the text
       set minus the OTHER active facets (standard exclude-self facet semantics; see railFacets). Every
       token must appear somewhere in name/title/company/location/skills (AND-of-terms, R110 out-of-scope
       for boolean OR/NOT). */
    const textMatched = terms.length
      ? enriched.filter((c) => {
          const m = cmeta(c.id);
          const hay = [c.name, c.title, c.company, c.location || m.location, (c.skills || []).join(' ')].filter(Boolean).join(' ').toLowerCase();
          return terms.every((t) => hay.indexOf(t) > -1);
        })
      : enriched;
    const searched = textMatched.filter((c) => matchesFilters(c, filters));
    /* R106 T3: derive data freshness once over the scoped+facet+searched set (keyed on storeVer, so a
       single-record verify or a bulk re-verify campaign - both replayable setMeta writes - re-derive the
       column + the cohort counts). freshById feeds the table cell + the cohort filter; freshCounts drives
       the cohort SubTabs. O(n)-once here, not per badge render. */
    const freshFn = window.candidateFreshness;
    const freshById = {};
    const freshCounts = { all: searched.length, never: 0, aging: 0, stale: 0, verified: 0 };
    if (freshFn) searched.forEach((c) => { const f = freshFn(c); freshById[c.id] = f; if (freshCounts[f.state] != null) freshCounts[f.state] += 1; });
    return { enriched, textMatched, searched, orphanCount: orphaned.length, opsOwnedCount: opsOwned.length, freshById, freshCounts };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [D.candidates, personaKey, isOps, scope, filters, q, storeVer]);
  const scopedRows = model.enriched; // scoped + enriched, before facet/search filters
  const searched = model.searched;   // scoped + facet + text query (all statuses) - drives board/counts

  /* R110 T2: the faceted-search rail data. For each dimension, count over the scoped+text set minus every
     OTHER active facet (exclude-self), so within a dimension you still see selectable sibling counts while
     the counts react to selections in the other dimensions. skills/location/availability come from the
     canonical aggregator window.candidateFacets (which scales counts to the simulated total in scale mode
     - an honest server aggregate); source (R104 candidateMeta.source vocab - the field matchesFilters +
     the segments key off) and owner are tallied the same way with the same scale factor. Memoized on the
     scoped+text set + the active facets + storeVer, and computed over the BOUNDED working set, so a
     keystroke/chip-click never O(1M)-scans. */
  const dataMode = (window.E8DataMode && window.E8DataMode.get) ? window.E8DataMode.get() : (window.E8_DATA_MODE || 'demo');
  const scaleMode = dataMode === 'scale';
  const totalCount = window.candidateTotalCount ? window.candidateTotalCount() : (D.candidates || []).length;
  /* R110 fix: ONE shared scale factor for BOTH the facet-rail chip counts AND the header "matching" count,
     so a chip's number stays on the same denominator as the header - the FIXED scoped-set size
     (model.enriched), never the per-dimension exclude-self subset. Using the subset length inflated a
     chip's count as you narrowed other facets (chips grew past the result you got by clicking them). The
     membership stays exclude-self (subsetExcept, below); only the projection divisor is fixed. Demo/empty
     mode => factor 1 (counts stay exact). Divide-by-zero guarded (empty scoped set => 1). */
  const scaleFactor = (scaleMode && model.enriched.length) ? (totalCount / model.enriched.length) : 1;
  const railFacets = React.useMemo(() => {
    const tm = model.textMatched || [];
    // tm filtered by every active facet EXCEPT `skip` (exclude-self base for that dimension's counts).
    const subsetExcept = (skip) => tm.filter((c) => {
      const m = cmeta(c.id);
      if (skip !== 'source' && (filters.source || []).length && filters.source.indexOf(m.source) < 0) return false;
      if (skip !== 'avail' && (filters.avail || []).length && filters.avail.indexOf(m.availability) < 0) return false;
      if (skip !== 'owner' && (filters.owner || []).length && filters.owner.indexOf(c.owner) < 0) return false;
      if (skip !== 'location' && (filters.location || []).length && filters.location.indexOf(c.location || m.location) < 0) return false;
      if (skip !== 'skills' && (filters.skills || []).length && !(c.skills || []).some((s) => filters.skills.indexOf(s) > -1)) return false;
      return true;
    });
    const F = window.candidateFacets;
    // local tally mirroring candidateFacets: project each raw membership count by the ONE shared
    // scaleFactor (fixed scoped-set denominator), so the rail and the header stay on the same basis.
    const tally = (rows, getVal, cap) => {
      const map = {};
      rows.forEach((c) => { let v = getVal(c); if (v == null) return; v = ('' + v).trim(); if (!v) return; map[v] = (map[v] || 0) + 1; });
      let out = Object.keys(map).map((k) => ({ value: k, count: Math.round(map[k] * scaleFactor) }));
      out.sort((a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)));
      if (cap && out.length > cap) out = out.slice(0, cap);
      return out;
    };
    const skillsSet = subsetExcept('skills'), locSet = subsetExcept('location'), availSet = subsetExcept('avail');
    // Pass the shared factor into candidateFacets so its scale projection uses the same fixed divisor.
    return {
      skills: F ? F(skillsSet, { cap: 10, factor: scaleFactor }).skills : tally(skillsSet, (c) => null, 10),
      location: F ? F(locSet, { cap: 8, factor: scaleFactor }).location : tally(locSet, (c) => c.location, 8),
      availability: F ? F(availSet, { factor: scaleFactor }).availability : tally(availSet, (c) => cmeta(c.id).availability, 0),
      source: tally(subsetExcept('source'), (c) => cmeta(c.id).source, 0),
      owner: tally(subsetExcept('owner'), (c) => c.owner, 8),
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [model.textMatched, filters, storeVer, scaleMode, totalCount, scaleFactor]);
  // If the last orphan is reassigned while ops is on the Unassigned scope, that tab disappears - snap
  // back to All so the toggle never shows a stale-active tab over an empty list.
  React.useEffect(() => { if (scope === 'unassigned' && !model.orphanCount) setScope('all'); }, [model.orphanCount, scope]);

  /* R106 T3: freshness cohorts. freshById/freshCounts are derived once in the model memo (over the
     scoped+facet+searched set, all statuses). The cohort SubTabs show freshCounts; selecting a non-'all'
     cohort narrows freshSearched, which then feeds the board, the status counts/funnel, the list rows and
     the count header - so it composes with (ANDs into) persona scope + facets + search + the status tab
     without clobbering any of them. */
  const freshById = model.freshById || {};
  const freshCounts = model.freshCounts || { all: 0, never: 0, aging: 0, stale: 0, verified: 0 };
  const freshSearched = freshCohort === 'all' ? searched : searched.filter((c) => (freshById[c.id] || {}).state === freshCohort);
  const FRESH_COHORT_LABEL = { never: 'never verified', stale: 'stale', aging: 'aging', verified: 'verified' };
  /* Bulk "Request re-verification" campaign: one replayable setMeta per candidate (verifyState='requested'),
     one audit entry, one toast, and ONE Undo restoring every prior verifyState. This is the self-service
     answer to a large aging base - the same request the record action fires, fanned across a cohort. The
     setMeta writes fire store:changed -> storeVer bump -> the list re-derives and each row reflects the
     requested state. Empty selection is guarded. */
  /* sendReverify is the shared executor: one replayable setMeta per candidate (verifyState='requested'),
     one audit entry, one toast with ONE Undo restoring every prior verifyState. */
  const sendReverify = (ids, opts) => {
    const list = (ids || []).filter(Boolean);
    if (!list.length) return;
    opts = opts || {};
    const prev = {};
    list.forEach((id) => { prev[id] = cmeta(id).verifyState || 'idle'; });
    if (window.E8Store) list.forEach((id) => window.E8Store.setMeta(id, { verifyState: 'requested' }));
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Requested re-verification for ' + list.length + ' candidate' + (list.length === 1 ? '' : 's') + (opts.excludedCount ? ' (' + opts.excludedCount + ' with no touchpoint, sent context-light)' : ''), target: list.length + ' candidates', route: 'candidates' });
    showToast(opts.message || ('Re-verification requested for ' + list.length + ' candidate' + (list.length === 1 ? '' : 's') + ' - sending via sequence'), 'Undo', () => {
      if (!window.E8Store) return;
      list.forEach((id) => window.E8Store.setMeta(id, { verifyState: prev[id] }));
    });
    setSel([]);
  };
  /* R109 T4: eligibility gate. Split the target by candidateReverifyEligible so a cold re-verify never
     fires at never-engaged / AI-screen-only candidates by default: the eligible go out now; if any are
     excluded, a gate dialog shows the "N excluded - no prior touchpoint" line + an explicit opt-in to
     include them with a context-light message (a conscious choice, never the default). */
  const requestReverifyBulk = (ids) => {
    const list = (ids || []).filter(Boolean);
    if (!list.length) { showToast('Select candidates to request re-verification'); return; }
    const elig = window.candidateReverifyEligible;
    const eligible = [], excluded = [];
    list.forEach((id) => { ((elig ? elig(id).eligible : true) ? eligible : excluded).push(id); });
    if (!excluded.length) { sendReverify(eligible); return; } // common path - nothing to gate
    setReverifyGate({ eligible, excluded });                  // visible exclusion + opt-in before any send
  };

  /* R109 T2: the "Possible duplicates" review queue. candidateDupPairs sweeps the PERSONA-SCOPED book
     (window.myCandidates) with the matcher + any possibleDuplicateOf flags, deduped into pairs with
     D.dupDismissed removed - memoized on persona + storeVer so a dismiss (a replayable dupDismissed op)
     re-derives and the pair leaves live. Ops sees the whole desk; a recruiter/CSM sees only their book. */
  /* R111: the count only - the full review queue now lives on its own paged page (#/duplicates, the
     DuplicatesScreen). The list shows a slim entry that routes there; the big inline block is gone so a
     thousands-deep queue never dominates the candidates list. */
  const dupPairs = React.useMemo(
    () => (window.candidateDupPairs ? window.candidateDupPairs(persona) : []),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [personaKey, isOps, storeVer]
  );
  const [reverifyGate, setReverifyGate] = React.useState(null); // { eligible:[ids], excluded:[ids] } when a bulk re-verify has excluded candidates, else null

  /* R110 T3: talent pools / saved searches. A pool round-trips the FULL active query - text terms + all 5
     facet buckets + freshness cohort + status - the same `query` shape T2's saveSegment captured, now
     promoted from localStorage segments to first-class, ownable, persistent data on D.talentPools. Every
     create/rename/delete is a replayable E8Store array op (add/set/remove - no new op type), so a saved
     pool survives reload via op replay and delete-undo restores it. Source of truth is D.talentPools,
     re-read on storeVer (the store:changed subscriber bumps it). `filters` uses candEmptyFilters()'s 5-key
     shape; cloneFilters deep-copies so a pool's arrays never alias the live filter state. */
  const cloneFilters = (f) => { const e = candEmptyFilters(); f = f || {}; Object.keys(e).forEach((k) => { e[k] = [...(f[k] || [])]; }); return e; };
  const captureQuery = () => ({ status, terms: query, freshness: freshCohort, filters: cloneFilters(filters) });
  const allPools = React.useMemo(() => ((D.talentPools || []).slice()), [D.talentPools, storeVer]);
  /* Persona scope (mirrors myCandidates): ops sees every pool; a recruiter/CSM sees shared pools (no
     ownerId - seeded/global) + their own (ownerId === their repId). So one recruiter never sees another
     recruiter's private pool, and no pool leaks a scope the viewer can't already see. */
  const pools = React.useMemo(() => {
    if (isOps) return allPools;
    const rid = persona && persona.repId;
    return allPools.filter((p) => !p.ownerId || p.ownerId === rid);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allPools, isOps, personaKey]);
  /* Full-query predicate for a pool's live count - the SAME pipeline the list uses (multi-term AND text +
     facet AND-across/OR-within + freshness cohort + status), evaluated over a candidate row. Freshness is
     read live via candidateFreshness (not the model's freshById, which is scoped to the CURRENT search). */
  const poolMatches = (pq, c) => {
    pq = pq || {};
    if (pq.status && pq.status !== 'All' && c.status !== pq.status) return false;
    const t = (pq.terms || '').trim().toLowerCase();
    const toks = t ? t.split(/\s+/).filter(Boolean) : [];
    if (toks.length) {
      const m = cmeta(c.id);
      const hay = [c.name, c.title, c.company, c.location || m.location, (c.skills || []).join(' ')].filter(Boolean).join(' ').toLowerCase();
      if (!toks.every((tok) => hay.indexOf(tok) > -1)) return false;
    }
    if (!matchesFilters(c, cloneFilters(pq.filters))) return false;
    const fr = pq.freshness || 'all';
    if (fr !== 'all') {
      const st = window.candidateFreshness ? window.candidateFreshness(c).state : null;
      if (st !== fr) return false;
    }
    return true;
  };
  /* Live matching counts, memoized over the persona-scoped set (model.enriched = the bounded working set).
     In scale mode the raw count is scaled up by total/working-set - the SAME honest server-aggregate factor
     the header + facet counts use - so pool counts read large. O(pools x workingSet) once per relevant
     change, never an O(1M) per-render scan. */
  const poolCounts = React.useMemo(() => {
    const rowsB = model.enriched || [];
    const factor = (scaleMode && rowsB.length) ? (totalCount / rowsB.length) : 1;
    const out = {};
    pools.forEach((p) => {
      let n = 0;
      rowsB.forEach((c) => { if (poolMatches(p.query, c)) n += 1; });
      out[p.id] = Math.round(n * factor);
    });
    return out;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pools, model.enriched, storeVer, scaleMode, totalCount]);
  // Which pool (if any) exactly matches the live query - drives the "running" highlight. O(pools), inline.
  const sameQuery = (a, b) => {
    if ((a.status || 'All') !== (b.status || 'All')) return false;
    if ((a.terms || '').trim() !== (b.terms || '').trim()) return false;
    if ((a.freshness || 'all') !== (b.freshness || 'all')) return false;
    const fa = a.filters || {}, fb = b.filters || {};
    return ['source', 'avail', 'owner', 'skills', 'location'].every((k) => {
      const xa = (fa[k] || []).slice().sort(), xb = (fb[k] || []).slice().sort();
      return xa.length === xb.length && xa.every((v, i) => v === xb[i]);
    });
  };
  const currentQuery = { status, terms: query, freshness: freshCohort, filters };
  const activePoolId = (pools.find((p) => sameQuery(p.query || {}, currentQuery)) || {}).id;
  // Run a pool -> restore its full query (search box + facets + freshness + status). A fresh query drops any
  // stale AI re-order.
  const runPool = (p) => {
    const pq = p.query || {};
    setStatus(pq.status || 'All');
    setQuery(pq.terms || '');
    setFreshCohort(pq.freshness || 'all');
    setFilters(cloneFilters(pq.filters));
    setAiRank(null);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Ran saved search "' + p.name + '"', target: p.name, route: 'candidates' });
  };
  const saveCurrentSearch = () => {
    const name = window.prompt && window.prompt('Name this saved search', 'My candidates');
    if (!name || !name.trim()) return;
    const id = 'tp-' + Date.now().toString(36);
    const rid = (persona && persona.repId) ? persona.repId : null;
    const pool = { id, name: name.trim(), ownerId: rid, owner: persona ? persona.name : null, query: captureQuery() };
    window.E8Store.add('talentPools', pool);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Saved search "' + pool.name + '"', target: pool.name, route: 'candidates' });
    showToast('Saved search "' + pool.name + '"', 'Undo', () => window.E8Store.remove('talentPools', id));
  };
  const renamePool = (p) => {
    const name = window.prompt && window.prompt('Rename saved search', p.name);
    if (name == null) return;
    const nm = name.trim();
    if (!nm || nm === p.name) return;
    window.E8Store.set('talentPools', p.id, { name: nm });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Renamed saved search to "' + nm + '"', target: nm, route: 'candidates' });
    showToast('Renamed to "' + nm + '"', 'Undo', () => window.E8Store.set('talentPools', p.id, { name: p.name }));
  };
  const deletePool = (p) => {
    window.E8Store.remove('talentPools', p.id);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Deleted saved search "' + p.name + '"', target: p.name, route: 'candidates' });
    showToast('Deleted "' + p.name + '"', 'Undo', () => window.E8Store.add('talentPools', p));
  };
  const poolProps = { pools, counts: poolCounts, activeId: activePoolId, onRun: runPool, onSave: saveCurrentSearch, onRename: renamePool, onDelete: deletePool };
  /* One-time migration: promote a user's pre-existing localStorage segments (id 'seg-*' - the T2 saved
     views) into owned talent pools via the replayable add op, so upgrading never loses them. Guarded by a
     flag; the 'all'/'ready'/'ai' defaults are seeds, not user data, so they are skipped. */
  React.useEffect(() => {
    try {
      if (localStorage.getItem('e8-tp-migrated-v1')) return;
      const raw = JSON.parse(localStorage.getItem('e8-cand-segments-v1') || 'null');
      const userSegs = Array.isArray(raw) ? raw.filter((s) => s && typeof s.id === 'string' && s.id.indexOf('seg-') === 0) : [];
      const have = (D.talentPools || []).map((p) => p.id);
      const rid = (persona && persona.repId) ? persona.repId : null;
      userSegs.forEach((s) => {
        const id = 'tp-mig-' + s.id;
        if (have.indexOf(id) > -1) return;
        window.E8Store.add('talentPools', {
          id, name: s.label || 'Saved search', ownerId: rid, owner: persona ? persona.name : null,
          query: { status: s.status || 'All', terms: s.terms || '', freshness: s.freshness || 'all', filters: cloneFilters(s.filters) },
        });
      });
      localStorage.setItem('e8-tp-migrated-v1', '1');
    } catch (e) {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const rows = freshSearched.filter((c) => status === 'All' || c.status === status);
  /* On-device semantic search: embed candidate profiles + the query locally (no server), rank by cosine. */
  const buildAiDoc = (c) => {
    const m = cmeta(c.id);
    return [c.title, c.company, (c.skills || []).join(', '), m.location, c.years ? c.years + ' years experience' : '', m.availability ? 'available ' + m.availability : '', c.about].filter(Boolean).join('. ');
  };
  const displayRows = aiRank ? rows.slice().sort((a, b) => (aiRank[b.id] != null ? aiRank[b.id] : -1) - (aiRank[a.id] != null ? aiRank[a.id] : -1)) : rows;
  const topMatch = aiRank ? displayRows[0] : null;
  const runAiSearch = async () => {
    const term = (query || '').trim();
    if (!rows.length) { showToast('No candidates to rank in this view'); return; }
    setAiBusy(true);
    try {
      await E8AI.enable();
      // Rank the already text-filtered set: the box filters live, the AI layer re-orders on top.
      const intent = term || 'best fit for the open roles';
      const scored = await E8AI.rank(intent, rows.map((c) => ({ key: c.id, text: buildAiDoc(c) })));
      const map = {}; scored.forEach((s) => { map[s.key] = s.score; });
      setAiRank(map);
      const top = rows.find((c) => c.id === (scored[0] || {}).key);
      showToast('Ranked ' + rows.length + ' candidates on-device' + (top ? ' · top match ' + top.name : ''));
    } catch (e) {
      showToast('On-device AI could not load in this browser');
    } finally {
      setAiBusy(false);
    }
  };
  const clearAiRank = () => setAiRank(null);
  const counts = Object.fromEntries(D.candidateStatuses.map((s) => [s, s === 'All' ? freshSearched.length : freshSearched.filter((c) => c.status === s).length]));
  const activeFilters = ['source', 'avail', 'owner', 'skills', 'location'].reduce((n, k) => n + (filters[k] || []).length, 0);
  const toggleFilter = (group, val) => setFilters((f) => { const cur = f[group] || []; return { ...f, [group]: cur.indexOf(val) > -1 ? cur.filter((x) => x !== val) : [...cur, val] }; });
  /* R110 T2: the facet groups fed to the desktop rail + the mobile sheet (identical counts). Skills,
     Location, Availability, Source are always shown; Owner only for ops (a recruiter's book is a single
     owner, so the facet would be a no-op). Freshness + Status are deliberately absent - they keep their
     richer single-select SubTabs (cohort re-verify campaign / funnel) and compose via freshCohort/status,
     so there is never a double control for the same dimension. */
  const facetGroups = [
    { key: 'skills', label: 'Skills', values: railFacets.skills },
    { key: 'location', label: 'Location', values: railFacets.location },
    { key: 'avail', label: 'Availability', values: railFacets.availability },
    { key: 'source', label: 'Source', values: railFacets.source },
  ].concat(isOps ? [{ key: 'owner', label: 'Owner', values: railFacets.owner }] : []);
  /* R110 T2: the true-scale count header. The scoped working set (model.enriched) is a bounded sample that
     REPRESENTS candidateTotalCount() candidates in scale mode; any subset scales up by total/working-set -
     the same honest server-aggregate factor the facet counts use. With no search/facet/status/freshness
     narrowing, matched == the total, so the header reads the pure scale headline; otherwise it reads
     "N matching · showing <page> · of <total>" - never a "showing 50 of 100" that hides the scale. */
  const headerBase = mode === 'board' ? freshSearched : rows;
  // R110 fix: the header "matching" count uses the SAME shared scaleFactor as the facet rail (fixed
  // scoped-set denominator), so a chip's count and the header stay reconciled and monotonic as you narrow.
  const matched = Math.round(headerBase.length * scaleFactor);
  const anyNarrowing = terms.length > 0 || activeFilters > 0 || status !== 'All' || freshCohort !== 'all';
  const pageShown = mode === 'board' ? headerBase.length : Math.min(rows.length, 50);

  React.useEffect(() => {
    if (!isMobile) { mobileAct.setAction(null); return; }
    mobileAct.setAction({ icon: 'filter_list', label: 'Filters', badge: activeFilters || undefined, onClick: openFilterSheet });
    return () => mobileAct.setAction(null);
  }, [isMobile, activeFilters]);

  return (
    <React.Fragment>
      <Topbar
        crumbs={[{ label: 'Candidates' }]}
        actions={
          <React.Fragment>
            {/* R104 Task 4 / R105: shared "Viewing as" persona switch (same control as Consultants / Sequences) -
                drives which candidates are scoped as mine and whether the ops Mine|All|Unassigned scope shows. */}
            <window.PersonaViewAs />
            <DSc.InsightsButton
              items={insights.map((i) => ({
                id: i.id,
                text: <span><b>{i.strong}</b>{i.rest}</span>,
                action: { label: i.action, onClick: () => (i.id === 'i2' ? navigate('voice/r-okafor') : showToast('3 candidates enrolled in "Memphis JVM re-engage"', 'Open', () => navigate('sequences'))) },
              }))}
              onDismiss={(id) => setInsights(insights.filter((i) => i.id !== id))}
            />
            <DSc.MenuButton label="View" icon="grid_view" size="sm" variant="ghost" align="right"
              items={[
                { icon: 'tune', label: 'Edit columns & layout…', onClick: () => showToast('Column & view settings open here') },
                { icon: 'bookmark_add', label: 'Save current search…', onClick: saveCurrentSearch },
              ]} />
            <DSc.Button variant="secondary" size="sm" icon="download">Import</DSc.Button>
            <DSc.Button variant="primary" size="sm" icon="add" onClick={() => (cap ? cap.open({ target: 'candidate' }) : window.e8OpenCreate && window.e8OpenCreate('candidate'))}>Add candidate</DSc.Button>
          </React.Fragment>
        }
      />
      <div className="e8-content">
        <div className="e8-page" style={{ position: 'relative' }}>
          {/* R104 Task 4: ops-only ownership scope - Mine (owned) | All (whole desk) | Unassigned (orphaned,
              needs an owner). Recruiter/CSM see only their own book, so the toggle would be a no-op for them
              and is hidden - "All" is never reachable for a non-ops persona, so no one else's rows leak. */}
          {isOps ? (
            <div style={{ margin: '2px 0 10px' }}>
              <DSc.SubTabs
                items={[
                  { id: 'mine', label: 'Mine', count: model.opsOwnedCount },
                  { id: 'all', label: 'All candidates', count: (D.candidates || []).length },
                ].concat(model.orphanCount ? [{ id: 'unassigned', label: 'Unassigned · needs owner', count: model.orphanCount }] : [])}
                active={scope}
                onChange={setScope}
              />
            </div>
          ) : null}

          {/* R110 T3: saved searches / talent pools now live in the facet rail (desktop) + the filter sheet
              (mobile), so the old localStorage saved-segments Tabs are retired for a single, persistent,
              persona-scoped surface. */}

          {/* Ops orphan pool: a departed recruiter's candidates need a new owner. Mirrors the R101
              consultant Unassigned attention card + reassign; the write is a replayable plain-array op. */}
          {isOps && scope === 'unassigned' && model.orphanCount ? (
            <div className="e8-card e8-cown-attn" style={{ marginTop: 12 }}>
              <span className="e8-cown-attn-glyph material-symbols-outlined" aria-hidden="true">person_alert</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div className="e8-cown-attn-title">{model.orphanCount} candidate{model.orphanCount === 1 ? '' : 's'} lost their owner - reassign them to keep coverage.</div>
                <div className="e8-cown-attn-sub">The owning recruiter departed. Assign each to an active owner so nothing goes uncovered.</div>
              </div>
            </div>
          ) : null}

          {/* R110 T2: search is primary - a prominent multi-term box (whitespace terms AND-combine across
              name/title/skills/company/location). The always-visible facet rail (below) carries source/
              availability/owner - so the old Filters dropdown is gone; the on-device AI rank stays as an
              explicit re-order layered on the text filter. */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '14px 0 4px', flexWrap: 'wrap' }}>
            <div style={{ flex: '0 1 560px', minWidth: 320 }}>
              <DSc.Input icon="search" size="lg" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name, title, skills, company, location…" kbd="⌘K" />
            </div>
            <DSc.Button
              variant={aiRank ? 'secondary' : 'ghost'}
              size="sm"
              icon={aiState.status === 'loading' ? 'progress_activity' : aiRank ? 'close' : 'auto_awesome'}
              onClick={aiRank ? clearAiRank : runAiSearch}
              disabled={aiBusy}
              title="Rank candidates by semantic relevance, on your device"
            >
              {aiState.status === 'loading' ? (aiState.detail || 'Loading on-device AI…') : aiRank ? 'Clear AI ranking' : 'Rank with on-device AI'}
            </DSc.Button>
            <span className="e8-list-toolbar-desktop" style={{ display: 'contents' }}>
            <DSc.Button variant="ghost" size="sm" icon="swap_vert">Sorted by last activity</DSc.Button>
            <DSc.SubTabs
              items={[{ id: 'list', label: 'List' }, { id: 'board', label: 'Board' }]}
              active={mode}
              onChange={setMode}
            />
            </span>
          </div>
          {aiRank ? (
            <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--ui-text-xs)', color: 'var(--ui-daybreak-text)', margin: '6px 0 10px' }}>
              <span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
              Ranked on-device by relevance{topMatch ? ' · top match ' + topMatch.name : ''}
            </div>
          ) : <div style={{ marginBottom: 8 }}></div>}

          {/* R110 T2: the true-scale count header. Headline = the honest total; when anything narrows, the
              matched (server-aggregate) count + the current page, always with the total in view - never a
              bare "showing 50 of 100" that hides the ~1M scale. */}
          <div className="e8-fac-count" aria-live="polite">
            {anyNarrowing ? (
              <React.Fragment>
                <span className="e8-fac-count-n tnum">{matched.toLocaleString()}</span>
                <span className="e8-fac-count-l">matching</span>
                <span className="e8-fac-count-sep" aria-hidden="true">·</span>
                <span className="e8-fac-count-sub">showing {pageShown.toLocaleString()}</span>
                <span className="e8-fac-count-sep" aria-hidden="true">·</span>
                <span className="e8-fac-count-sub">of {totalCount.toLocaleString()} candidate{totalCount === 1 ? '' : 's'}</span>
              </React.Fragment>
            ) : (
              <React.Fragment>
                <span className="e8-fac-count-n tnum">{totalCount.toLocaleString()}</span>
                {/* R110 fix: qualify the whole-pool total so the big number reads as the company-wide
                    searchable pool, not the persona-scoped list under it (esp. demo's small book). */}
                <span className="e8-fac-count-l">candidate{totalCount === 1 ? '' : 's'} in the talent pool</span>
                {scaleMode ? <span className="e8-fac-count-sub">· search or pick a facet to narrow</span> : null}
              </React.Fragment>
            )}
          </div>

          {/* R110 T2: the search-first body - an always-visible faceted rail (desktop) beside the results.
              On mobile the rail routes into the CandidateFilterSheet (same counts); the layout collapses to
              a single column. */}
          <div className={'e8-fac-layout' + (isMobile ? ' is-mobile' : '')}>
            {!isMobile ? (
              <aside className="e8-fac-rail" aria-label="Refine candidates">
                {/* R110 T3: saved searches / talent pools lead the rail; the facets refine below. */}
                <TalentPoolPanel
                  pools={pools} counts={poolCounts} activeId={activePoolId}
                  onRun={runPool} onSave={saveCurrentSearch} onRename={renamePool} onDelete={deletePool}
                />
                <div className="e8-fac-railhead e8-fac-railhead-refine">
                  <span className="e8-fac-railhead-t">Refine</span>
                  {activeFilters ? <span className="e8-fac-railhead-n">{activeFilters} active</span> : null}
                  {activeFilters ? <button type="button" className="e8-fac-clear" onClick={() => setFilters(candEmptyFilters())}>Clear all</button> : null}
                </div>
                <CandidateFacetGroups groups={facetGroups} filters={filters} toggleFilter={toggleFilter} />
              </aside>
            ) : null}
          <div className="e8-fac-main">

          {/* R106 T3: data-freshness cohorts - the "most of your base is stale by default" view. Counts are
              live over the scoped+searched set; selecting one narrows the list + board + count header and
              composes with the status tab, facets, search and persona scope. When a cohort is active, a
              bulk "Request re-verification" campaign fans the record's request across the shown cohort. */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', margin: '14px 0 2px' }}>
            <span style={{ fontSize: 'var(--ui-text-xs)', fontWeight: 600, color: 'var(--ui-text-tertiary)', textTransform: 'uppercase', letterSpacing: '.04em' }}>Freshness</span>
            <DSc.SubTabs
              items={[
                { id: 'all', label: 'All', count: freshCounts.all },
                { id: 'never', label: 'Never verified', count: freshCounts.never },
                { id: 'stale', label: 'Stale', count: freshCounts.stale },
                { id: 'aging', label: 'Aging', count: freshCounts.aging },
                { id: 'verified', label: 'Verified', count: freshCounts.verified },
              ]}
              active={freshCohort}
              onChange={setFreshCohort}
            />
            {freshCohort !== 'all' && rows.length ? (
              <DSc.Button variant="secondary" size="sm" icon="mark_email_read" onClick={() => requestReverifyBulk(rows.map((r) => r.id))}>
                Request re-verification · {rows.length}
              </DSc.Button>
            ) : null}
          </div>

          {/* R111: a slim entry to the dedicated Duplicates page. The full review queue moved off this list
              (it can be thousands of pairs at scale) onto its own paged screen; this is just a persona-
              scoped count that routes there when there is something to review. */}
          {dupPairs.length ? (
            <button type="button" className="e8-dupentry" onClick={() => navigate('duplicates')} aria-label={'Review ' + dupPairs.length + ' possible duplicates'}>
              <span className="material-symbols-outlined e8-dupentry-ico" aria-hidden="true">content_copy</span>
              <span className="e8-dupentry-t"><b className="tnum">{dupPairs.length.toLocaleString()}</b> possible {dupPairs.length === 1 ? 'duplicate' : 'duplicates'} to review</span>
              <span className="e8-dupentry-go">Review<span className="material-symbols-outlined e8-dupentry-arrow" aria-hidden="true">chevron_right</span></span>
            </button>
          ) : null}

          {/* Status filter tabs - list mode only */}
          {mode === 'list' ? (
            <div style={{ margin: '14px 0 10px' }}>
              <DSc.SubTabs
                items={D.candidateStatuses.map((s) => ({ id: s, label: s, count: counts[s] }))}
                active={status}
                onChange={setStatus}
              />
            </div>
          ) : null}

          {/* R-hierarchy: the pipeline funnel was removed - it restated the Status SubTabs' counts
              (same stages, same numbers) and doubled the filter chrome above the table. The SubTabs
              above own status filtering (they also carry All + Archived). */}

          {mode === 'board' ? (
            <CandidatesBoard rows={freshSearched} />
          ) : isMobile ? (
            <CandidateCardList rows={displayRows} status={status} />
          ) : (
            <React.Fragment>
              <ConfigurableTable
                listKey="candidates"
                selectable
                selected={sel}
                onSelect={setSel}
                density={density}
                onRowClick={(r) => navigate('candidate/' + r.id + '?q=status-' + status)}
                /* Column order leads with the fit signals a recruiter scans on (match, AI screen,
                   stage, experience, skills) and pushes metadata right; contact PII + owner are
                   hidden by default (one click away in the Columns menu) so the default view fits
                   without heavy horizontal scroll. */
                registry={[
                  { key: 'name', label: 'Name', locked: true, sortable: true, sortValue: (r) => r.name, render: (r) => <NameCell name={r.name} sub={`${r.title} · ${r.company}`} linkedin={r.linkedin} /> },
                  { key: 'match', label: 'Best job match', icon: 'join_inner', render: (r) => {
                    const bm = cmeta(r.id).bestMatch;
                    const cj = bm ? ((D.candidateJobs || {})[r.id] || []).find((e) => e.jobId === bm.jobId) : null;
                    return !bm ? <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span> : (
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
                        <DSc.MatchBadge tier={bm.tier} score={cj ? cj.score : undefined} dot />
                        <a className="e8-link" style={{ fontSize: 'var(--ui-text-sm)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={(e) => { e.stopPropagation(); navigate('job/' + bm.jobId + '/matching'); }}>
                          {bm.job} · {bm.client}
                        </a>
                      </span>
                    );
                  } },
                  /* R104 Task 4: surface the applicant AI-screen verdict carried onto the candidate
                     (candidateMeta.verdict, written by Task 5's qualify path; also tolerates a verdict
                     stamped on the row). Until that lands the value is absent, so render a plain dash -
                     never crash. Reuses the applicants screen .e8-ap-verdict token styling. */
                  { key: 'verdict', label: 'AI screen', icon: 'auto_awesome', render: (r) => {
                    const v = cmeta(r.id).verdict || r.verdict || r.aiScreen;
                    if (!v) return <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span>;
                    const t = CAND_VERDICT_TIER[v] || 3;
                    return (
                      <span className="e8-ap-verdict" style={{ color: 'var(--ui-tier-' + t + ')', background: 'var(--ui-tier-' + t + '-tint)', borderColor: 'var(--ui-tier-' + t + '-br)' }}>
                        <span className="e8-ap-verdict-dot" style={{ background: 'var(--ui-tier-' + t + '-dot)' }}></span>
                        {v}
                      </span>
                    );
                  } },
                  { key: 'stage', label: 'Stage', icon: 'linear_scale', sortable: true, sortValue: (r) => r.status, render: (r) => (
                    <DSc.Badge tone={{ Sourced: 'neutral', Engaged: 'accent', Screening: 'info', Submitted: 'success', Archived: 'neutral' }[r.status] || 'neutral'} dot>{r.status}</DSc.Badge>
                  ) },
                  { key: 'exp', label: 'Experience', icon: 'work', sortable: true, sortValue: (r) => r.years || 0, render: (r) => <ExpCell title={r.title} company={r.company} years={r.years} more={r.moreRoles} /> },
                  { key: 'skills', label: 'Skills', icon: 'sell', render: (r) => <DSc.SkillChips skills={r.skills} max={2} /> },
                  /* R106 T3: data-freshness lifecycle on the list - the same tone Badge the record renders,
                     from candidateFreshness (derived once in the model memo). A 'requested' sub-badge shows
                     when a re-verify (single or bulk) is in flight. Sort by age (never = most stale). */
                  { key: 'fresh', label: 'Freshness', icon: 'verified', sortable: true,
                    sortValue: (r) => { const f = freshById[r.id]; return f ? (f.ageDays != null ? f.ageDays : 1e9) : -1; },
                    render: (r) => {
                    const f = freshById[r.id] || (window.candidateFreshness ? window.candidateFreshness(r) : null);
                    if (!f) return <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span>;
                    const requested = cmeta(r.id).verifyState === 'requested';
                    return (
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
                        <DSc.Badge tone={f.tone} dot>{f.label}</DSc.Badge>
                        {requested ? <DSc.Badge tone="info" dot>requested</DSc.Badge> : null}
                      </span>
                    );
                  } },
                  { key: 'rate', label: 'Rate', icon: 'payments', num: true, render: (r) => r.rate ? <span className="tnum">{r.rate}</span> : <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span> },
                  { key: 'last', label: 'Last activity', icon: 'schedule', render: (r) => (
                    <span title={r.lastNote}>
                      <span className="tnum" style={{ color: 'var(--ui-text-secondary)' }}>{r.last}</span>
                      {r.lastBy === 'ai' ? <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-daybreak-text)' }}> · AI surfaced</span> : null}
                    </span>
                  ) },
                  { key: 'email', label: 'Personal email', icon: 'alternate_email', defaultHidden: true, render: (r) => <CellChip icon="mail">{r.email}</CellChip> },
                  { key: 'phone', label: 'Personal phone', icon: 'call', defaultHidden: true, render: (r) => r.phone ? <CellChip icon="call">{r.phone}</CellChip> : <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span> },
                  { key: 'owner', label: 'Owner', icon: 'person', defaultHidden: true, render: (r) => <ActorChip name={r.owner} /> },
                ]}
                rows={displayRows}
                empty={<div style={{ maxWidth: 460, margin: '40px auto' }}>
                  {scopedRows.length === 0 ? (
                    /* R104 Task 4: a genuinely empty scope is onboarding, not a filter problem. */
                    <DSc.EmptyState icon="person_add"
                      title={isOps ? 'No candidates yet' : 'No candidates assigned to you yet'}
                      body={isOps ? 'Add your first candidate, or import a list, to start building the pipeline.' : 'Candidates you own show here. Add one, or switch "Viewing as" to Ops to see the whole desk.'}
                      cta={<DSc.Button variant="primary" icon="add" onClick={() => (cap ? cap.open({ target: 'candidate' }) : window.e8OpenCreate && window.e8OpenCreate('candidate'))}>Add candidate</DSc.Button>} />
                  ) : (
                    <DSc.EmptyState icon="person_search" title="No candidates match"
                      body={(query || activeFilters) ? 'No candidates match the current search and filters. Clear them to see the rest of this view.' : 'No candidates in “' + status + '”. Switch status to see the rest.'}
                      cta={<DSc.Button variant="secondary" icon="filter_alt_off" onClick={() => { setStatus('All'); setQuery(''); setFreshCohort('all'); setFilters(candEmptyFilters()); }}>Clear search & filters</DSc.Button>} />
                  )}
                </div>}
                rowActions={(r) => (
                  <React.Fragment>
                    <DSc.Button variant="ghost" size="sm" icon="send" onClick={(e) => { e.stopPropagation(); submitCand(r.id); }}>Submit</DSc.Button>
                    <WorkspacePinButton type="candidate" id={r.id} compact />
                    {isOps
                      ? <DSc.Button variant="ghost" size="sm" icon="swap_horiz" onClick={(e) => { e.stopPropagation(); setReassign(r); }}>Reassign</DSc.Button>
                      : <DSc.Button variant="ghost" size="sm" icon="more_horiz" iconOnly title="More"></DSc.Button>}
                  </React.Fragment>
                )}
              />
              <DSc.BulkBar
                count={sel.length}
                actions={[
                  { icon: 'compare_arrows', label: 'Compare', onClick: () => setCompareOpen(true) },
                  { icon: 'send', label: 'Submit to job', onClick: () => {
                    /* R104 nit A: heterogeneous multiselect has no single best-fit job - open the real
                       submission dialog for the first selected candidate (prefilled to their best-fit job)
                       and note that submissions go out one at a time. No fake JO-44219 toast. */
                    const first = sel[0];
                    if (first) {
                      if (sel.length > 1) showToast('Submissions go out one candidate at a time - starting with ' + ((byId[first] || {}).name || 'the first selected'));
                      submitCand(first);
                    }
                    setSel([]);
                  } },
                  { icon: 'campaign', label: 'Add to sequence', onClick: () => { showToast(`${sel.length} added to "Memphis JVM re-engage"`, 'Open', () => navigate('sequences')); setSel([]); } },
                  /* R106 T3: bulk re-verify campaign - same replayable request the record + cohort button fire. */
                  { icon: 'mark_email_read', label: 'Request re-verification', onClick: () => requestReverifyBulk(sel) },
                  { icon: 'archive', label: 'Archive', onClick: () => { showToast(`${sel.length} archived`, 'Undo'); setSel([]); } },
                ]}
                onClear={() => setSel([])}
              />
              {compareOpen && sel.length ? (() => {
                const STAGE_TONE = { Sourced: 'neutral', Engaged: 'accent', Screening: 'info', Submitted: 'success', Archived: 'neutral' };
                const cands = sel.map((id) => ({ ...byId[id], ...(D.candidates.find((c) => c.id === id) || {}), _m: cmeta(id) })).filter((c) => c.id);
                const attrs = [
                  ['Best match', (c) => {
                    const bm = c._m.bestMatch;
                    if (!bm) return <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span>;
                    const cj = ((D.candidateJobs || {})[c.id] || []).find((e) => e.jobId === bm.jobId);
                    return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--ui-text-sm)' }}><DSc.MatchBadge tier={bm.tier} score={cj ? cj.score : undefined} dot />{bm.job}</span>;
                  }],
                  ['Title', (c) => <span>{c.title}<span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{c.company}</span></span>],
                  ['Experience', (c) => (c.years || '-') + ' yrs'],
                  ['Rate', (c) => c.rate || '-'],
                  ['Availability', (c) => c._m.availability || '-'],
                  ['Source', (c) => c._m.source || '-'],
                  ['Stage', (c) => <DSc.Badge tone={STAGE_TONE[c.status] || 'neutral'} dot>{c.status}</DSc.Badge>],
                  ['Skills', (c) => <DSc.SkillChips skills={c.skills || []} max={4} />],
                  ['Owner', (c) => <ActorChip name={c.owner} />],
                ];
                return (
                  <div className="e8-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setCompareOpen(false); }}>
                    <div ref={compareRef} className="e8-compare" role="dialog" aria-modal="true" aria-label="Compare candidates">
                      <div className="e8-compare-head">
                        <span style={{ fontSize: 'var(--ui-text-md)', fontWeight: 600 }}>Compare candidates</span>
                        <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{cands.length} selected</span>
                        <DSc.Button variant="ghost" size="sm" icon="close" iconOnly title="Close" onClick={() => setCompareOpen(false)}></DSc.Button>
                      </div>
                      <div className="e8-compare-grid" style={{ gridTemplateColumns: '128px repeat(' + cands.length + ', minmax(150px, 1fr))' }}>
                        <div className="e8-compare-corner"></div>
                        {cands.map((c) => (
                          <div key={c.id} className="e8-compare-person">
                            <DSc.Avatar name={c.name} size="sm" />
                            <a className="e8-link" style={{ fontWeight: 600, fontSize: 'var(--ui-text-sm)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => navigate('candidate/' + c.id)}>{c.name}</a>
                          </div>
                        ))}
                        {attrs.map(([label, fn]) => (
                          <React.Fragment key={label}>
                            <div className="e8-compare-k">{label}</div>
                            {cands.map((c) => <div key={c.id + label} className="e8-compare-v">{fn(c)}</div>)}
                          </React.Fragment>
                        ))}
                      </div>
                      <div className="e8-compare-foot">
                        <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', flex: 1 }}>Side-by-side from the matching engine and enriched profiles.</span>
                        <DSc.Button variant="secondary" size="sm" icon="send" onClick={() => {
                          /* R104 nit A: open the real submission dialog for the first compared candidate
                             (their best-fit job) rather than a fake JO-44219 toast; the rest go one at a time. */
                          const first = cands[0];
                          if (first) {
                            if (cands.length > 1) showToast('Submissions go out one candidate at a time - starting with ' + first.name);
                            submitCand(first.id);
                          }
                          setCompareOpen(false); setSel([]);
                        }}>Submit all</DSc.Button>
                      </div>
                    </div>
                  </div>
                );
              })() : null}
              <div className="e8-listfoot">
                <span className="tnum">{rows.length}</span> candidate{rows.length === 1 ? '' : 's'}
                {status !== 'All' ? <span style={{ color: 'var(--ui-text-tertiary)' }}> · {status.toLowerCase()}</span> : null}
                {freshCohort !== 'all' ? <span style={{ color: 'var(--ui-text-tertiary)' }}> · {FRESH_COHORT_LABEL[freshCohort]}</span> : null}
              </div>
            </React.Fragment>
          )}
          </div>{/* /.e8-fac-main */}
          </div>{/* /.e8-fac-layout */}
          <CandidateFilterSheet
            open={filterSheetOpen}
            onClose={() => setFilterSheetOpen(false)}
            D={D}
            status={status} setStatus={setStatus}
            filters={filters} setFilters={setFilters} toggleFilter={toggleFilter}
            facetGroups={facetGroups}
            poolProps={poolProps}
          />
          {reassign ? <CandidateReassignDialog cand={reassign} onClose={() => setReassign(null)} showToast={showToast} /> : null}
          {reverifyGate ? (
            <ReverifyGateDialog
              eligible={reverifyGate.eligible} excluded={reverifyGate.excluded} byId={byId}
              onClose={() => setReverifyGate(null)}
              onSend={(include) => {
                const { eligible, excluded } = reverifyGate;
                if (include) {
                  sendReverify(eligible.concat(excluded), { excludedCount: excluded.length, message: 'Re-verification requested for ' + (eligible.length + excluded.length) + ' - ' + excluded.length + ' with a context-light note' });
                } else {
                  sendReverify(eligible, { message: eligible.length ? 'Re-verification requested for ' + eligible.length + ' - ' + excluded.length + ' excluded, no prior touchpoint' : '' });
                }
                setReverifyGate(null);
              }}
            />
          ) : null}
        </div>
      </div>
    </React.Fragment>
  );
}

/* R105 Pass 2a: the ONE reassign-owner dialog. The candidate / sequence / consultant flows all
   render this (window.E8ReassignDialog) - it owns the DS Modal shell, the "New owner" radio picker
   and the footer + selection state. Each caller passes the subject preview node, the owner options
   ([{ key, name, ownerId?, sub }]), the current key, the hint copy and an onReassign(selectedOption)
   that runs its own (domain-specific) persistence + undo toast. Reuses the .e8-cown-dlg shell and the
   enroll-option radio rows from app.css - no bespoke chrome, and the Modal gives it a focus trap + Esc. */
function ReassignDialog({ title = 'Reassign owner', ariaLabel, subjectLabel, subject, owners, currentKey, hint, pickerHint, confirmLabel = 'Reassign', onReassign, onClose }) {
  const [sel, setSel] = React.useState(currentKey);
  return (
    <DSc.Modal title={title} ariaLabel={ariaLabel || title} closeTitle="Cancel" onClose={onClose} className="e8-cown-dlg">
      <div style={{ padding: '14px 16px', display: 'grid', gap: 14 }}>
        <div>
          <div className="e8-sectionlabel" style={{ marginBottom: 7 }}>{subjectLabel}</div>
          {subject}
        </div>
        <div>
          <div className="e8-sectionlabel" style={{ marginBottom: 7 }}>New owner</div>
          <div className="e8-seq-enroll-opts" role="radiogroup" aria-label="New owner">
            {(owners || []).map((o) => {
              const on = sel === o.key;
              return (
                <div key={o.key} className={'e8-seq-enroll-opt' + (on ? ' on' : '')} role="radio" aria-checked={on} tabIndex={0}
                  onClick={() => setSel(o.key)}
                  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSel(o.key); } }}>
                  <DSc.Avatar name={o.name} size="sm" />
                  <span style={{ minWidth: 0, flex: 1 }}>
                    <span className="e8-seq-enroll-lbl">{o.name}</span>
                    <span className="e8-seq-enroll-sub">{o.sub}</span>
                  </span>
                  <span className="material-symbols-outlined e8-seq-enroll-chk" aria-hidden="true">{on ? 'radio_button_checked' : 'radio_button_unchecked'}</span>
                </div>
              );
            })}
          </div>
          {pickerHint || null}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', flex: 1 }}>{hint}</span>
          <DSc.Button variant="ghost" size="sm" onClick={onClose}>Cancel</DSc.Button>
          <DSc.Button variant="primary" size="sm" icon="swap_horiz" onClick={() => onReassign((owners || []).find((o) => o.key === sel))}>{confirmLabel}</DSc.Button>
        </div>
      </div>
    </DSc.Modal>
  );
}

/* R109 T4: the re-verify eligibility gate. Only shown when a bulk re-verify campaign has excluded some
   candidates (never-engaged / AI-screen-only - no real touchpoint). Makes the exclusion VISIBLE (the
   "N excluded - no prior touchpoint" line) and the include an explicit, off-by-default opt-in, so a cold
   re-verify is never the default. onSend(include) sends the eligible set (include=false) or the eligible
   set plus the excluded with a context-light note (include=true). Reuses the DS Modal (focus trap + Esc);
   the opt-in is a native checkbox in a label (keyboard-accessible). Token-based styling, no hex. */
function ReverifyGateDialog({ eligible, excluded, byId, onClose, onSend }) {
  const [include, setInclude] = React.useState(false);
  const nameOf = (id) => (byId[id] && byId[id].name) || id;
  const total = eligible.length + (include ? excluded.length : 0);
  const excludedNames = excluded.map(nameOf);
  return (
    <DSc.Modal title="Request re-verification" icon="mark_email_read" ariaLabel="Request re-verification" closeTitle="Cancel" onClose={onClose} className="e8-reverify-dlg">
      <div style={{ padding: '14px 16px', display: 'grid', gap: 14 }}>
        <div style={{ fontSize: 'var(--ui-text-base)', lineHeight: 1.5, color: 'var(--ui-text-secondary)' }}>
          {eligible.length
            ? <span><b style={{ color: 'var(--ui-text)' }}>{eligible.length}</b> candidate{eligible.length === 1 ? '' : 's'} with a real touchpoint {eligible.length === 1 ? 'gets' : 'get'} a Stand8 re-verify request that references how you last connected.</span>
            : <span>None of the selected candidates have a prior touchpoint on file.</span>}
        </div>
        <div role="note" style={{ display: 'flex', gap: 10, padding: '10px 12px', borderRadius: 10, background: 'color-mix(in srgb, var(--ui-warning) 10%, transparent)', border: '1px solid color-mix(in srgb, var(--ui-warning) 30%, transparent)' }}>
          <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 20, color: 'var(--ui-warning-text)' }}>info</span>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontWeight: 600, color: 'var(--ui-text)' }}>{excluded.length} excluded - no prior touchpoint</div>
            <div style={{ fontSize: 'var(--ui-text-sm)', lineHeight: 1.5, color: 'var(--ui-text-secondary)', marginTop: 2 }}>These have only an AI screen or a sourced record with no human contact. A cold "re-verify your profile" can feel unexpected, so they are held back by default.</div>
            {excludedNames.length ? <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', marginTop: 5 }}>{excludedNames.slice(0, 4).join(', ')}{excludedNames.length > 4 ? ' +' + (excludedNames.length - 4) + ' more' : ''}</div> : null}
          </div>
        </div>
        <label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 'var(--ui-text-base)', color: 'var(--ui-text)', cursor: 'pointer' }}>
          <input type="checkbox" checked={include} onChange={(e) => setInclude(e.target.checked)} style={{ marginTop: 2, accentColor: 'var(--ui-accent)' }} />
          <span>Include the {excluded.length} with no touchpoint <span style={{ color: 'var(--ui-text-tertiary)' }}>- sends a clearly context-light message</span></span>
        </label>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', flex: 1 }}>Every request is undoable.</span>
          <DSc.Button variant="ghost" size="sm" onClick={onClose}>Cancel</DSc.Button>
          <DSc.Button variant="primary" size="sm" icon="mark_email_read" disabled={!total} onClick={() => onSend(include)}>
            {total ? 'Send ' + total + ' request' + (total === 1 ? '' : 's') : 'Nothing to send'}
          </DSc.Button>
        </div>
      </div>
    </DSc.Modal>
  );
}

/* R104 Task 4: ops-only reassignment of a candidate's owning recruiter (thin adapter over the shared
   ReassignDialog). Owner options are the active reps (departed excluded) plus the CSM 'Dana Whitfield'
   (owner name only, ownerId null), defaulting to the candidate's current owner. On confirm it snapshots
   the prior {owner, ownerId}, writes the new pair through the replayable plain-array store op (E8Store.set
   persists across reload AND re-scopes the list - the row leaves a non-owner's Mine and enters the new
   owner's), logs an audit entry, and raises a single Undo toast that restores the snapshot. Gated to ops
   by the caller. */
function CandidateReassignDialog({ cand, onClose, showToast }) {
  const D = window.E8DATA;
  const options = (D.reps || [])
    .filter((r) => !r.departed)
    .map((r) => ({ key: r.id, name: r.name, ownerId: r.id, sub: r.role }))
    .concat([{ key: 'csm-dana', name: 'Dana Whitfield', ownerId: null, sub: 'Sales' }]);
  const currentKey = (function () {
    if (cand.ownerId) { const m = options.find((o) => o.ownerId === cand.ownerId); if (m) return m.key; }
    const byName = options.find((o) => o.name === cand.owner);
    return byName ? byName.key : (options[0] ? options[0].key : '');
  })();
  const doReassign = (opt) => {
    if (!opt) return;
    if (opt.name === cand.owner && (opt.ownerId || null) === (cand.ownerId || null)) { onClose(); return; }
    const prev = { owner: cand.owner, ownerId: cand.ownerId != null ? cand.ownerId : null };
    window.E8Store.set('candidates', cand.id, { owner: opt.name, ownerId: opt.ownerId });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Reassigned ' + cand.name + ' to ' + opt.name, target: cand.name, route: 'candidate/' + cand.id });
    onClose();
    showToast('Reassigned ' + cand.name + ' to ' + opt.name, 'Undo', () => {
      window.E8Store.set('candidates', cand.id, { owner: prev.owner, ownerId: prev.ownerId });
    });
  };
  return (
    <ReassignDialog
      ariaLabel="Reassign candidate owner"
      subjectLabel="Candidate"
      subject={
        <div className="e8-cown-dlg-row">
          <DSc.Avatar name={cand.name} size="sm" />
          <span style={{ minWidth: 0, flex: 1 }}>
            <span style={{ display: 'block', fontWeight: 500, fontSize: 'var(--ui-text-base)' }}>{cand.name}</span>
            <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{(cand.title || 'Candidate') + ' · currently ' + (cand.owner || 'unowned')}</span>
          </span>
        </div>
      }
      owners={options}
      currentKey={currentKey}
      hint="Reassigning moves the candidate into the new owner's book and clears the orphan flag."
      onReassign={doReassign}
      onClose={onClose}
    />
  );
}

/* ============================== CANDIDATE DETAIL ============================== */
/* Merge explicit candidate profile data with sensible derived fallbacks so every
   candidate renders a full Spott-style record, even those without authored profiles. */
function profileOf(c, listMeta) {
  const D = window.E8DATA;
  const p = (D.candidateProfiles || {})[c.id] || {};
  const derivedApps = D.submissions
    .filter((s) => s.cand === c.name)
    .map((s) => ({ job: s.job, jobId: s.jobId, client: s.client, stage: D.submissionStages[s.stage], tone: s.resp ? s.resp.tone : 'neutral', when: (s.when || '').replace(' ago', '') }));
  return {
    about: p.about || `${c.title} at ${c.company}${c.location ? `, based in ${c.location}` : ''}${c.years ? `, with ${c.years} years of experience` : ''}. Full bio syncs from LinkedIn once the profile is enriched.`,
    aboutSource: p.aboutSource || 'ai',
    languages: p.languages || ['English'],
    techStack: p.techStack || c.skills || [],
    mainContact: p.mainContact || listMeta.owner || 'Sarah Kim',
    cv: p.cv || { file: c.name.split(' ').reverse().join('_') + '_CV.pdf', updated: '-', source: 'resume' },
    lists: p.lists || [],
    campaigns: p.campaigns || [],
    applications: p.applications && p.applications.length ? p.applications : derivedApps,
    experience: p.experience || [
      { role: c.title, co: c.company, span: 'Current', sub: null },
    ],
    experienceParsed: !!p.experience,
  };
}

/* R104 T2: shared activity-timeline plumbing. A record's timeline is one sorted merge of its
   own base events + its structured notes (by {refType, refId}) + its submissions (by candId),
   newest first. `ts` is an epoch; where an event only has a display `when` string we derive a
   stable epoch from it so everything interleaves. Fresh notes stamp Date.now() -> float to top. */
function e8WhenToTs(when) {
  const now = Date.now(); const d = 864e5; const h = 36e5;
  const w = String(when || '').toLowerCase();
  if (!w) return now - 7 * d;
  if (w.indexOf('just now') > -1) return now;
  if (w.indexOf('today') > -1) return now - 3 * h;
  if (w.indexOf('yesterday') > -1) return now - d;
  let mm;
  if ((mm = w.match(/(\d+)\s*mo\b/))) return now - parseInt(mm[1], 10) * 30 * d;
  if ((mm = w.match(/(\d+)\s*min\b/))) return now - parseInt(mm[1], 10) * 6e4;
  if ((mm = w.match(/(\d+)\s*w\b/))) return now - parseInt(mm[1], 10) * 7 * d;
  if ((mm = w.match(/(\d+)\s*d\b/))) return now - parseInt(mm[1], 10) * d;
  if ((mm = w.match(/(\d+)\s*h\b/))) return now - parseInt(mm[1], 10) * h;
  if ((mm = w.match(/(\d+)\s*m\b/))) return now - parseInt(mm[1], 10) * 6e4;
  return now - 4 * d;
}

/* A friendly relative label from an epoch (used for real stageHistory timestamps). Future epochs
   (a scheduled interview) read as "in Nd". */
function e8AgoLabel(ts) {
  if (ts == null) return 'Recently';
  const diff = Date.now() - ts;
  if (diff < 0) { const d = Math.round(-diff / 864e5); return d <= 0 ? 'Soon' : 'in ' + d + 'd'; }
  const h = diff / 36e5;
  if (h < 1) return 'Just now';
  if (h < 24) return Math.round(h) + 'h ago';
  const d = Math.round(h / 24);
  if (d < 30) return d + 'd ago';
  return Math.round(d / 30) + 'mo ago';
}

/* A structured note -> a timeline event. Modality (voice/type) is split from authorship:
   a voice note is human-authored (prov 'human'), never rendered as an AI-drafted item. */
function e8NoteToEvent(n, opts) {
  opts = opts || {};
  const isAi = n.prov ? n.prov === 'ai' : !!n.ai;
  /* ProvenanceBadge knows drafted|advised|qa|human; notes store prov:'human'|'ai'. Map 'ai' -> 'drafted'
     for the badge kind so an AI note reads "AI-drafted", not the "Human-only" fallback. */
  const rawProv = n.prov || (n.ai ? 'drafted' : 'human');
  const prov = rawProv === 'ai' ? 'drafted' : rawProv;
  const points = n.points || [];
  let body = points.join(' · ') || undefined;
  if (opts.candidatePhase) body = '(From candidate record) ' + (body || '');
  return {
    id: 'note-' + n.id,
    ts: (n.ts != null ? n.ts : e8WhenToTs(n.when)),
    when: n.when || 'Recently',
    actor: n.author || n.by || 'You',
    ai: isAi,
    prov: prov,
    title: (opts.prefix || '') + (n.modality === 'voice' ? 'Voice · ' : '') + (n.title || 'Note'),
    body: body,
  };
}

/* Notes keyed to a record. */
function e8NoteEvents(refType, refId, opts) {
  const D = window.E8DATA || {};
  if (!refId) return [];
  return (D.notes || []).filter((n) => n.refType === refType && n.refId === refId).map((n) => e8NoteToEvent(n, opts));
}

/* Submissions for a candidate become stage items on the candidate's timeline. R104 T3: each real
   stageHistory entry is its own event with its OWN epoch (the move's `at`), so the timeline shows the
   true progression and a fresh advance lands a fresh, correctly-timed item. Older rows without a history
   fall back to a single item derived from `when`. */
function e8SubmissionEvents(candId) {
  const D = window.E8DATA || {};
  if (!candId) return [];
  const stages = D.submissionStages || [];
  const out = [];
  (D.submissions || []).filter((s) => s.candId === candId).forEach((s) => {
    const hist = (Array.isArray(s.stageHistory) && s.stageHistory.length)
      ? s.stageHistory
      : [{ stage: s.stage | 0, at: e8WhenToTs(s.when), by: s.owner }];
    hist.forEach((h, i) => {
      const label = stages[h.stage] || 'Submitted';
      const ts = (h.at != null ? h.at : e8WhenToTs(s.when));
      const title = h.withdrawn ? ('Withdrew from ' + s.job)
        : i === 0 ? ('Submitted to ' + s.job)
        : ('Advanced to ' + label + ' · ' + s.job);
      out.push({
        id: 'sub-' + s.id + '-' + i,
        ts: ts,
        when: e8AgoLabel(ts),
        actor: h.by || s.owner || 'You',
        ai: false, prov: 'human',
        title: title,
        body: (i === hist.length - 1 && s.resp) ? s.resp.label : (s.client ? s.client + (s.rate ? ' · ' + s.rate : '') : undefined),
        link: s.jobId ? 'Open ' + s.jobId : undefined, linkTo: s.jobId ? 'job/' + s.jobId + '/submissions' : undefined,
      });
    });
  });
  return out;
}

/* R104 T5: plain candidateJobs reasons are strings; give them a rough +/- weight for the why cards
   (caveat-flavored lines read negative). Mirrors screens-job's seedReasonWeight so a candidate whose
   best-fit job is not the flagship still gets weighted why-matched cards from that job's real reasons. */
function e8ReasonWeight(label, tier) {
  return /\b(no|not|needed|ramp|junior|declined|legacy|unconfirmed|upgrade|but)\b/i.test(label) ? -1 : tier === 1 ? 3 : tier === 2 ? 2 : 1;
}

/* R104 T5: the candidate's REAL top match - the best-fit entry from candidateJobs (live-preferred),
   the job it points at, and the why-matched reasons. Authored match-row reasons (c.reasons) are
   richer, so keep them when the best-fit tier still matches the authored tier; otherwise synthesize
   weighted reasons from the entry so a candidate whose best job is not the flagship shows that job's
   real reasons. One source drives the score badge, why panel, match rail, submit prefill and timeline. */
function e8TopMatch(c) {
  const D = window.E8DATA;
  const ranked = window.E8Match ? window.E8Match.candidateJobs(c.id) : ((D.candidateJobs && D.candidateJobs[c.id]) || []);
  /* R104 nit F: a qualified candidate carries candidateMeta.bestMatch but no candidateJobs row - synthesize
     a top from it so the why-matched header shows "JO-XXXX · Title" instead of a bare "ranked by ..." line.
     Also used as the job-label source when the best-fit job is no longer an open req (filled internally). */
  const bm = ((D.candidateMeta && D.candidateMeta[c.id]) || {}).bestMatch || null;
  const top = ranked[0] || (bm && bm.jobId ? { jobId: bm.jobId, tier: bm.tier || c.tier, score: undefined, reasons: [] } : null);
  const job = top
    ? (D.jobs.find((x) => x.id === top.jobId)
       || (bm && bm.jobId === top.jobId ? { id: bm.jobId, title: bm.job, client: bm.client } : null))
    : null;
  const tier = top ? top.tier : c.tier;
  const reasons = top
    ? ((top.tier === c.tier && c.reasons && c.reasons.length)
        ? c.reasons
        : (top.reasons || []).map((label) => ({ label, weight: e8ReasonWeight(label, top.tier) })))
    : c.reasons;
  return { top, job, tier, score: top ? top.score : undefined, reasons };
}

/* Merge + sort newest-first; anything without an explicit ts gets one from its `when`. */
function e8MergeEvents(events) {
  return (events || []).filter(Boolean)
    .map((e) => (e.ts != null ? e : Object.assign({}, e, { ts: e8WhenToTs(e.when) })))
    .sort((a, b) => b.ts - a.ts);
}

/* Per-candidate activity timeline. Daniel has an authored base timeline in the demo; everyone
   else gets a base built from their own status, owner, match and latest message. Either way the
   record's own notes + submissions merge in, so the "saved to the record" promise is real and no
   candidate shows another candidate's history. */
function timelineFor(c, listMeta, convo) {
  const D = window.E8DATA;
  const base = [];
  if (c.id === 'c-okafor') {
    base.push.apply(base, (D.timeline || []));
  } else {
    const tm = e8TopMatch(c);
    const tierLabel = (DSc.MATCH_TIERS[((tm.tier || 3)) - 1] || {}).label || 'Possible match';
    if (convo) {
      const last = convo.thread[convo.thread.length - 1];
      base.push({
        id: 'tl-msg', when: convo.lastWhen + ' ago', actor: last.who === 'me' ? (last.name || 'Sarah Kim') : c.name,
        ai: !!last.agent, prov: last.agent ? 'advised' : (last.who === 'me' ? 'human' : undefined),
        title: (last.who === 'me' ? 'You messaged ' + c.name.split(' ')[0] : c.name.split(' ')[0] + ' replied') + ' via ' + CHANNEL_LABEL(last.ch),
        body: last.text,
      });
    }
    base.push({
      id: 'tl-rank', when: 'Today · 8:05 am', actor: 'Matching', ai: true, prov: 'advised',
      title: 'Ranked ' + tierLabel.toLowerCase() + (tm.top ? ' for ' + ((tm.job && tm.job.title) || tm.top.jobId) : ''),
      body: 'Weighted by skills, experience and location. Open the why-matched cards for the breakdown.',
    });
    base.push({
      id: 'tl-add', when: listMeta.last || 'This week', actor: listMeta.owner || 'Sarah Kim',
      ai: listMeta.lastBy === 'ai', prov: listMeta.lastBy === 'ai' ? 'advised' : 'human',
      title: listMeta.lastNote || 'Added to the candidate pool',
      body: 'Status: ' + (listMeta.status || 'Sourced') + '.',
    });
  }
  return e8MergeEvents(base.concat(e8NoteEvents('candidate', c.id), e8SubmissionEvents(c.id)));
}

/* Friendly channel name for timeline copy. */
function CHANNEL_LABEL(ch) {
  return ({ email: 'email', whatsapp: 'WhatsApp', sms: 'text', linkedin: 'LinkedIn', inmail: 'InMail', call: 'a call' })[ch] || 'message';
}

/* Collapsible rail section - the chevron groups in Spott's right panel. */
function RailGroup({ title, count, defaultOpen = true, action, children }) {
  const [open, setOpen] = React.useState(defaultOpen);
  return (
    <div className="e8-rail-block">
      <div className="e8-rail-collapse-row">
        <button type="button" className="e8-rail-collapse" onClick={() => setOpen((v) => !v)}>
          <span className="material-symbols-outlined" style={{ fontSize: 16, color: 'var(--ui-text-tertiary)' }}>{open ? 'expand_more' : 'chevron_right'}</span>
          <span className="e8-rail-title" style={{ margin: 0 }}>{title}</span>
          {count != null ? <span className="e8-rail-count">{count}</span> : null}
        </button>
        {action ? <span style={{ marginLeft: 'auto', flex: 'none' }}>{action}</span> : null}
      </div>
      {open ? <div style={{ marginTop: 9 }}>{children}</div> : null}
    </div>
  );
}

/* Section header with a data-source icon: LinkedIn / AI / resume / manual. */
function SourceLabel({ label, source }) {
  const map = {
    linkedin: { node: <span className="e8-srcdot li"><b>in</b></span>, text: 'from LinkedIn' },
    ai: { node: <DSc.ProvenanceBadge kind="drafted" />, text: null },
    resume: { node: <span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--ui-text-tertiary)' }}>description</span>, text: 'from résumé' },
    manual: { node: <span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--ui-text-tertiary)' }}>edit</span>, text: 'added by you' },
  };
  const s = map[source] || map.manual;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
      <span className="e8-sectionlabel">{label}</span>
      {s.node}
      {s.text ? <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{s.text}</span> : null}
    </div>
  );
}

/* Resolve the review queue a candidate was opened from (carried in the URL as ?q=…),
   so prev/next and the review bar cycle that set - not the whole roster. */
function resolveQueue(q) {
  const D = window.E8DATA;
  if (!q) return null;
  /* review-<jobId> (plain 'review' = the flagship, kept for old links): that job's live
     For-review queue - decisions overlay included, via the matching tab's shared helpers.
     jobId in the result arms the review bar's REAL accept/reject semantics. */
  if (q === 'review' || q.indexOf('review-') === 0) {
    const jid = q === 'review' ? 'JO-44219' : q.slice(7);
    const dec = window.E8Match ? window.E8Match.decisions(jid) : {};
    const pool = window.matchPoolFor ? window.matchPoolFor(jid) : (D.matches || []).filter((m) => !m.jobId || m.jobId === jid);
    const ids = pool
      .filter((m) => (window.matchStateOf ? window.matchStateOf(m, jid, dec) : m.state) === 'review')
      .slice().sort((a, b) => a.tier - b.tier).map((m) => m.id);
    return { jobId: jid, label: jid + ' · for review', backTo: 'job/' + jid + '/matching', ids };
  }
  if (q.indexOf('status-') === 0) {
    const st = q.slice(7);
    const ids = D.candidates.filter((c) => st === 'All' || c.status === st).map((c) => c.id);
    return { label: st === 'All' ? 'All candidates' : st, backTo: 'candidates', ids };
  }
  if (q.indexOf('job-') === 0) {
    const jid = q.slice(4);
    return { jobId: jid, label: jid + ' · matches', backTo: 'job/' + jid + '/matching', ids: D.matches.map((m) => m.id) };
  }
  return null;
}

/* R106 T2: freshness field labels + value formatting for the diff / conflict review + timeline copy.
   Hyphen arrows only (no em dashes); '-' for empty. */
const E8_FRESH_FIELD_LABEL = {
  location: 'Location', rate: 'Rate', availability: 'Availability', email: 'Email',
  phone: 'Phone', consent: 'Consent', skills: 'Skills', title: 'Title',
};
const E8_ENTRY_SOURCE_LABEL = {
  'web-application': 'Web application', referral: 'Referral', sourced: 'Sourced', inbound: 'Inbound',
};
function e8FreshFieldLabel(field) { return E8_FRESH_FIELD_LABEL[field] || field; }
function e8FreshVal(v) {
  if (v == null || v === '' || (Array.isArray(v) && !v.length)) return '-';
  return Array.isArray(v) ? v.join(', ') : String(v);
}

/* R109 T3: the human-reviewed merge review. Side-by-side of two records of the SAME person, field-by-
   field, each value showing its provenance (candidate-verified / recruiter / enrichment + when). The
   provenance precedence PRESELECTS the winning value per field (window.e8MergePreview); a per-field toggle
   overrides it. The survivor defaults to the more-complete record (window.e8MergeSurvivorDefault) and can
   be swapped. Confirm calls window.mergeCandidates (the reversible, audited, replayable op) and lands on
   the survivor with a working Undo. POLICY: this dialog is the ONLY merge path - nothing auto-merges. */
const E8_MERGE_PROV = { candidate: 'Candidate-verified', recruiter: 'Recruiter-entered', enrichment: 'Enrichment', none: 'Not verified' };
function e8MergeProvText(info) {
  if (info.kind === 'enrichment' && info.prov && info.prov.source) return 'Enrichment · ' + info.prov.source;
  return E8_MERGE_PROV[info.kind] || 'Not verified';
}
function MergeReviewDialog({ aId, bId, onClose }) {
  const D = window.E8DATA;
  const { showToast } = React.useContext(E8Ctx);
  const a = D.matches.find((m) => m.id === aId);
  const b = D.matches.find((m) => m.id === bId);
  const [survivorId, setSurvivorId] = React.useState(() => (window.e8MergeSurvivorDefault ? window.e8MergeSurvivorDefault(aId, bId) : aId));
  const [choices, setChoices] = React.useState({});
  if (!a || !b) {
    return (
      <DSc.Modal title="Merge records" icon="merge" ariaLabel="Merge records" closeTitle="Close" onClose={onClose} className="e8-merge-dlg">
        <div style={{ padding: 16 }}>
          <DSc.EmptyState icon="person_off" title="Record unavailable" body="One of these records no longer exists - it may already have been merged." cta={<DSc.Button variant="primary" onClick={onClose}>Close</DSc.Button>} />
        </div>
      </DSc.Modal>
    );
  }
  const mergedId = survivorId === aId ? bId : aId;
  const survivor = survivorId === aId ? a : b;
  const merged = survivorId === aId ? b : a;
  const preview = window.e8MergePreview ? window.e8MergePreview(survivorId, mergedId) : [];
  const counts = window.e8MergeCounts ? window.e8MergeCounts(aId, bId) : { submissions: 0, notes: 0, engagements: 0 };
  const chosenOf = (p) => choices[p.key] || p.defaultChoice;
  /* Swapping survivor also clears per-field choices: 'survivor'/'merged' are relative to who survives, so
     the fresh precedence defaults must re-apply after a swap. */
  const makeSurvivor = (id) => { setSurvivorId(id); setChoices({}); };
  const setChoice = (k, v) => setChoices((c) => Object.assign({}, c, { [k]: v }));
  const freshOf = (id) => (window.candidateFreshness ? window.candidateFreshness(id) : null);
  const subCount = (id) => (D.submissions || []).filter((s) => s.candId === id).length;

  const confirm = () => {
    const fc = {};
    preview.forEach((p) => { fc[p.key] = chosenOf(p); });
    const res = window.mergeCandidates ? window.mergeCandidates(survivorId, mergedId, fc) : null;
    onClose();
    navigate('candidate/' + survivorId);
    showToast('Merged ' + merged.name + ' into ' + survivor.name, 'Undo', res && res.undo);
  };

  const recCard = (rec, isSurvivor) => {
    const f = freshOf(rec.id);
    return (
      <div className={'e8-merge-rec' + (isSurvivor ? ' e8-merge-rec-on' : '')}>
        <DSc.Avatar name={rec.name} size="sm" />
        <div style={{ minWidth: 0, flex: 1 }}>
          <div className="e8-merge-rec-name">{rec.name}</div>
          <div className="e8-merge-rec-sub">{rec.email}</div>
        </div>
        <div className="e8-merge-rec-meta">
          {isSurvivor ? <span className="e8-merge-pill">Survivor</span> : <DSc.Button variant="ghost" size="sm" icon="swap_horiz" onClick={() => makeSurvivor(rec.id)}>Make survivor</DSc.Button>}
          {f ? <DSc.Badge tone={f.tone} dot>{f.label}</DSc.Badge> : null}
          <span className="e8-merge-rec-c">{subCount(rec.id)} submission{subCount(rec.id) === 1 ? '' : 's'}</span>
        </div>
      </div>
    );
  };

  const cell = (p, side, info) => {
    const on = chosenOf(p) === side;
    const provTxt = e8MergeProvText(info);
    const ago = info.prov && info.prov.verifiedAt != null ? e8AgoLabel(info.prov.verifiedAt) : null;
    return (
      <button type="button" className={'e8-merge-cell' + (on ? ' e8-merge-cell-on' : '')} aria-pressed={on} onClick={() => setChoice(p.key, side)}>
        <span className="e8-merge-cell-check material-symbols-outlined" aria-hidden="true">{on ? 'radio_button_checked' : 'radio_button_unchecked'}</span>
        <span className="e8-merge-cell-body">
          <span className="e8-merge-cell-val">{e8FreshVal(info.value)}</span>
          <span className={'e8-merge-cell-prov e8-merge-k-' + info.kind}>{provTxt}{ago ? ' · ' + ago : ''}</span>
        </span>
      </button>
    );
  };

  return (
    <DSc.Modal title="Merge duplicate records" icon="merge" ariaLabel="Merge duplicate records" closeTitle="Cancel" onClose={onClose} className="e8-merge-dlg">
      <div className="e8-merge-body">
        <div className="e8-merge-intro">Same person across two records. Choose the surviving record and the winning value for each field - the winner is preselected by provenance (candidate-verified beats recruiter, recruiter beats enrichment). Nothing is deleted; undo fully un-merges.</div>
        <div className="e8-merge-head">
          {recCard(survivor, true)}
          {recCard(merged, false)}
        </div>
        <div className="e8-merge-grid" role="group" aria-label="Field-by-field merge">
          {preview.map((p) => (
            <div key={p.key} className={'e8-merge-frow' + (p.same ? ' e8-merge-frow-same' : '')}>
              <div className="e8-merge-flabel">{p.label}{p.same ? <span className="e8-merge-same">match</span> : null}</div>
              <div className="e8-merge-cells">
                {cell(p, 'survivor', p.survivor)}
                {cell(p, 'merged', p.merged)}
              </div>
            </div>
          ))}
        </div>
        <div className="e8-merge-counts">
          <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 17, color: 'var(--ui-text-tertiary)' }}>merge_type</span>
          <span>Folds into one record:</span>
          <span className="e8-merge-count tnum">{counts.submissions} submission{counts.submissions === 1 ? '' : 's'}</span>
          <span className="e8-merge-count tnum">{counts.notes} note{counts.notes === 1 ? '' : 's'}</span>
          {counts.engagements ? <span className="e8-merge-count tnum">{counts.engagements} engagement{counts.engagements === 1 ? '' : 's'}</span> : null}
        </div>
      </div>
      <div className="e8-merge-foot">
        <span className="e8-merge-foot-note">The other record redirects here after merging.</span>
        <DSc.Button variant="ghost" size="sm" onClick={onClose}>Cancel</DSc.Button>
        <DSc.Button variant="primary" size="sm" icon="merge" onClick={confirm}>Merge into {survivor.name.split(' ')[0]}</DSc.Button>
      </div>
    </DSc.Modal>
  );
}

/* R109 T3: a tombstone landing. A merged record keeps its D.matches row (with mergedInto -> the survivor)
   so this guard can resolve the old id and redirect; the active-list row is gone. Navigating to the old
   id lands here, then bounces to the survivor - whose timeline carries the "Merged ... into this record"
   event, so the redirect is explained. */
function MergedTombstone({ mergedId, survivorId }) {
  const D = window.E8DATA;
  const surv = D.matches.find((m) => m.id === survivorId);
  React.useEffect(() => { navigate('candidate/' + survivorId); }, [survivorId]);
  return (
    <React.Fragment>
      <Topbar crumbs={[{ label: 'Candidates', to: 'candidates' }, { label: 'Merged' }]} />
      <div className="e8-content"><div className="e8-page"><div style={{ maxWidth: 460, margin: '48px auto' }}>
        <DSc.EmptyState icon="merge" title="This record was merged" body={surv ? 'It is now part of ' + surv.name + "'s record. Taking you there." : 'Taking you to the surviving record.'} cta={<DSc.Button variant="primary" icon="person" onClick={() => navigate('candidate/' + survivorId)}>Open the record</DSc.Button>} />
      </div></div></div>
    </React.Fragment>
  );
}

function CandidateDetailScreen({ id, q }) {
  const D = window.E8DATA;
  const c = D.matches.find((m) => m.id === id);
  /* R109 T3: tombstone redirect. A merged record's row carries mergedInto -> the survivor; land on the
     survivor instead of showing a stale duplicate. Runs before the hooks below (like the not-found guard). */
  if (c && c.mergedInto && D.matches.some((m) => m.id === c.mergedInto)) {
    return <MergedTombstone mergedId={id} survivorId={c.mergedInto} />;
  }
  if (!c) {
    return (
      <React.Fragment>
        <Topbar crumbs={[{ label: 'Candidates', to: 'candidates' }, { label: 'Not found' }]} />
        <div className="e8-content"><div className="e8-page"><div style={{ maxWidth: 460, margin: '48px auto' }}>
          <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) || {};
  /* 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 [present, setPresent] = React.useState(false);
  const [reviewReject, setReviewReject] = React.useState(false);
  const [showResume, setShowResume] = React.useState(false);
  /* 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 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', {
        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'], () => setActTick((t) => t + 1)) : undefined), []);
  const prof = profileOf(c, listMeta);
  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 = () => {
    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;
  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. */
  React.useEffect(() => {
    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;
      if (reviewReject || present) return;
      const k = e.key.toLowerCase();
      if (k === 'j' || e.key === 'ArrowDown') { e.preventDefault(); go(1); }
      else if (k === 'k' || e.key === 'ArrowUp') { e.preventDefault(); go(-1); }
      else if (k === 'a') { e.preventDefault(); accept(); }
      else if (k === 'r') { e.preventDefault(); setReviewReject(true); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [qPos, queueIds.length, reviewReject, present]);

  /* 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 = useSwipeTabs(['overview', 'experience', 'skills', 'messages', 'activity'], tab, setTab);
  const isMobile = useIsMobile();

  /* 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: () => showToast('Upload a new CV - fields re-extract with confidence levels') },
    { icon: 'outgoing_mail', label: 'Request updated CV', onClick: () => showToast('CV request drafted to ' + c.name + ' - review in Approvals', 'Open approvals', () => navigate('approvals')) },
  ];

  return (
    <React.Fragment>
      <Topbar
        crumbs={[{ label: 'Candidates', to: 'candidates' }, { label: c.name }]}
        actions={
          <React.Fragment>
            {!isMobile ? (
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: 4 }}>
              <DSc.Button variant="ghost" size="sm" icon="keyboard_arrow_up" iconOnly title="Previous candidate" onClick={() => go(-1)}></DSc.Button>
              <DSc.Button variant="ghost" size="sm" icon="keyboard_arrow_down" iconOnly title="Next candidate" onClick={() => go(1)}></DSc.Button>
              <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', whiteSpace: 'nowrap' }}>{qPos + 1} of {queueIds.length}</span>
            </span>
            ) : null}
            {!isMobile && c.linkedin ? <DSc.Button variant="ghost" size="sm" icon="open_in_new" iconOnly title="View LinkedIn profile" onClick={() => showToast('Opens the LinkedIn profile in a new tab')}></DSc.Button> : null}
            <WorkspacePinButton type="candidate" id={c.id} compact />
            <DSc.Button variant="ghost" size="sm" icon="note_add" onClick={() => cap.open({ target: 'note', entityLabel: c.name, refType: 'candidate', refId: c.id })}>Add note</DSc.Button>
            {!isMobile ? <DSc.Button variant="secondary" size="sm" icon="slideshow" onClick={() => setPresent(true)}>Present</DSc.Button> : null}
            <DSc.MenuButton
              label="Actions"
              icon="bolt"
              size="sm"
              variant="secondary"
              items={[
                { icon: 'call', label: 'Call', onClick: () => navigate(convo ? 'inbox/' + convo.id : 'voice') },
                { icon: 'mail', label: 'Message', onClick: () => navigate(convo ? 'inbox/' + convo.id : 'inbox') },
                { icon: 'campaign', label: 'Add to sequence', onClick: () => showToast(c.name + ' enrolled in "Memphis JVM re-engage"', 'Open', () => navigate('sequences')) },
                { icon: 'bookmark_add', label: 'Add to list', onClick: () => showToast('Added to a list') },
                { separator: true },
                { icon: 'auto_awesome', label: 'Generate tailored CV', onClick: () => showToast('Tailored CV for ' + (matchJob ? matchJob.title : 'the best-fit role') + ' - live CV drafting is a prototype stub') },
                { icon: 'sync', label: 'Check for profile updates', onClick: () => showToast('Checking LinkedIn for profile changes…') },
                { separator: true },
                { icon: 'person_add', label: 'Add as contact', onClick: () => showToast('Added ' + c.name + ' as a contact') },
                { icon: 'archive', label: 'Archive candidate', danger: true, onClick: () => showToast(c.name + ' archived', 'Undo') },
              ]}
            />
            <DSc.Button variant="secondary" size="sm" icon="send" onClick={openSubmit}>Submit to job</DSc.Button>
          </React.Fragment>
        }
      />
      <div className="e8-content">
        <div className="e8-page" style={{ paddingBottom: 96 }}>
          {/* Header */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
            <DSc.Avatar name={c.name} size="xl" />
            <div style={{ minWidth: 0 }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
                <h1 className="e8-h1">{c.name}</h1>
                <window.FitScoreChip score={jScore} tier={matchTier} label={fitTier.label} />
                <DSc.Badge tone={placement ? 'success' : 'info'} dot>{headerStage}</DSc.Badge>
                {fresh ? <DSc.Badge tone={fresh.tone} dot title={'Data freshness · ' + fresh.label}>{fresh.label}</DSc.Badge> : null}
                {reqRequested ? <DSc.Badge tone="info" dot>re-verify requested</DSc.Badge> : null}
              </div>
              <div style={{ fontSize: 'var(--ui-text-base)', color: 'var(--ui-text-secondary)', marginTop: 2 }}>
                {[c.title + ' at ' + c.company, c.location, c.years ? c.years + 'y experience' : null].filter(Boolean).join(' · ')}
              </div>
            </div>
          </div>

          {/* 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 className="e8-record-2col">
            <div style={{ flex: 1, minWidth: 0 }}>
              <DSc.Tabs
                items={[
                  { id: 'overview', label: 'Overview' },
                  { id: 'experience', label: 'Experience', count: prof.experience.length },
                  { id: 'skills', label: 'Skills' },
                  { id: 'messages', label: 'Messages', count: convo ? convo.thread.length : undefined },
                  { id: 'activity', label: 'Activity', count: tl.length },
                ]}
                active={tab}
                onChange={setTab}
              />
              <div style={{ paddingTop: 18, minWidth: 0, ...(swt.style || {}) }} {...swt.bind}>
                {tab === 'overview' ? (
                  <div className="e8-cand-overview">
                    {/* Left - who they are */}
                    <div className="e8-cand-ov-main">
                      <div>
                        <SourceLabel label="About" source={prof.aboutSource} />
                        <p style={{ margin: 0, fontSize: 'var(--ui-text-base)', lineHeight: 1.6, color: 'var(--ui-text-secondary)', textWrap: 'pretty' }}>{prof.about}</p>
                      </div>
                      {c.aiSummary ? (
                        <div>
                          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
                            <span className="e8-sectionlabel">AI summary</span>
                            <DSc.ProvenanceBadge kind="drafted" />
                            <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>updated 2h ago</span>
                          </div>
                          <p style={{ margin: 0, fontSize: 'var(--ui-text-base)', lineHeight: 1.55, color: 'var(--ui-text-secondary)', textWrap: 'pretty' }}>{c.aiSummary}</p>
                        </div>
                      ) : null}
                      {facts.length ? (
                        <div>
                          <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>AI-extracted facts</div>
                          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                            {facts.map((f, i) => <DSc.ConfidenceChip key={i} state={f.state}>{f.label}</DSc.ConfidenceChip>)}
                          </div>
                          <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', marginTop: 6 }}>
                            From the screening call today 9:41 am - suggested facts need your confirmation.
                          </div>
                        </div>
                      ) : null}
                      <EnrichCandidatePanel candidateId={c.id} />
                      {(() => {
                        /* R79: live E8Match runs override/extend the seeded best-fit list.
                           R104 T5: the "Why we matched" panel + rail read this same ranking (via
                           e8TopMatch) so the score/tier/reasons all describe the candidate's real
                           best-fit job, not a hardcoded flagship. */
                        const rankedAll = window.E8Match ? window.E8Match.candidateJobs(c.id) : ((D.candidateJobs && D.candidateJobs[c.id]) || []);
                        // The "Why we matched" panel owns the recommended job in full; drop it here so
                        // the best-fit list surfaces the OTHER opportunities instead of repeating it.
                        const ranked = (matchJob && rankedAll.length > 1) ? rankedAll.filter((rj) => rj.jobId !== matchJob.id) : rankedAll;
                        if (!ranked.length) return null;
                        const anyLive = ranked.some((rj) => rj.live);
                        return (
                          <div>
                            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
                              <span className="e8-sectionlabel">Best-fit open jobs</span>
                              <DSc.ProvenanceBadge kind="advised" />
                              <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{anyLive ? 'live on-device scores for ' + c.name.split(' ')[0] : 'ranked for ' + c.name.split(' ')[0] + ' by the matching engine'}</span>
                            </div>
                            <div style={{ display: 'grid', gap: 8 }}>
                              {ranked.map((rj) => {
                                const job = D.jobs.find((j) => j.id === rj.jobId) || {};
                                return (
                                  <button type="button" key={rj.jobId} className="e8-jobmatch" onClick={() => navigate('job/' + rj.jobId + '/matching')}>
                                    <span style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
                                      <span style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
                                        <DSc.MatchBadge tier={rj.tier} score={rj.score} dot />
                                        <span style={{ fontWeight: 600, fontSize: 'var(--ui-text-base)' }}>{job.title || rj.jobId}</span>
                                        <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{job.client}{job.rate ? ' · ' + job.rate : ''}</span>
                                        {anyLive && !rj.live ? <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', border: '1px solid var(--ui-border)', borderRadius: 4, padding: '0 4px' }}>simulated</span> : null}
                                      </span>
                                      <span style={{ display: 'flex', flexWrap: 'wrap', gap: '2px 12px', marginTop: 5 }}>
                                        {rj.reasons.map((why, i) => (
                                          <span key={i} className="e8-jobmatch-why"><span className="material-symbols-outlined" style={{ fontSize: 12, color: 'var(--ui-success-text)' }}>check</span>{why}</span>
                                        ))}
                                      </span>
                                    </span>
                                    <span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--ui-text-tertiary)', alignSelf: 'center' }}>chevron_right</span>
                                  </button>
                                );
                              })}
                            </div>
                          </div>
                        );
                      })()}

                      <div>
                        <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>Latest activity</div>
                        <Timeline events={tl.slice(0, 2)} />
                        <a className="e8-link" style={{ fontSize: 'var(--ui-text-base)' }} tabIndex={0} onKeyDown={window.keyActivate} onClick={() => setTab('activity')}>View full activity →</a>
                      </div>
                    </div>

                    {/* Right - why we matched (the decision column) */}
                    <div className="e8-cand-ov-why">
                      <div className="e8-whypanel-head">
                        <span className="material-symbols-outlined" style={{ fontSize: 15, color: 'var(--ui-tier-' + matchTier + ')' }}>join_inner</span>
                        <span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 600, color: 'var(--ui-tier-' + matchTier + ')' }}>Why we matched</span>
                        <span style={{ marginLeft: 'auto' }}><DSc.MatchBadge tier={matchTier} score={jScore} /></span>
                      </div>
                      <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', margin: '0 0 12px' }}>{matchJobLabel ? matchJobLabel + ' · ranked by the matching engine' : 'Ranked by the matching engine'}</div>
                      <WhyMatchedCards reasons={matchReasons} />
                    </div>
                  </div>
                ) : null}
                {tab === 'experience' ? (
                  <div style={{ maxWidth: 720 }}>
                    {/* CV toolkit */}
                    <div className="e8-cv-toolbar">
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
                        <span className="material-symbols-outlined" style={{ fontSize: 16, color: 'var(--ui-text-tertiary)' }}>description</span>
                        <span style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                          {prof.cv.updated === '-'
                            ? <span style={{ color: 'var(--ui-text-tertiary)' }}>No CV on file yet</span>
                            : <React.Fragment>{prof.cv.file} <span style={{ color: 'var(--ui-text-tertiary)' }}>· updated {prof.cv.updated}</span></React.Fragment>}
                        </span>
                      </span>
                      <span style={{ marginLeft: 'auto', display: 'inline-flex', gap: 6, flex: 'none' }}>
                        {c.resumeText ? <DSc.Button variant="secondary" size="sm" icon="visibility" onClick={() => setShowResume(true)}>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>
                      </span>
                    </div>

                    <div style={{ display: 'grid', gridTemplateColumns: `repeat(${prof.experienceParsed ? 3 : 1}, 1fr)`, gap: 10, margin: '16px 0 18px' }}>
                      {(prof.experienceParsed
                        ? [['Average tenure', '2y 8m'], ['Current tenure', '4y 11m'], ['Total experience', c.years ? `${c.years}y` : '-']]
                        : [['Total experience', c.years ? `${c.years}y` : '-']]
                      ).map(([k, v]) => (
                        <div key={k} style={{ border: '1px solid var(--ui-border)', borderRadius: 'var(--ui-radius-lg)', padding: '10px 14px' }}>
                          <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{k}</div>
                          <div className="tnum" style={{ fontSize: 'var(--ui-text-lg)', fontWeight: 500, marginTop: 2 }}>{v}</div>
                        </div>
                      ))}
                    </div>
                    <div style={{ display: 'grid', gap: 0 }}>
                    {prof.experience.map((x, i, arr) => (
                      <div key={i} style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: i < arr.length - 1 ? '1px solid var(--ui-border)' : 'none' }}>
                        <div style={{ flex: 1 }}>
                          <div style={{ fontSize: 'var(--ui-text-base)', fontWeight: 500 }}>{x.role} · {x.co}</div>
                          {x.sub ? <div style={{ fontSize: 'var(--ui-text-base)', color: 'var(--ui-text-secondary)', marginTop: 2, maxWidth: 560 }}>{x.sub}</div> : null}
                        </div>
                        <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', whiteSpace: 'nowrap' }}>{x.span}</span>
                      </div>
                    ))}
                    </div>
                    {!prof.experienceParsed ? (
                      <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', marginTop: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
                        <span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
                        Earlier roles parse from the résumé once it's uploaded - use "Update with new CV" above.
                      </div>
                    ) : null}
                  </div>
                ) : null}
                {tab === 'skills' ? (
                  <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
                    <div>
                      <SourceLabel label="Skills" source="linkedin" />
                      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                        {c.skills.map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
                      </div>
                    </div>
                    {c.id === 'c-okafor' ? (
                      <div>
                        <SourceLabel label="Also extracted" source="resume" />
                        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                          {['Docker', 'Jenkins', 'GraphQL', 'JUnit 5'].map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
                        </div>
                      </div>
                    ) : null}
                    <div>
                      <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>Add a skill</div>
                      <DSc.Button variant="secondary" size="sm" icon="add" onClick={() => showToast('Skill editor opens here')}>Add skill</DSc.Button>
                    </div>
                  </div>
                ) : null}
                {tab === 'messages' ? <CandidateMessagesTab convo={convo} name={c.name} /> : null}
                {tab === 'activity' ? <Timeline events={tl} /> : null}
              </div>
            </div>

            {/* Right rail - Spott-style collapsible record panel */}
            <div className="e8-rail">
              <RailGroup title="Personal">
                <div className="e8-kv"><span className="e8-kv-k">Location</span><span className="e8-kv-v">{c.location}</span></div>
                <div className="e8-kv"><span className="e8-kv-k">Email</span><span className="e8-kv-v">{c.email}</span></div>
                <div className="e8-kv"><span className="e8-kv-k">Phone</span><span className="e8-kv-v tnum">{c.phone}</span></div>
                <div className="e8-kv"><span className="e8-kv-k">LinkedIn</span><span className="e8-kv-v">{c.linkedin ? <a className="e8-link" tabIndex={0} onKeyDown={window.keyActivate} onClick={() => showToast('Opens LinkedIn profile')}>{c.name.toLowerCase().replace(/[^a-z]/g, '-')}</a> : <span style={{ color: 'var(--ui-text-tertiary)' }}>-</span>}</span></div>
                <div className="e8-kv"><span className="e8-kv-k">Languages</span><span className="e8-kv-v">{prof.languages.join(', ')}</span></div>
                <div className="e8-kv"><span className="e8-kv-k">Rate</span><span className="e8-kv-v tnum">{c.rate} W2</span></div>
                {/* R107 fix A: work authorization is an own-truth field (candidateMeta.workAuth, auto-applied
                    by the portal receiver - never a flagged conflict). Show the applied value plus its
                    per-field provenance (candidateMeta.fieldProv.workAuth) so the recruiter sees where it came
                    from + when it was verified. Seeded values have no fieldProv, so the subline only appears
                    once a candidate has verified it through the portal. */}
                {cmeta.workAuth ? (
                  <div className="e8-kv">
                    <span className="e8-kv-k">Work authorization</span>
                    <span className="e8-kv-v">
                      {cmeta.workAuth}
                      {(() => {
                        const p = (cmeta.fieldProv || {}).workAuth;
                        if (!p) return null;
                        const who = p.source === 'candidate' ? 'from candidate' : 'from ' + p.source;
                        return <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', fontWeight: 400, color: 'var(--ui-text-tertiary)', marginTop: 2 }}>{who} · verified {e8AgoLabel(p.verifiedAt)}</span>;
                      })()}
                    </span>
                  </div>
                ) : null}
                <div style={{ marginTop: 8 }}>
                  <div className="e8-kv-k" style={{ marginBottom: 6 }}>Tech stack</div>
                  <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
                    {prof.techStack.map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
                  </div>
                </div>
                <div className="e8-kv" style={{ marginTop: 10 }}><span className="e8-kv-k">Main contact</span><span className="e8-kv-v"><ActorChip name={prof.mainContact} /></span></div>
              </RailGroup>

              {/* R106 T2 / R107 T3: data-freshness lifecycle - the freshness chip + facts + the request loop.
                  "Preview candidate portal" opens the real candidate self-service portal (the R106 "Simulate"
                  stand-in was retired; the portal is now the one verify path). */}
              <RailGroup title="Data freshness">
                {fresh ? (
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
                    <DSc.Badge tone={fresh.tone} dot>{fresh.label}</DSc.Badge>
                    {reqRequested ? <DSc.Badge tone="info" dot>re-verify requested</DSc.Badge> : null}
                  </div>
                ) : null}
                <div className="e8-kv"><span className="e8-kv-k">Last verified</span><span className="e8-kv-v">{cmeta.lastVerified ? e8AgoLabel(cmeta.lastVerified) : 'Never'}</span></div>
                {cmeta.verifiedBy ? <div className="e8-kv"><span className="e8-kv-k">Verified by</span><span className="e8-kv-v" style={{ textTransform: 'capitalize' }}>{cmeta.verifiedBy}</span></div> : null}
                <div className="e8-kv"><span className="e8-kv-k">Source</span><span className="e8-kv-v">{E8_ENTRY_SOURCE_LABEL[cmeta.entrySource] || 'Unknown'}</span></div>
                <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 10 }}>
                  <DSc.Button variant="secondary" size="sm" icon="mark_email_read" onClick={requestReverify}>Request re-verification</DSc.Button>
                  <DSc.Button variant="ghost" size="sm" icon="open_in_new" onClick={() => navigate('portal/' + c.id)}>Preview candidate portal</DSc.Button>
                </div>
                <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', marginTop: 6, lineHeight: 1.45 }}>
                  Preview the candidate self-service portal - what the candidate sees when they verify their profile.
                </div>
              </RailGroup>

              {/* R111: the possible-duplicate flag moved to the UPPER-section banner (under the header). */}

              {/* R107 T3: pending updates from a portal submit. Assessive claims (skill / title) the candidate
                  changed are flagged (never silently overwritten) and PERSISTED on candidateMeta.pendingConflicts
                  so they are reviewable after the fact - the portal is async. Accept applies the new value +
                  stamps provenance; Keep current leaves it. Both are undoable and clear the item live. */}
              {(() => {
                const pend = Array.isArray(cmeta.pendingConflicts) ? cmeta.pendingConflicts : [];
                if (!pend.length) return null;
                return (
                  <RailGroup title="Pending updates from candidate" count={pend.length}>
                    <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-secondary)', lineHeight: 1.45, marginBottom: 8 }}>
                      The candidate reported these in their portal. Accept to apply the new value, or keep the current one.
                    </div>
                    <div style={{ display: 'grid', gap: 6 }}>
                      {pend.map((cf) => (
                        <div key={cf.field} style={{ border: '1px solid var(--ui-border)', borderRadius: 'var(--ui-radius-md)', padding: '8px 10px', background: 'var(--ui-warning-tint)' }}>
                          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                            <span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 600 }}>{e8FreshFieldLabel(cf.field)}</span>
                            <span style={{ marginLeft: 'auto' }}><DSc.Badge tone="warning" dot>flagged</DSc.Badge></span>
                          </div>
                          <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)', marginTop: 4 }}>
                            {e8FreshVal(cf.old)} <span style={{ color: 'var(--ui-text-tertiary)' }}>-&gt;</span> <b style={{ fontWeight: 600 }}>{e8FreshVal(cf['new'])}</b>
                          </div>
                          <div style={{ display: 'inline-flex', gap: 6, marginTop: 8 }}>
                            <DSc.Button variant="primary" size="sm" icon="check" onClick={() => resolveConflict(cf.field, 'accept')}>Accept</DSc.Button>
                            <DSc.Button variant="ghost" size="sm" onClick={() => resolveConflict(cf.field, 'keep')}>Keep current</DSc.Button>
                          </div>
                        </div>
                      ))}
                    </div>
                  </RailGroup>
                );
              })()}

              {/* R104 nit C: placed candidate -> a navigable link to the engagement/consultant they became. */}
              {placement && placementCid ? (
                <RailGroup title="Engagement">
                  <button type="button" className="e8-applrow" onClick={() => navigate('consultant/' + placementCid)}>
                    <span style={{ minWidth: 0 }}>
                      <span style={{ display: 'block', fontSize: 'var(--ui-text-sm)', fontWeight: 500 }}>Now a consultant</span>
                      <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{placement.eng ? placement.eng.role + ' · ' + placement.eng.client : 'View engagement'}</span>
                    </span>
                    <span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' }}>
                      <DSc.Badge tone="success" dot>Placed</DSc.Badge>
                      <span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--ui-text-tertiary)' }}>chevron_right</span>
                    </span>
                  </button>
                </RailGroup>
              ) : null}

              <RailGroup title="Active applications" count={prof.applications.length}>
                {prof.applications.length ? prof.applications.map((a, i) => (
                  <button key={i} type="button" className="e8-applrow" onClick={() => navigate('job/' + a.jobId + '/overview')}>
                    <span style={{ minWidth: 0 }}>
                      <span style={{ display: 'block', fontSize: 'var(--ui-text-sm)', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.job}</span>
                      <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{a.jobId} · {a.client}</span>
                    </span>
                    <span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' }}>
                      <DSc.Badge tone={a.tone || 'info'} dot>{a.stage}</DSc.Badge>
                      <span className="tnum" style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{a.when}</span>
                    </span>
                  </button>
                )) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>Not in any active process yet.</div>}
              </RailGroup>

              {/* R104 T3: the candidate's real submissions (by candId) - the bidirectional half of the
                  job's Submissions tab. Each row shows the live stage stepper + last-activity from the
                  real stageHistory, and opens the job's Submissions tab. */}
              {(() => {
                const subs = (D.submissions || []).filter((s) => s.candId === c.id);
                return (
                  <RailGroup title="Submissions" count={subs.length}>
                    {subs.length ? subs.map((s) => {
                      const hist = (Array.isArray(s.stageHistory) && s.stageHistory.length) ? s.stageHistory : null;
                      const last = hist ? hist[hist.length - 1] : null;
                      const when = last && last.at != null ? e8AgoLabel(last.at) : (s.when || '');
                      return (
                        <button key={s.id} type="button" className="e8-applrow" style={{ alignItems: 'flex-start' }} onClick={() => navigate('job/' + s.jobId + '/submissions')}>
                          <span style={{ minWidth: 0 }}>
                            <span style={{ display: 'block', fontSize: 'var(--ui-text-sm)', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.job}</span>
                            <span style={{ display: 'block', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{s.jobId} · {s.client}</span>
                            <span style={{ display: 'block', marginTop: 6 }}><DSc.StageCell stages={D.submissionStages} current={s.stage} /></span>
                          </span>
                          <span className="tnum" style={{ marginLeft: 'auto', flex: 'none', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{when}</span>
                        </button>
                      );
                    }) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>No submittals yet.</div>}
                  </RailGroup>
                );
              })()}

              {window.RecordTasksRail ? <window.RecordTasksRail refType="candidate" refId={c.id} /> : null}

              {(() => {
                const noteEvents = e8NoteEvents('candidate', c.id);
                return (
                  <RailGroup title="Notes" count={noteEvents.length} defaultOpen={noteEvents.length > 0}
                    action={<DSc.Button variant="ghost" size="sm" icon="add" iconOnly title="Add note" onClick={() => cap.open({ target: 'note', entityLabel: c.name, refType: 'candidate', refId: c.id })}></DSc.Button>}>
                    {noteEvents.length ? noteEvents.slice(0, 5).map((n, i) => (
                      <div key={n.id} style={{ padding: '7px 0', borderTop: i ? '1px solid var(--ui-border)' : 'none' }}>
                        <div style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 500, lineHeight: 1.35 }}>{n.title}</div>
                        {n.body ? <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-secondary)', marginTop: 2, lineHeight: 1.4 }}>{n.body}</div> : null}
                        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 5 }}>
                          <ActorChip name={n.actor} ai={n.ai} />
                          {n.prov ? <DSc.ProvenanceBadge kind={n.prov} /> : null}
                          <span className="tnum" style={{ marginLeft: 'auto', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{n.when}</span>
                        </div>
                      </div>
                    )) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>No notes on this record yet. Add the first one.</div>}
                  </RailGroup>
                );
              })()}

              <RailGroup title={'Best match' + (matchJob ? ' · ' + matchJob.id : '')} defaultOpen={false}>
                <DSc.MatchBadge tier={matchTier} score={jScore} reasons={matchReasons} footnote="Weighted by skills, experience and location" />
                <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)', marginTop: 8 }}>
                  {(matchJob ? matchJob.title + ' · ' + matchJob.client + ' · ' : '') + 'ranked by the matching engine'}
                </div>
              </RailGroup>

              <RailGroup title="Lists" count={prof.lists.length} defaultOpen={false} action={<DSc.Button variant="ghost" size="sm" icon="add" iconOnly title="Add to list" onClick={() => showToast('Added to a list')}></DSc.Button>}>
                {prof.lists.length ? (
                  <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
                    {prof.lists.map((l) => <span key={l} className="e8-listpill"><span className="material-symbols-outlined" style={{ fontSize: 13 }}>bookmark</span>{l}</span>)}
                  </div>
                ) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>Not on any list.</div>}
              </RailGroup>

              <RailGroup title="Campaigns" count={prof.campaigns.length} defaultOpen={false}>
                {prof.campaigns.length ? prof.campaigns.map((cp) => (
                  <button key={cp.name} type="button" className="e8-applrow" onClick={() => navigate('sequences')}>
                    <span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 500, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cp.name}</span>
                    <span style={{ marginLeft: 'auto', flex: 'none' }}><DSc.Badge tone="success" dot>{cp.state}</DSc.Badge></span>
                  </button>
                )) : <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>Not enrolled in a sequence.</div>}
              </RailGroup>
            </div>
          </div>
        </div>
      </div>

      {/* Floating review bar - queue-aware triage: back to queue, prev/next, accept, reject */}
      <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>
          <button type="button" className="e8-rb-nav" title="Previous candidate (K)" onClick={() => go(-1)}><span className="material-symbols-outlined">chevron_left</span></button>
          <span className="tnum" style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', minWidth: 48, textAlign: 'center' }} title={'Position in ' + queueLabel}>{qPos + 1} of {queueIds.length}</span>
          <button type="button" className="e8-rb-nav" title="Next candidate (J)" onClick={() => go(1)}><span className="material-symbols-outlined">chevron_right</span></button>
          <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) },
          ]} />
        </div>
      </div>

      {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}
      {reverifyConfirm}
    </React.Fragment>
  );
}

/* Present mode - a client-safe one-pager. Hides internal data (rate, owner, contact PII,
   match internals) and shows what's appropriate to put in front of a hiring manager. */
function CandidatePresentOverlay({ c, prof, onClose }) {
  const { showToast } = React.useContext(E8Ctx);
  /* R105 Pass 2a: this client-facing full-screen overlay keeps its own .e8-present chrome, but earns
     the same focus trap (window.useDialog) + Esc-to-close as the Modal-shell dialogs. */
  const ref = React.useRef(null);
  window.useDialog(true, ref);
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);
  return (
    <div className="e8-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div ref={ref} className="e8-present" role="dialog" aria-modal="true" aria-label="Client-safe candidate view">
        <div className="e8-present-bar">
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)' }}>
            <span className="material-symbols-outlined" style={{ fontSize: 15 }}>visibility</span>
            Client-safe view - rate, contact details and internal notes are hidden
          </span>
          <span style={{ marginLeft: 'auto', display: 'inline-flex', gap: 6 }}>
            <DSc.Button variant="ghost" size="sm" icon="download" onClick={() => showToast('Exported a client-safe PDF one-pager')}>Export PDF</DSc.Button>
            <DSc.Button variant="ghost" size="sm" icon="link" onClick={() => showToast('Share link copied - expires in 14 days')}>Copy link</DSc.Button>
            <DSc.Button variant="ghost" size="sm" icon="close" iconOnly title="Close" onClick={onClose}></DSc.Button>
          </span>
        </div>
        <div className="e8-present-body">
          <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
            <DSc.Avatar name={c.name} size="xl" />
            <div>
              <h1 className="e8-h1">{c.name}</h1>
              <div style={{ fontSize: 'var(--ui-text-md)', color: 'var(--ui-text-secondary)', marginTop: 2 }}>{[c.title, c.years ? c.years + " years' experience" : null, c.location].filter(Boolean).join(' · ')}</div>
            </div>
          </div>
          <div style={{ display: 'grid', gap: 18 }}>
            <div>
              <div className="e8-sectionlabel" style={{ marginBottom: 6 }}>Summary</div>
              <p style={{ margin: 0, fontSize: 'var(--ui-text-md)', lineHeight: 1.6, color: 'var(--ui-text-secondary)', textWrap: 'pretty' }}>{c.aiSummary || prof.about}</p>
            </div>
            <div>
              <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>Core skills</div>
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                {prof.techStack.map((s) => <DSc.SkillChip key={s}>{s}</DSc.SkillChip>)}
              </div>
            </div>
            {(() => {
              const fits = window.clientSafe.reasons(c.reasons); /* R78: shared redaction (app/redact.js) */
              return fits.length ? (
                <div>
                  <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>Why this candidate fits</div>
                  <WhyMatchedCards reasons={fits} />
                </div>
              ) : null;
            })()}
            <div>
              <div className="e8-sectionlabel" style={{ marginBottom: 8 }}>Recent experience</div>
              {prof.experience.slice(0, 3).map((x, i) => (
                <div key={i} style={{ display: 'flex', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--ui-border)' }}>
                  <span style={{ flex: 1, fontSize: 'var(--ui-text-base)', fontWeight: 500 }}>{x.role} · {x.co}</span>
                  <span className="tnum" style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>{x.span}</span>
                </div>
              ))}
            </div>
          </div>
          <div style={{ marginTop: 18, paddingTop: 12, borderTop: '1px solid var(--ui-border)', fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>
            Presented by Stand8 · prepared for the hiring team
          </div>
        </div>
      </div>
    </div>
  );
}

/* Candidate ⇄ inbox bridge: every channel they're reachable on, recent thread, one click into the full inbox. */
function CandidateMessagesTab({ convo, name }) {
  if (!convo) {
    return (
      <div style={{ maxWidth: 460, margin: '24px auto' }}>
        <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 counts = {};
  convo.thread.forEach((m) => { counts[m.ch] = (counts[m.ch] || 0) + 1; });
  const recent = convo.thread.slice(-3);
  return (
    <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
        <span className="e8-sectionlabel">One thread, every channel</span>
        {Object.entries(counts).map(([ch, n]) => (
          <span key={ch} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
            <ChannelChip ch={ch} />
            <span className="tnum" style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{n}</span>
          </span>
        ))}
        <span style={{ marginLeft: 'auto' }}>
          <DSc.Button variant="secondary" size="sm" icon="forum" onClick={() => navigate('inbox/' + convo.id)}>Open in Inbox</DSc.Button>
        </span>
      </div>
      <div className="e8-card" style={{ boxShadow: 'none', padding: '12px 16px', display: 'grid', gap: 13 }}>
        {recent.map((m, i) => (
          <div key={i} className="e8-msg">
            <span className="e8-convo-avatar" style={{ marginTop: 1 }}>
              {m.agent ? <DSc.Avatar agent size="sm" /> : <DSc.Avatar name={m.who === 'me' ? (m.name || 'Sarah Kim') : name} size="sm" />}
              <ChannelDot ch={m.ch} size={12} />
            </span>
            <span style={{ flex: 1, minWidth: 0 }}>
              <span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                <span style={{ fontSize: 'var(--ui-text-sm)', fontWeight: 500 }}>{m.who === 'me' ? (m.name || 'Sarah Kim') : m.agent ? m.name : name}</span>
                <ChannelChip ch={m.ch} />
                <span className="tnum" style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>{m.when}</span>
              </span>
              <span style={{ display: 'block', fontSize: 'var(--ui-text-base)', lineHeight: 1.5, color: 'var(--ui-text)', marginTop: 3, textWrap: 'pretty' }}>{m.text}</span>
            </span>
          </div>
        ))}
      </div>
      <div style={{ fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-tertiary)' }}>
        Replies route through whichever channel you pick in the composer - email, LinkedIn, WhatsApp, or text via RingCentral.
      </div>
    </div>
  );
}

Object.assign(window, { ActorChip, NameCell, CellChip, ExpCell, WhyMatchedCards, Timeline, DashboardScreen, CandidatesBoard, CandidateBoardCard, groupByStatus, DuplicatesScreen, CandidatesScreen, NewCandidateDialog, CandidateCard, CandidateCardList, CandidateFilterSheet, CandidateDetailScreen, CandidateMessagesTab, CandidatePresentOverlay, ReassignDialog, E8ReassignDialog: ReassignDialog, MergeReviewDialog, RailGroup, SourceLabel, profileOf, e8WhenToTs, e8NoteToEvent, e8NoteEvents, e8SubmissionEvents, e8MergeEvents });
