/* Argus Workspace — three-pane reading instrument.
   ┌────────────────────┬──────────────┬──────────────┐
   │  ESSAY READING     │  INSPECTOR   │  SOURCE PAGE │
   │  (citation chips)  │  (claim/src) │  (the actual │
   │                    │  diff + log  │   page text) │
   └────────────────────┴──────────────┴──────────────┘
   The source pane is collapsible — defaults open on first view of a
   citation that has an excerpt; close button in its header tucks it
   away and the inspector grows back. */

/* Real job IDs from the backend are `uuid.uuid4()[:12]` — hex + dashes,
   never the short word-stems we use for the design-palette cohort
   (`austin`, `mira`, `jin`, …). Real reports go through `WorkspaceReport`
   which fetches `/argus/api/report/<id>` and transforms the payload into
   the workspace's essay shape. The mockup branch is preserved for the
   design-palette navigation. */
const REAL_JOB_RE = /^[a-f0-9-]{6,64}$/;
const isRealJobId = (id) => !!id && REAL_JOB_RE.test(id);

function _normalisePage(value) {
  if (value === null || value === undefined || value === "") return null;
  const n = Number(value);
  return Number.isInteger(n) && n > 0 ? n : null;
}

function _normalisePages(values) {
  return Array.isArray(values)
    ? values.map(_normalisePage).filter(p => p !== null)
    : [];
}

/* Error boundary — surfaces JSX render exceptions instead of leaving
   the workspace blank. Logs to console + shows a recoverable message. */
class WorkspaceErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { error: null }; }
  static getDerivedStateFromError(error) { return { error }; }
  componentDidCatch(error, info) {
    console.error("[Argus] workspace render crash", error, info);
  }
  render() {
    if (this.state.error) {
      return (
        <div style={{height: "100%", display: "grid", placeItems: "center", padding: 24, background: "var(--bg)"}}>
          <div style={{maxWidth: 520, textAlign: "center"}}>
            <h2 style={{fontSize: 18, fontWeight: 600, color: "var(--miss)", margin: 0}}>Workspace render error</h2>
            <p style={{margin: "12px 0", fontSize: 14, color: "var(--ink-2)"}}>
              Something in this report's shape tripped the workspace. The error has been logged to the browser console (open DevTools to copy it).
            </p>
            <pre style={{
              textAlign: "left", fontSize: 12, color: "var(--ink-3)",
              background: "var(--surface-2)", padding: "10px 12px",
              borderRadius: 6, overflow: "auto", maxHeight: 200,
            }}>{String(this.state.error?.stack || this.state.error)}</pre>
            <button className="btn primary" style={{marginTop: 14}}
                    onClick={() => this.setState({error: null})}>Try again</button>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

function ViewWorkspace({ essayId }) {
  return (
    <WorkspaceErrorBoundary>
      {isRealJobId(essayId)
        ? <WorkspaceReport jobId={essayId}/>
        : <WorkspaceEssayView essay={ESSAY_AUSTIN}/>}
    </WorkspaceErrorBoundary>
  );
}

/* ──────────────────────────────────────────────────────────────────
   Real-data path — fetch report, transform, render the same view the
   mockup uses.
   ────────────────────────────────────────────────────────────────── */
function WorkspaceReport({ jobId }) {
  const [phase, setPhase] = useState("loading");      // loading | pending | ready | error
  const [errorKind, setErrorKind] = useState(null);
  const [essay, setEssay] = useState(null);
  const [pending, setPending] = useState(null);       // {refs?, progress?, stage?, state?}

  // Pass 4 — try /report first. On 404 (report not yet compiled), drop
  // into "pending" state and poll /status for the partial preview
  // (bib + progress). Citation cards render as skeletons until /report
  // lands; then we swap to live data.
  useEffect(() => {
    let cancelled = false;
    let timer = null;

    async function tickStatus() {
      if (!Api.status) return;
      const r = await Api.status(jobId);
      if (cancelled || !r.ok || !r.data) return;
      setPending({
        state: r.data.state,
        stage: r.data.stage,
        progress: r.data.progress || {},
        refs: Array.isArray(r.data.refs) ? r.data.refs : [],
        reason: r.data.friendly_reason || null,
      });
      if (r.data.state === "failed") {
        setErrorKind("job_failed");
        setPhase("error");
      }
    }

    async function tickReport() {
      const r = await Api.report(jobId);
      if (cancelled) return;
      if (r.ok) {
        const built = reportToEssay(r.data, jobId);
        for (const [k, v] of Object.entries(built._sourcesById)) SOURCES[k] = v;
        setEssay(built);
        try { window.__ARGUS_ACTIVE_JOB_ID = jobId; } catch {/* ssr-safe */}
        setPhase("ready");
        try {
          window.dispatchEvent(new CustomEvent("argus-tab-label", {
            detail: { jobId, label: built.title || "Report" },
          }));
        } catch {}
        return;
      }
      if (r.status === 404) {
        setPhase("pending");
        await tickStatus();
        timer = setTimeout(tickReport, 4000);
        return;
      }
      setErrorKind("fetch_failed");
      setPhase("error");
    }

    setPhase("loading");
    setEssay(null);
    setErrorKind(null);
    tickReport();
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
  }, [jobId]);

  // Silent in-place refresh. The client-extension lane delivers paywalled
  // sources AFTER compile_report marks the job completed, and a background
  // verifier then rewrites the report JSON (e.g. training_fallback ->
  // verified_full). Without this, the citation pill keeps showing the stale
  // stale fallback state for the life of the open report. Called by
  // WorkspaceEssayView when a client-fetch row flips to `delivered`.
  const reloadReport = React.useCallback(async () => {
    try {
      const r = await Api.report(jobId);
      if (r && r.ok && r.data) {
        const built = reportToEssay(r.data, jobId);
        for (const [k, v] of Object.entries(built._sourcesById)) SOURCES[k] = v;
        setEssay(built);
      }
    } catch { /* best-effort live refresh; next poll retries */ }
  }, [jobId]);

  if (phase === "loading") return <WorkspaceLoadingState/>;
  if (phase === "error")   return <WorkspaceErrorState jobId={jobId} kind={errorKind} reason={pending?.reason}/>;
  if (phase === "pending") return <WorkspacePendingView jobId={jobId} pending={pending || {}}/>;
  if (!essay)              return <WorkspaceLoadingState/>;
  return <WorkspaceEssayView essay={essay} onSourcesDelivered={reloadReport}/>;
}

/* Pass 4 — pending workspace. Renders the chrome + right-pane stack
   using the partial /status payload (bib labels + progress counters).
   Centre column shows a friendly "Argus is verifying…" placeholder
   until /report lands. Citation cards in skeleton state. */
function WorkspacePendingView({ jobId, pending }) {
  const refs = pending.refs || [];
  const progress = pending.progress || {};
  // Synthetic essay shape so CitationStack/Inspector render without
  // crashing. Each ref becomes a "loading" citation.
  const essay = useMemo(() => {
    const citations = refs.map((r, i) => ({
      id: r.id || `pending_${i}`,
      label: r.label || `Citation ${i + 1}`,
      page: _normalisePages(r.cited_pages)[0] || null,
      source: null,
      verdict: null,
      pillState: null,
      claim: "",
      evidence: "",
      source_quote: "",
    }));
    return {
      id: jobId,
      title: "Awaiting analysis",
      author: "", style: progress.style_detected || "",
      discipline: progress.discipline_detected || "",
      submitted: "", word_count: 0,
      body: [], citations,
    };
  }, [jobId, refs, progress.style_detected, progress.discipline_detected]);

  const refsFound    = progress.refs_found    ?? refs.length;
  const refsResolved = progress.refs_resolved ?? 0;
  const refsVerified = progress.refs_verified ?? 0;

  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: "minmax(360px,1fr) clamp(380px, 34vw, 480px)",
      height: "100%", overflow: "hidden", minHeight: 0,
    }}>
      <div style={{
        display: "flex", flexDirection: "column", minHeight: 0,
        borderRight: "1px solid var(--hair)",
        padding: "60px 48px",
        overflow: "auto",
      }}>
        <div style={{
          maxWidth: 560, margin: "8vh auto 0", textAlign: "center",
          color: "var(--ink-3)",
        }}>
          <div style={{
            display: "inline-flex", alignItems: "center", gap: 10,
            marginBottom: 20,
          }}>
            <Spinner/>
            <span style={{fontSize: 13, color: "var(--ink-3)"}}>
              Argus is verifying the bibliography
            </span>
          </div>
          <h1 style={{
            fontSize: 22, fontWeight: 600, color: "var(--ink)",
            margin: "0 0 12px", letterSpacing: "-0.01em",
          }}>
            {refsFound} reference{refsFound === 1 ? "" : "s"} found
          </h1>
          <p style={{fontSize: 14, lineHeight: 1.55, margin: "0 0 28px"}}>
            Reading each one and asking whether the page being cited
            actually says what the essay claims it says. The findings
            panel fills in as Argus works through the list — you can
            start reviewing the moment a citation lights up.
          </p>
          <div style={{
            display: "grid", gridTemplateColumns: "repeat(3, 1fr)",
            gap: 14, marginTop: 6,
          }}>
            <PendingStat label="Found"    value={refsFound}/>
            <PendingStat label="Resolved" value={refsResolved}/>
            <PendingStat label="Verified" value={refsVerified}/>
          </div>
        </div>
      </div>
      <CitationStack essay={essay}
                     activeCite={null}
                     setActiveCite={() => {}}
                     reviewed={new Set()}
                     toggleReviewed={() => {}}
                     notes={{}}
                     setNotes={() => {}}
                     openSource={() => {}}
                     sourceOpen={false}
                     clientFetch={null}
                     filter="all" setFilter={() => {}}/>
    </div>
  );
}

function PendingStat({ label, value }) {
  return (
    <div style={{
      padding: "12px 10px", textAlign: "center",
      border: "1px solid var(--hair)", borderRadius: 8,
      background: "var(--surface)",
    }}>
      <div style={{
        fontSize: 22, fontWeight: 600, color: "var(--ink)",
        fontVariantNumeric: "tabular-nums",
      }}>
        {value}
      </div>
      <div style={{fontSize: 11, color: "var(--ink-4)", marginTop: 2}}>
        {label}
      </div>
    </div>
  );
}

function WorkspaceLoadingState() {
  return (
    <div style={{height: "100%", display: "grid", placeItems: "center", background: "var(--bg)"}}>
      <div style={{textAlign: "center"}}>
        <div style={{
          fontFamily: "var(--font-mono)", fontSize: 10.5, letterSpacing: "0.18em",
          textTransform: "uppercase", color: "var(--ink-4)",
        }}>
          Loading report
        </div>
        <div style={{
          marginTop: 10, fontSize: 14, color: "var(--ink-3)",
        }}>
          Argus is opening the dossier…
        </div>
      </div>
    </div>
  );
}

function WorkspaceErrorState({ jobId, kind, reason }) {
  const isJobFailed = kind === "job_failed";
  const eyebrow = isJobFailed ? "Analysis failed"
                : kind === "not_found" ? "Report missing"
                : "Report unavailable";
  const msg = isJobFailed
    ? (reason || "The pipeline did not complete for this essay. The submission is kept on file; re-running may resolve transient failures.")
    : kind === "not_found"
    ? "This report could not be found. It may have been deleted, or the link may be wrong."
    : "The report could not be loaded. Try again shortly.";
  return (
    <div style={{height: "100%", display: "grid", placeItems: "center", background: "var(--bg)"}}>
      <div style={{maxWidth: 460, textAlign: "center", padding: "0 24px"}}>
        <div style={{fontSize: 12, color: "var(--miss)", fontWeight: 500}}>
          {eyebrow}
        </div>
        <p style={{marginTop: 12, fontSize: 14.5, lineHeight: 1.55, color: "var(--ink-2)"}}>
          {msg}
        </p>
        <p style={{marginTop: 14, fontSize: 11.5, color: "var(--ink-5)", fontVariantNumeric: "tabular-nums"}}>
          job · {jobId}
        </p>
        {isJobFailed && window.Api && window.Api.rerun && (
          <button className="btn primary" style={{marginTop: 18}}
                  onClick={async () => {
                    if (!confirm("Re-run this job? The original failed attempt stays on record.")) return;
                    const r = await window.Api.rerun(jobId);
                    if (!r.ok) {
                      alert(r.status === 404 ? "Job not found."
                          : r.status === 403 ? "Re-run requires admin."
                          : "Couldn't queue re-run.");
                      return;
                    }
                    location.reload();
                  }}>
            <Icon.Refresh size={12}/> Re-run analysis
          </button>
        )}
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────────────
   Backend report → workspace essay transform.
   ────────────────────────────────────────────────────────────────── */
const _verificationToPill = {
  verified_full:     "verified_full",
  verified_abstract: "verified_abstract",
  training_fallback: "training_fallback",
  auth_required:     "auth_required",
  not_resolved:      "not_resolved",
  source_mismatch:   "source_mismatch",
  source_unavailable:"source_unavailable",
  page_absent:       "page_absent",
  partial_support:   "attribution_concern",
  unsupported:       "unsupported",
  supported:         "verified_full",
  no_specific_pages_requested: "verified_full",
};

const _knownDepths = new Set(["substantive", "moderate", "superficial", "inaccurate", "padding"]);

const _POLICY_SKIP_COPY = {
  standalone_book_without_page_locator: {
    label: "Skipped: standalone book citation has no page locator",
    detail: "Argus located the standalone book, but did not fetch the whole monograph because the essay did not cite a page or claim-specific locator.",
    evidence: "Source retrieval was intentionally skipped: this is a standalone book citation without a cited page or claim-specific locator, so Argus did not verify the claim against the full source text.",
  },
};

function policySkipReason(c) {
  const direct = c?.source_policy_skip_reason || c?._raw?.source_policy_skip_reason ||
    c?.retrieval?.source_policy_skip_reason || c?._raw?.retrieval?.source_policy_skip_reason || "";
  if (direct) return String(direct);
  const tag = c?.source_tag || c?._raw?.source_tag ||
    c?.retrieval?.source_tag || c?._raw?.retrieval?.source_tag || "";
  return String(tag).startsWith("policy:") ? String(tag).slice("policy:".length) : "";
}

function policySkipCopy(c) {
  const reason = policySkipReason(c);
  return reason ? (_POLICY_SKIP_COPY[reason] || {
    label: "Skipped by source policy",
    detail: reason.replace(/_/g, " "),
    evidence: `Source retrieval was intentionally skipped by policy: ${reason.replace(/_/g, " ")}.`,
  }) : null;
}

function _citationField(c, key) {
  return c?.[key] || c?._raw?.[key] || c?.retrieval?.[key] || c?._raw?.retrieval?.[key] || "";
}

function _fallbackHeadlineLabel(c) {
  if (policySkipCopy(c)) return "source skipped";
  const status = String(_citationField(c, "verification_status") || c?.pillState || "").toLowerCase();
  const retrievalStatus = String(_citationField(c, "retrieval_status") || _citationField(c, "status") || "").toLowerCase();
  const tag = String(_citationField(c, "source_tag") || _citationField(c, "source") || "").toLowerCase();
  const joined = `${status} ${retrievalStatus} ${tag}`;
  if (
    status === "source_unavailable" ||
    status === "not_resolved" ||
    joined.includes("orchestrator_unresolved") ||
    joined.includes("source_unavailable") ||
    joined.includes("no_pdf_or_full_text") ||
    joined.includes("incomplete_source") ||
    joined.includes("preview") ||
    joined.includes("metadata_only") ||
    joined.includes("auth_required")
  ) {
    return "source unavailable";
  }
  return "unverified";
}

function _sourceRetrieved(c) {
  const retrieval = c && typeof c.retrieval === "object" ? c.retrieval : {};
  return c?.source_retrieved === true ||
    retrieval.successful === true ||
    retrieval.usable === true ||
    ["pdf", "full_text", "text", "html"].includes(String(c?.retrieval_status || retrieval.status || ""));
}

function _normaliseRefIndex(value, fallback = null) {
  const n = Number(value);
  return Number.isInteger(n) && n >= 0 ? n : fallback;
}

function _pillForCitation(c) {
  const raw = c?.verification_status || "training_fallback";
  return _verificationToPill[raw] || "training_fallback";
}

function _instanceStatusFor(citation, instance) {
  const sourceStatus = String(citation?.verification_status || "training_fallback").toLowerCase();
  const explicit = instance?.verification_status;
  if (explicit) return String(explicit).toLowerCase();
  const support = String(instance?.final_page_support || instance?.page_support || "").toLowerCase();
  if (support === "unsupported") return "unsupported";
  if (support === "edition_mismatch") return "source_mismatch";
  if (support === "page_absent") return "page_absent";
  if (support === "source_unavailable") return "source_unavailable";
  if (support === "supported" || support === "no_page_claim") {
    if (sourceStatus === "verified_full" || sourceStatus === "verified_abstract") return sourceStatus;
    if (["unsupported", "page_absent", "source_unavailable", "source_mismatch"].includes(sourceStatus)) {
      return "verified_full";
    }
  }
  return sourceStatus;
}

function _pillForStatus(citation, status) {
  const raw = String(status || citation?.verification_status || "training_fallback").toLowerCase();
  return _verificationToPill[raw] || "training_fallback";
}

function _extractAuthor(source) {
  const m = String(source || "").match(/^([A-Z][A-Za-zÀ-ſ\-']+)/);
  return m ? m[1] : null;
}
function _extractYear(source) {
  const m = String(source || "").match(/\((\d{4})/);
  return m ? m[1] : null;
}
function _escapeRegex(s) { return s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); }
function _buildCitationPattern(author, year) {
  const a = _escapeRegex(author);
  const y = _escapeRegex(year);
  const ws = "[\\t \\u00A0]";
  const coAuthorTail = "(?:" + ws + "*(?:," + ws + "*(?:and" + ws + "+)?|and" + ws + "+|&" + ws + "*)[A-ZÀ-Þ][A-Za-zÀ-ſ\\-']+(?:" + ws + "+[A-ZÀ-Þ][A-Za-zÀ-ſ\\-']+)*){0,4}";
  const etAlTail = "(?:" + ws + "+et" + ws + "+al\\.?)?";
  return new RegExp(
    "(?:" +
      "\\b" + a + "(?:" + etAlTail + "|" + coAuthorTail + ")?[^)\\n(]{0,90}\\b" + y + "[a-z]?\\b\\)?" +
      "|" +
      "\\b" + a + "(?:" + etAlTail + "|" + coAuthorTail + ")?" + ws + "*\\(" + ws + "*" + y + "[a-z]?" + ws + "*\\)" +
    ")",
    "g"
  );
}

function _findBibliographyStartOffset(essayText) {
  const text = String(essayText || "");
  const lines = text.split(/\n/);
  const offsets = [];
  let pos = 0;
  for (const line of lines) {
    offsets.push(pos);
    pos += line.length + 1;
  }
  for (let i = lines.length - 1; i >= Math.floor(lines.length * 0.4); i--) {
    if (_BIB_HEADING_RE.test(lines[i])) return offsets[i];
  }
  return text.length;
}

function _sentenceBoundsForOffset(text, start, end) {
  let lo = 0;
  for (let i = Math.max(0, start - 1); i >= 0; i--) {
    if (".!?".includes(text[i])) { lo = i + 1; break; }
    if (i > 0 && text.slice(i - 1, i + 1) === "\n\n") { lo = i + 1; break; }
  }
  let hi = text.length;
  for (let i = end; i < text.length; i++) {
    if (".!?".includes(text[i])) { hi = i + 1; break; }
    if (i + 1 < text.length && text.slice(i, i + 2) === "\n\n") { hi = i; break; }
  }
  return [lo, hi];
}

function _passageForInterval(essayText, start, end) {
  const text = String(essayText || "");
  const [lo, hi] = _sentenceBoundsForOffset(text, start, end);
  return text.slice(lo, hi).replace(/[ \t]+/g, " ").replace(/ *\n+ */g, " ").trim();
}

function _intervalFromKnownOffset(essayText, cite, idx, bodyLimit) {
  const offset = Number(cite.essayOffset);
  if (!Number.isFinite(offset) || offset < 0 || offset >= bodyLimit) return null;
  const author = _extractAuthor(cite.sourceLabel || cite.label || cite.sourceTitle);
  const year = _extractYear(cite.sourceLabel || cite.label || cite.sourceTitle);
  if (!author || !year) return null;

  const lo = Math.max(0, offset - 4);
  const hi = Math.min(essayText.length, offset + 160);
  const slice = essayText.slice(lo, hi);
  const re = _buildCitationPattern(author, year);
  let m, best = null, guard = 0;
  while ((m = re.exec(slice)) !== null && guard++ < 20) {
    const start = lo + m.index;
    const end = start + m[0].length;
    if (start <= offset + 2 && end >= offset) return { start, end, citeId: cite.id, refIdx: idx };
    if (!best && start >= offset - 2) best = { start, end, citeId: cite.id, refIdx: idx };
    if (m.index === re.lastIndex) re.lastIndex++;
  }
  if (best) return best;

  const tail = essayText.slice(offset, Math.min(essayText.length, offset + 120));
  const ym = new RegExp("\\b" + _escapeRegex(year) + "[a-z]?\\b\\)?").exec(tail);
  if (ym) return { start: offset, end: offset + ym.index + ym[0].length, citeId: cite.id, refIdx: idx };
  return null;
}

function _collectCitationIntervals(essayText, citations) {
  const intervals = [];
  const bodyLimit = _findBibliographyStartOffset(essayText);
  citations.forEach((c, idx) => {
    const offsetInterval = _intervalFromKnownOffset(essayText, c, idx, bodyLimit);
    if (offsetInterval) {
      intervals.push(offsetInterval);
      return;
    }

    const author = _extractAuthor(c.sourceLabel || c.label || c.sourceTitle);
    const year = _extractYear(c.sourceLabel || c.label || c.sourceTitle);
    if (!author || !year) return;
    const re = _buildCitationPattern(author, year);
    let m, guard = 0;
    while ((m = re.exec(essayText)) !== null && guard++ < 500) {
      if (m.index < bodyLimit) {
        intervals.push({ start: m.index, end: m.index + m[0].length, citeId: c.id, refIdx: idx });
      }
      if (m.index === re.lastIndex) re.lastIndex++;
    }
  });
  intervals.sort((a, b) => a.start - b.start || a.end - b.end);
  const merged = [];
  for (const iv of intervals) {
    const last = merged[merged.length - 1];
    if (last && iv.start < last.end) continue;
    merged.push(iv);
  }
  return merged;
}

/* Block kinds emitted into essay.body:
 *   { kind: "h",   text }       — heading (essay-internal h2)
 *   { kind: "p",   text }       — prose paragraph; may contain <CITE/> markers
 *   { kind: "bibH", text }      — bibliography heading ("References", …)
 *   { kind: "bib", text }       — one bibliography entry, hanging-indent rendered
 *
 * v2.29.3 — earlier versions split the whole essay on double-newline and
 * normalised every single \n to a space, which collapsed bibliography
 * entries (one-per-line, no blank lines between) into a single illegible
 * run-on paragraph. Detect a bibliography boundary by heading-line match
 * and switch to line-per-entry rendering from there on.
 */
const _BIB_HEADING_RE = /^\s*(references?|bibliography|works\s+cited|cited\s+works|sources?|further\s+reading)\s*\.?\s*$/i;

function _buildBodyWithCiteMarkers(essayText, citations, prefix) {
  if (!essayText) return [];
  const intervals = _collectCitationIntervals(essayText, citations);
  let buf = "", cursor = 0;
  for (const iv of intervals) {
    buf += essayText.slice(cursor, iv.start);
    buf += `<CITE id="${iv.citeId || `${prefix}${iv.refIdx}`}"/>`;
    cursor = iv.end;
  }
  buf += essayText.slice(cursor);

  // Locate the bibliography boundary, if any. We search line-by-line for
  // a heading that matches _BIB_HEADING_RE — and only if the candidate
  // sits roughly in the last 40% of the document, to avoid matching a
  // section heading like "Sources of conflict" mid-essay.
  const lines = buf.split(/\n/);
  let bibStart = -1;
  for (let i = lines.length - 1; i >= Math.floor(lines.length * 0.4); i--) {
    if (_BIB_HEADING_RE.test(lines[i])) { bibStart = i; break; }
  }

  const preBibText  = bibStart >= 0 ? lines.slice(0, bibStart).join("\n") : buf;
  const bibHeading  = bibStart >= 0 ? lines[bibStart].trim() : null;
  const bibTail     = bibStart >= 0 ? lines.slice(bibStart + 1) : [];

  const out = preBibText.split(/\n\s*\n+/)
    .map(p => p.replace(/\n/g, " ").replace(/[ \t]{2,}/g, " ").trim())
    .filter(Boolean)
    .map(text => ({ kind: "p", text }));

  if (bibHeading) {
    out.push({ kind: "bibH", text: bibHeading });
    // Coalesce wrapped continuation lines into the entry above them so a
    // PDF-extracted bib with mid-entry wraps still renders one paragraph
    // per reference.
    //
    // v2.29.4 — broadened from APA-only matching ("Smith, J. (2020)") to
    // also catch Chicago author-date ("Smith, John. 2020.") and notes-
    // bibliography ("Smith, John. 2020. Title."). Heuristic: a line is an
    // entry-start if it begins with a capital and contains a 4-digit year
    // (19xx | 20xx) within the first 200 chars — that catches APA, MLA,
    // Chicago, Harvard, and Vancouver in one pattern. Numbered prefixes
    // are still recognised separately.
    const ENTRY_START_RE = /^(?:\[?\d+\]?[.)]\s+[A-ZÀ-Þ]|[A-ZÀ-Þ][^\n]{0,200}?\b(?:19|20)\d{2}[a-z]?\b)/;
    const entries = [];
    for (let raw of bibTail) {
      const line = raw.trim();
      if (!line) { if (entries.length) entries.push(null); continue; }   // blank = entry-separator marker
      if (entries.length === 0 || entries[entries.length - 1] === null || ENTRY_START_RE.test(line)) {
        if (entries.length && entries[entries.length - 1] === null) entries.pop();
        entries.push(line);
      } else {
        // Continuation of the prior entry — append with a single space.
        entries[entries.length - 1] = entries[entries.length - 1].replace(/\s+$/, "") + " " + line;
      }
    }
    for (const e of entries) if (e) out.push({ kind: "bib", text: e.replace(/[ \t]{2,}/g, " ") });
  }

  return out;
}

function _formatAnalysedDate(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return String(iso);
  const pad = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function _formatDiscipline(dd) {
  if (!dd || typeof dd !== "object") return "";
  const dom = dd.domain || dd.discipline || "";
  const sub = dd.subfield || "";
  if (dom && dom !== "unknown" && sub) return `${dom} · ${sub}`;
  if (dom && dom !== "unknown") return dom;
  return "";
}

function reportToEssay(report, jobId) {
  const meta = report.meta || {};
  const rawCitations = (report.citation_analysis && report.citation_analysis.citations) || [];
  const style = report.style_detection || {};
  const essayText = report.essay_text || "";
  const prefix = `r${jobId}_`;   // namespace for citation/source keys

  const sourcesById = {};

  const citations = [];
  rawCitations.forEach((c, idx) => {
    const sourceKey = `${prefix}src${idx}`;
    const refIdx = _normaliseRefIndex(c.ref_index, idx);
    const instances = Array.isArray(c.instances) ? c.instances : [];
    const author = _extractAuthor(c.source);
    const year = _extractYear(c.source);
    const short = (author && year)
      ? `${author} ${year}`
      : (c.source ? c.source.slice(0, 60) : "Unresolved");
    const sourceRetrieved = _sourceRetrieved(c);
    const looksResolvable = !!(author && year);
    const pagesCited = _normalisePages(c.pages_cited);
    const detectedSynthetic = instances.length
      ? []
      : _collectCitationIntervals(essayText, [{
          id: `${prefix}${idx}_scan`,
          sourceLabel: c.source,
          label: c.source,
          sourceTitle: c.source,
        }]).map(iv => ({
          essay_offset: iv.start,
          page_cited: null,
          passage: _passageForInterval(essayText, iv.start, iv.end),
          note: c.note || "",
          engagement_depth: c.engagement_depth,
          attribution_concerns: c.attribution_concerns || [],
          _aggregate_fallback: true,
        }));
    const rows = instances.length ? instances : detectedSynthetic;
    const totalMentions = rows.length || c.frequency || 1;

    sourcesById[sourceKey] = {
      id: sourceKey,
      short,
      author: author || "",
      year: year ? Number(year) : null,
      title: c.source || "",
      venue: "",
      via: c.retrieval_provider || c.retrieved_via || "—",
      retrieval_status: c.retrieval_status || c.retrieval?.status || "",
      source_retrieved: sourceRetrieved,
      pages: Number.isFinite(Number(c.source_pages)) ? Number(c.source_pages) : null,
      cited_by: totalMentions,
    };

    const makeRow = (inst, instIdx, rowCount) => {
      const page = _normalisePage(inst?.page_cited) ??
                   (pagesCited.length === 1 ? pagesCited[0] : null) ??
                   null;
      const instanceStatus = _instanceStatusFor(c, inst);
      const rawDepth = String(inst?.final_engagement_depth || inst?.engagement_depth || c.engagement_depth || "moderate").toLowerCase();
      const depth = _knownDepths.has(rawDepth) ? rawDepth : "moderate";
      const pillState = _pillForStatus(c, instanceStatus);
      const label = page != null
        ? `(${short}, ${page})`
        : (looksResolvable ? `(${short})` : (c.source || "(unresolved)"));
      const id = rowCount > 1 || inst
        ? `${prefix}${idx}_i${Math.max(0, instIdx)}`
        : `${prefix}${idx}`;
      const policyCopy = policySkipCopy(c);

      return {
        id,
        source: looksResolvable ? sourceKey : null,   // null → UnresolvedRefBlock
        sourceLabel: c.source || "",
        sourceTitle: c.source || "",
        page,
        label,
        mentionLabel: rowCount > 1 ? `Citation ${instIdx + 1}/${rowCount}` : "Citation",
        verdict: depth,
        depth,
        claim: inst?.passage || "",
        evidence: policyCopy?.evidence || inst?.note || c.note || "",
        source_quote: null,                            // backend doesn't surface this
        pillState,
        citationStatus: instanceStatus,
        essayOffset: Number.isFinite(Number(inst?.essay_offset)) ? Number(inst.essay_offset) : null,
        refIdx,
        instanceIdx: inst ? instIdx : null,
        aggregateFallback: !!inst?._aggregate_fallback,
        sourceRetrieved,
        retrievalStatus: c.retrieval_status || c.retrieval?.status || "",
        _raw: { ...c, verification_status: instanceStatus, source_verification_status: c.verification_status, ref_index: refIdx, instance_index: inst ? instIdx : null },
      };
    };

    if (rows.length) {
      rows.forEach((inst, instIdx) => citations.push(makeRow(inst, instIdx, rows.length)));
    } else {
      citations.push(makeRow(null, -1, 1));
    }
  });
  const body = _buildBodyWithCiteMarkers(essayText, citations, prefix);

  return {
    id: jobId,
    title: meta.assignment_name || "Untitled essay",
    author: meta.student_name || meta.author || "—",
    word_count: meta.word_count || 0,
    style: style.style || "—",
    discipline: _formatDiscipline(report.discipline_detection),
    submitted: _formatAnalysedDate(meta.analysed_at),
    body: body.length ? body : [{ kind: "p", text: essayText || "(essay text unavailable)" }],
    citations,
    _sourcesById: sourcesById,
  };
}

/* ──────────────────────────────────────────────────────────────────
   The three-pane reading view. Used by both the mockup branch
   (`essay = ESSAY_AUSTIN`) and the real-data branch (`essay` from
   `reportToEssay`).
   ────────────────────────────────────────────────────────────────── */
function WorkspaceEssayView({ essay, onSourcesDelivered }) {
  // Broadcast the current essay so sibling panels (NavWorkspace's
  // Outline + Citations) can mirror it instead of hardcoding ESSAY_AUSTIN.
  useEffect(() => {
    window.dispatchEvent(new CustomEvent("argus-essay-set", { detail: { essay } }));
  }, [essay]);

  const [activeCite, setActiveCite] = useState(essay.citations[0]?.id || null);
  const [reviewed, setReviewed] = useState(() => new Set());
  const [notes, setNotes] = useState({});
  const [filter, setFilter] = useState(() => firstCitationFilter(essay.citations || []));
  const [sourceOpen, setSourceOpen] = useState(true);
  const [rightTab, setRightTab] = useState("inspector"); // narrow viewports
  const [isNarrow, setIsNarrow] = useState(() => typeof window !== "undefined" && window.innerWidth < 1320);
  // Pass 3 — text-size adjuster (Aa controls in EssayHeader). Persists in
  // localStorage so a marker's preference survives reload. Three steps:
  // small 15.5, medium 16.5 (default), large 18.5.
  const [textSize, setTextSize] = useState(() => {
    try {
      const v = parseFloat(window.localStorage.getItem("argus.textSize"));
      if (Number.isFinite(v) && v >= 14 && v <= 22) return v;
    } catch {}
    return 16.5;
  });
  useEffect(() => {
    try { window.localStorage.setItem("argus.textSize", String(textSize)); } catch {}
  }, [textSize]);
  const bumpTextSize = (delta) => setTextSize(s => {
    const next = Math.max(14, Math.min(22, +(s + delta).toFixed(1)));
    return next;
  });
  const essayRef = useRef(null);

  useEffect(() => {
    setFilter(firstCitationFilter(essay.citations || []));
  }, [essay && essay.id]);

  useEffect(() => {
    const h = () => setIsNarrow(window.innerWidth < 1320);
    window.addEventListener("resize", h);
    return () => window.removeEventListener("resize", h);
  }, []);

  useEffect(() => {
    const scrollContainerTo = (node) => {
      const cont = essayRef.current;
      if (!cont || !node) return;
      const cRect = cont.getBoundingClientRect();
      const nRect = node.getBoundingClientRect();
      const delta = nRect.top - cRect.top - 24;
      cont.scrollTop = cont.scrollTop + delta;
    };
    const focusCite = (e) => {
      setActiveCite(e.detail);
      const node = essayRef.current?.querySelector(`[data-cite-id="${e.detail}"]`);
      scrollContainerTo(node);
    };
    const scrollHeading = (e) => {
      const node = essayRef.current?.querySelector(`[data-heading-idx="${e.detail}"]`);
      scrollContainerTo(node);
    };
    window.addEventListener("argus-focus-cite", focusCite);
    window.addEventListener("argus-scroll-heading", scrollHeading);
    return () => {
      window.removeEventListener("argus-focus-cite", focusCite);
      window.removeEventListener("argus-scroll-heading", scrollHeading);
    };
  }, []);

  const active = essay.citations.find(c => c.id === activeCite) || essay.citations[0] || null;
  const excerpt = active ? PAGE_EXCERPTS[active.id] : null;

  /* v2.26.0 — client-extension lane status. Polls /client-fetch/status/{job_id}
     and exposes a map keyed by ref_index. Map of ref_index → array position is
     by convention (backend writes refs in array order). Polling backs off when
     no active items remain. */
  const [clientFetchByIdx, setClientFetchByIdx] = useState({});
  // Keep the (re-created-each-render) reload callback current without
  // re-arming the poll loop, and remember which refs we've already pulled a
  // refresh for so a delivery only triggers one reload cycle.
  const onSourcesDeliveredRef = useRef(onSourcesDelivered);
  onSourcesDeliveredRef.current = onSourcesDelivered;
  const deliveredSeenRef = useRef(new Set());
  useEffect(() => {
    if (!essay || !essay.id || !window.Api?.clientFetchStatus) return;
    let cancelled = false;
    let timer = null;
    const reloadTimers = [];
    const tick = async () => {
      const r = await window.Api.clientFetchStatus(essay.id);
      if (cancelled) return;
      if (r.ok && r.data && Array.isArray(r.data.items)) {
        const map = {};
        let freshDelivery = false;
        for (const it of r.data.items) {
          map[it.ref_index] = it;
          if (it.status === "delivered"
              && !deliveredSeenRef.current.has(it.ref_index)) {
            deliveredSeenRef.current.add(it.ref_index);
            freshDelivery = true;
          }
        }
        setClientFetchByIdx(map);
        // v2.27.6 — broadcast live fetch state so chrome.jsx's
        // citation-nav can render dots neutral while the underlying
        // fetch is still in flight (rather than inheriting the stale
        // compile_report verification_status).
        window.dispatchEvent(new CustomEvent("argus-client-fetch-set", {
          detail: { essayId: essay.id, byIdx: map },
        }));
        // A client-extension delivery patches the citation's
        // verification_status on the backend a beat AFTER the row flips to
        // `delivered` (the background verifier reads the bytes, then rewrites
        // the report JSON, e.g. training_fallback -> verified_full). Re-pull
        // the report so the pill stops showing the stale fallback label.
        // Two delayed attempts absorb the verifier's write-back lag.
        if (freshDelivery && typeof onSourcesDeliveredRef.current === "function") {
          for (const delay of [2500, 9000]) {
            reloadTimers.push(setTimeout(() => {
              if (!cancelled) onSourcesDeliveredRef.current();
            }, delay));
          }
        }
        const stillBusy = r.data.items.some(it =>
          ["pending", "claimed", "deferred", "acquired"].includes(String(it.status || "")));
        timer = setTimeout(tick, stillBusy ? 6000 : 30000);
      } else {
        timer = setTimeout(tick, 30000);
      }
    };
    tick();
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
      reloadTimers.forEach(clearTimeout);
    };
  }, [essay && essay.id]);

  /* v2.27.0 — pre-flight publisher probes. The backend enqueues one probe
     per captcha-likely publisher at the front of the queue when Analyse
     runs. The probes ride through the extension's interactive lane so the
     marker can solve all captchas in one sitting before per-ref fetches
     begin. We render an unobtrusive band above the essay grid while any
     probes are active. */
  const [probes, setProbes] = useState([]);
  useEffect(() => {
    if (!essay || !essay.id || !window.Api?.clientFetchProbes) return;
    let cancelled = false;
    let timer = null;
    const tick = async () => {
      const r = await window.Api.clientFetchProbes(essay.id);
      if (cancelled) return;
      if (r.ok && r.data && Array.isArray(r.data.items)) {
        setProbes(r.data.items);
        const stillBusy = r.data.items.some(it =>
          it.status === "pending" || it.status === "claimed" || it.status === "auth_needed");
        timer = setTimeout(tick, stillBusy ? 4000 : 30000);
      } else {
        timer = setTimeout(tick, 30000);
      }
    };
    tick();
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
  }, [essay && essay.id]);

  const toggleReviewed = (id) => {
    setReviewed(prev => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };

  const concernsCount = essay.citations.filter(c => citationIsConcern(c)).length;
  const unverifiedCount = essay.citations.filter(c => citationNeedsUpload(c)).length;
  const reviewedCount = reviewed.size;

  /* v2.27.3 — live fetch-progress signal. The workspace lands as
     "completed" the moment compile_report finishes, but the client-
     extension queue can keep draining for several minutes after.
     This summary drives a small header chip so the marker has a clear
     signal that sources are still arriving rather than staring at
     unverified-looking rows wondering whether to act. Hides itself
     when there's no in-flight work. */
  const fetchProgress = useMemo(() => {
    const items = Object.values(clientFetchByIdx);
    if (items.length === 0) return null;
    let inflight = 0, delivered = 0, gated = 0;
    for (const it of items) {
      if (["pending", "claimed", "deferred", "acquired"].includes(String(it.status || ""))) inflight++;
      else if (it.status === "delivered") delivered++;
      else if (it.status === "auth_needed") gated++;
    }
    if (inflight === 0 && gated === 0) return null;
    return { inflight, delivered, gated, total: items.length };
  }, [clientFetchByIdx]);

  const openSource = () => {
    setSourceOpen(true);
    if (isNarrow) setRightTab("source");
  };

  // The third (source) pane has no useful content when the essay has zero
  // page excerpts — true for every real report today, since the backend
  // doesn't surface source-page text. Suppress the pane in that case so
  // the inspector grows out.
  const hasAnyExcerpts = useMemo(
    () => essay.citations.some(c => !!PAGE_EXCERPTS[c.id]),
    [essay]
  );
  const wideSourceOpen = sourceOpen && hasAnyExcerpts;

  // Grid: Pass 2 — citation stack is always visible (whether or not a card
  // is expanded). At narrow widths the right pane swaps between stack and
  // source via RightTabs. At wide widths source opens as a 3rd column.
  const gridCols = isNarrow
    ? "minmax(0,1fr) clamp(340px, 38vw, 460px)"
    : (wideSourceOpen
        ? "minmax(360px,1fr) clamp(360px, 26vw, 420px) clamp(380px, 28vw, 500px)"
        : "minmax(360px,1fr) clamp(380px, 34vw, 480px)");

  const wideShowInspector = !isNarrow;
  const wideShowSource = !isNarrow && wideSourceOpen;
  const narrowShowInspector = isNarrow && rightTab === "inspector";
  const narrowShowSource    = isNarrow && rightTab === "source";

  const onActivateCite = (id) => {
    setActiveCite(id);
    window.dispatchEvent(new CustomEvent("argus-focus-cite", { detail: id }));
  };

  return (
    <div style={{height: "100%", display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden"}}>
      <PreFlightBand probes={probes}/>
    <div style={{flex: 1, display: "grid", gridTemplateColumns: gridCols, overflow: "hidden", minHeight: 0}}>

      {/* ─── CENTRE: essay reading column ─── */}
      <div style={{display: "flex", flexDirection: "column", minHeight: 0, borderRight: "1px solid var(--hair)"}}>
        <EssayHeader essay={essay}
                     filter={filter} setFilter={setFilter}
                     refsCount={essay.citations.length}
                     concernsCount={concernsCount}
                     unverifiedCount={unverifiedCount}
                     reviewedCount={reviewedCount}
                     fetchProgress={fetchProgress}
                     textSize={textSize}
                     onBumpTextSize={bumpTextSize}/>
        <div ref={essayRef} style={{flex: 1, overflowY: "auto", padding: "28px clamp(20px, 5vw, 60px) 80px"}}>
          <article style={{
            maxWidth: 720, margin: "0 auto",
            fontFamily: "var(--font-serif)",
            fontSize: textSize, lineHeight: 1.72,
            color: "var(--ink)",
          }}>
            <EssayBody essay={essay} activeCite={activeCite}
                       onCiteClick={setActiveCite} filter={filter}
                       reviewed={reviewed}/>
            <div style={{marginTop: 48, padding: "16px 0", borderTop: "1px solid var(--hair)", textAlign: "center"}}>
              <span style={{fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--ink-5)", letterSpacing: "0.2em", textTransform: "uppercase"}}>
                · end of essay · {essay.citations.length} citations analysed ·
              </span>
            </div>
          </article>
        </div>
      </div>

      {/* ─── RIGHT pane: stacked citation cards (Pass 2) ───
            Narrow: single column with tabs (cards + source pane).
            Wide:   cards always visible, source pane opens as 3rd col. */}
      {(isNarrow ?
        <div style={{display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden"}}>
          <RightTabs tab={rightTab} setTab={setRightTab} hasExcerpt={!!excerpt}/>
          {narrowShowInspector &&
            <CitationStack essay={essay}
                           activeCite={activeCite} setActiveCite={setActiveCite}
                           reviewed={reviewed} toggleReviewed={toggleReviewed}
                           notes={notes} setNotes={setNotes}
                           openSource={openSource} sourceOpen={sourceOpen}
                           clientFetchByIdx={clientFetchByIdx}
                           filter={filter} setFilter={setFilter}
                           onActivateCite={onActivateCite} embedded/>}
          {narrowShowSource && active &&
            <SourcePane cite={active} excerpt={excerpt}
                        onClose={() => { setSourceOpen(false); setRightTab("inspector"); }}
                        embedded/>}
        </div>
        :
        <>
          {wideShowInspector &&
            <CitationStack essay={essay}
                           activeCite={activeCite} setActiveCite={setActiveCite}
                           reviewed={reviewed} toggleReviewed={toggleReviewed}
                           notes={notes} setNotes={setNotes}
                           openSource={openSource} sourceOpen={sourceOpen}
                           clientFetchByIdx={clientFetchByIdx}
                           filter={filter} setFilter={setFilter}
                           onActivateCite={onActivateCite}/>}
          {wideShowSource && active &&
            <SourcePane cite={active} excerpt={excerpt}
                        onClose={() => setSourceOpen(false)}/>}
        </>
      )}
    </div>
    </div>
  );
}

function RightTabs({ tab, setTab, hasExcerpt }) {
  return (
    <div style={{
      display: "flex", borderBottom: "1px solid var(--hair)",
      background: "var(--bg)", flexShrink: 0,
    }}>
      <button onClick={() => setTab("inspector")}
              style={{
                flex: 1, padding: "9px 12px",
                fontSize: 11, fontFamily: "var(--font-mono)",
                letterSpacing: "0.12em", textTransform: "uppercase",
                color: tab === "inspector" ? "var(--accent)" : "var(--ink-3)",
                background: tab === "inspector" ? "var(--accent-bg)" : "transparent",
                boxShadow: tab === "inspector" ? "inset 0 -2px 0 0 var(--accent)" : "none",
                cursor: "pointer",
                border: "none", borderRight: "1px solid var(--hair)",
              }}>
        Inspector
      </button>
      <button onClick={() => setTab("source")}
              disabled={!hasExcerpt}
              style={{
                flex: 1, padding: "9px 12px",
                fontSize: 11, fontFamily: "var(--font-mono)",
                letterSpacing: "0.12em", textTransform: "uppercase",
                color: !hasExcerpt ? "var(--ink-5)" :
                       tab === "source" ? "var(--accent)" : "var(--ink-3)",
                background: tab === "source" ? "var(--accent-bg)" : "transparent",
                boxShadow: tab === "source" ? "inset 0 -2px 0 0 var(--accent)" : "none",
                cursor: hasExcerpt ? "pointer" : "default",
                border: "none",
              }}>
        Source page
      </button>
    </div>
  );
}

/* ─────────── verdict helper ─────────── */
/* Returns the headline finding — the worse of {retrieval state, engagement
   depth}. A successful retrieval with an inaccurate claim is a worse
   finding than a successful retrieval alone; surface that.            */
/* v2.27.0 — pre-flight publisher probes band. Rendered above the workspace
   grid when an essay has captcha-likely publishers needing verification.
   Stays out of the way once all probes deliver. Findings-oriented copy:
   no instructions, just publisher + status. */
const PUBLISHER_LABELS = {
  muse: "Project MUSE",
  sage: "SAGE Journals",
  tandf: "Taylor & Francis",
};

function probeStatusText(p) {
  switch (p.status) {
    case "pending":   return "queued";
    case "claimed":   return "browser tab opening";
    case "auth_needed": return "verification open in browser tab";
    case "delivered": return "cleared";
    case "failed":    return p.error_message || "could not verify";
    default:          return p.status;
  }
}

function probeStatusTone(s) {
  if (s === "delivered") return "good";
  if (s === "failed") return "miss";
  if (s === "auth_needed") return "miss";
  return "warn";
}

function PreFlightBand({ probes }) {
  if (!probes || probes.length === 0) return null;
  const active = probes.filter(p => p.status !== "delivered");
  if (active.length === 0) return null;  // all cleared — hide band entirely
  const tones = {
    warn: { bg: "var(--warn-bg)", fg: "var(--warn-ink)" },
    miss: { bg: "var(--miss-bg)", fg: "var(--miss-ink)" },
    good: { bg: "var(--good-bg)", fg: "var(--good-ink)" },
  };
  const anyGated = active.some(p => p.status === "auth_needed" || p.status === "failed");
  const headline = anyGated
    ? `Pre-flight verification needed (${active.length})`
    : `Pre-flight in progress (${active.length})`;
  return (
    <div style={{
      padding: "10px 24px",
      borderBottom: "1px solid var(--hair)",
      background: "var(--panel-2)",
      display: "flex",
      alignItems: "center",
      gap: 18,
      flexWrap: "wrap",
      fontSize: 12.5,
    }}>
      <span style={{
        fontFamily: "var(--font-mono)",
        fontSize: 10.5,
        letterSpacing: "0.16em",
        textTransform: "uppercase",
        color: "var(--ink-5)",
      }}>{headline}</span>
      {active.map(p => {
        const tone = tones[probeStatusTone(p.status)] || tones.warn;
        return (
          <span key={p.id} style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 8,
            padding: "4px 10px",
            borderRadius: 4,
            background: tone.bg,
            color: tone.fg,
            fontSize: 12,
          }}>
            <strong style={{fontWeight: 600}}>
              {PUBLISHER_LABELS[p.publisher] || p.publisher}
            </strong>
            <span style={{opacity: 0.75}}>· {probeStatusText(p)}</span>
          </span>
        );
      })}
      {anyGated &&
        <span style={{
          color: "var(--ink-4)",
          fontSize: 11.5,
          marginLeft: "auto",
        }}>
          Solve the captcha in the open browser tab to clear this publisher for the whole essay.
        </span>}
    </div>
  );
}

/* v2.26.0 — translate client-extension queue state into a band descriptor.
   Returns null when the lane is not in play for this ref. Tones are
   constrained to the existing palette per CLAUDE.md aesthetic rule:
     warn (amber)   → in-flight (pending, claimed)
     miss (warm)    → permanent failure (failed, auth_needed)
   Delivered rows render nothing — the patched citation now carries the
   real verdict, and Inspector's normal banner handles it. */
function clientExtensionBand(cf) {
  if (!cf) return null;
  switch (cf.status) {
    case "deferred":
      return { tone: "warn", label: "Waiting for report context before source retrieval starts." };
    case "pending":
      return { tone: "warn", label: "Queued for your browser — waiting for the Argus Companion extension." };
    case "claimed":
      return { tone: "warn", label: "Fetching through your library session…" };
    case "acquired":
      return { tone: "warn", label: "Source retrieved; verification is being patched into the report." };
    case "auth_needed":
      return {
        tone: "miss",
        label: "Captcha or login required. Solve it in your browser, then retry.",
        retryable: true,
      };
    case "failed":
      return {
        tone: "miss",
        label: cf.error_message || "Browser fetch could not retrieve this source. Try a manual upload.",
        retryable: true,
      };
    case "delivered":
    default:
      return null;
  }
}

function clientFetchForCitation(cite, clientFetchByIdx) {
  if (!cite || !clientFetchByIdx || !Number.isFinite(Number(cite.refIdx))) return null;
  const cf = clientFetchByIdx[Number(cite.refIdx)];
  if (!cf) return null;
  if (cite.sourceRetrieved && ["auth_needed", "failed"].includes(String(cf.status || ""))) return null;
  return cf;
}

function isClientFetchPending(cf) {
  return ["pending", "claimed", "deferred", "acquired"].includes(String(cf?.status || "").toLowerCase());
}

function headlineVerdict(cite, clientFetch = null) {
  if (isClientFetchPending(clientFetch)) {
    return { tone: "warn", label: "source pending", secondary: null };
  }
  if (policySkipCopy(cite)) {
    return { tone: "warn", label: "source skipped", secondary: null };
  }

  const pill = VERDICTS[cite.pillState];
  const depth = DEPTHS[cite.depth];

  // hard retrieval failures dominate
  if (["not_resolved", "source_mismatch", "unsupported"].includes(cite.pillState)) {
    return { tone: pill.tone, label: pill.label, secondary: null };
  }
  // a successful retrieval with an inaccurate verdict — depth wins
  if (cite.depth === "inaccurate") {
    return { tone: "miss", label: "inaccurate", secondary: pill };
  }
  // warn-tier retrieval issues
  if (["training_fallback", "attribution_concern", "source_unavailable", "auth_required", "page_absent"].includes(cite.pillState)) {
    const label = ["training_fallback", "source_unavailable", "auth_required"].includes(cite.pillState)
      ? _fallbackHeadlineLabel(cite)
      : pill.label;
    return { tone: pill.tone, label, secondary: depth ? { label: depth.label, tone: depth.tone, pillLabel: depth.label } : null };
  }
  // depth-led warnings
  if (["padding", "superficial"].includes(cite.depth)) {
    return { tone: depth.tone, label: depth.label, secondary: pill };
  }
  // verified_abstract is a partial success
  if (cite.pillState === "verified_abstract") {
    return { tone: pill.tone, label: pill.label, secondary: depth ? { label: depth.label, tone: depth.tone, pillLabel: depth.label } : null };
  }
  // healthy: depth carries the headline
  return {
    tone: depth?.tone || pill?.tone || "neutral",
    label: depth?.label || pill?.label || "—",
    secondary: pill,
  };
}

/* ─────────── essay header ─────────── */
function FetchProgressChip({ progress }) {
  if (!progress) return null;
  const { inflight, delivered, gated, total } = progress;
  const labelBits = [`${delivered}/${total} retrieved`];
  if (inflight > 0) labelBits.push(`${inflight} in flight`);
  if (gated > 0) labelBits.push(`${gated} awaiting verification`);
  const tone = gated > 0 ? "miss" : "warn";
  const bg = tone === "miss" ? "var(--miss-bg)" : "var(--warn-bg)";
  const fg = tone === "miss" ? "var(--miss-ink)" : "var(--warn-ink)";
  return (
    <span style={{
      display: "inline-flex", alignItems: "center", gap: 8,
      padding: "4px 10px",
      borderRadius: 4,
      background: bg, color: fg,
      fontFamily: "var(--font-mono)",
      fontSize: 11,
      letterSpacing: "0.04em",
    }} title="Sources still being fetched through your authenticated browser session. Citations patch in place as bytes arrive.">
      <span style={{
        width: 6, height: 6, borderRadius: "50%",
        background: "currentColor",
        opacity: inflight > 0 ? 1 : 0.5,
        animation: inflight > 0 ? "argusPulse 1.6s ease-in-out infinite" : "none",
      }}/>
      <span>Sources fetching · {labelBits.join(" · ")}</span>
    </span>
  );
}

function EssayHeader({ essay, filter, setFilter, refsCount, concernsCount, unverifiedCount, reviewedCount, fetchProgress, textSize, onBumpTextSize }) {
  return (
    <header style={{padding: "16px clamp(16px, 3vw, 28px) 12px", borderBottom: "1px solid var(--hair)", background: "var(--bg)"}}>
      <div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 16}}>
        <div style={{minWidth: 0}}>
          <p style={{fontSize: 12, color: "var(--ink-3)", margin: 0, fontWeight: 500}}>
            {essay.course ? `Source analysis · ${essay.course}${essay.cohort ? ` · ${essay.cohort}` : ""}` : "Source analysis"}
          </p>
          <h1 style={{margin: "3px 0 0", fontWeight: 600, fontSize: "clamp(18px, 1.4vw + 8px, 24px)", color: "var(--ink)", letterSpacing: "-0.01em", lineHeight: 1.25}}>
            {essay.title}
          </h1>
          <p style={{margin: "4px 0 0", fontSize: 12, color: "var(--ink-4)"}}>
            {[essay.author, essay.word_count ? `${essay.word_count} words` : null, essay.style, essay.submitted].filter(Boolean).join(" · ")}
          </p>
        </div>
        <div style={{display: "flex", gap: 6, flexShrink: 0, alignItems: "center"}}>
          {/* Pass 3 — text-size adjuster (Aa). Affects essay reading column only. */}
          {onBumpTextSize && (
            <div role="group" aria-label="Text size" style={{
              display: "inline-flex", alignItems: "center",
              border: "1px solid var(--hair)", borderRadius: 8,
              overflow: "hidden",
            }}>
              <button onClick={() => onBumpTextSize(-1)}
                      aria-label="Decrease text size"
                      title="Decrease text size"
                      style={{
                        padding: "5px 10px", background: "transparent", border: "none",
                        cursor: textSize > 14 ? "pointer" : "default",
                        color: textSize > 14 ? "var(--ink-2)" : "var(--ink-5)",
                        fontSize: 11, fontWeight: 500,
                      }}>
                A−
              </button>
              <div style={{width: 1, background: "var(--hair)", alignSelf: "stretch"}}/>
              <button onClick={() => onBumpTextSize(1)}
                      aria-label="Increase text size"
                      title="Increase text size"
                      style={{
                        padding: "5px 10px", background: "transparent", border: "none",
                        cursor: textSize < 22 ? "pointer" : "default",
                        color: textSize < 22 ? "var(--ink-2)" : "var(--ink-5)",
                        fontSize: 13, fontWeight: 500,
                      }}>
                A+
              </button>
            </div>
          )}
          <button className="btn ghost sm" title="Export references-only offprint"><Icon.Download size={11}/> PDF</button>
          <button className="btn ghost sm"><Icon.Note size={11}/> Notes</button>
        </div>
      </div>

      {/* Tally + filter chips removed — counts + filter both live in
          the right-pane Citations & findings header (no duplication).
          FetchProgressChip kept inline since it's not duplicated. */}
      {fetchProgress && (
        <div style={{marginTop: 8}}>
          <FetchProgressChip progress={fetchProgress}/>
        </div>
      )}
    </header>
  );
}

/* ─────────── essay body ─────────── */
function EssayBody({ essay, activeCite, onCiteClick, filter, reviewed }) {
  const passFilter = (c) => {
    return citationMatchesFilter(c, filter);
  };
  // give each h-block a stable numeric index so the nav can scroll to it
  let headingIdx = -1;
  return essay.body.map((block, idx) => {
    if (block.kind === "h") {
      headingIdx += 1;
      const hid = headingIdx;
      return <h2 key={idx} data-heading-idx={hid} style={{
        fontFamily: "var(--font-serif)", fontWeight: 600,
        fontSize: 22, color: "var(--ink)",
        marginTop: idx === 0 ? 0 : 36, marginBottom: 10,
        letterSpacing: "-0.005em",
        scrollMarginTop: 20,
      }}>{block.text}</h2>;
    }
    if (block.kind === "p") {
      const parts = block.text.split(/(<CITE id="[^"]+"\/>)/);
      return (
        <p key={idx} style={{margin: "0 0 16px"}}>
          {parts.map((p, i) => {
            const m = p.match(/<CITE id="([^"]+)"\/>/);
            if (m) {
              const c = essay.citations.find(c => c.id === m[1]);
              if (!c) return null;
              return <CitationChip key={i} cite={c}
                                   active={activeCite === c.id}
                                   reviewed={reviewed.has(c.id)}
                                   onClick={() => onCiteClick(c.id)}
                                   dim={!passFilter(c)}/>;
            }
            return <span key={i}>{p}</span>;
          })}
        </p>
      );
    }
    if (block.kind === "bibH") {
      // Bibliography heading — same scale as essay h2 but with a top
      // separator so the marker can tell prose has ended.
      return <h2 key={idx} style={{
        fontFamily: "var(--font-serif)", fontWeight: 600,
        fontSize: 20, color: "var(--ink)",
        marginTop: 40, marginBottom: 14,
        paddingTop: 24, borderTop: "1px solid var(--hair)",
        letterSpacing: "-0.005em",
      }}>{block.text}</h2>;
    }
    if (block.kind === "bib") {
      // One bibliography entry per paragraph, hanging-indent so author
      // surnames align flush-left and continuation lines indent under
      // the title — APA/Chicago convention; works for MLA too.
      const parts = block.text.split(/(<CITE id="[^"]+"\/>)/);
      return (
        <p key={idx} style={{
          margin: "0 0 10px",
          paddingLeft: "2em", textIndent: "-2em",
          fontSize: "0.95em", lineHeight: 1.5,
          color: "var(--ink-2)",
        }}>
          {parts.map((p, i) => {
            const m = p.match(/<CITE id="([^"]+)"\/>/);
            if (m) {
              // Bibliography sometimes contains in-text patterns that
              // look like citations; render them as plain text so the
              // entry stays readable. The original chip will still
              // appear in the body where it actually was cited.
              return <span key={i}>{p}</span>;
            }
            return <span key={i}>{p}</span>;
          })}
        </p>
      );
    }
    return null;
  });
}

/* ─────────── citation chip ─────────── */
function CitationChip({ cite, active, reviewed, onClick, dim }) {
  const tone = headlineVerdict(cite).tone;
  const src = cite.source ? SOURCES[cite.source] : null;
  const labelText = src ? cite.label
    : `[${cite.label.length > 40 ? cite.label.slice(0, 40) + "…" : cite.label}]`;

  return (
    <span data-cite-id={cite.id}
          onClick={onClick}
          role="button"
          style={{
            display: "inline-flex", alignItems: "baseline", gap: 4,
            padding: "1px 6px 2px",
            margin: "0 1px",
            borderRadius: 3,
            fontFamily: "var(--font-mono)",
            fontSize: 12.5,
            letterSpacing: "0.005em",
            cursor: "pointer",
            color: active ? "var(--ink)" : `var(--${tone})`,
            background: active ? `var(--${tone}-bg)` : "transparent",
            boxShadow: active
              ? `inset 0 0 0 1px var(--${tone})`
              : `inset 0 -1px 0 0 var(--${tone})`,
            opacity: dim ? 0.3 : 1,
            transition: "all 90ms ease",
            whiteSpace: "nowrap",
          }}>
      <span className={"vdot " + tone} style={{width: 5, height: 5, transform: "translateY(-1px)"}}/>
      <span>{labelText}</span>
      {reviewed && <Icon.Check size={10} stroke={2} style={{color: "var(--ok)", transform: "translateY(1px)"}}/>}
    </span>
  );
}

function Tally({ label, value, tone }) {
  return (
    <span style={{display: "inline-flex", alignItems: "baseline", gap: 5}}>
      <span style={{fontSize: 14, fontWeight: 600, fontVariantNumeric: "tabular-nums", color: tone ? `var(--${tone})` : "var(--ink)"}}>{value}</span>
      <span style={{fontSize: 12, color: "var(--ink-4)"}}>{label}</span>
    </span>
  );
}

/* ════════════════════════════════════════════════════════════════
   INSPECTOR — redesigned around a Claim ↔ Source diff.
   New hierarchy:
   1. Verdict banner (large, the eye anchors here)
   2. Diff card — essay says / source says, with the verdict glyph
      between them
   3. Verifier note (single paragraph)
   4. Source metadata (small chip card)
   5. More — collapsible: retrieval log, mismatch detail
   6. Actions row — sticky on scroll
   7. Note
   ════════════════════════════════════════════════════════════════ */
/* ─────────── CITATION STACK (Pass 2 — stacked-cards model) ───────────
   Replaces single-citation Inspector with a vertical stack of expandable
   cards. One card expanded at a time (driven by activeCite). Skeleton +
   spinner state for unresolved citations (Pass 4 will firm up detection).
   Filter tabs at top, scrollable list below.
*/
function CitationStack({
  essay, activeCite, setActiveCite, reviewed, toggleReviewed,
  notes, setNotes, openSource, sourceOpen, clientFetchByIdx,
  filter, setFilter, onActivateCite, embedded,
}) {
  const cites = essay.citations || [];
  const groups = useMemo(() => buildCitationGroups(cites), [cites]);
  const filteredGroups = groups
    .map(group => ({
      ...group,
      visibleMentions: group.mentions.filter(c => citationMatchesFilter(c, filter)),
    }))
    .filter(group => group.visibleMentions.length > 0);
  const emptyMessage = citationFilterEmptyMessage(filter);

  return (
    <aside style={{
      background: "var(--bg)",
      display: "flex", flexDirection: "column",
      overflow: "hidden", minHeight: 0,
      borderRight: embedded ? "none" : "1px solid var(--hair)",
    }}>
      <CitationStackHeader cites={cites} groups={groups} reviewed={reviewed}
                           filter={filter} setFilter={setFilter}/>
      <div style={{flex: 1, overflowY: "auto"}}>
        {filteredGroups.map(group => (
          <CitationGroupCard key={group.key} group={group} essay={essay}
            activeCite={activeCite} setActiveCite={setActiveCite}
            reviewedSet={reviewed}
            toggleReviewed={toggleReviewed}
            notes={notes}
            setNotes={setNotes}
            onOpenSource={openSource}
            sourceOpen={sourceOpen}
            clientFetchByIdx={clientFetchByIdx}
            onActivateCite={onActivateCite}/>
        ))}
        {filteredGroups.length === 0 && (
          <div style={{
            padding: "48px 24px", textAlign: "center",
            color: "var(--ink-4)", fontSize: 13,
          }}>
            {emptyMessage}
          </div>
        )}
      </div>
    </aside>
  );
}

function citationFilterEmptyMessage(filter) {
  if (filter === "concerns") return "No citation concerns found.";
  if (filter === "needs_source") return "No citations need an additional source.";
  if (filter === "skipped") return "No citations were skipped.";
  if (filter === "verified") return "No citations are fully verified yet.";
  return "No citations match this section.";
}

function citationMatchesFilter(c, filter) {
  if (filter === "all") return true;
  if (filter === "verified") return citationIsVerified(c);
  if (filter === "concerns") return citationIsConcern(c);
  if (filter === "needs_source") return citationIsSourceIssue(c);
  if (filter === "skipped") return citationIsPolicySkipped(c);
  return true;
}

function firstCitationFilter(cites) {
  const filters = ["verified", "concerns", "needs_source", "skipped"];
  return filters.find(filter => (cites || []).some(cite => citationMatchesFilter(cite, filter))) || "concerns";
}

function citationIsPolicySkipped(cite) {
  return !!policySkipCopy(cite);
}

function citationIsSourceIssue(cite, clientFetch = null) {
  if (!cite || (!cite.verdict && !cite.source && !cite.pillState)) return false;
  if (isClientFetchPending(clientFetch)) return false;
  if (citationIsPolicySkipped(cite)) return false;
  return [
    "training_fallback",
    "not_resolved",
    "verified_abstract",
    "auth_required",
    "source_unavailable",
    "page_absent",
  ].includes(cite.pillState);
}

function citationIsConcern(cite, clientFetch = null) {
  if (!cite || (!cite.verdict && !cite.source && !cite.pillState)) return false;
  if (isClientFetchPending(clientFetch)) return false;
  if (citationIsPolicySkipped(cite)) return false;
  if (citationIsSourceIssue(cite, clientFetch)) return false;
  if (["source_mismatch", "unsupported"].includes(cite.pillState)) return true;
  return ["inaccurate", "padding", "superficial"].includes(cite.depth || cite.verdict)
    || (Array.isArray(cite._raw?.attribution_concerns) && cite._raw.attribution_concerns.length > 0);
}

function citationIsVerified(cite, clientFetch = null) {
  if (!cite || (!cite.verdict && !cite.source && !cite.pillState)) return false;
  if (citationIsConcern(cite, clientFetch) || citationIsSourceIssue(cite, clientFetch) || citationIsPolicySkipped(cite)) return false;
  const tone = headlineVerdict(cite, clientFetch).tone;
  return tone === "good" || tone === "ok";
}

function citationNeedsUpload(cite, clientFetch = null) {
  return citationIsSourceIssue(cite, clientFetch);
}

function citationUploadRefIdx(cite) {
  return cite && cite._raw && Number.isFinite(Number(cite._raw.ref_index))
    ? Number(cite._raw.ref_index)
    : null;
}

function citationBaseLabel(cite) {
  const src = cite.source ? SOURCES[cite.source] : null;
  if (src && src.short) return src.short;
  const sourceText = cite.sourceLabel || cite.sourceTitle || "";
  const author = _extractAuthor(sourceText);
  const year = _extractYear(sourceText);
  if (author && year) return `${author} ${year}`;
  return String(cite.label || sourceText || "Unresolved reference")
    .replace(/,\s*(?:p{1,2}\.?\s*)?\d[\d–-]*(?=\))/i, "")
    .slice(0, 96);
}

function citationSourceTitle(cite) {
  const src = cite.source ? SOURCES[cite.source] : null;
  return (src && src.title) || cite.sourceTitle || cite.sourceLabel || cite.label || "";
}

function citationGroupKey(cite, idx) {
  if (Number.isFinite(Number(cite.refIdx))) return `ref:${Number(cite.refIdx)}`;
  if (cite.source) return `source:${cite.source}`;
  const label = citationSourceTitle(cite);
  return label ? `source-label:${label}` : `cite:${cite.id || idx}`;
}

function toneWeight(tone) {
  return {
    miss: 6, warn: 5, accent: 4, gold: 4,
    neutral: 3, good: 2, ok: 1,
  }[tone || "neutral"] ?? 3;
}

function clientFetchForMention(cite, clientFetchByIdx) {
  if (!clientFetchByIdx || !Number.isFinite(Number(cite?.refIdx))) return null;
  return clientFetchByIdx[Number(cite.refIdx)] || null;
}

function groupHeadline(mentions, clientFetchByIdx = null) {
  const ranked = mentions
    .map(cite => ({ cite, headline: headlineVerdict(cite, clientFetchForMention(cite, clientFetchByIdx)) }))
    .sort((a, b) => toneWeight(b.headline.tone) - toneWeight(a.headline.tone));
  return ranked[0]?.headline || { tone: "neutral", label: "—" };
}

function buildCitationGroups(cites) {
  const byKey = new Map();
  cites.forEach((cite, idx) => {
    const key = citationGroupKey(cite, idx);
    if (!byKey.has(key)) {
      byKey.set(key, {
        key,
        label: citationBaseLabel(cite),
        sourceTitle: citationSourceTitle(cite),
        mentions: [],
      });
    }
    byKey.get(key).mentions.push(cite);
  });
  return Array.from(byKey.values()).map(group => {
    const headline = groupHeadline(group.mentions);
    return {
      ...group,
      headline,
      tone: headline.tone || "neutral",
      concernCount: group.mentions.filter(c => citationMatchesFilter(c, "concerns")).length,
      sourceNeededCount: group.mentions.filter(c => citationMatchesFilter(c, "needs_source")).length,
      skippedCount: group.mentions.filter(c => citationMatchesFilter(c, "skipped")).length,
      verifiedCount: group.mentions.filter(c => citationMatchesFilter(c, "verified")).length,
    };
  });
}

function citationGroupSubline(group, visibleMentions) {
  const total = group.mentions.length;
  const shown = visibleMentions ? visibleMentions.length : total;
  const visible = visibleMentions || group.mentions;
  const concernCount = visible.filter(c => citationMatchesFilter(c, "concerns")).length;
  const sourceNeededCount = visible.filter(c => citationMatchesFilter(c, "needs_source")).length;
  const skippedCount = visible.filter(c => citationMatchesFilter(c, "skipped")).length;
  const verifiedCount = visible.filter(c => citationMatchesFilter(c, "verified")).length;
  const bits = [
    `${shown} mention${shown === 1 ? "" : "s"}`,
  ];
  if (concernCount > 0) bits.push(`${concernCount} concern${concernCount === 1 ? "" : "s"}`);
  else if (sourceNeededCount > 0) bits.push(`${sourceNeededCount} needs source`);
  else if (skippedCount > 0) bits.push(`${skippedCount} skipped`);
  else if (verifiedCount > 0) bits.push("verified");
  else if (group.headline?.label) bits.push(group.headline.label);
  return bits.join(" · ");
}

function CitationStackHeader({ cites, groups, reviewed, filter, setFilter }) {
  const counts = {
    verified:     cites.filter(c => citationMatchesFilter(c, "verified")).length,
    concerns:     cites.filter(c => citationMatchesFilter(c, "concerns")).length,
    needs_source: cites.filter(c => citationMatchesFilter(c, "needs_source")).length,
    skipped:      cites.filter(c => citationMatchesFilter(c, "skipped")).length,
  };
  const tabs = [
    { k: "verified",     label: "Verified",     n: counts.verified     },
    { k: "concerns",     label: "Concerns",     n: counts.concerns     },
    { k: "needs_source", label: "Needs source", n: counts.needs_source },
    { k: "skipped",      label: "Skipped",      n: counts.skipped      },
  ];
  return (
    <div style={{
      padding: "14px 16px 12px",
      borderBottom: "1px solid var(--hair)",
      flexShrink: 0, background: "var(--bg)",
    }}>
      <div style={{
        fontSize: 13, fontWeight: 600,
        color: "var(--ink-2)", marginBottom: 10,
        letterSpacing: "-0.005em",
      }}>
        Citation analysis
      </div>
      <div style={{
        fontSize: 11.5, color: "var(--ink-4)", margin: "-5px 0 10px",
        fontFamily: "var(--font-mono)",
      }}>
        {groups.length} reference{groups.length === 1 ? "" : "s"} · {cites.length} mention{cites.length === 1 ? "" : "s"}
      </div>
      <div style={{display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 6}}>
        {tabs.map(t => (
          <button key={t.k} onClick={() => setFilter(t.k)}
            style={{
              padding: "7px 10px",
              border: "1px solid " + (filter === t.k ? "var(--accent-dim)" : "var(--hair)"),
              background: filter === t.k ? "var(--accent-bg)" : "transparent",
              color: filter === t.k ? "var(--accent)" : "var(--ink-3)",
              borderRadius: 6, fontSize: 12.5, fontWeight: 500,
              cursor: "pointer", lineHeight: 1,
              display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8,
            }}>
            <span style={{overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{t.label}</span>
            <span style={{opacity: 0.55, fontWeight: 400}}>{t.n}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

function CitationGroupCard({
  group, essay, activeCite, setActiveCite, reviewedSet, toggleReviewed,
  notes, setNotes, onOpenSource, sourceOpen, clientFetchByIdx, onActivateCite,
}) {
  const visibleMentions = group.visibleMentions || group.mentions;
  const expanded = visibleMentions.some(cite => cite.id === activeCite);
  const uploadCite = visibleMentions.find(cite => citationNeedsUpload(cite, clientFetchForMention(cite, clientFetchByIdx)));
  const uploadRefIdx = citationUploadRefIdx(uploadCite);
  const subline = citationGroupSubline(group, visibleMentions);
  const activate = (id) => {
    setActiveCite(id);
    if (id && onActivateCite) onActivateCite(id);
  };

  return (
    <div style={{
      borderBottom: "1px solid var(--hair)",
      background: expanded ? "var(--surface)" : "transparent",
    }}>
      <button onClick={() => activate(expanded ? null : visibleMentions[0]?.id)}
              aria-expanded={expanded}
              title={group.sourceTitle && group.sourceTitle !== group.label ? group.sourceTitle : undefined}
        style={{
          width: "100%", padding: "14px 18px",
          background: "transparent", border: "none", cursor: "pointer",
          display: "flex", alignItems: "center", gap: 12, textAlign: "left",
          color: "inherit", fontFamily: "inherit",
        }}>
        <div style={{flex: 1, minWidth: 0}}>
          <div style={{
            fontSize: 14, fontWeight: 600, color: "var(--ink)",
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>
            {group.label}
          </div>
          <div style={{
            fontSize: 12, color: "var(--ink-3)", marginTop: 3,
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>
            {subline}
          </div>
        </div>
        {uploadRefIdx !== null && essay && essay.id && (
          <span onClick={(e) => e.stopPropagation()}
                style={{flexShrink: 0, marginRight: 4}}>
            <UploadAffordance
              jobId={essay.id}
              refIdx={uploadRefIdx}
              reason={null}
              compact={true}
              onUploaded={() => window.location && window.location.reload && window.location.reload()}/>
          </span>
        )}
        <span style={{
          color: "var(--ink-4)", fontSize: 14, marginLeft: 4,
          display: "inline-block",
          transform: expanded ? "rotate(180deg)" : "none",
          transition: "transform 0.15s ease",
        }}>▾</span>
      </button>

      {expanded && (
        <div style={{padding: "0 12px 14px 40px"}}>
          {visibleMentions.map(cite => (
            <CitationCard key={cite.id} cite={cite} essay={essay}
              nested
              expanded={activeCite === cite.id}
              onToggle={() => activate(activeCite === cite.id ? null : cite.id)}
              reviewed={reviewedSet.has(cite.id)}
              onToggleReviewed={() => toggleReviewed(cite.id)}
              note={notes[cite.id] || ""}
              onSetNote={(v) => setNotes(prev => ({...prev, [cite.id]: v}))}
              onOpenSource={onOpenSource}
              sourceOpen={sourceOpen}
              clientFetch={clientFetchForCitation(cite, clientFetchByIdx)}
              onActivateCite={onActivateCite}/>
          ))}
        </div>
      )}
    </div>
  );
}

function CitationCard({
  cite, essay, expanded, onToggle, reviewed, onToggleReviewed,
  note, onSetNote, onOpenSource, sourceOpen, clientFetch, onActivateCite,
  nested,
}) {
  const headline = headlineVerdict(cite, clientFetch);
  const tone = headline.tone || "neutral";
  // Loading state heuristic — Pass 4 will firm this up with actual
  // client_fetch_queue introspection. For now: no verdict, no source,
  // no pillState = still resolving.
  const isLoading = !cite.verdict && !cite.source && !cite.pillState;
  // v2.29.0 — surface a "needs upload" affordance on the collapsed card
  // row for any pillState where a manual source would rescue the verdict.
  // Without this, the upload control is only reachable after the user
  // expands the card.
  const needsUpload = citationNeedsUpload(cite, clientFetch);
  const uploadRefIdx = cite._raw && Number.isFinite(Number(cite._raw.ref_index))
    ? Number(cite._raw.ref_index) : null;
  const subline = [cite.mentionLabel, cite.aggregateFallback ? "aggregate verdict" : null, headline.label].filter(Boolean).join(" · ");

  return (
    <div style={{
      borderBottom: nested ? "none" : "1px solid var(--hair)",
      border: nested ? "1px solid var(--hair)" : "none",
      borderRadius: nested ? 7 : 0,
      background: expanded || nested ? "var(--surface)" : "transparent",
      marginBottom: nested ? 8 : 0,
      overflow: nested ? "hidden" : "visible",
    }}>
      <button onClick={onToggle} aria-expanded={expanded}
        style={{
          width: "100%", padding: nested ? "10px 12px" : "14px 18px",
          background: "transparent", border: "none", cursor: "pointer",
          display: "flex", alignItems: "center", gap: nested ? 10 : 12, textAlign: "left",
          color: "inherit", fontFamily: "inherit",
        }}>
        <span className={"vdot " + tone}
              style={{width: nested ? 8 : 10, height: nested ? 8 : 10, flexShrink: 0}}/>
        <div style={{flex: 1, minWidth: 0}}>
          <div style={{
            fontSize: nested ? 13.5 : 14, fontWeight: nested ? 500 : 500, color: "var(--ink)",
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>
            {cite.label}
          </div>
          {subline && (
            <div style={{
              fontSize: 12, color: "var(--ink-3)", marginTop: 3,
              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
            }}>
              {subline}
            </div>
          )}
        </div>
        {needsUpload && uploadRefIdx !== null && essay && essay.id && (
          <span onClick={(e) => e.stopPropagation()}
                style={{flexShrink: 0, marginRight: 4}}>
            <UploadAffordance
              jobId={essay.id}
              refIdx={uploadRefIdx}
              reason={null}
              compact={true}
              onUploaded={() => window.location && window.location.reload && window.location.reload()}/>
          </span>
        )}
        {isLoading && <Spinner/>}
        <span style={{
          color: "var(--ink-4)", fontSize: 14, marginLeft: 4,
          display: "inline-block",
          transform: expanded ? "rotate(180deg)" : "none",
          transition: "transform 0.15s ease",
        }}>▾</span>
      </button>
      {expanded && !isLoading && (
        <div style={{padding: "0 14px 16px"}}>
          <Inspector essay={essay} cite={cite}
            reviewed={reviewed} onToggleReviewed={onToggleReviewed}
            note={note} onSetNote={onSetNote}
            onOpenSource={onOpenSource} sourceOpen={sourceOpen}
            clientFetch={clientFetch} onActivateCite={onActivateCite}
            inStack/>
        </div>
      )}
    </div>
  );
}

function Spinner() {
  return (
    <span aria-label="Loading" style={{
      display: "inline-block",
      width: 14, height: 14,
      border: "2px solid var(--hair-2)",
      borderTopColor: "var(--ink-3)",
      borderRadius: "50%",
      animation: "argus-spin 0.8s linear infinite",
      flexShrink: 0,
    }}/>
  );
}

/* ─────────── STACKED FINDING (Pass 2.1) — Inside an expanded card.
   Replaces the diff-card pattern with a labelled-sections "TurnItIn
   report" layout: Headline (tone-coloured square + verdict word) +
   Confidence chip + Claim + Why it matters + Evidence from source. */
function StackedFinding({ cite, headline, tone, src, hasExcerpt, sourceOpen, onOpenSource }) {
  const confidence = deriveConfidence(cite);
  const headlineGlyph = ["miss","warn"].includes(tone) ? "!"
                      : ["ok","good"].includes(tone) ? "✓"
                      : "?";
  return (
    <div style={{padding: "6px 4px 4px", fontFamily: "var(--font-ui)"}}>
      {/* HEADLINE — sans bold (CLAUDE.md: no display font outside wordmark) */}
      <div style={{display: "flex", alignItems: "center", gap: 12, padding: "4px 0 10px"}}>
        <span style={{
          width: 32, height: 32, borderRadius: 6,
          background: `var(--${tone})`, color: "white",
          display: "grid", placeItems: "center",
          fontSize: 18, fontWeight: 700,
          flexShrink: 0, lineHeight: 1,
        }}>
          {headlineGlyph}
        </span>
        <span style={{
          fontSize: 21, fontWeight: 600,
          color: `var(--${tone})`,
          letterSpacing: "-0.01em",
          lineHeight: 1.1,
        }}>
          {headline.label}
        </span>
      </div>

      {/* CONFIDENCE chip */}
      {confidence && (
        <div style={{padding: "0 0 6px"}}>
          <span style={{
            display: "inline-flex", alignItems: "center", gap: 7,
            padding: "4px 11px", borderRadius: 999,
            border: "1px solid var(--hair)",
            fontSize: 11.5, color: "var(--ink-3)",
            fontWeight: 500,
          }}>
            <span style={{
              width: 7, height: 7, borderRadius: "50%",
              background: confidence === "high" ? "var(--good)"
                        : confidence === "medium" ? "var(--gold)"
                        : "var(--ink-4)",
            }}/>
            {confidence} confidence
          </span>
        </div>
      )}

      {cite.claim && (
        <FindingSection title="Claim">
          <p style={{margin: 0, fontSize: 14, lineHeight: 1.55, color: "var(--ink)"}}>
            {cite.claim}
          </p>
        </FindingSection>
      )}

      {cite.evidence && (
        <FindingSection title="Why it matters">
          <p style={{margin: 0, fontSize: 14, lineHeight: 1.55, color: "var(--ink-2)"}}>
            {cite.evidence}
          </p>
        </FindingSection>
      )}

      {cite.source_quote ? (
        <FindingSection title="Evidence from source">
          <div style={{
            border: "1px solid var(--hair)",
            borderRadius: 8,
            padding: "13px 15px",
            background: "var(--surface)",
          }}>
            {src && (
              <div style={{
                fontSize: 11, color: "var(--ink-3)",
                marginBottom: 7,
                fontFamily: "var(--font-mono)",
                letterSpacing: "0.02em",
              }}>
                {src.short}{cite.page ? `, ${cite.page}` : ""}
              </div>
            )}
            <p style={{
              margin: 0,
              fontFamily: "var(--font-serif)",
              fontStyle: "italic",
              fontSize: 13.5, lineHeight: 1.55,
              color: "var(--ink-2)",
            }}>
              “{cite.source_quote}”
            </p>
          </div>
        </FindingSection>
      ) : null}

      {src ? (
        <FindingSection title="Source">
          <div style={{
            border: "1px solid var(--hair)",
            borderRadius: 8,
            padding: "12px 14px",
            background: "var(--surface)",
          }}>
            <p style={{margin: 0, fontSize: 13, lineHeight: 1.45, color: "var(--ink-2)"}}>
              {src.title || cite.sourceTitle || cite.sourceLabel || cite.label}
            </p>
            <div style={{
              display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap",
              fontFamily: "var(--font-mono)", fontSize: 10.5,
              color: "var(--ink-4)",
            }}>
              {src.via && src.via !== "—" && <span>via {src.via}</span>}
              {cite.page && <span>page {cite.page}</span>}
              {src.pages && <span>{src.pages}pp</span>}
            </div>
          </div>
        </FindingSection>
      ) : (
        <FindingSection title="Source">
          <UnresolvedRefBlock cite={cite}/>
        </FindingSection>
      )}

      {hasExcerpt && !sourceOpen && (
        <div style={{padding: "10px 0 4px"}}>
          <button onClick={onOpenSource} className="btn ghost"
            style={{padding: "8px 14px", fontSize: 13, borderRadius: 6}}>
            <Icon.External size={12}/> Open source
          </button>
        </div>
      )}
    </div>
  );
}

function FindingSection({ title, children }) {
  return (
    <div style={{padding: "14px 0 4px"}}>
      <div style={{
        fontSize: 12.5, fontWeight: 500,
        color: "var(--ink-3)", marginBottom: 7,
        letterSpacing: 0,
      }}>
        {title}
      </div>
      {children}
    </div>
  );
}

function deriveConfidence(cite) {
  if (!cite || (!cite.verdict && !cite.pillState)) return null;
  if (policySkipCopy(cite)) return null;
  if (cite.pillState === "verified_full") return "high";
  if (cite.verdict === "inaccurate") return "high";
  if (cite.pillState === "source_mismatch") return "high";
  if (cite.pillState === "verified_abstract") return "medium";
  if (cite.pillState === "training_fallback") return "low";
  if (cite.pillState === "not_resolved") return null;
  return null;
}


function Inspector({ essay, cite, reviewed, onToggleReviewed, note, onSetNote, onOpenSource, sourceOpen, embedded, clientFetch, onActivateCite, inStack }) {
  const headline = headlineVerdict(cite, clientFetch);
  const tone = headline.tone;
  const src = cite.source ? SOURCES[cite.source] : null;
  const hasExcerpt = !!PAGE_EXCERPTS[cite.id];

  /* v2.26.0 — client-extension acquisition status. Shows a thin band
     above the verdict banner when the backend is routing this ref
     through the user's MV3 browser extension. Tones use the existing
     palette (amber for in-flight, warm for failure) — no new hues. */
  const cfBand = clientExtensionBand(clientFetch);

  // Prev/next citation navigation. Disabled at the edges; clicking also
  // dispatches `argus-focus-cite` (via `onActivateCite`) so the essay
  // reading column scrolls to the new active span.
  const cIdx = essay.citations.findIndex(c => c.id === cite.id);
  const prevId = cIdx > 0 ? essay.citations[cIdx - 1].id : null;
  const nextId = cIdx >= 0 && cIdx < essay.citations.length - 1 ? essay.citations[cIdx + 1].id : null;
  const goPrev = () => { if (prevId && onActivateCite) onActivateCite(prevId); };
  const goNext = () => { if (nextId && onActivateCite) onActivateCite(nextId); };

  // inStack: rendered inside a CitationCard (Pass 2). Suppress the outer
  // <aside>, the compact header (card owns its own header), and the body's
  // flex/scroll (the stack is the scroll container, the card grows tall).
  const OuterTag = inStack ? "div" : "aside";
  const outerStyle = inStack ? {} : {
    background: "var(--bg)",
    display: "flex", flexDirection: "column",
    overflow: "hidden", minHeight: 0,
    borderRight: embedded ? "none" : "1px solid var(--hair)",
  };
  const bodyStyle = inStack ? {} : {flex: 1, overflowY: "auto", padding: 0};
  return (
    <OuterTag style={outerStyle}>
      {!inStack && (
      <div style={{
        padding: "10px 18px",
        borderBottom: "1px solid var(--hair)",
        display: "flex", alignItems: "center", justifyContent: "space-between",
        gap: 10, flexShrink: 0,
      }}>
        <div style={{display: "flex", alignItems: "center", gap: 10, minWidth: 0}}>
          <span style={{fontFamily: "var(--font-mono)", fontSize: 10, letterSpacing: "0.18em", textTransform: "uppercase", color: "var(--ink-4)"}}>
            {cite.id}
          </span>
          <span style={{fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--ink-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
            {cite.label}
          </span>
        </div>
        <div style={{display: "flex", gap: 2, flexShrink: 0, alignItems: "center"}}>
          <button className="btn ghost sm" style={{padding: "0 6px", opacity: prevId ? 1 : 0.4, cursor: prevId ? "pointer" : "default"}} aria-label="Previous citation" title="Previous citation" disabled={!prevId} onClick={goPrev}><Icon.Caret size={11} style={{transform: "rotate(90deg)"}}/></button>
          <span style={{fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-4)", padding: "0 4px"}}>{cIdx + 1}/{essay.citations.length}</span>
          <button className="btn ghost sm" style={{padding: "0 6px", opacity: nextId ? 1 : 0.4, cursor: nextId ? "pointer" : "default"}} aria-label="Next citation" title="Next citation" disabled={!nextId} onClick={goNext}><Icon.Caret size={11} style={{transform: "rotate(-90deg)"}}/></button>
        </div>
      </div>
      )}

      <div style={bodyStyle}>

        {/* ── 0 · CLIENT-EXTENSION ACQUISITION BAND (v2.26.0) ─
              v2.26.9: retry button on terminal-failure states so the
              user can re-queue after manually solving a captcha or
              warming a publisher session. */}
        {cfBand &&
          <div style={{
            padding: "10px 22px",
            borderBottom: "1px solid var(--hair)",
            background: `var(--${cfBand.tone}-bg)`,
            display: "flex", alignItems: "center", gap: 10,
          }}>
            <span className={"vdot " + cfBand.tone}/>
            <span style={{
              fontFamily: "var(--font-mono)", fontSize: 9.5,
              letterSpacing: "0.18em", textTransform: "uppercase",
              color: "var(--ink-3)",
            }}>
              Source acquisition
            </span>
            <span style={{fontSize: 12.5, color: "var(--ink-2)", flex: 1}}>
              {cfBand.label}
            </span>
            {cfBand.retryable && clientFetch && essay && essay.id &&
              <button className="btn ghost sm"
                      onClick={async () => {
                        const r = await window.Api.retryClientFetch(essay.id, clientFetch.ref_index);
                        if (!r.ok && r.error !== "already_queued") {
                          alert("Retry failed: " + (r.error || `http_${r.status}`));
                        }
                      }}
                      style={{fontFamily: "var(--font-mono)", fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase"}}>
                Retry
              </button>}
          </div>}

        {/* ── 1 · VERDICT BANNER ───────────────────────────────
              In-stack mode: card header already shows verdict + tone dot;
              suppress this heavy banner. Render a compact metadata strip
              below instead. */}
        {!inStack && (
        <div style={{
          padding: "22px 22px 18px",
          background: `linear-gradient(180deg, var(--${tone}-bg), transparent 75%)`,
          borderBottom: "1px solid var(--hair)",
        }}>
          <div style={{display: "flex", alignItems: "center", gap: 12}}>
            <span style={{
              width: 28, height: 28, borderRadius: "50%",
              background: `var(--${tone}-bg)`,
              border: `1.5px solid var(--${tone})`,
              display: "grid", placeItems: "center",
              boxShadow: `0 0 14px color-mix(in oklch, var(--${tone}) 30%, transparent)`,
              flexShrink: 0,
            }}>
              <span className={"vdot " + tone} style={{width: 10, height: 10}}/>
            </span>
            <div style={{minWidth: 0}}>
              <div style={{
                fontFamily: "var(--font-display)",
                fontStyle: "italic",
                fontSize: 26, lineHeight: 1.05,
                fontWeight: 400,
                color: `var(--${tone})`,
                letterSpacing: "-0.005em",
              }}>
                {headline.label}
              </div>
              <div style={{
                marginTop: 4,
                fontFamily: "var(--font-mono)", fontSize: 10,
                letterSpacing: "0.18em", textTransform: "uppercase",
                color: "var(--ink-4)",
              }}>
                Argus finding
              </div>
            </div>
          </div>

          {/* secondary pill — the OTHER signal */}
          {headline.secondary &&
            <div style={{marginTop: 14, display: "flex", alignItems: "center", gap: 8}}>
              <span style={{fontFamily: "var(--font-mono)", fontSize: 9.5, letterSpacing: "0.18em", textTransform: "uppercase", color: "var(--ink-4)"}}>
                also ·
              </span>
              <span className={"pill " + (headline.secondary.tone || "neutral")} style={{padding: "1px 8px"}}>
                <span className={"vdot " + (headline.secondary.tone || "neutral")}/>
                {headline.secondary.pillLabel || headline.secondary.label}
              </span>
            </div>
          }
        </div>
        )}

        {/* ── In-stack: report-style labelled sections ───────── */}
        {inStack && (
          <StackedFinding cite={cite} headline={headline} tone={tone} src={src}
            hasExcerpt={hasExcerpt} sourceOpen={sourceOpen}
            onOpenSource={onOpenSource}/>
        )}

        {/* ── 2 · CLAIM ↔ SOURCE DIFF (non-stack only) ───────── */}
        {!inStack && (
        <div style={{padding: "20px 22px 22px"}}>
          <DiffCard
            tone={tone}
            essaySays={cite.claim}
            sourceSays={cite.source_quote}
            sourceLabel={src ? `${src.short}${cite.page ? `, p. ${cite.page}` : ""}` : "—"}
            noSource={!src}
            hasExcerpt={hasExcerpt}
            sourceOpen={sourceOpen}
            onOpenSource={onOpenSource}
            verdictLabel={headline.label}/>
        </div>
        )}

        {/* ── 3 · VERIFIER NOTE (non-stack only) ─────────────── */}
        {!inStack && cite.evidence && (
        <div style={{padding: "0 22px 22px"}}>
          <div style={{
            fontFamily: "var(--font-mono)", fontSize: 9.5, letterSpacing: "0.18em",
            textTransform: "uppercase", color: "var(--ink-4)", marginBottom: 8,
          }}>
            Verifier note
          </div>
          <p style={{
            margin: 0,
            fontSize: 13.5, lineHeight: 1.6,
            color: "var(--ink-2)",
          }}>
            {cite.evidence}
          </p>
        </div>
        )}

        {/* ── 4 · SOURCE METADATA / SUPPLEMENT (non-stack only) */}
        {!inStack && (
        <div style={{padding: "0 22px 22px"}}>
          {src ?
            <SourceMetaCard src={src} page={cite.page} hasExcerpt={hasExcerpt}
                            sourceOpen={sourceOpen} onOpenSource={onOpenSource}/>
            :
            <UnresolvedRefBlock cite={cite}/>
          }
        </div>
        )}

        {/* ── 5 · MORE (collapsible) — hidden in-stack (TurnItIn posture).
              source_mismatch upload-correct-PDF affordance kept, since
              it's a content correction not a workflow action. */}
        {!inStack && (
        <CollapsibleSection title="Retrieval log" defaultOpen={false}>
          <RetrievalLog cite={cite} src={src}/>
        </CollapsibleSection>
        )}

        {/* v2.29.0 — upload affordances are content corrections, not
            workflow actions, so they render in BOTH stack mode (the
            default rendering inside CitationCard) and non-stack mode.
            Previously gated behind !inStack, which made every upload
            button unreachable in the live UI.  */}
        {cite.pillState === "source_mismatch" && (
          <CollapsibleSection title="Why this is a mismatch" tone="miss" defaultOpen={true}>
            <p style={{margin: "0 0 10px", fontSize: 12.5, lineHeight: 1.55, color: "var(--ink-2)"}}>
              {cite.evidence || "Argus retrieved a file but its identity check rejected it — the retrieved text does not appear to be the cited work."}
            </p>
            <UploadAffordance
              jobId={essay.id}
              refIdx={cite._raw && Number.isFinite(Number(cite._raw.ref_index)) ? Number(cite._raw.ref_index) : null}
              reason="Replace the retrieved file with the correct PDF."
              compact={inStack}
              onUploaded={() => window.location && window.location.reload && window.location.reload()}/>
          </CollapsibleSection>
        )}

        {/* Unverified states — surface upload for the four pillStates
            where a manual source PDF rescues the verdict. */}
        {citationNeedsUpload(cite, clientFetch) && cite.pillState !== "source_mismatch" && (
          <CollapsibleSection
              title={cite.pillState === "verified_abstract" ? "Improve this verdict" : "Help Argus retrieve this source"}
              tone={cite.pillState === "verified_abstract" ? "accent" : "warn"}
              defaultOpen={cite.pillState !== "verified_abstract"}>
            <p style={{margin: "0 0 10px", fontSize: 12.5, lineHeight: 1.55, color: "var(--ink-2)"}}>
              {cite.pillState === "training_fallback" && "Argus couldn't retrieve the source through any of its retrieval lanes. Uploading the PDF re-runs verification against the real work."}
              {cite.pillState === "not_resolved"      && "Argus couldn't find this reference in any library or open-access lane. If you have the source, upload it to verify the claim."}
              {cite.pillState === "verified_abstract" && "Only the abstract was available. Upload the full-text PDF for the strongest verdict."}
              {cite.pillState === "source_unavailable" && "Argus found source metadata but could not access usable source text. Upload the source file to verify the cited claim."}
              {cite.pillState === "auth_required" && "This source appears to need an authenticated browser or library session. Upload the source file if you have it."}
              {cite.pillState === "page_absent" && "Argus retrieved the source, but the cited page was not available in the retrieved copy. Upload the cited source file or edition to verify the claim."}
            </p>
            <UploadAffordance
              jobId={essay.id}
              refIdx={cite._raw && Number.isFinite(Number(cite._raw.ref_index)) ? Number(cite._raw.ref_index) : null}
              reason={null}
              compact={inStack}
              onUploaded={() => window.location && window.location.reload && window.location.reload()}/>
          </CollapsibleSection>
        )}

        {/* ── 7 · NOTE — non-stack only ──────────────────────── */}
        {!inStack && (
        <CollapsibleSection title="Your note" defaultOpen={!!note}>
          <textarea
            value={note}
            onChange={(e) => onSetNote(e.target.value)}
            placeholder="Private note. Not shared with the student."
            rows={3}
            style={{
              width: "100%",
              resize: "vertical",
              background: "var(--surface)",
              color: "var(--ink)",
              border: "1px solid var(--hair)",
              borderRadius: 4,
              padding: "8px 10px",
              fontFamily: "var(--font-serif)",
              fontSize: 13,
              lineHeight: 1.5,
              outline: "none",
            }}
            onFocus={(e) => e.currentTarget.style.borderColor = "var(--accent-dim)"}
            onBlur={(e) => e.currentTarget.style.borderColor = "var(--hair)"}/>
        </CollapsibleSection>
        )}

        <div style={{height: inStack ? 4 : 18}}/>
      </div>

      {/* ─── 6 · ACTIONS (sticky bottom) — non-stack only ─── */}
      {!inStack && (
      <div style={{
        padding: "10px 18px",
        borderTop: "1px solid var(--hair)",
        background: "var(--surface)",
        display: "flex", gap: 6,
        flexShrink: 0,
      }}>
        <button onClick={onToggleReviewed} className={"btn sm " + (reviewed ? "primary" : "")}>
          <Icon.Check size={11} stroke={2}/> {reviewed ? "Reviewed" : "Mark reviewed"}
        </button>
        <button className="btn ghost sm"><Icon.Pin size={11}/> Pin</button>
        <button className="btn ghost sm"><Icon.Tag size={11}/> Tag</button>
        <div style={{flex: 1}}/>
        <button className="btn ghost sm" title="Jump to citation in essay">
          <Icon.External size={11}/>
        </button>
      </div>
      )}
    </OuterTag>
  );
}

/* ─────────── DIFF CARD ─────────── */
function DiffCard({ tone, essaySays, sourceSays, sourceLabel, noSource, hasExcerpt, sourceOpen, onOpenSource, verdictLabel, reasoning }) {
  return (
    <div style={{
      border: "1px solid var(--hair)",
      borderRadius: 8,
      background: "var(--surface)",
      overflow: "hidden",
    }}>
      {/* essay side — reasoning leads, cited passage supports */}
      <div style={{padding: "16px 18px 16px"}}>
        {reasoning && (
          <p style={{
            margin: 0,
            fontSize: 14.5,
            lineHeight: 1.55,
            color: "var(--ink)",
          }}>
            {reasoning}
          </p>
        )}
        {essaySays && (
          <p style={{
            margin: reasoning ? "12px 0 0" : 0,
            fontFamily: "var(--font-serif)",
            fontStyle: "italic",
            fontSize: 13,
            lineHeight: 1.55,
            color: "var(--ink-3)",
            paddingLeft: 10,
            borderLeft: "2px solid var(--hair-2)",
          }}>
            “{essaySays}”
          </p>
        )}
      </div>

      {/* verdict band — full-width tinted strip, not a floating chip */}
      <div style={{
        padding: "8px 16px",
        background: `color-mix(in oklch, var(--${tone}-bg) 70%, transparent)`,
        borderTop: `1px solid color-mix(in oklch, var(--${tone}) 25%, var(--hair))`,
        borderBottom: `1px solid color-mix(in oklch, var(--${tone}) 25%, var(--hair))`,
        display: "flex", alignItems: "center", gap: 8,
      }}>
        <span className={"vdot " + tone} style={{width: 7, height: 7, flexShrink: 0}}/>
        <span style={{
          fontFamily: "var(--font-mono)", fontSize: 10,
          letterSpacing: "0.18em", textTransform: "uppercase",
          color: `var(--${tone})`,
          fontWeight: 500,
        }}>
          {verdictLabel || "compared against source"}
        </span>
        <span style={{
          marginLeft: "auto",
          fontFamily: "var(--font-mono)", fontSize: 9.5,
          color: "var(--ink-4)", letterSpacing: "0.08em",
        }}>
          vs ·
        </span>
      </div>

      {/* source side */}
      <div style={{padding: "16px 16px 14px"}}>
        <div style={{
          display: "flex", alignItems: "center", gap: 6, marginBottom: 8,
          fontFamily: "var(--font-mono)", fontSize: 9.5,
          letterSpacing: "0.18em", textTransform: "uppercase",
          color: "var(--ink-4)",
        }}>
          <Icon.Book size={11} stroke={1.6}/>
          <span>source says · {sourceLabel}</span>
        </div>
        {noSource ?
          <p style={{margin: 0, fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 14, lineHeight: 1.55, color: `var(--${tone})`}}>
            No matching reference in the bibliography.
          </p>
          :
          sourceSays ?
            <>
              <p style={{
                margin: 0,
                fontFamily: "var(--font-serif)",
                fontStyle: "italic",
                fontSize: 15,
                lineHeight: 1.55,
                color: "var(--ink)",
              }}>
                “{sourceSays}”
              </p>
              {hasExcerpt && !sourceOpen &&
                <div style={{marginTop: 12, display: "flex"}}>
                  <button onClick={onOpenSource}
                          style={{
                            marginLeft: "auto",
                            background: "transparent",
                            border: "1px solid var(--hair-strong)",
                            padding: "3px 10px", borderRadius: 4,
                            fontFamily: "var(--font-mono)", fontSize: 10,
                            color: "var(--gold)",
                            letterSpacing: "0.04em",
                            cursor: "pointer",
                            display: "inline-flex", alignItems: "center", gap: 4,
                          }}>
                    Read on the page <span aria-hidden>→</span>
                  </button>
                </div>
              }
            </>
            :
            <p style={{margin: 0, fontSize: 13, color: "var(--ink-3)", fontStyle: "italic"}}>
              Source text not extracted on this citation.
            </p>
        }
      </div>
    </div>
  );
}

/* ─────────── Source metadata card (small) ─────────── */
function SourceMetaCard({ src, page, hasExcerpt, sourceOpen, onOpenSource }) {
  return (
    <div style={{
      padding: "12px 14px",
      background: "var(--surface)",
      border: "1px solid var(--hair)",
      borderRadius: 6,
    }}>
      <div style={{
        fontFamily: "var(--font-mono)", fontSize: 9.5, letterSpacing: "0.18em",
        textTransform: "uppercase", color: "var(--ink-4)", marginBottom: 8,
        display: "flex", alignItems: "center", gap: 6,
      }}>
        <Icon.Book size={11} stroke={1.5}/>
        Retrieved source
      </div>
      <div style={{fontFamily: "var(--font-serif)", fontSize: 13, lineHeight: 1.5, color: "var(--ink)"}}>
        <span>{src.author} ({src.year}). </span>
        <span style={{fontStyle: "italic"}}>{src.title}</span>. <span style={{color: "var(--ink-3)"}}>{src.venue}</span>
      </div>
      <div style={{display: "flex", gap: 10, marginTop: 8, fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--ink-4)", alignItems: "center", flexWrap: "wrap"}}>
        <span style={{display: "inline-flex", gap: 4, alignItems: "center"}}>
          <span className="vdot ok"/> retrieved
        </span>
        <span>· {src.via}</span>
        <span>· {src.pages}pp</span>
        {page && <span>· page {page}</span>}
      </div>
      <div style={{display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap"}}>
        {hasExcerpt && !sourceOpen &&
          <button className="btn sm" onClick={onOpenSource}>
            <Icon.EyeOn size={11}/> View page text
          </button>
        }
        <button className="btn ghost sm"><Icon.External size={11}/> Open PDF</button>
        <button className="btn ghost sm"><Icon.Link size={11}/> Backlinks ({src.cited_by})</button>
      </div>
    </div>
  );
}

function UnresolvedRefBlock({ cite }) {
  const label = cite?.label || cite?._raw?.source || "this reference";
  return (
    <div style={{
      padding: "12px 14px",
      border: "1px dashed color-mix(in oklch, var(--miss) 40%, var(--hair))",
      borderRadius: 6,
      background: "color-mix(in oklch, var(--miss-bg) 60%, transparent)",
    }}>
      <div style={{fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--miss)", textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: 6}}>
        no matching reference
      </div>
      <p style={{margin: 0, fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.55}}>
        Argus could not locate <em>{label}</em> in the bibliography or retrieve it from the open-access lanes. Likely a typo, an unsourced claim, or a fabricated attribution.
      </p>
    </div>
  );
}

/* ─────────── Collapsible section ─────────── */
function CollapsibleSection({ title, children, defaultOpen = false, tone }) {
  const [open, setOpen] = useState(defaultOpen);
  return (
    <div style={{borderTop: "1px solid var(--hair)"}}>
      <button onClick={() => setOpen(!open)}
              style={{
                width: "100%",
                display: "flex", alignItems: "center", gap: 8,
                padding: "12px 22px",
                color: tone ? `var(--${tone})` : "var(--ink-3)",
                cursor: "pointer",
                textAlign: "left",
                fontFamily: "var(--font-mono)",
                fontSize: 10.5,
                letterSpacing: "0.16em",
                textTransform: "uppercase",
              }}>
        <Icon.CaretRight size={10}
          style={{transform: open ? "rotate(90deg)" : "rotate(0)", transition: "transform 120ms"}}/>
        <span>{title}</span>
      </button>
      {open &&
        <div style={{padding: "0 22px 16px"}}>
          {children}
        </div>
      }
    </div>
  );
}

function RetrievalLog({ cite, src }) {
  // Real reports carry `_raw` straight off the backend. Render the
  // honest summary of what the proxy exposes; the mockup keeps the
  // seven-lane ledger for the design palette.
  if (cite && cite._raw) return <RetrievalLogReal cite={cite}/>;
  return (
    <ol style={{margin: 0, padding: 0, listStyle: "none", fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--ink-3)"}}>
      <RetrievalStep ok={src?.via?.includes("CORE")} label="1 · CORE open-access" detail={src?.via?.includes("CORE") ? "match" : "no match"}/>
      <RetrievalStep ok={src?.via?.includes("publisher")} label="2 · Publisher landing" detail={src?.via?.includes("publisher") ? "match" : "—"}/>
      <RetrievalStep ok={!!src} label="3 · DOI resolver" detail="checked"/>
      <RetrievalStep ok={src?.via?.includes("JSTOR")} label="4 · JSTOR" detail={src?.via?.includes("JSTOR") ? "match" : "no match"}/>
      <RetrievalStep ok={src?.via?.includes("SSRN")}  label="5 · SSRN" detail={src?.via?.includes("SSRN") ? "match" : "no match"}/>
      <RetrievalStep ok={src?.via?.includes("Internet Archive")} label="6 · Internet Archive" detail={src?.via?.includes("Internet Archive") ? "match" : "no match"}/>
      <RetrievalStep ok={src?.via?.includes("Library scan") || src?.via?.includes("Marxists")} label="7 · Library shadow corpus" detail={src?.via?.includes("Library scan") || src?.via?.includes("Marxists") ? "match" : "no match"}/>
    </ol>
  );
}

const _RETRIEVAL_STATUS_LABEL = {
  verified_full:     "Source retrieved and verified",
  verified_abstract: "Abstract retrieved (full text not available)",
  training_fallback: "Source text unavailable",
  auth_required:     "Authenticated access required",
  not_resolved:      "Source could not be located",
  source_mismatch:   "Retrieved file rejected by identity check",
  source_unavailable:"Source text unavailable",
  page_absent:       "Source retrieved; cited page absent",
  unsupported:       "Source retrieved; claim unsupported",
  supported:         "Source retrieved; claim supported",
  partial_support:   "Source retrieved; partial support",
  no_specific_pages_requested: "Source retrieved; no specific page requested",
};

function RetrievalLogReal({ cite }) {
  const raw = cite._raw || {};
  const status = raw.verification_status || "training_fallback";
  const skipCopy = policySkipCopy(raw) || policySkipCopy(cite);
  const handoff = raw.access_handoff || null;
  const authHosts = Array.isArray(handoff?.auth_hosts) ? handoff.auth_hosts : [];
  const links = Array.isArray(handoff?.links) ? handoff.links : [];
  const doi = handoff?.doi || "";
  const libraryAdded = raw.library_added === true;
  const dossierMatch = raw.dossier_match;
  const retrieved = cite.sourceRetrieved || raw.source_retrieved === true || raw.retrieval?.successful === true;
  const okOutcome = retrieved || status === "verified_full" || status === "supported";
  return (
    <div style={{fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--ink-3)"}}>
      <RetrievalStep ok={okOutcome} label="Outcome" detail={skipCopy?.label || _RETRIEVAL_STATUS_LABEL[status] || status}/>
      {skipCopy &&
        <RetrievalStep ok={false} label="Reason" detail={skipCopy.detail}/>
      }
      {doi &&
        <RetrievalStep ok={true} label="DOI"
                       detail={<a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer" style={{color: "var(--gold)"}}>{doi}</a>}/>
      }
      {authHosts.length > 0 &&
        <RetrievalStep ok={false} label="Auth required" detail={authHosts.join(", ")}/>
      }
      {dossierMatch === false &&
        <RetrievalStep ok={false} label="Dossier match" detail="rejected"/>
      }
      {libraryAdded &&
        <RetrievalStep ok={true} label="Library" detail="added to cache"/>
      }
      {Array.isArray(raw.pages_cited) && raw.pages_cited.length > 0 &&
        <RetrievalStep ok={true} label="Pages cited" detail={raw.pages_cited.join(", ")}/>
      }
      {links.length > 0 &&
        <li style={{display: "flex", alignItems: "flex-start", gap: 8, padding: "3px 0", listStyle: "none"}}>
          <span className="vdot neutral" style={{width: 5, height: 5, marginTop: 5}}/>
          <span style={{color: "var(--ink-4)", flex: 1}}>Source links</span>
          <span style={{display: "flex", flexDirection: "column", gap: 2, textAlign: "right"}}>
            {links.slice(0, 4).map((l, i) =>
              <a key={i} href={l.url} target="_blank" rel="noopener noreferrer" style={{color: "var(--gold)", maxWidth: 220, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
                {l.label || "Open source"}
              </a>
            )}
          </span>
        </li>
      }
      {!skipCopy && !doi && !authHosts.length && dossierMatch !== false && !links.length && !libraryAdded &&
        <div style={{padding: "8px 0", color: "var(--ink-5)", fontStyle: "italic"}}>
          The backend does not expose per-lane retrieval steps. Outcome above is the terminal status.
        </div>
      }
    </div>
  );
}

function RetrievalStep({ ok, label, detail }) {
  return (
    <li style={{display: "flex", alignItems: "center", gap: 8, padding: "3px 0"}}>
      <span className={"vdot " + (ok ? "ok" : "neutral")} style={{width: 5, height: 5}}/>
      <span style={{color: ok ? "var(--ink-2)" : "var(--ink-4)", flex: 1}}>{label}</span>
      <span style={{color: ok ? "var(--ok)" : "var(--ink-5)", textAlign: "right", maxWidth: 320, overflowWrap: "anywhere"}}>{detail}</span>
    </li>
  );
}

/* ════════════════════════════════════════════════════════════════
   SOURCE PANE — the third pane.
   Renders a stylised PDF page: chapter title, page number, body
   paragraphs in book-set type, with the cited line highlighted.
   ════════════════════════════════════════════════════════════════ */
function SourcePane({ cite, excerpt, onClose, embedded }) {
  const src = cite.source ? SOURCES[cite.source] : null;
  return (
    <aside style={{
      background: "var(--bg-rail)",
      display: "flex", flexDirection: "column",
      overflow: "hidden", minHeight: 0,
      flex: embedded ? 1 : "initial",
    }}>
      {/* Pane header */}
      <header style={{
        padding: "10px 18px",
        borderBottom: "1px solid var(--hair)",
        background: "var(--bg)",
        display: "flex", alignItems: "center", gap: 10,
        flexShrink: 0,
      }}>
        <Icon.Book size={13} stroke={1.5} style={{color: "var(--gold)"}}/>
        <div style={{minWidth: 0, flex: 1}}>
          <div style={{fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--ink-3)", letterSpacing: "0.04em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
            {src ? src.short : "Unresolved source"}
          </div>
          {excerpt &&
            <div style={{fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-4)", letterSpacing: "0.04em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
              page {excerpt.page} of {excerpt.of}
            </div>
          }
        </div>
        <div style={{display: "flex", gap: 2, flexShrink: 0}}>
          <button className="btn ghost sm" style={{padding: "0 6px"}} title="Previous page" aria-label="Previous page"><Icon.Caret size={11} style={{transform: "rotate(90deg)"}}/></button>
          <button className="btn ghost sm" style={{padding: "0 6px"}} title="Next page" aria-label="Next page"><Icon.Caret size={11} style={{transform: "rotate(-90deg)"}}/></button>
          <button className="btn ghost sm" style={{padding: "0 6px"}} title="Open PDF in viewer"><Icon.External size={11}/></button>
          <button onClick={onClose} className="btn ghost sm" style={{padding: "0 6px"}} title="Close source pane"><Icon.X size={11} stroke={1.8}/></button>
        </div>
      </header>

      {/* The page */}
      <div style={{
        flex: 1, overflowY: "auto",
        padding: "22px 18px 40px",
        background: "var(--bg-rail)",
      }}>
        {excerpt ?
          <PageRender excerpt={excerpt} src={src}/>
          :
          <NoExcerptState cite={cite}/>
        }
      </div>

      {/* Pane footer — quick context */}
      <footer style={{
        padding: "8px 18px",
        borderTop: "1px solid var(--hair)",
        background: "var(--bg)",
        display: "flex", alignItems: "center", gap: 10,
        fontFamily: "var(--font-mono)", fontSize: 10.5,
        color: "var(--ink-4)", letterSpacing: "0.04em",
        flexShrink: 0,
      }}>
        <span style={{display: "inline-flex", alignItems: "center", gap: 5}}>
          <span style={{width: 10, height: 10, background: "var(--gold-bg-2)", border: "1px solid var(--gold-dim)", borderRadius: 1}}/>
          highlighted · the cited line
        </span>
        {src && <span style={{marginLeft: "auto"}}>via {src.via}</span>}
      </footer>
    </aside>
  );
}

function PageRender({ excerpt, src }) {
  return (
    <div style={{
      background: "var(--page-bg, var(--surface))",
      border: "1px solid var(--hair)",
      borderRadius: 4,
      padding: "30px 36px 36px",
      boxShadow: "var(--shadow-1)",
      maxWidth: 460, margin: "0 auto",
      color: "var(--page-ink, var(--ink))",
    }}>
      {/* Page header (running head) */}
      <div style={{
        display: "flex", justifyContent: "space-between",
        fontFamily: "var(--font-mono)", fontSize: 9.5,
        color: "var(--ink-4)", letterSpacing: "0.08em",
        textTransform: "uppercase",
        marginBottom: 18, paddingBottom: 8,
        borderBottom: "1px solid var(--hair)",
      }}>
        <span style={{overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", paddingRight: 8}}>
          {excerpt.chapter}
        </span>
        <span>{excerpt.page}</span>
      </div>

      {/* Body paragraphs */}
      <div style={{
        fontFamily: "var(--font-serif)",
        fontSize: 13.5,
        lineHeight: 1.62,
        color: "var(--page-ink, var(--ink))",
        textAlign: "justify",
        hyphens: "auto",
      }}>
        {excerpt.paragraphs.map((p, i) => {
          // split on <HL>...</HL> markers
          const parts = p.split(/(<HL>[\s\S]*?<\/HL>)/);
          return (
            <p key={i} style={{margin: "0 0 12px", textIndent: i === 0 ? 0 : "1.2em"}}>
              {parts.map((part, j) => {
                const m = part.match(/<HL>([\s\S]*?)<\/HL>/);
                if (m) return <mark key={j} style={{
                  background: "var(--gold-bg-2)",
                  color: "var(--ink)",
                  padding: "1px 2px",
                  borderRadius: 2,
                  boxShadow: "inset 0 -1px 0 0 var(--gold)",
                }}>{m[1]}</mark>;
                return <span key={j}>{part}</span>;
              })}
            </p>
          );
        })}
      </div>

      {/* Page footer */}
      <div style={{
        marginTop: 24, paddingTop: 10,
        borderTop: "1px solid var(--hair)",
        fontFamily: "var(--font-mono)", fontSize: 9.5,
        color: "var(--ink-5)", letterSpacing: "0.08em",
        textAlign: "center",
      }}>
        — {excerpt.page} —
      </div>
    </div>
  );
}

function NoExcerptState({ cite }) {
  const tone = VERDICTS[cite.pillState]?.tone || "neutral";
  const skipCopy = policySkipCopy(cite);
  return (
    <div style={{padding: "40px 20px", textAlign: "center"}}>
      <div style={{
        width: 36, height: 36,
        margin: "0 auto 14px",
        display: "grid", placeItems: "center",
        background: `var(--${tone}-bg)`,
        border: `1px solid color-mix(in oklch, var(--${tone}) 40%, transparent)`,
        borderRadius: 8,
        color: `var(--${tone})`,
      }}>
        <Icon.Book size={16} stroke={1.5}/>
      </div>
      <p style={{margin: "0 0 6px", fontSize: 13, color: "var(--ink-2)"}}>
        No page text was extracted for this citation.
      </p>
      <p style={{margin: 0, fontSize: 11.5, color: "var(--ink-4)", lineHeight: 1.55, maxWidth: 280, marginInline: "auto"}}>
        {skipCopy && skipCopy.detail}
        {!skipCopy && cite.pillState === "training_fallback" && "The source couldn't be retrieved through the available retrieval lanes, so Argus did not verify this citation against source text."}
        {cite.pillState === "source_mismatch" && "Retrieval returned the wrong work — Argus rejected it before reading."}
        {cite.pillState === "not_resolved" && "No matching reference exists in the bibliography."}
        {cite.pillState === "verified_abstract" && "Only the abstract was retrievable. The full page text is behind a paywall."}
        {cite.pillState === "source_unavailable" && "Argus found source metadata but could not access usable source text."}
        {cite.pillState === "auth_required" && "This source appears to need an authenticated browser or library session."}
        {cite.pillState === "page_absent" && "Argus retrieved the source, but the cited page was not available in the retrieved copy."}
        {cite.pillState === "unsupported" && "Argus retrieved the source, but the cited claim was not supported."}
      </p>
      {citationNeedsUpload(cite) && (
        <div style={{display: "flex", gap: 6, marginTop: 14, justifyContent: "center", flexWrap: "wrap"}}>
          <UploadAffordance
            jobId={(typeof window !== "undefined" && window.__ARGUS_ACTIVE_JOB_ID) || ""}
            refIdx={cite && cite._raw && Number.isFinite(Number(cite._raw.ref_index)) ? Number(cite._raw.ref_index) : null}
            reason={null}
            compact={true}
            onUploaded={() => window.location && window.location.reload && window.location.reload()}/>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { ViewWorkspace });
