// The shared idioms. One of each, deliberately.
//
// The UI inventory found four near-duplicate stat-tile components in the v3
// prototype. That is not a tidiness problem: four implementations of "what is
// waiting on you" drift apart, and the one nobody looks at is the one that goes
// wrong quietly. WP-U07 consolidates them into one strip and one list, and every
// workspace opens on those.

const { useState, useEffect, useCallback, useRef } = React;

// ── How long has this been waiting ────────────────────────────────────────
// Rendered from the recorded timestamp, never from a stored "age" — an age
// column is a fact that starts being wrong the moment it is written.
function since(ts) {
  if (!ts) return '—';
  const recorded = new Date(ts).getTime();
  if (!Number.isFinite(recorded)) return '—';
  const ms = Date.now() - recorded;
  const mins = Math.floor(ms / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h`;
  return `${Math.floor(hrs / 24)}d`;
}

// ── A sum of money, and the absence of one ────────────────────────────────
//
// AN ABSENT VALUE IS NOT ZERO, and this is the whole of the function. "Nobody
// has priced this deal" and "this deal is worth nothing" are different facts
// about a row, and only one of them is ever true — so a missing value draws an
// em-dash, the same mark every unmeasured figure in this application already
// wears, rather than a number nobody recorded.
//
// SHORTENED, AND THE EXACT FIGURE IS ON THE ELEMENT. A table of forty-eight
// deals is scanned for magnitude, not read for cents; `$1.2m` compares at a
// glance where `$1,240,000` does not. The caller puts the full number in a
// `title`, so nothing is hidden — the shortening is a reading aid and never
// the only place the figure exists.
//
// Written here rather than in the one pane that needs it today, because the
// sourcing register, the uncompeted register and the exposure report all carry
// `value_usd` and will each want it — and a second copy is how the first two
// come to round differently (S325).
function money(value) {
  if (value === null || value === undefined || value === '') return '—';
  const n = Number(value);
  if (!Number.isFinite(n)) return '—';
  const abs = Math.abs(n);
  if (abs >= 1e9) return `$${(n / 1e9).toFixed(1)}bn`;
  if (abs >= 1e6) return `$${(n / 1e6).toFixed(1)}m`;
  if (abs >= 1e3) return `$${Math.round(n / 1e3)}k`;
  return `$${n}`;
}

// ── The stat-tile strip ───────────────────────────────────────────────────
// Zero is shown, muted, never hidden. "Nothing is waiting" is a real answer and
// a person needs to be able to tell it apart from a tile that failed to load.
// A tile with a `to` is a way into the set it counted; one without stays
// completely inert. Same bargain as StatBox, and the same reason: an
// affordance is a claim that there is somewhere to go, and one that leads
// nowhere teaches people to stop pressing.
function TileStrip({ tiles }) {
  return (
    <div className="tile-strip">
      {tiles.map((t) => {
        const measured = t.n !== null && t.n !== undefined;
        const live = Boolean(t.to) && measured;
        // `aria-pressed` ONLY WHERE THERE IS A PRESSED STATE, which is the
        // rule StatBox already keeps and this copy did not. A tile that
        // SCROLLS to a section below is not a toggle, and announcing it to a
        // screen reader as "not pressed" says it has an off state it does not
        // have. Absent is not `false`, and the difference is the whole claim.
        const props = live
          ? { ...openableRow(t.to, t.describe || `show the ${t.n} ${t.label}`),
              ...(t.on === undefined ? {} : { 'aria-pressed': Boolean(t.on) }) }
          : {};
        return (
          <div className={`tile${live ? ' tile--drill' : ''}`} key={t.label} {...props}>
            <div className={`tile-n${t.n === 0 ? ' none' : ''}`}>
              {measured ? t.n : '—'}
            </div>
            <div className="tile-l">{t.label}</div>
          </div>
        );
      })}
    </div>
  );
}

// ── A proportion, drawn rather than divided ───────────────────────────────
//
// MOVED HERE FROM panel-measures.jsx on 2026-08-23, unchanged, when the
// retained-language figure became its second user. It was always a shared
// idiom; it simply had one user until then.
//
// SEGMENTS OF A MEASURED TOTAL, never a computed percentage. Each segment's
// width is its own count over the sum of the counts given — arithmetic the
// reader can check by looking, on a bar whose length is the same for every
// row so the rows can be compared at a glance.
//
// A ROW WITH NOTHING IN IT GETS NO BAR. A zero-width bar reads as an answer
// ("none of these are open") when the truth is that nothing was ever asked,
// and those are different facts about a discipline — the second is the one
// that means nobody is staffed.
function BandBar({ bands, total, empty }) {
  if (!total) {
    return <div className="caption">{empty}</div>;
  }
  return (
    <div>
      <div className="band-bar" role="img"
           aria-label={bands.map((b) => `${b.n} ${b.label}`).join(', ')
             + ` of ${total}`}>
        {bands.filter((b) => b.n > 0).map((b) => (
          <span key={b.key} className={`band band-${b.tone}`}
                style={{ flexGrow: b.n }} title={`${b.n} ${b.label}`} />
        ))}
      </div>
      <div className="flex gap-3 mt-1 flex-wrap">
        {bands.map((b) => (
          <span key={b.key} className="caption">
            <span className={`band-key band-${b.tone}`} aria-hidden="true" />
            {b.n} of {total} {b.label}
          </span>
        ))}
      </div>
    </div>
  );
}

// ── Taking a list out of the building ─────────────────────────────────────
// AN EXPORT IS EVIDENCE, AND EVIDENCE MUST SAY WHAT IT IS.
//
// Both CSV exports in this application wrote `filter.shown` — the rows left
// after whatever was typed in the search box — into a file named after the
// WHOLE record. Measured on the auditor's record, same button, same filename,
// one sitting: 379 rows with no filter, 87 with "agreement", 3 with "r.vance",
// and 0 with a needle matching nothing. Nothing in the file, its name, or the
// button said which of those you had.
//
// The access history was the worse of the two: its own subtitle says "Append-
// only, so this is the whole story" in the same header block as the button.
//
// This matters here more than it would elsewhere. An auditor's export LEAVES
// THE BUILDING — it is handed to somebody as the record. A file called
// `the-record.csv` holding three of three hundred and seventy-nine rows,
// because a filter was left in a box on a screen nobody else saw, is the
// filtered-sample trap of S319 with a filename on it.
//
// THE FIX IS NOT TO EXPORT EVERYTHING. Exporting what you narrowed to is a
// real and useful act; an auditor pulling one person's grants wants those. It
// is the SILENCE that is wrong. So the counts travel — in the button before
// you press it, and in the filename afterwards, because the filename is the
// part that stays with the file once it is out of the building.
//
// The CSV BODY is deliberately left alone: a comment line would be honest and
// would also break every naive parser that opens it, and an export nobody can
// open is not evidence either.
function csvCell(v) {
  const s = v === null || v === undefined ? '' : String(v);
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

// `shown` is what will be written; `total` is how many exist behind it.
//
// AND WHO TOOK IT, AND WHEN, ON EVERY ROW (Mike, 2026-08-22). The filename
// already said how much of the record the file is; it said nothing about who
// made it. An auditor's export leaves the building and is handed to somebody
// as evidence, and evidence that cannot say where it came from is worth less
// than evidence that can.
//
// ON EVERY ROW RATHER THAN IN A HEADER LINE, and that is the whole design
// decision. A comment line above the columns would be tidier and would also
// break every naive parser that opens the file - and, worse, it does not
// SURVIVE. A spreadsheet gets sorted, filtered, and half of it gets pasted
// into an email; a header is left behind at the first of those and the rows
// that travel on carry no origin at all. Two repeated columns are the price
// of a fragment that is still attributable.
//
// `exported_at` IS THIS BROWSER'S CLOCK, and the column is deliberately not
// called `recorded_at`: nothing about this export is on the chain. The system
// does not know the file was made. Whether taking a copy of the record should
// ITSELF be a recorded act is a real question and a bigger one - see the
// handoff.
const CSV_PROVENANCE = ['exported_by', 'exported_at'];

function downloadCsv({ stem, head, rows, total, cell, by }) {
  const at = new Date().toISOString();
  const cols = [...head, ...CSV_PROVENANCE];
  const body = rows.map((r) => cols.map((k) => {
    if (k === 'exported_by') return csvCell(by || 'unknown');
    if (k === 'exported_at') return csvCell(at);
    return csvCell(cell ? cell(r, k) : r[k]);
  }).join(','));
  const blob = new Blob([[cols.join(','), ...body].join('\n')], { type: 'text/csv' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = rows.length === total
    ? `${stem}-${total}.csv`
    : `${stem}-${rows.length}-of-${total}.csv`;
  a.click();
  URL.revokeObjectURL(a.href);
}

// What the button says BEFORE it is pressed, so nobody exports a narrowed set
// without being told. Said in the same breath as the number, which is the
// whole lesson of S319.
function csvLabel(shown, total) {
  return shown === total ? `↓ export ${total}` : `↓ export ${shown} of ${total}`;
}

// ── A figure, and the way to what it counted ──────────────────────────────
// TWO IDIOMS FOR ONE IDEA, and only one of them was a component. TileStrip
// drew `.tile`; `.stat-box` was written out by hand at ten sites across three
// files, including a local `statBox` helper in reviewer.jsx. This is the shape
// S325 named: the copies drift before anybody notices.
//
// AND ALMOST NONE OF THEM LED ANYWHERE. Counted across the running application
// in all six roles: 63 summary figures, of which 3 were controls — and those
// three only because the buyer home was repaired first. A desk that states
// "72 awaiting review" over a list of 72, and cannot be pressed to reach it,
// is a picture of the work rather than a way into it.
//
// `to` is what makes a figure a control, and it is a FUNCTION rather than a
// destination, because the two kinds of drill are genuinely different: some
// figures are counting a section further down the same page, and some are
// counting a pane that belongs to another tab. The caller knows which; this
// does not need to.
//
// A FIGURE WITH NO `to` STAYS COMPLETELY INERT — no pointer, no tab stop, no
// role. That is not laziness, it is the same rule the stage strip keeps: an
// affordance is a claim that there is somewhere to go, and offering one that
// leads nowhere teaches people to stop pressing.
//
// A MEASURED ZERO STILL DRILLS. Nought override decisions is a fact, and the
// pane that says so plainly is a real destination — "there is nothing" is an
// answer. What must never drill is an UNMEASURED figure: `n` of null draws an
// em-dash, and there is no set behind it to show.
// `on` SAYS WHICH FIGURE IS CURRENTLY NARROWING THE LIST, and it is not
// decoration. `TileStrip` has carried it since the library's figures became
// controls; StatBox did not, so a pane whose figures FOCUS a list — rather
// than scroll to a section — had no way to show which one was pressed. A set
// of toggles where none of them looks pressed is a set of buttons that appear
// to do nothing the second time.
//
// Optional and defaulted to undefined, so the twenty-odd existing call sites
// that scroll rather than focus are unchanged: `aria-pressed` is absent unless
// a caller passes the prop, and an absent attribute is not `false`.
function StatBox({ label, n, mark, to, describe, nStyle, on }) {
  const measured = n !== null && n !== undefined;
  const live = Boolean(to) && measured;
  const props = live
    ? { ...openableRow(to, describe || `show the ${n} ${label}`),
        ...(on === undefined ? {} : { 'aria-pressed': Boolean(on) }) }
    : {};
  return (
    <div className={`stat-box${live ? ' stat-box--drill' : ''}`} {...props}>
      <span className="stat-label">{label}</span>
      {/* `nStyle` carries the one thing the hand-written copies did that the
          class cannot: the obligations book tints `due` and `overdue` with the
          ink those states already wear everywhere else. Kept as a prop rather
          than dropped, because converting a copy must not quietly take a
          behaviour away from the site that had it. */}
      <div className="flex items-end justify-between gap-2">
        <span className={`stat-n${n === 0 ? ' none' : ''}`} style={nStyle}>
          {measured ? n : '—'}
        </span>
        {mark}
      </div>
    </div>
  );
}

// Bringing a figure's section into view when the figure is pressed. Kept here
// beside StatBox because every caller wants the same behaviour and the same
// two lines of it — and because `block: 'nearest'` rather than 'start' is the
// detail that stops a short section jumping under the masthead.
function showSection(ref) {
  return () => ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}

// ── WHAT KIND OF PROPOSAL A REVIEW TICKET CARRIES (0107; #153) ────────────
//
// `cw.clause_draft.kind` has allowed three shapes since 0008 — 'clause',
// 'rung' and 'rule' — and the Library Builder drafts all three into the same
// queue. Since 0107 `cw.verify_review_ticket()` REFUSES the last two by name,
// naming the act that does handle each. None of that reached the list: a
// proposed ladder rung sat among proposed wording with nothing saying so, and
// the desk offered it an approve control whose only possible outcome was that
// refusal. A person learned what they were holding by being refused.
//
// IT LIVES HERE BECAUSE FOUR SCREENS ASK IT — the review desk, the ticket
// register, the refer-a-ticket picker and the auditor's quality register. Four
// copies of one derivation is how this repository has been wrong before.
//
// IT MIRRORS THE DATABASE'S OWN LOOKUP RATHER THAN INVENTING ONE. 0107 reads
// the kind only `if t.draft_id is not null`, and falls through to minting a
// clause otherwise — so a ticket citing no draft is wording, here as there.
// `reads.py` states the same case in SQL; this is the screen's half of it, and
// a-proposal-that-is-not-a-clause.test.mjs drives both against each other.
//
// SELF-CONTAINED ON PURPOSE. It names no outer constant, so the test can lift
// this one declaration out of the file and run it rather than reading it.
function proposalKind(ticket) {
  const draft = ticket.draft_id;
  // No draft at all: ordinary vendor language or an escalation. Wording, and
  // decided where wording is decided.
  if (draft === null || draft === undefined) {
    return { key: 'clause', label: 'proposed wording', decidedHere: true };
  }
  switch (ticket.kind) {
    case 'clause':
      return { key: 'clause', label: 'proposed wording', decidedHere: true };
    case 'rung':
      return {
        key: 'rung', label: 'proposed ladder rung', decidedHere: false,
        where: 'the library builder', hash: '#/builder',
        why: 'A rung is a position on a fallback ladder as well as wording. '
           + 'Approving it here would mint the words and leave the ladder as '
           + 'it was, so it is placed on the library builder instead, where '
           + 'the ladder is shown as it would stand.',
      };
    case 'rule':
      return {
        key: 'rule', label: 'proposed validation rule', decidedHere: false,
        where: 'the library builder', hash: '#/builder',
        why: 'A rule is machine-readable logic, not contract language. '
           + 'Approving it here would publish it as approved wording an '
           + 'agreement could be assembled from, so it is published on the '
           + 'library builder instead, after the system has said whether the '
           + 'predicate is even legal.',
      };
    default:
      // A ticket that CITES a draft whose row this reader cannot see. That is
      // a fourth answer and not a synonym for wording — guessing 'clause' here
      // is exactly how the defect above would come back quietly.
      return {
        key: 'unknown', label: 'kind not visible to you', decidedHere: false,
        why: 'This ticket cites a draft, and the draft record is not one you '
           + 'are shown. What kind of proposal it is cannot be stated here.',
      };
  }
}

// The chip, in the established idiom. Wording draws NOTHING — it is what the
// queue is for, and a chip on every row would be noise rather than a label.
// The two that are not wording are drawn in the ink weight, because they
// change what a person does next; the fourth is the dashed `unknown` chip this
// stylesheet already keeps for a fact the reader is not shown.
function ProposalKindChip({ ticket }) {
  const kind = proposalKind(ticket);
  if (kind.key === 'clause') return null;
  return (
    <span className={`chip ${kind.key === 'unknown' ? 'chip-unknown' : 'chip-high'}`}
          title="what kind of proposal this ticket carries">
      {kind.label}
    </span>
  );
}

// ── AN UNTOUCHED TICKET IS NOT WAITING, IT IS LOST (ticket_expiry, 0013) ──
//
// The operational row said "the age at which an untouched review ticket is
// flagged as overdue" and NOTHING READ IT until 2026-08-30 — an Administrator
// could set it to a day or a year and no screen anywhere moved. This is the
// half a person sees; the derivation is in reads.py, at both ticket lists.
//
// DRAWN ONLY ON `true`, never on absent. The read returns NULL when the age is
// unset, which means UNKNOWN rather than "not overdue" — and an unset age must
// never paint the whole queue red, nor quietly claim every ticket is fine. A
// null draws nothing, which is the same rule the navigation rack keeps: an
// area the application has not measured shows NO NUMBER rather than a nought.
//
// It FLAGS and never closes or decides, which is the setting's own words and
// the reason it is operational rather than a judgement.
function OverdueChip({ ticket }) {
  if (ticket.overdue !== true) return null;
  return (
    <span className="chip chip-err"
          title="Older than the age the Administrator set for an untouched review ticket. Nothing has been closed or decided.">
      overdue
    </span>
  );
}

// ── WHAT AN EXPERT MAY SAY, AND WHERE A CONSULTATION STANDS (0090) ────
//
// MOVED HERE FROM `panel.jsx` WHOLE on 2026-08-25, when the review desk became
// the second screen to draw a consultation. Two copies of a four-state
// vocabulary is the drift S325 wrote up, and these words are load-bearing
// rather than decorative: they are where the product tells a reader that an
// expert's opinion gates nothing.
//
// THREE ANSWERS RATHER THAN TWO, because the honest middle is the common case:
// most expert answers are "yes, provided", and a scheme that forces that into
// 'sound' loses the proviso — which is the part that matters.
const ADVICE = [
  { key: 'sound',     label: 'sound',     ink: 'effective',
    help: 'Acceptable as it stands.' },
  { key: 'qualified', label: 'qualified', ink: 'pending',
    help: 'Acceptable only with the qualification stated below.' },
  { key: 'unsound',   label: 'unsound',   ink: 'refused',
    help: 'Not acceptable in my discipline. This does NOT block the ticket — '
        + 'Legal reads it and decides.' },
];

// A consultation's state in the words a person reads. NOT "not X": the record
// allows three states plus the not-asked case, and a two-way test against a
// four-way vocabulary is S312's defect exactly.
function ConsultationMark({ row }) {
  if (row.state === 'answered') {
    const said = ADVICE.find((a) => a.key === row.advice);
    return (
      <Status state={said ? said.ink : 'neutral'}
              title={said ? said.help : undefined}>
        answered · {row.advice}
      </Status>
    );
  }
  if (row.state === 'waived') {
    return <Status state="superseded" title={row.waiver_reason}>
      waived by {row.waived_by}
    </Status>;
  }
  if (row.state === 'not_asked') {
    return <Status state="never">not asked</Status>;
  }
  return <Status state="pending">waiting on {row.discipline || row.discipline_key}</Status>;
}

// WHETHER ONE CONSULTATION HOLDS THE TICKET, in the database's own terms rather
// than a screen's. `cw.outstanding_consultations()` (0090) selects the REQUIRED
// disciplines whose state is neither 'answered' nor 'waived', and the verify
// trigger refuses to mint while that set is non-empty. This is that same
// sentence and no screen may invent a second one. TWO THINGS IT DELIBERATELY
// DOES NOT LOOK AT: an advisory consultation, which gates nothing whatever its
// state; and the ADVICE GIVEN — an answer of 'unsound' passes exactly as
// 'sound' does. A screen that consulted either would be teaching a rule this
// system does not have.
const consultationHolds = (row) =>
  row.necessity === 'required'
  && row.state !== 'answered' && row.state !== 'waived';

// ── What `cw.waiting_for()` returns, in words ─────────────────────────────
// ONE VOCABULARY, TWO SCREENS. This derivation is rendered in two places — the
// buyer's home and the obligations book — and each had written out its own
// copy of what the six kinds are called. The copies had already drifted: the
// buyer home names WHAT KIND OF REFERENCE each row carries, and the
// obligations book did not, so the same row read "an envelope is out for
// signature · AG-26-041" on one screen and "· 9" on the other.
//
// It belongs here because it is a fact about the DERIVATION, not about either
// screen — the same reason the screen and the digest must not disagree.
//
// TWELVE KINDS, AND THIS STOPPED AT SEVEN. 0090, 0093, 0095 and 0098 each
// added an arm to cw.waiting_for() and none of them came back here, so five
// kinds arrived with no sentence at all and fell through to `?? w.kind` — the
// raw database word. On the seeded database that put
//
//     notice · 3        since 2026-08-22
//
// on the desk of the Legal Admin, the Legal Reviewer, the Auditor AND the
// Administrator, among seventy-two rows that read properly. A panel whose
// whole job is to say what you still owe, saying it in enum.
//
// `the-waiting-vocabulary-is-complete.test.mjs` now reads the kinds out of
// backend/db/migrations/ and fails BY NAME for any kind these two maps cannot
// say, so a thirteenth arm is in scope the hour it is written rather than five
// migrations later.
const WAITING_KINDS = {
  obligation: 'an obligation is due',
  override_socialisation: 'an override was socialised to you',
  renewal_window: 'a renewal window is open',
  envelope_out: 'an envelope is out for signature',
  countersign: 'a role grant waits on your countersign',
  review_ticket: 'a ticket waits for review',
  review_escalation: 'an unclaimed ticket escalated to you',
  notice: 'a notice was raised to you',
  deal_comment: 'a comment on a deal is addressed to you',
  consultation: 'an expert opinion was asked of you',
  routing_suggestion: 'a suggested route has not been acted on',
  waiver_countersign: 'a waived consultation waits on your countersign',
};

// WHAT THE REFERENCE IS, per kind. `cw.waiting_for()` answers one
// `subject_ref` column across twelve kinds, so the column holds a deal
// reference on one row and a bare envelope id on the next — and a naked "9"
// under the same heading as "AG-26-041" reads as though it were the same sort
// of thing. `null` means the reference already says what it is.
const WAITING_REF_KINDS = {
  obligation:             'obligation',
  override_socialisation: 'request',
  renewal_window:         null,
  envelope_out:           'envelope',
  countersign:            'grant',
  review_ticket:          'ticket',
  review_escalation:      'ticket',
  notice:                 'notice',
  deal_comment:           'comment',
  consultation:           'consultation',
  routing_suggestion:     'suggestion',
  waiver_countersign:     'consultation',
};

// A reminder points at the kind of record it names, not a similarly numbered deal.
// The destination still applies its own read and act permissions. Unknown kinds
// stay inert; a reference alone never establishes what sort of record it names.
function waitingRecordDestination(row, role) {
  const tabKey = row?.kind === 'obligation' ? 'obligations'
    : ['review_ticket', 'review_escalation'].includes(row?.kind) ? 'review-desk' : null;
  if (!tabKey || row.subject_ref == null ||
      String(row.subject_ref).trim() === '') return null;
  if (!(WORKSPACES[role]?.tabs ?? []).some((tab) => tab.key === tabKey)) return null;
  return `#/${tabKey}/${encodeURIComponent(String(row.subject_ref))}`;
}

function WaitingRecordLink({ row, me }) {
  const destination = waitingRecordDestination(row, me.role);
  if (!destination) return null;
  const noun = WAITING_REF_KINDS[row.kind];
  return (
    <button type="button" className="kind-chip"
            aria-label={`open ${noun} ${row.subject_ref}`}
            data-testid={`waiting-${noun}-link`}
            onClick={() => { window.location.hash = destination; }}>
      open {noun} →
    </button>
  );
}

// ── WHICH KIND OF DATE A WAITING ROW CARRIES ──────────────────────────────
// cw.waiting_for() answers TWO different quantities in two columns, and only
// two of its twelve kinds fill the first one.
//
//   due_on   A DEADLINE. An obligation's due date; a renewal's term end.
//   since    The moment the thing STARTED waiting — when an envelope was sent,
//            when a ticket was opened, when a notice was raised.
//
// The buyer's home printed both under a column headed "Due", bare and
// identically formatted. So three envelopes SENT three weeks ago read as three
// deadlines MISSED three weeks ago:
//
//     an envelope is out for signature   envelope 9   Due 2026-07-28
//
// Nothing was due. The obligations pane had already written `due X` / `since
// X` to tell the two apart — one rule, two sites, and the home page was the
// one that never got it. It lives here now so they cannot drift again, which
// is the same reason WAITING_REF_KINDS was hoisted out of the panes.
//
// A start timestamp supports an AGE, which is the reading WaitingList already
// uses everywhere else in this application, so that is what this says. A
// deadline supports "overdue", which is a measured fact worth marking — with
// the WORD, in the pending vocabulary. Red means error only, and nothing is
// said by colour alone. Both are rules of this desk.
//
// WHAT THIS REFUSES TO DO: give a row a deadline the record does not hold. An
// envelope has no due date anywhere in the schema, and inventing one is
// exactly the tempting addition nobody would notice was invented.
function waitingWhen(w) {
  if (w && w.due_on) {
    const on = String(w.due_on).slice(0, 10);
    // Compared as calendar days, both sides parsed the same way, so a row due
    // today is neither overdue nor silently rounded into yesterday.
    const now = new Date();
    const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
    const at = Date.parse(`${on}T00:00:00Z`);
    const days = Number.isFinite(at) ? Math.round((today - at) / 86400000) : 0;
    return { sort: 'due', label: `due ${on}`, exact: on, overdueDays: days > 0 ? days : 0 };
  }
  if (w && w.since) {
    return {
      sort: 'since',
      label: `waiting ${since(w.since)}`,
      exact: String(w.since),
      overdueDays: 0,
    };
  }
  return { sort: 'none', label: '—', exact: '', overdueDays: 0 };
}

// The one rendering of the above, so the two panes cannot disagree about what
// a date means OR about how it looks.
function WaitingWhen({ row }) {
  const w = waitingWhen(row);
  return (
    <span className="waiting-when" title={w.exact}>
      <span className="waiting-when-label">{w.label}</span>
      {w.overdueDays > 0 && (
        <span className="chip chip-pending">
          {w.overdueDays} {w.overdueDays === 1 ? 'day' : 'days'} overdue
        </span>
      )}
    </span>
  );
}

// ── A way through a long list ─────────────────────────────────────────────
// WRITTEN ONCE, because five copies of it had already been written by hand and
// fourteen panes were still without one.
//
// The census that produced this: eighteen panes render fifteen rows or more,
// and only four of them offered any way through. Seventy-two review tickets,
// seventy-two routing rows, seventy-two obligations, thirty-six deal rooms,
// thirty-six negotiations — all of them a single wall, in the workspaces of
// the people who live in them. Meanwhile the library, the audit record and the
// access history each carried their own hand-built copy of the same pair, so
// the idiom existed five times and reached none of the panes that needed it
// most. That is the shape this repository keeps catching, and the answer is
// not a sixth copy.
//
// `useListFilter` holds the state and does the narrowing; `ListFilter` draws
// the controls. A pane says which fields the search covers and (optionally)
// which column the dropdown filters, and gets the same behaviour, the same
// wording and the same `N of M` count as every other list.
//
// WHAT THIS IS NOT: it is not fetching broadly and filtering for permission.
// Every read behind these panes already answered only what the caller may see;
// this narrows what is SHOWN, and there is no wider list behind it to leak.

// ── The views a person has named (0110) ───────────────────────────────────
// ONE FETCH FOR THE WHOLE SESSION, held here rather than in each list.
//
// A person has a handful of saved views and thirty-seven lists that need to
// know whether any of them are theirs. A read per list would ask the service
// thirty-seven times on a workspace walk to be told "none" thirty-five times,
// so `GET /me/views` answers every list at once and each one takes its own
// slice by `list_key`.
//
// AND IT IS A CACHE OF A PREFERENCE, NOT OF DATA. What it holds is the three
// things a person typed; the ROWS a view narrows are re-read by the pane on
// every load exactly as before, so nothing here can serve a stale contract, a
// stale ticket or a stale figure. The cache is dropped whenever the person
// saves or forgets one, and `forgetSavedViews()` drops it at sign-out beside
// the drafts.
let SAVED_VIEWS = null;          // rows once they have arrived, else null
let SAVED_VIEWS_CALL = null;     // the in-flight promise, so N lists make 1 call
const SAVED_VIEW_SUBS = new Set();

// THE VIEWER HOLDS NO GRANT ON cw.saved_view, deliberately (0110, following
// 0108): a viewer changes nothing, anywhere. So the screen does not draw them
// a control the database would refuse, and does not call an endpoint that
// would answer them a refusal on every list they open.
const roleMayKeepViews = () =>
  Boolean(API.session) && API.session.role !== 'viewer';

function loadSavedViews() {
  if (SAVED_VIEWS || SAVED_VIEWS_CALL) return SAVED_VIEWS_CALL;
  SAVED_VIEWS_CALL = API.myViews().then((r) => {
    // A REFUSAL IS AN EMPTY SET HERE, and that is the one place in this file
    // where that is the right reading. Saved views are a convenience over a
    // list the person is already looking at; a role that cannot keep them
    // gets the list without them, never a broken pane.
    SAVED_VIEWS = r.ok ? (r.rows || []) : [];
    SAVED_VIEWS_CALL = null;
    SAVED_VIEW_SUBS.forEach((fn) => fn());
    return SAVED_VIEWS;
  });
  return SAVED_VIEWS_CALL;
}

function dropSavedViews() {
  SAVED_VIEWS = null;
  SAVED_VIEWS_CALL = null;
}

function forgetSavedViews() {
  dropSavedViews();
  SAVED_VIEW_SUBS.forEach((fn) => fn());
}

// The slice of them belonging to one list, and a way to make the store fetch
// again after a save or a forget.
function useSavedViews(listKey) {
  const [, bump] = useState(0);
  useEffect(() => {
    if (!listKey || !roleMayKeepViews()) return undefined;
    const fn = () => bump((n) => n + 1);
    SAVED_VIEW_SUBS.add(fn);
    loadSavedViews();
    return () => { SAVED_VIEW_SUBS.delete(fn); };
  }, [listKey]);
  const mine = (SAVED_VIEWS || []).filter((v) => v.list_key === listKey);
  return {
    rows: mine,
    ready: SAVED_VIEWS !== null,
    reload: () => { dropSavedViews(); loadSavedViews(); },
  };
}

function useListFilter(rows, { fields, facet, view, focuses } = {}) {
  const [q, setQ] = useState('');
  const [pick, setPick] = useState('');
  // A FOCUS IS WHAT A FIGURE NARROWS TO. The search box and the facet are what
  // somebody types; a focus is the set a tile counted — "the 1 expiring within
  // 90 days" — handed over whole so the number on the tile and the rows below
  // it are the same set by construction rather than by coincidence.
  //
  // It is held HERE rather than in each pane because the narrowing has to be
  // VISIBLE and clearable, and that belongs beside the other two controls. A
  // list quietly showing a subset is the trap S319 is about; a list showing a
  // subset with a chip saying which subset, and a way to drop it, is a control.
  const [focus, setFocus] = useState(null);   // { key, label, test }
  const all = rows || [];
  const needle = q.trim().toLowerCase();
  const shown = all.filter((r) =>
    (!focus || focus.test(r)) &&
    (!facet || !pick || String(r[facet] ?? '') === pick) &&
    (!needle || (fields || []).some((f) =>
      String(r[f] ?? '').toLowerCase().includes(needle))));
  const options = facet
    ? [...new Set(all.map((r) => r[facet]).filter((v) => v !== null && v !== undefined && v !== ''))]
        .map(String).sort()
    : [];

  // ── The view you come back to (0110) ────────────────────────────────────
  // A SAVED VIEW IS THE THREE THINGS THIS HOOK ALREADY HOLDS — the search
  // text, the facet pick, and the focus — under a name the person chose.
  // Nothing else. It is not a query, not a report, and above all not a scope:
  // the read behind this pane already answered only what the caller may see
  // (the header above), so the SAME VIEW APPLIED BY TWO PEOPLE WITH DIFFERENT
  // GRANTS SHOWS EACH OF THEM THEIR OWN ROWS.
  //
  // A LIST WITH NO `view` KEY GETS NOTHING. Everything below is inert without
  // one, so a site is wired by adding a key and by nothing else.
  const saved = useSavedViews(view);
  const [applied, setApplied] = useState(null);   // the name in force, or null
  const catalogue = focuses || [];
  // THE OTHER WAY A LIST HANDS OVER ITS CATALOGUE (handoff 43 §7). Most panes
  // build each figure's focus at the tile rather than as a declared list, so
  // `focusable` lets the tile register its focus AS IT RENDERS — by the time a
  // saved view could ask for the key back, the tile that knows how to put it
  // back has already said so. A ref, not state: registering during render must
  // not schedule another one, and re-registering the same key is idempotent.
  const registered = useRef(new Map());
  const focusable = (key, label, test) => {
    const f = { key, label, test };
    registered.current.set(key, f);
    return f;
  };
  const findFocus = (key) =>
    catalogue.find((f) => f.key === key) || registered.current.get(key) || null;

  const put = (v) => {
    setQ(v.q || '');
    setPick(v.pick || '');
    // A FOCUS IS RESTORED FROM THE CATALOGUE, never rebuilt from the stored
    // label. Its `test` is a function and no store holds one; the key is what
    // survives, and the pane hands back the test that belongs to it. A key
    // whose figure has since been retired restores NO focus and leaves the
    // search and the facet exactly as they were saved — inert, not wrong.
    setFocus(v.focus_key ? findFocus(v.focus_key) : null);
    setApplied(v.name);
  };

  // THE DEFAULT APPLIES ITSELF ONCE, ON ARRIVAL. Guarded by a ref rather than
  // by the applied name, so that clearing a default view does not immediately
  // re-apply it and trap somebody inside their own preference.
  const defaultDone = useRef(false);
  useEffect(() => {
    if (!view || !saved.ready || defaultDone.current) return;
    const d = saved.rows.find((v) => v.is_default);
    if (!d) { defaultDone.current = true; return; }
    // AND IT WAITS FOR THE CATALOGUE. Nearly every pane returns <Loading /> long
    // before its figures have rendered, so a default carrying a focus can be
    // ready before the list that knows how to put that focus back. Applying it
    // then would drop the narrowing silently and leave a chip claiming a set
    // the screen is not showing. It gives up once the rows are in: by then the
    // figures have rendered, and a key still missing belongs to a figure that
    // has since been retired, which 0110 says is inert rather than wrong.
    if (d.focus_key && !findFocus(d.focus_key) && all.length === 0) return;
    defaultDone.current = true;
    put(d);
  }, [view, saved.ready, all.length]);

  const activeRow = saved.rows.find((v) => v.name === applied) || null;
  // EDITED IS DRAWN RATHER THAN GUESSED AT. Once a view is applied and then
  // typed over, the chip must stop claiming the saved narrowing — a person has
  // to be able to tell from the screen what they are looking at.
  const dirty = Boolean(activeRow) && (
    (activeRow.q || '') !== q ||
    (activeRow.pick || '') !== pick ||
    (activeRow.focus_key || '') !== (focus ? focus.key : ''));

  // AND THE ONE THING THE CONTROL REFUSES TO DO. Where a focus is in force and
  // this list handed over no catalogue, the key could be stored and never
  // restored — so the view would come back showing a different set under the
  // same name. That is the "control that leads somewhere other than where it
  // says" this repository has closed four times, and it is refused with a
  // sentence rather than saved quietly.
  const focusUnsavable = Boolean(focus) && !findFocus(focus.key);

  const views = {
    key: view || null,
    on: Boolean(view) && roleMayKeepViews(),
    rows: saved.rows,
    ready: saved.ready,
    applied, active: activeRow, dirty,
    focusUnsavable,
    apply: (name) => {
      const v = saved.rows.find((r) => r.name === name);
      if (v) put(v);
    },
    // Dropping the view drops the narrowing with it. A chip that clears itself
    // and leaves the rows narrowed would be the same lie from the other side.
    clear: () => { setQ(''); setPick(''); setFocus(null); setApplied(null); },
    save: async (name, asDefault) => {
      const r = await API.saveMyView({
        list_key: view, name,
        q, pick,
        focus_key: focus && !focusUnsavable ? focus.key : '',
        focus_label: focus && !focusUnsavable ? focus.label : '',
        is_default: asDefault ? 'true' : 'false',
      });
      if (r.ok) { saved.reload(); setApplied(name); }
      return r;
    },
    forget: async (name) => {
      const r = await API.forgetMyView({ list_key: view, name });
      if (r.ok) { saved.reload(); if (applied === name) setApplied(null); }
      return r;
    },
  };

  return {
    shown, q, setQ, pick, setPick, options, focus, setFocus, focusable,
    total: all.length,
    filtering: needle !== '' || pick !== '' || focus !== null,
    // Pressing the figure that is already showing clears it, so a focus is a
    // toggle rather than a state you can only leave through the chip.
    //
    // AND IT CLEARS THE SEARCH ON THE WAY, which is S333's rule kept at the one
    // place every figure goes through. A figure promises "the 9 with no answer
    // yet"; a needle left in the search box from five minutes ago answers that
    // promise with 0 rows while the figure still says 9. The chip and the
    // `0 of 10` count do make the narrowing visible rather than silent — but a
    // control that leads somewhere other than where it says it leads is broken
    // whether or not it explains itself afterwards.
    //
    // HERE RATHER THAN AT THE CALL SITES: six of them across three panes today,
    // and the seventh is the one somebody writes next month. A rule applied by
    // hand reaches a fraction of its sites (S314).
    focusOn: (next) => {
      setQ('');
      setPick('');
      setFocus((cur) => (cur && cur.key === next.key ? null : next));
    },
    views,
  };
}

// ── The views control (0110) ──────────────────────────────────────────────
// Beside the search box, on every list that was given a `view` key, and drawn
// nowhere else. Three things and no more: choose one, save what is on screen
// under a name, forget one.
//
// SAVING ASKS FOR THE NAME INLINE. `prompt()` is a browser dialog nobody can
// style, nobody can test through the DOM and nothing else in this application
// uses; the naming row appears in the strip itself.
function SavedViews({ filter, testid }) {
  const v = filter.views;
  const [naming, setNaming] = useState(false);
  const [name, setName] = useState('');
  const [asDefault, setAsDefault] = useState(false);
  const [refused, setRefused] = useState(null);
  const [busy, setBusy] = useState(false);
  if (!v || !v.on) return null;

  const commit = async () => {
    const trimmed = name.trim();
    if (!trimmed) return;
    setBusy(true); setRefused(null);
    const r = await v.save(trimmed, asDefault);
    setBusy(false);
    // THE DATABASE'S OWN SENTENCE, unreworded — every refusal in this
    // application is rendered that way.
    if (!r.ok) { setRefused(r.reason); return; }
    setNaming(false); setName(''); setAsDefault(false);
  };

  return (
    <>
      {v.rows.length > 0 && (
        <select
          className="font-mono" style={{ padding: '5px 9px' }}
          value={v.applied || ''}
          aria-label="your saved views of this list"
          data-testid={testid ? `${testid}-views` : undefined}
          onChange={(e) => (e.target.value ? v.apply(e.target.value) : v.clear())}>
          <option value="">your views</option>
          {v.rows.map((r) => (
            <option key={r.name} value={r.name}>
              {r.name}{r.is_default ? ' · opens here' : ''}
            </option>
          ))}
        </select>
      )}

      {/* EDITED IS SAID OUT LOUD. A chip that went on claiming the saved
          narrowing after somebody typed over it would be describing a set
          that is not the one on screen. */}
      {v.applied && (
        <span className="chip" data-testid={testid ? `${testid}-view-chip` : undefined}>
          {v.applied}{v.dirty ? ' · edited' : ''}
        </span>
      )}

      {!naming && (filter.filtering || v.applied) && (
        <button type="button" className="btn btn-sm"
                data-testid={testid ? `${testid}-view-save` : undefined}
                onClick={() => { setNaming(true); setName(v.applied || ''); }}>
          save this view
        </button>
      )}

      {v.applied && !naming && (
        <button type="button" className="btn btn-sm"
                data-testid={testid ? `${testid}-view-forget` : undefined}
                disabled={busy}
                onClick={async () => {
                  setBusy(true); setRefused(null);
                  const r = await v.forget(v.applied);
                  setBusy(false);
                  if (!r.ok) setRefused(r.reason); else v.clear();
                }}>
          forget it
        </button>
      )}

      {naming && (
        <>
          <input
            className="font-mono min-w-0" style={{ padding: '5px 9px', maxWidth: '15rem' }}
            placeholder="call it what you like" value={name}
            aria-label="a name for this view"
            data-testid={testid ? `${testid}-view-name` : undefined}
            onChange={(e) => setName(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') commit(); }} />
          <label className="caption flex items-center gap-1">
            <input type="checkbox" checked={asDefault}
                   aria-label="open this list on this view"
                   data-testid={testid ? `${testid}-view-default` : undefined}
                   onChange={(e) => setAsDefault(e.target.checked)} />
            opens here
          </label>
          <button type="button" className="btn btn-sm" disabled={busy || !name.trim()}
                  data-testid={testid ? `${testid}-view-keep` : undefined}
                  onClick={commit}>keep it</button>
          <button type="button" className="btn btn-sm" disabled={busy}
                  onClick={() => { setNaming(false); setRefused(null); }}>cancel</button>
        </>
      )}

      {/* THE ONE THING IT WILL NOT DO QUIETLY. A focus this list handed over no
          catalogue for can be stored and never restored, so the view would come
          back showing a different set under the same name. Said, rather than
          saved. */}
      {naming && v.focusUnsavable && (
        <span className="caption" data-testid={testid ? `${testid}-view-nofocus` : undefined}>
          the narrowing on this figure will not be kept — this list cannot put it back
        </span>
      )}
      {refused && <span className="caption" style={{ color: 'var(--accent-2)' }}>{refused}</span>}
    </>
  );
}

// The controls, and the count beside them. Drawn only when there is a list
// worth going through — a search box over three rows is furniture.
function ListFilter({ filter, placeholder, facetLabel, testid, minRows = 8 }) {
  // A FOCUS IS DRAWN EVEN WHEN THE REST OF THE ROW IS NOT. A short list needs
  // no search box, but if a figure has narrowed it then the narrowing must be
  // on screen whatever its length — an unexplained subset is the whole defect.
  //
  // AND A SAVED VIEW IN FORCE COUNTS THE SAME WAY (0110): a list showing what
  // one says is a narrowed list, and the chip that names it must be on screen
  // whatever the length.
  const viewOn = Boolean(filter.views && filter.views.applied);
  if (filter.total < minRows && !filter.focus && !viewOn) return null;
  return (
    <div className="list-filter flex flex-wrap gap-2 mb-3 mt-2">
      {filter.focus && (
        <button type="button" className="chip chip-focus"
                data-testid={testid ? `${testid}-focus` : undefined}
                aria-label={`showing only ${filter.focus.label}; press to show all`}
                onClick={() => filter.setFocus(null)}>
          {filter.focus.label} <span aria-hidden="true">×</span>
        </button>
      )}
      <input
        className="font-mono min-w-0 grow" style={{ padding: '5px 9px' }}
        placeholder={placeholder} value={filter.q}
        aria-label={placeholder}
        onChange={(e) => filter.setQ(e.target.value)}
        data-testid={testid ? `${testid}-search` : undefined} />
      {filter.options.length > 1 && (
        <select
          className="font-mono" style={{ padding: '5px 9px' }} value={filter.pick}
          aria-label={facetLabel}
          onChange={(e) => filter.setPick(e.target.value)}
          data-testid={testid ? `${testid}-facet` : undefined}>
          <option value="">{facetLabel}</option>
          {filter.options.map((o) => <option key={o} value={o}>{o}</option>)}
        </select>
      )}
      <SavedViews filter={filter} testid={testid} />
    </div>
  );
}

// The count that goes in a PanelHead's `right`. Says the whole number when
// nothing is filtered, and `N of M` when something is — the wording the
// library already used, so every list reads the same way.
function FilterCount({ filter }) {
  return (
    <span className="caption">
      {filter.filtering ? `${filter.shown.length} of ${filter.total}` : `${filter.total}`}
    </span>
  );
}

// A filter that matches nothing is NOT an empty book, and must not wear its
// clothes. The difference between "there is none of this" and "you typed
// something that matches nothing" is exactly the difference this product keeps
// insisting on everywhere else.
function NoMatch({ kicker, noun }) {
  return (
    <Empty
      kicker={kicker}
      line={`No ${noun} matches that.`}
      sub="Clear the search and the filter to see all of them again." />
  );
}

// ── The waiting list ──────────────────────────────────────────────────────
// (openableRow is declared further down this file. A function declaration
//  hoists, and it is called at render time rather than at load time, so the
//  order below is a reading choice and not a dependency.)
//
// THE OLDEST MARK IS DERIVED. It used to be `i === 0` — the FIRST row the
// server happened to return, wearing a mark that states a measured fact about
// time. The comment above this function claimed "oldest first, always"; the
// function never sorted, and not one of its twenty callers sorted either. So
// the mark went wherever the endpoint's ORDER BY put row zero. Piloted on
// 2026-08-23, six roles, eleven lists carrying rows:
//
//   the record          379 rows, strictly NEWEST first  → newest row marked oldest
//   access history        7 rows, strictly NEWEST first  → newest row marked oldest
//   my negotiations      36 rows, strictly NEWEST first  → newest row marked oldest
//   reporting ×2, vendors                 NO dates at all → a row told it waited longest
//   review desk, routing  72 rows, every timestamp EQUAL → a vacuous pass, not a right one
//
// This is S312's shape and S333's: a screen stating a fact it has not measured.
// It is worse than the pipeline cards' confident zero, because a desk exists to
// surface the longest wait and this pointed at the shortest.
//
// `order` is REQUIRED, and `db/test/a-waiting-list-declares-its-order.test.mjs`
// fails by name when a call site omits it. A default would be a twenty-first
// site nobody chose:
//
//   'oldest'  a QUEUE. Sorted ascending by `at` here, so the pane cannot hand
//             it the wrong order, and the longest wait is marked.
//   'newest'  an append-only RECORD. Sorted descending. Nothing is marked —
//             "the oldest thing in the audit log" is not a call to action.
//   'given'   a DOMAIN order that is not time: ranked by share, by friction,
//             by due date, by round. Untouched, and nothing is marked.
//
// WHAT THE MARK REFUSES TO CLAIM. Nothing is marked unless at least two rows
// carry a date AND the winner is strictly older than every other. Seventy-two
// tickets seeded in one transaction share a timestamp to the microsecond;
// pointing at one of them says "this is the one that has waited longest" when
// the record does not know that. An unmeasured superlative is the defect.
//
// AND IT SAYS SO IN WORDS. `.oldest` was a colour and a font weight, on a desk
// whose own sign-in sheet promises nothing is said by colour alone.
const WAITING_ORDERS = ['oldest', 'newest', 'given'];

function orderWaiting(items, order) {
  const at = (it) => {
    const t = it && it.at ? Date.parse(it.at) : NaN;
    return Number.isFinite(t) ? t : null;
  };
  // THE MARK IS COMPUTED FROM THE LIST AS GIVEN, before any sorting, and the
  // two halves are deliberately independent. Deriving it from the SORTED rows
  // would make `rows[0]` trivially correct — so a regression to placing the
  // mark by position would pass every fixture, which is exactly what happened
  // when this guard was first planted with a fault on 2026-08-23. A check that
  // cannot fail is not a check.
  let markKey = null;
  if (order === 'oldest') {
    const dated = items.map(at).filter((t) => t !== null);
    if (dated.length >= 2) {
      const min = Math.min(...dated);
      // STRICTLY older than every other dated row, or nothing is marked.
      if (dated.filter((t) => t === min).length === 1) {
        const winner = items.find((it) => at(it) === min);
        markKey = winner ? winner.key : null;
      }
    }
  }
  let rows = items;
  if (order === 'oldest' || order === 'newest') {
    const dir = order === 'oldest' ? 1 : -1;
    // A row with no date keeps its place at the end rather than sorting as
    // epoch zero, which would put "we do not know when" at the top of a desk
    // as though it were the oldest thing on it.
    rows = [...items].sort((a, b) => {
      const x = at(a), y = at(b);
      if (x === null && y === null) return 0;
      if (x === null) return 1;
      if (y === null) return -1;
      return (x - y) * dir;
    });
  }
  return { rows, markKey };
}

function WaitingList({ items, empty, onOpen, order }) {
  if (!items || items.length === 0) return empty;
  const { rows, markKey } = orderWaiting(items, order);
  return (
    <div className="panel">
      {/* OPENABLE THE SAME WAY THE TABLES ARE. Iteration 3 made three
          clickable <tr> rows keyboard-reachable and wrote openableRow() so a
          fourth could not be added without one — and missed THIS, the shared
          component, because it is built from divs rather than table rows. It
          was the biggest site of the four: 72 rows on Legal's review desk, 72
          on the Legal admin's, 36 on the requester's negotiations, 12 in the
          viewer's reading room. A rule about a BEHAVIOUR, fixed at one of its
          SHAPES. Fixed here, it reaches every waiting list there will be. */}
      {rows.map((it) => (
        <div
          className={`waiting-row waiting-list-row${it.key === markKey ? ' oldest' : ''}`}
          key={it.key}
          {...(onOpen
            ? openableRow(() => onOpen(it), `open ${it.title}`)
            : {})}
          style={onOpen ? { cursor: 'pointer' } : undefined}
        >
          <div className="min-w-0">
            <div className="text-[13px] truncate" style={{ color: 'var(--ink)' }}>
              {it.title}
            </div>
            {it.sub && (
              <div className="caption mt-0.5 truncate">{it.sub}</div>
            )}
          </div>
          <div className="flex items-center gap-3 shrink-0">
            {it.chips}
            {it.key === markKey && (
              <span className="chip chip-pending">longest wait</span>
            )}
            <span className="waiting-age" title={it.at ?? ''}>{since(it.at)}</span>
          </div>
        </div>
      ))}
    </div>
  );
}

// ── Honest empty states ───────────────────────────────────────────────────
// The existing idiom: a kicker, one serif sentence, at most one primary action.
//
// Two DIFFERENT emptinesses, and conflating them is the failure this package
// most needs to avoid:
//
//   <Empty>       nothing is here yet, and that is a true fact about the data.
//   <NotBuiltYet> this pane has no endpoint behind it. Saying so is the whole
//                 point — the alternative is a screen implying the system does
//                 something it does not.
function Empty({ kicker, line, sub, action }) {
  return (
    <div className="empty">
      <div className="empty-kicker">{kicker}</div>
      <div className="empty-line">{line}</div>
      {sub && <div className="empty-sub">{sub}</div>}
      {action && <div className="mt-5">{action}</div>}
    </div>
  );
}

function NotBuiltYet({ what, lands }) {
  return (
    <div className="empty">
      <div className="empty-kicker">not built yet</div>
      <div className="empty-line">{what}</div>
      <div className="empty-sub">
        There is no endpoint behind this pane, so there is nothing to show. It is
        empty rather than filled with an example, because a screen that
        demonstrates something the system cannot do is the thing this rebuild
        exists to stop. {lands && <>Lands in <span className="font-mono">{lands}</span>.</>}
      </div>
    </div>
  );
}

// The state a role reaches by typing a URL its own endpoints would refuse.
// Deliberately styled as an empty state and not as an error: nothing has gone
// wrong, the system is working. Red means error, never merely "no".
function Refused({ what, role, reason }) {
  return (
    <div className="refusal mt-6">
      <Empty
        kicker="refused"
        line={what}
        sub={
          <>
            You are acting as <span className="font-mono">{role}</span>, and this
            part of the system belongs to somebody else.{' '}
            {reason
              ? <>The database said: <span style={{ color: 'var(--mute)' }}>“{reason}”</span></>
              : <>Nothing was fetched — the address is not one your role can load,
                 so no data for it ever reached this browser.</>}
          </>
        }
      />
    </div>
  );
}

// ── The one status mark ───────────────────────────────────────────────────
// FIVE STATES, FIVE INKS, AND NO SIXTH. Four near-duplicate stat tiles once
// drifted apart in this product and WP-U07 consolidated them; status marks were
// heading the same way — every pane deciding its own chip class from its own
// ternary. This is the single component they should all go through.
//
// Every mark carries a WORD as well as an ink and a shape. Roughly one man in
// twelve cannot separate the amber from the green, and this product's entire
// vocabulary is amber against green, so colour may reinforce a state and may
// never be the thing that carries it.
//
//   effective   something is in force and confers what it says
//   pending     asked for, not yet granted — CONFERS NOTHING YET, never green
//   refused     the database said no. an ERROR, not an ordinary "no"
//   never       the check has not run. neither a pass nor a failure
//   superseded  replaced, and kept rather than deleted
//
// A state outside these five is a design decision somebody has to make on
// purpose, not a class somebody types in a hurry.
const STATE_INK = {
  effective:  'chip-ok',
  pending:    'chip-pending',
  refused:    'chip-err',
  never:      'chip-unknown',
  superseded: 'chip-gone',
  neutral:    'chip-std',
};

function Status({ state, children, title }) {
  const ink = STATE_INK[state] || STATE_INK.neutral;
  return (
    <span className={`chip ${ink}`} data-state={state || 'neutral'} title={title}>
      {children}
    </span>
  );
}

// Shared by review and negotiation actions. Keep the child mounted on refusal;
// the caller's selected-record key owns the lifetime of both draft and response.
// ActRefusal supplies the existing alert and verbatim-reason presentation.
function ReviewActionFeedback({ label, children }) {
  const [refusal, setRefusal] = useState(null);
  const message = useRef(null);
  const report = (reason) => setRefusal(reason ? { reason } : null);
  React.useEffect(() => {
    if (!refusal) return;
    message.current?.focus({ preventScroll: true });
    message.current?.scrollIntoView({ block: 'start', behavior: 'auto' });
  }, [refusal]);
  return (
    <div>
      {refusal && (
        <div ref={message} tabIndex={-1} aria-label={`${label}: refusal`}
             data-testid="review-action-feedback" className="mt-3"
             style={{ overflowWrap: 'anywhere' }}>
          <div className="section-label">{label}</div>
          <ActRefusal reason={refusal.reason} />
        </div>
      )}
      {children(report)}
    </div>
  );
}

// ── Loading, and failing to load ──────────────────────────────────────────
// A pane that cannot load says so. It never renders as empty: "nothing is
// waiting on you" and "we could not ask" are different facts, and showing the
// second as the first is how somebody misses a queue.
function Loading() {
  return (
    <div className="empty">
      <div className="empty-kicker">loading</div>
      <div className="empty-line" style={{ color: 'var(--mute-2)' }}>…</div>
    </div>
  );
}

function LoadFailed({ reason }) {
  return (
    <div className="empty">
      <div className="empty-kicker">could not load</div>
      <div className="empty-line">This did not load.</div>
      <div className="empty-sub">
        <span style={{ color: 'var(--mute)' }}>“{reason}”</span>
        <br />
        Shown rather than rendered as empty, because “nothing is waiting on you”
        and “we could not ask” are different facts.
      </div>
    </div>
  );
}

// ── The hook every pane uses ──────────────────────────────────────────────
// Fetch once, hold three states — loading, failed, loaded — and never collapse
// the first two into an empty list.
//
// `body` is the whole reply, for the few endpoints that answer a RECORD rather
// than a list of rows — GET /intake/probes is the first, and it answers a walk
// version and a set of probes. Kept on this hook rather than given a second
// one: the generation guard below (S214) is the part that is easy to get
// wrong, and a parallel hook is how a screen ends up with the version of it
// that was written before the bug was found.
function usePane(fetcher, deps = []) {
  const [state, setState] = useState({
    status: 'loading', rows: [], body: null, reason: null });
  const generation = useRef(0);
  const reload = useCallback(async () => {
    const request = ++generation.current;
    setState((s) => ({ ...s, status: 'loading' }));
    const r = await fetcher();
    if (request !== generation.current) return;
    if (r.ok) setState({ status: 'loaded', rows: r.rows, body: r.body, reason: null });
    else setState({ status: 'failed', rows: [], body: null,
                    reason: r.reason, http: r.status });
  }, deps);
  useEffect(() => {
    reload();
    return () => { generation.current += 1; };
  }, [reload]);
  return { ...state, reload };
}

// A small panel header in the established idiom: title, italic serif subtitle.
// ── A button whose act cannot fire twice ──────────────────────────────────
// THE SAME GUARD AS useActs, MOVED TO WHERE THE CLICK IS.
//
// `useActs()` works and 43 buttons use it. The trouble is that adopting it
// means restructuring a handler — `onClick={async () => {…}}` becomes
// `onClick={() => acts.run(k, async () => {…})}`, which changes the OPENING
// and the CLOSING of the handler. S322 did exactly that at eight sites, got
// the opening right and the closing wrong at four of them, and three files
// stopped parsing — and one bad .jsx leaves every component in it undefined,
// so a whole workspace goes blank.
//
// Counted 2026-08-22, deriving the write set from api.jsx's own verbs rather
// than from a list: THIRTY-SEVEN write buttons across eight files still fired
// from a plain `onClick={async () => …}` guarded only by `disabled={busy}`.
// Four were confirmed by double-clicking them with the network stubbed; one
// was confirmed against the database itself, where a single double-click on
// "verify the chain" left TWO rows in cw.integrity_check 300ms apart.
//
// `disabled={busy}` READS as a guard and is not one: React state does not take
// effect until the next render, and two clicks land in the same tick — which
// is what a double-click IS.
//
// So this takes the handler UNCHANGED and guards the element instead. A site
// adopts it by renaming its tag, which cannot get a brace wrong. Everything
// else about the button — className, disabled, data-testid, title — passes
// straight through, so the label a pane already computes from its own `busy`
// keeps working exactly as it did.
//
// WHAT IT DOES NOT DO: it does not stop a SECOND, LATER click once the first
// has finished. That is a different thing and usually correct — running a
// check twice in a minute is a person's decision. This stops the two clicks of
// one double-click, which is nobody's decision at all.
function ActButton({ onClick, children, ...rest }) {
  const inFlight = useRef(false);
  const [running, setRunning] = useState(false);
  const guarded = async (e) => {
    // SET BEFORE THE FIRST await, and read on the way in. This is the whole
    // mechanism: a ref is written synchronously, so the second click of a
    // double-click sees it on the same tick that the first click set it.
    if (inFlight.current) return;
    inFlight.current = true;
    setRunning(true);
    try { return await onClick(e); }
    finally { inFlight.current = false; setRunning(false); }
  };
  // The caller's own `disabled` still wins when it says true — a control that
  // was disabled for a REASON (nothing typed, nothing selected) must not
  // become pressable because this component also has an opinion.
  return (
    <button {...rest} disabled={rest.disabled || running} onClick={guarded}>
      {children}
    </button>
  );
}

// ── An act that must not fire twice ──────────────────────────────────────
//
// A run, a decision, a grant and a hold are PERMANENT — cw.run_immutable()
// refuses to delete a run even to the owner. So a button that fires a write
// twice does not make a mistake somebody can tidy up; it makes two records.
//
// Counted across the app: 35 of 43 write buttons already guarded themselves
// with a local `busy` flag. Eight did not — three with no `disabled` at all
// (the U6 countersign among them) and five disabled only on VALIDITY, which
// says nothing about whether the first click is still in the air.
//
// Two of the eight write a genuinely new row on the second click, because
// their tables key on a surrogate rather than on what makes them unique:
//
//     cw.legal_hold        PRIMARY KEY (hold_id)      → two holds
//     cw.override_watcher  PRIMARY KEY (watcher_id)   → two watchers
//
// THE REF IS THE GUARD, NOT THE DISABLED ATTRIBUTE. `disabled` only takes
// effect after React re-renders, and two clicks can land in the same tick —
// which is exactly what a double-click is. The ref is set synchronously
// before the await, so the second call returns without touching the network.
// `busy` is returned as well so the button can also say so.
//
// Keyed, because most of these are one button per row: `busy` holds the key
// of the act in flight, and the whole set is held while any one of them runs.
// Two permanent records asked for at once, from one screen, is the thing being
// prevented — not merely two of the same one.
function useActs() {
  const [busy, setBusy] = useState(null);
  const inFlight = useRef(false);
  const run = useCallback(async (key, fn) => {
    if (inFlight.current) return undefined;
    inFlight.current = true;
    setBusy(key);
    try { return await fn(); }
    finally { inFlight.current = false; setBusy(null); }
  }, []);
  return { busy, run };
}

// ── The record you have open, held in the address ────────────────────────
//
// main.jsx says why this matters, and does not treat it as a nicety: "Deep
// links matter here for a reason beyond convenience". That rule was kept for
// TABS and for nothing else. Six panes swap themselves for a record — a deal,
// a negotiation, a room, a ticket, a shared agreement — and every one of them
// held it in component state, so:
//
//   · you could not send a colleague to a deal. "Look at AG-26-040" was a
//     sentence somebody had to follow by hand.
//   · the browser's Back button left the APPLICATION rather than closing the
//     deal, because opening the deal never went anywhere.
//   · a reload lost your place.
//
//   #/my-deals              the list
//   #/my-deals/AG-26-001    that deal, open
//
// PERCENT-ENCODED going in, decoded coming out. Agreement references are tame
// today; S301 was exactly this shape — three of four parameterised calls
// encoded their arguments and the fourth did not, and "nothing was
// exploitable" is precisely why it survived.
//
// Returns the same [value, set] shape useState does, so a pane adopts it by
// changing one line and nothing else.
function useAddressedRecord(tab) {
  const read = React.useCallback(() => {
    const parts = window.location.hash.replace(/^#\/?/, '').split('/');
    if (parts[0] !== tab || parts.length < 2) return null;
    const raw = parts.slice(1).join('/');
    if (!raw) return null;
    try { return decodeURIComponent(raw); } catch { return raw; }
  }, [tab]);

  const [id, setId] = useState(read);

  useEffect(() => {
    const on = () => setId(read());
    on();
    window.addEventListener('hashchange', on);
    return () => window.removeEventListener('hashchange', on);
  }, [read]);

  const open = useCallback((value) => {
    const next = value === null || value === undefined || value === ''
      ? `#/${tab}`
      : `#/${tab}/${encodeURIComponent(value)}`;
    if (window.location.hash !== next) window.location.hash = next;
    setId(value === null || value === undefined || value === '' ? null : String(value));
  }, [tab]);

  return [id, open];
}

// ── A row that opens something ───────────────────────────────────────────
// Three tables let you click a row to open what it names — the requester's
// deals, and the negotiation lists in the negotiate and deal-room panes. All
// three were `<tr onClick>` and NOTHING ELSE: tabIndex -1, no role, no key
// handler. 48 deal rows, none of them reachable from a keyboard, and opening
// a deal is the whole point of the requester's workspace.
//
// WRITTEN ONCE, HERE, rather than three times at the call sites. Three copies
// of "and remember the keyboard" is how one of them ends up without it, and a
// fourth clickable row added later inherits this by using the helper rather
// than by somebody remembering the rule.
//
// Enter and Space both open, because a role="button" is expected to answer
// both, and Space has its page-scroll default suppressed so the row opens
// instead of the page jumping.
function openableRow(onOpen, label) {
  return {
    'data-open': 'true',
    tabIndex: 0,
    role: 'button',
    'aria-label': label,
    onClick: onOpen,
    onKeyDown: (e) => {
      if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
      e.preventDefault();
      onOpen();
    },
  };
}

// ── Work in progress that survives a change of tab ────────────────────────
// A pane UNMOUNTS the moment somebody clicks the navigation rack, and
// everything it held in useState goes with it. For a form one field wide that
// is no loss. For the intake walk — six prose answers about what is being
// bought, its value, what personal data it touches, what could go wrong and
// how we would exit — it is the most effortful input in the application, and
// it was being destroyed in silence by one click on the way to check a
// reference. Measured: six answers typed, `#/my-deals`, back to `#/intake`,
// six empty boxes and the deal unchosen.
//
// HELD IN MEMORY, NEVER IN STORAGE, and that is the whole design. Putting a
// half-written intake in localStorage would leave a draft naming a real
// counterparty on the disk of a shared desk, outliving the session that typed
// it. This system refuses to leave papers out for exactly that reason. A
// module-level Map dies with the page — which is already when the session
// dies, since a reload signs you out.
//
// CLEARED ON SIGN-OUT, because two people at one desk is the case that
// matters: without it, the next person to sign in during the same page load
// inherits the last one's half-written intake. main.jsx calls forgetDrafts()
// in the same breath as it forgets the token, on the deliberate sign-out AND
// on the 401 that means the session is gone.
const DRAFTS = new Map();

function forgetDrafts() { DRAFTS.clear(); }
function discardDraft(key) { DRAFTS.delete(key); }

// The SAME [value, set] shape useState returns, so a pane adopts it by
// changing one line — the same bargain useAddressedRecord makes. `set` takes a
// value or an updater, because half this app's callers pass an updater and a
// hook that quietly accepted only one of those would be a trap.
function useRetainedState(key, initial) {
  const [v, setV] = useState(() => (DRAFTS.has(key) ? DRAFTS.get(key) : initial));
  const set = useCallback((next) => {
    setV((cur) => {
      const val = typeof next === 'function' ? next(cur) : next;
      DRAFTS.set(key, val);
      return val;
    });
  }, [key]);
  return [v, set];
}

// ── The page header every pane opens on ───────────────────────────────────
// THE OTHER HALF OF THE HEADING REPAIR. Marking PanelHead as a heading gave 34
// of 34 views an outline; it did not give them a TITLE. 24 of those views
// opened on a section — the intake pane's first heading was "What is this
// for" — so there was no h1 anywhere, and promoting a section to h1 would have
// been wrong rather than right.
//
// Five panes already drew this exact block by hand (Buyer Home, Legal Home,
// Legal Deals, Deal Rooms, Obligations) and looked like the front of the
// product; the other 22 opened on a small uppercase label and looked like a
// fragment of one. Extracting what those five share and giving it to the rest
// is the repair — and the originals are converted in the same change, because
// pulling out a shared component and leaving the copies is how five copies
// become six (S325).
//
// `right` takes the counts and stamps the flagship panes already put up there.
// `sub` is the one line under the rule that says what the pane is for.
function PaneHead({ title, kicker, sub, right }) {
  return (
    <>
      <div className="sheet-head">
        <div>
          {kicker && <div className="sheet-kicker">{kicker}</div>}
          <h1 className={`sheet-title${kicker ? ' mt-1' : ''}`}>{title}</h1>
        </div>
        {right}
      </div>
      {sub && (
        <div className="font-serif italic mt-2" style={{ fontSize: 14, color: 'var(--mute)' }}>
          {sub}
        </div>
      )}
    </>
  );
}

// THE TITLE IS A HEADING, not a styled line of text. Until 2026-08-22 it was a
// <div>, and because 22 of the application's 27 panes use PanelHead as their
// page title, those panes contained NO heading element at all — h1 through h6,
// none. A screen reader has no outline to move through and no way to answer
// "where am I", on every screen but five.
//
// This is S315 one layer up: a visible label is not a name, and a visible
// TITLE is not a heading. The word on the screen was never the problem.
//
// Tailwind's preflight already sets `h1..h6 { font-size: inherit; font-weight:
// inherit }` and `margin: 0`, so the element changes and NOTHING MOVES —
// verified by measuring, not by assuming.
//
// `as` defaults to h2 because the common case is a section inside a pane. A
// pane whose first PanelHead is its page title passes as="h1"; the five panes
// that already carry <h1 className="sheet-title"> keep it and their sections
// stay h2, which is the outline the right way up.
function PanelHead({ title, sub, right, as: Heading = 'h2' }) {
  return (
    <div className="panel-head-row flex items-end justify-between mb-3">
      <div>
        <Heading className="section-label">{title}</Heading>
        {sub && (
          <div className="font-serif italic mt-1" style={{ fontSize: 15, color: 'var(--mute)' }}>
            {sub}
          </div>
        )}
      </div>
      {right}
    </div>
  );
}


// ══════════════════════════════════════════════════════════════════════════
// CROSS-CONTRACT IMPACT — THE WARNING, AND THE COUNT THAT MAKES IT HONEST
// ══════════════════════════════════════════════════════════════════════════
//
// HERE RATHER THAN IN A PANE, because three panes draw it and Mike's decision
// 6 (2026-08-24) is that BOTH the requester and Legal see the SAME warning,
// each fenced by the reads they already hold:
//
//   suppliers.jsx   the supplier page, on five rails — the portfolio view
//   negotiate.jsx   the requester's open negotiation, where the change is
//                   actually being made
//   reviewer.jsx    Legal's ticket desk, where the decision is taken
//
// There is NO role check in any of the three, and there must not be. The fence
// is `cw.cross_contract_echo`, scoped through `cw.contract_paragraph` on BOTH
// sides, so a requester sees echoes only among their own deals and Legal —
// who may read every deal — sees them all. One statement, three screens,
// different rows.
//
// LEGAL OWNS THE DECISION. These components own nothing: no act, no control
// that decides anything, and nothing here can refuse or delay anybody.
// ── HOW MUCH OF THEIR PAPER THIS ANSWER WAS COMPUTED OVER ─────────────────
//
// The single most important thing on the screen, and it is a component of its
// own so that the second site drawing this warning cannot draw it differently
// — or leave it off. Extracting it now rather than copying it is the rule this
// repository keeps having to relearn: five copies become six.
//
// LOUDEST WHEN SHORT. A complete reading is a quiet line; anything less is
// marked, because an incomplete answer that LOOKS complete is the failure this
// whole feature is built around.
function HowMuchWasRead({ seen }) {
  const contracts = Number(seen.contracts || 0);
  const indexed = Number(seen.contracts_indexed || 0);
  const stale = Number(seen.contracts_with_stale_readings || 0);

  if (seen.complete && !stale) {
    return (
      <p className="note" data-testid="looked-at">
        All {contracts} of this supplier’s assembled contracts have been read.
        Anything not shown below was looked at and did not match.
      </p>
    );
  }

  return (
    <div className="note-card note-card--rule" data-testid="looked-at">
      <div className="note-title">how much was read</div>
      <strong>
        {indexed} of {contracts} of this supplier’s contracts have been read.
      </strong>{' '}
      {contracts === 0
        ? 'Nothing has been assembled for this supplier yet, so there is no other paper to compare against — this is not the same as nothing matching.'
        : `Anything below was worked out from those ${indexed}. It is not a
           statement about the other ${contracts - indexed}, which nobody has
           looked at.`}
      {seen.deals > contracts && (
        <> {Number(seen.deals) - contracts} more deal
          {Number(seen.deals) - contracts === 1 ? ' has' : 's have'} no
          assembled contract at all.</>
      )}
      {stale > 0 && (
        <> {stale} contract{stale === 1 ? '' : 's'} were read from wording that
          has since changed; those readings are shown as out of date and are
          not counted as read.</>
      )}
    </div>
  );
}

// ── The findings themselves ───────────────────────────────────────────────
// A WARNING, DRAWN AS ONE. No control here is disabled by a finding and
// nothing below leads to a gate.
//
// TWO SIGNALS SIDE BY SIDE AND NEVER BLENDED. What Legal's tags say, and how
// near the two readings point — and a paragraph nobody has read draws no
// number rather than a nought, because a nought is a measurement meaning
// "unrelated" and would be a confident claim about something never measured.
function CrossContractEchoes({ agreementId }) {
  const found = usePane(() => API.crossContractEchoes(agreementId),
                        [agreementId]);
  // The severity readings (0129) — advice beside the pairs, fetched
  // separately so a refusal to show advice cannot take the warning down.
  // ABOVE EVERY EARLY RETURN, the standing hook rule.
  const readings = usePane(() => API.conflictAssessments());
  const [asking, setAsking] = useState(null);   // an echo's key, while busy
  const [refused, setRefused] = useState(null);
  const answer = found.body;

  const ask = async (bodyFields, key) => {
    setAsking(key); setRefused(null);
    const r = await API.assessConflict({ agreement_id: agreementId,
                                         ...bodyFields });
    setAsking(null);
    if (!r.ok) { setRefused(r.reason); return; }
    readings.reload();
  };
  const readingsHere = (readings.rows ?? [])
    .filter((a) => a.agreement_id === agreementId)
    .sort((a, b) => b.assessment_id - a.assessment_id);

  if (found.status === 'loading' && !answer) return <Loading />;
  if (found.status === 'failed') return <LoadFailed reason={found.reason} />;
  if (!answer) return null;

  // AN UNRESOLVED DEAL HAS NO OTHER CONTRACTS BY DEFINITION, and saying
  // "nothing was found" here would be the exact sentence this feature must
  // never produce.
  if (answer.resolution === 'unresolved') {
    return (
      <div className="note-card note-card--rule">
        <div className="note-title">nothing was compared</div>
        <strong>{agreementId} is attached to no supplier record.</strong> It is
        in no supplier’s set, so nothing was compared — which is not the same
        as nothing matching. Attach it above and this answers.
      </div>
    );
  }

  return (
    <div>
      <PanelHead
        title={`What else touches ${agreementId}`}
        sub={`Paragraphs of this supplier’s other contracts, at a nearness of ${answer.at_least} or on a tag Legal attached to both.`} />

      <HowMuchWasRead seen={answer.looked_at || {}} />

      {refused && (
        <Refused what="read this conflict" reason={refused} />
      )}

      {answer.truncated && (
        <p className="note">
          {answer.found} pairs match; the {answer.shown} closest are shown.
        </p>
      )}

      <WaitingList
        order="given"
        items={(answer.echoes || []).map((e) => ({
          key: `${e.other_agreement_id}-${e.other_clause_id}-${e.other_version}`,
          title: `${e.title || e.clause_id} → ${e.other_agreement_id}`,
          sub: `${e.other_title || e.other_clause_id} · `
             // A NUMBER ONLY WHERE THERE IS A MEASUREMENT. Both sides read, or
             // no figure at all — the navigation rack's rule, kept here.
             + (e.nearness === null || e.nearness === undefined
                ? 'not measured — one of these paragraphs has not been read'
                : `nearness ${Number(e.nearness).toFixed(2)}`)
             + (e.shared_tags && e.shared_tags.length
                ? ` · Legal tagged both ${e.shared_tags.join(', ')}`
                : ' · no tag in common'),
          at: null,
          chips: (
            <>
              {(e.wording_moved || e.other_wording_moved) && (
                <span className="chip chip-high"
                  title="the approved wording moved after this reading was taken">
                  out of date
                </span>
              )}
              {(!e.has_reading || !e.other_has_reading) && (
                <span className="chip chip-std"
                  title="one of these paragraphs has no reading, so only the tags Legal attached were compared">
                  tags only
                </span>
              )}
              <ActButton className="btn btn-sm"
                data-testid="read-this-conflict"
                disabled={asking !== null}
                onClick={() => ask({
                  clause_id: e.clause_id, version: e.version,
                  other_agreement_id: e.other_agreement_id,
                  other_clause_id: e.other_clause_id,
                  other_version: e.other_version,
                }, `${e.clause_id}-${e.other_agreement_id}`)}>
                read this conflict
              </ActButton>
            </>
          ),
        }))}
        empty={<Empty kicker="other contracts"
          line="Nothing in the contracts that were read says close to this."
          sub="Read the count above before treating that as an all-clear." />} />

      {/* ── The other side of the paper (0121) ─────────────────────────────
          The counterparty's OWN markup on this supplier's other deals, with
          its own denominator drawn first — the same honesty rule, second
          site. TWO SIGNALS SIDE BY SIDE AND NEVER BLENDED here too: the
          category both scorers filed a paragraph under, and how near the
          two readings point. */}
      <div className="mt-5" data-testid="vendor-echoes">
        <PanelHead
          title="Their own markup elsewhere"
          sub="Paragraphs this supplier proposed on their OTHER deals with us that say close to this contract's language — the same conflict, seen from their side of the paper." />
        <TheirMarkupWasRead seen={answer.vendor_looked_at || {}} />
        {answer.vendor_truncated && (
          <p className="note">
            {answer.vendor_found} pairs match; the {answer.vendor_shown} closest
            are shown.
          </p>
        )}
        <WaitingList
          order="given"
          items={(answer.vendor_echoes || []).map((e) => ({
            key: `v-${e.other_negotiation_id}-${e.other_paragraph_index}-${e.clause_id}-${e.version}`,
            title: `${e.title || e.clause_id} → ${e.other_agreement_id}`,
            sub: `their ¶ ${e.other_paragraph_index}, round ${e.other_round_no} · `
               + (e.nearness === null || e.nearness === undefined
                  ? 'not measured — one of these paragraphs has not been read'
                  : `nearness ${Number(e.nearness).toFixed(2)}`)
               + (e.same_category
                  ? ` · both filed under ${e.category_key}`
                  : ' · different categories'),
            at: null,
            chips: (
              <>
                {(e.wording_moved || e.other_reading_stale) && (
                  <span className="chip chip-high"
                    title="a reading here was taken of wording or a round that has since moved">
                    out of date
                  </span>
                )}
                {(!e.has_reading || !e.other_has_reading) && (
                  <span className="chip chip-std"
                    title="one of these paragraphs has no reading, so only the category labels were compared">
                    category only
                  </span>
                )}
                <ActButton className="btn btn-sm"
                  data-testid="read-this-conflict"
                  disabled={asking !== null}
                  onClick={() => ask({
                    clause_id: e.clause_id, version: e.version,
                    other_negotiation_id: e.other_negotiation_id,
                    other_paragraph_index: e.other_paragraph_index,
                  }, `v-${e.other_negotiation_id}-${e.other_paragraph_index}`)}>
                  read this conflict
                </ActButton>
              </>
            ),
          }))}
          empty={<Empty kicker="their markup"
            line="Nothing in the markup that was read says close to this."
            sub="Read the count above before treating that as an all-clear." />} />
      </div>

      {/* ── The severity readings on the record (0129) ─────────────────────
          Advice beside the pairs, in the advice pen. THE LABEL IS FOUR
          UNORDERED WORDS and this list draws the rows in the order they were
          asked — never sorted or grouped by label, because a rankable
          severity is a gate wearing advice's clothes. Where the other side
          is a counterparty paragraph the AI-6 entrance can draft a reply
          from the negotiation screen; where it is our own approved wording,
          harmonizing language is Legal's pen — the reading says so. */}
      {readingsHere.length > 0 && (
        <div className="advice-card mt-5" data-testid="conflict-readings">
          <div className="advice-label">Severity readings (advisory)</div>
          {readingsHere.map((a) => (
            <div className="panel-2 p-2.5 mt-2" key={a.assessment_id}
                 data-testid="conflict-reading">
              <div className="flex items-baseline justify-between gap-2 flex-wrap">
                <span className="font-mono text-[12.5px]">
                  {a.clause_id} v{a.version} →{' '}
                  {a.other_clause_id
                    ? `${a.other_agreement_id} · ${a.other_clause_id} v${a.other_version}`
                    : `${a.other_agreement_id} · their ¶ ${a.other_paragraph_index}`}
                </span>
                <span className="caption font-mono">
                  {a.assessed_by} · {since(a.assessed_at)}
                </span>
              </div>
              {Array.isArray(a.changed_because) && a.changed_because.length > 0 && (
                <div className="caption mt-1">{a.changed_because.join(' · ')}</div>
              )}
              {a.outcome === 'answered'
                ? <>
                    <div className="tag mt-1" style={{ color: 'var(--advice)' }}>
                      AI advice · {a.severity_label} · {a.model}
                      {a.model_version ? ` ${a.model_version}` : ''}
                    </div>
                    <div className="text-[13px] mt-1"
                         style={{ lineHeight: 1.6, color: 'var(--advice)' }}>
                      {a.assessment}
                    </div>
                  </>
                : <>
                    <div className="tag mt-1" style={{ color: 'var(--mute-2)' }}>
                      no model opinion
                    </div>
                    <div className="caption mt-1">{a.absent_reason}</div>
                  </>}
            </div>
          ))}
          <div className="advice-foot">
            Advisory only — does not gate the record, and the label is never
            an order.
          </div>
        </div>
      )}
    </div>
  );
}

// The vendor half's denominator (0121) — HowMuchWasRead's twin, kept
// separate because the two coverages count different things: contracts we
// assembled, versus deals where the counterparty actually marked something
// up.
function TheirMarkupWasRead({ seen }) {
  const deals = Number(seen.deals_with_their_paper ?? 0);
  const indexed = Number(seen.deals_indexed ?? 0);
  const stale = Number(seen.paragraphs_stale ?? 0);
  return (
    <div className="note-card note-card--rule" data-testid="vendor-looked-at">
      <div className="note-title">how much of their markup was read</div>
      <strong>
        {indexed} of {deals} deals with counterparty markup have been read.
      </strong>{' '}
      {deals === 0
        ? 'No analysed round of any of this supplier’s deals holds a counterparty change, so there is no markup to compare against — which is not the same as nothing matching.'
        : `Anything below was worked out from those ${indexed}. It is not a
           statement about the other ${deals - indexed}, which nobody has
           read.`}
      {stale > 0 && (
        <> {stale} paragraph{stale === 1 ? '' : 's'} were read from a round
          that has since been overtaken; those readings are shown as out of
          date and are not counted as read.</>
      )}
    </div>
  );
}
