/* Fieldwork: one stylesheet, no framework, no build step. */

/* The palette is the Proteus dashboard's, so the two internal tools on this
   tenant read as one suite: Tailwind's slate ramp for every neutral, and the
   same navy bar across the top.

   There is one theme, and it is dark. A light one shipped beside it until
   2026-09-02, and was removed for costing more than it earned: nobody used it,
   while every new component had to be checked twice and every colour named for
   both ends of the ramp. The neutrals run the ramp 900 / 800 / 700 / 400 / 200,
   and the semantic three sit at the 400 step, which is where they stay readable
   as body text on slate. color-scheme tells the browser to paint the controls it
   owns (scrollbars, checkboxes, the autofill wash) to match, rather than
   taking them from the OS and clashing with the page. */
:root {
    color-scheme: dark;

    --bg: #0f172a;
    --surface: #1e293b;
    --border: #334155;
    --text: #e2e8f0;
    --muted: #94a3b8;
    /* Blue-600 and the 700 semantics are too dark to read on slate-900, so the
       whole accent row sits at 400. Amber and red land on exactly the two
       Proteus already uses for its counter on the same navy. */
    --accent: #60a5fa;
    --ok: #4ade80;
    --warn: #fbbf24;
    --error: #f87171;

    /* The solid button is pale slate, not accent, so blue means "link" and
       nothing else on a page. Named for the job rather than reusing --text, so
       that restyling body text cannot silently repaint every button. */
    --btn-primary-bg: #e2e8f0;
    --btn-primary-fg: #0f172a;
    --btn-primary-hover: #f8fafc;

    /* The bar is the Proteus dashboard's navy, darker than --bg on purpose: it
       is the piece a colleague recognises. Nothing outside the topbar may use
       these three. --topbar-dim is the nav's resting colour, readable only
       against that navy. */
    --topbar: #06213b;
    --topbar-line: #1e3a5f;
    --topbar-dim: #94a3b8;
    --topbar-fg: #fff;

    /* The one shadow, under the help popover. */
    --shadow: rgba(0, 0, 0, .25);

    --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    --radius: 10px;
}

* { box-sizing: border-box; }

/* The UA stylesheet hides [hidden] with display:none, which any author rule
   setting display beats. .button sets display:inline-block, so a button the
   markup or a script hid stayed on screen. Global because the trap is: every
   component that sets display is one hidden attribute away from the same bug. */
[hidden] { display: none !important; }

body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

body.centered {
    display: flex;
    min-height: 100vh;
    align-items: center;
    justify-content: center;
    padding: 24px;
}

a { color: var(--accent); }

/* ── Topbar ───────────────────────────────────────────────────────────────── */
/* Navy, like the Proteus dashboard's, which is the one piece of this page a
   colleague recognises before reading a word of it. Nothing inside it may use
   the page tokens: those are cut to sit on the slate page below, and half the
   bar would go dark on dark. The navy is close enough to that slate that the
   bar needs a line under it to have an edge at all. */
.topbar {
    display: flex;
    align-items: center;
    gap: 24px;
    padding: 0 24px;
    height: 56px;
    background: var(--topbar);
    border-bottom: 1px solid var(--topbar-line);
    color: var(--topbar-fg);
}
.topbar .brand { font-weight: 650; text-decoration: none; color: var(--topbar-fg); }
/* The pilot's phase, beside the brand on every page. Amber because it is a
   standing caution rather than an error: nothing here has reached Vincere. */
.topbar .phase {
    margin-left: -12px;
    padding: 2px 8px;
    border: 1px solid var(--warn); border-radius: 999px;
    color: var(--warn);
    font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
}
/* Overview is a way in rather than a stage, so it sits outside `.pipeline` with
   a wider gap than the arrows leave between the stages themselves. The arrows
   align on the baseline, not on the box: the links carry padding and a 2px
   underline, so centring the boxes would hang every arrow below the words. */
.topbar nav { display: flex; align-items: center; gap: 30px; flex: 1; }
/* The four stages are boxed: Overview and Records beside them are ways in,
   and a row of six words would read as one menu. The box is the machine, and
   its outline is the bar's own divider token, so it is quieter than the
   words inside it and never competes with the active underline. */
.topbar nav .pipeline {
    display: flex; align-items: baseline; gap: 9px;
    padding: 3px 14px 4px;
    border: 1px solid var(--topbar-line); border-radius: 8px;
}
/* A connector, not a link. Same token as the resting nav, taken down far enough
   that the stage names still read first. Named nav-arrow rather than flow: the
   flow strips further down own that word, and a bare `.flow` in here would have
   made every one of their layout rules land on these three spans. */
.topbar nav .nav-arrow { color: var(--topbar-dim); opacity: .55; font-size: 12px; }
.topbar nav a { text-decoration: none; color: var(--topbar-dim); padding: 4px 0; border-bottom: 2px solid transparent; transition: color .1s, border-color .1s; }
.topbar nav a:hover { color: var(--topbar-fg); }
.topbar nav a.active { color: var(--topbar-fg); border-bottom-color: var(--accent); }
/* The admin plumbing (connection, logs) sits apart from the pipeline stages,
   so the left-to-right reading of the bar stays "text in, Vincere out". A
   divider is all that separation needs: this was `nav.system` until it
   started colliding with the `.system` block on /sources and drew that
   component's whole card border around the two links. */
.topbar nav.admin { flex: none; gap: 14px; padding-left: 18px; border-left: 1px solid var(--topbar-line); }
/* A batch runs on the server rather than in the page that started it, so the
   bar is where "still going" belongs: it is the one thing on screen wherever a
   person walks to while they wait. The phase chip's amber is a standing
   caution and this is live work, so it takes the accent instead, and the dot
   pulses because a line of text that has stopped changing looks exactly like a
   page that has stopped asking. Green and still once the batch has landed. */
.topbar .run-live {
    display: flex; align-items: center; gap: 8px;
    padding: 3px 11px;
    border: 1px solid var(--topbar-line); border-radius: 999px;
    color: var(--topbar-dim);
    font-size: 12px; text-decoration: none; white-space: nowrap;
    /* A candidate with a long name must not push Sign out off the bar, so the
       chip gives up its own text before it takes anyone else's room, and the
       name is the half that gives: how far through and how long it has been
       are the reason anybody looks. */
    max-width: 340px; min-width: 0;
}
.topbar .run-live .run-live-what { overflow: hidden; text-overflow: ellipsis; }
.topbar .run-live .run-live-count { flex: none; color: var(--muted); }
.topbar .run-live .run-live-count::before { content: "· "; }
.topbar .run-live:hover { color: var(--topbar-fg); }
.topbar .run-live .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: run-pulse 1.4s ease-in-out infinite; }
.topbar .run-live.done .dot { background: var(--ok); animation: none; }
@keyframes run-pulse { 50% { opacity: .25; } }
@media (prefers-reduced-motion: reduce) { .topbar .run-live .dot { animation: none; } }
.topbar .user { display: flex; align-items: center; gap: 12px; color: var(--topbar-dim); font-size: 13px; }
.topbar .user form { margin: 0; }
.topbar .user button.link { color: var(--topbar-dim); text-decoration: none; }
.topbar .user button.link:hover { color: var(--topbar-fg); }
/* ── Waiting on the server ───────────────────────────────────────────────── */
/* Drawn by apiFetch in common.js on every page, whenever a read has taken
   longer than a moment or the server has stopped answering. It floats over the
   page rather than sitting in it, because the page's own "Loading…" lines are
   muted text that reads the same whether the server is working or has gone.
   Accent while loading; amber while the server is away, which is a caution
   the person may have to act on if it lasts. The line along the top edge and
   the dot both move, so a wait that is still going never looks finished. */
.wait-line { position: fixed; z-index: 55; top: 0; left: 0; right: 0; height: 3px; overflow: hidden; pointer-events: none; }
.wait-line::before {
    content: ""; position: absolute; top: 0; bottom: 0; left: -30%; width: 30%;
    background: var(--accent); animation: wait-slide 1.3s ease-in-out infinite;
}
.wait-card {
    position: fixed; z-index: 55; top: 70px; left: 50%; transform: translateX(-50%);
    display: flex; align-items: baseline; gap: 10px;
    width: max-content; max-width: min(620px, calc(100vw - 24px));
    padding: 10px 14px;
    background: var(--surface); border: 1px solid var(--accent); border-radius: 8px;
    box-shadow: 0 6px 18px var(--shadow);
    font-size: 13px; line-height: 1.5; color: var(--text);
}
.wait-dot { flex: none; align-self: center; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: run-pulse 1.4s ease-in-out infinite; }
.wait-secs { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; }
.wait.restarting .wait-card { border-color: var(--warn); }
.wait.restarting .wait-dot,
.wait.restarting .wait-line::before { background: var(--warn); }
@keyframes wait-slide { to { left: 100%; } }
@media (prefers-reduced-motion: reduce) {
    .wait-dot { animation: none; }
    .wait-line::before { animation: none; left: 0; width: 100%; }
}
/* ── Layout ───────────────────────────────────────────────────────────────── */
.page { max-width: 900px; margin: 0 auto; padding: 28px 24px 64px; }
/* Three widths, and each one has to be earned: 900 for a page that is mostly
   words (Overview, Connection), 1500 for everything with a table in it, which
   is every other page, and 1850 for a table with more columns than 1500 can
   show without a cell being cut: the file archive's nine, and the email
   archive's eight, two of which are a subject and an address.
   1500 was 1200 until 2026-09-05, when Kevin measured 1152 of content on a
   1536-wide laptop and asked for the rest: this is a business tool that
   nobody opens on a phone, and the file archive's record column was clipping
   names for want of 300px that were sitting empty. 1850 followed on
   2026-09-08 for the same reason on a wider screen: on a 1536 laptop it
   changes nothing, the viewport being the smaller cap. Widening the page is
   only half of it, though. A line of prose 1,452px long cannot be read, so
   the paragraphs, and only the paragraphs, keep a measure of about 100
   characters; the tables take the whole width. */
.page.wide { max-width: 1500px; }
.page.widest { max-width: 1850px; }
.page.wide > p, .page.widest > p { max-width: 100ch; }
h1 { font-size: 22px; margin: 0 0 6px; }
h2 { font-size: 15px; margin: 0 0 10px; letter-spacing: .01em; }
.section-h2 { margin: 26px 0 2px; font-size: 17px; }

/* Sub-pages of a stage (an archive browser under Sources) say where they sit. */
.crumbs { margin: 0 0 4px; font-size: 13px; color: var(--muted); }
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--text); text-decoration: underline; }
.crumbs span { margin: 0 6px; }
.muted { color: var(--muted); }
.small { font-size: 13px; }

.card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 18px 20px;
    margin: 18px 0;
}
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin: 18px 0; }
.cards .card { margin: 0; }

/* ── Sources: systems as tabs, and the sources inside them ────────────────── */
/* Two levels: a system is where a fetcher connects (Vincere, Granola), a source
   is one kind of text it brings back. Systems were collapsed blocks until
   2026-08-28, so the page opened on nothing and Vincere, where every source
   built so far lives, cost a click on every visit. They are tabs instead, one
   system selected at all times, laying its sources out side by side with each
   fetcher's activity folded in at the bottom. A source that is not built yet
   keeps the same column, drawn muted, so the page shows where the next fetcher
   lands before it exists. */
/* Real tabs rather than an underlined word: the selected one is the panel's own
   surface with its bottom edge cut away, so tab and panel read as one sheet
   standing off the page, and an accent line along its top says which is chosen.
   The unselected ones stay flat on the page background, which is the whole of
   the distinction: one system is in front, the rest are behind it. */
.tabs {
    display: flex; align-items: flex-end; gap: 4px;
    margin: 20px 0 0;
    border-bottom: 1px solid var(--border);
}
.tab {
    display: inline-flex; align-items: baseline; gap: 8px;
    padding: 9px 16px 10px;
    /* Over the strip's line, so the selected tab's own bottom border can hide
       it and open the tab into the panel. */
    margin-bottom: -1px;
    border: 1px solid transparent; border-radius: var(--radius) var(--radius) 0 0;
    background: none; color: var(--muted);
    font: inherit; font-size: 16px; font-weight: 650; cursor: pointer;
}
.tab:hover { color: var(--text); background: color-mix(in srgb, var(--surface) 55%, transparent); }
.tab[aria-selected="true"] {
    color: var(--text);
    background: var(--surface);
    border-color: var(--border); border-bottom-color: var(--surface);
    box-shadow: inset 0 2px 0 var(--accent);
}
.tab-count { color: var(--muted); font-size: 13px; font-weight: 400; font-variant-numeric: tabular-nums; }

.system {
    background: var(--surface);
    border: 1px solid var(--border);
    border-top: 0;
    border-radius: 0 0 var(--radius) var(--radius);
    margin: 0 0 14px;
}

/* A system with one source that is not built yet (Granola) is still a block of
   prose in a panel, so it keeps the simple layout the three Vincere sources
   had until 2026-09-09. */
.system-body { display: grid; }
.system-body.cols-1 { grid-template-columns: minmax(0, 1fr); }
.source-col { padding: 16px 18px 18px; min-width: 0; }
.source-col h2 { margin: 0 0 2px; font-size: 15px; font-weight: 650; }
.source-col.planned h2 { color: var(--muted); }
.source-count { color: var(--muted); font-size: 13px; font-weight: 400; font-variant-numeric: tabular-nums; }

/* ── The sources table ────────────────────────────────────────────────────── */
/* The three Vincere sources answer the same questions, so the panel is one
   table: a row is a question asked once, and the three sources are the columns
   that answer it. They were three independent `.kv` lists side by side until
   2026-09-09, each sized to its own longest label, and Kevin's reading was that
   nothing lined up. It did not: the columns held 7, 11 and 8 rows, "Range" was
   the second row of one and the seventh of another, and since the columns ran
   to different lengths the three Fetch buttons sat 263px apart with a rule
   down the middle of the gap.

   Built on table.calls' conventions rather than table.browse's: an archive
   browser is one item a row, and this is the transpose of that, one fact a row
   and one source a column. What it borrows is the muted label column, the
   group heading that is a label and air rather than a filled band, and the
   rule down the left of each column group. */
table.sources { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
table.sources th, table.sources td { text-align: left; padding: 6px 14px; border-bottom: 1px solid var(--border); vertical-align: baseline; }
table.sources col.c-label { width: 190px; }
table.sources tbody th { color: var(--muted); font-weight: 500; }
table.sources td { font-variant-numeric: tabular-nums; }
/* The divider the three columns had before, now a rule per cell, since a
   collapsed border cannot be drawn down a grid that no longer exists. */
table.sources th.src, table.sources td.src { border-left: 1px solid var(--border); }
/* The empty corner above the label column carries no rule and no floor: it is
   the one cell of the table that is not about anything. */
table.sources thead td:first-child,
table.sources tr.r-activity td:first-child { border: 0; }

/* The heading is the source: its name, how it is doing, and the controls that
   act on it, each on its own line so the three heads are one shape and the
   state word sits at the same height across all three. The count that used to
   sit here went on 2026-09-09: "Items" is the first row of the table below,
   and the same number twice, six lines apart, is not a heading. */
table.sources thead th.src { padding: 14px 14px 14px; }
table.sources .s-name { font-size: 15px; font-weight: 650; color: var(--text); }
table.sources thead .status { font-size: 15px; margin: 6px 0 0; }
table.sources .s-note { margin: 1px 0 0; color: var(--muted); font-weight: 400; font-size: 12px; }
/* The buttons sit under the state they act on rather than at the foot of the
   column, where fourteen rows separated them from the reason for pressing
   them. */
table.sources thead .actions { margin-top: 12px; }

/* A group heading: a label and the air above it, never a filled band, which
   would compete with the figures the grouping exists to organise. */
table.sources tbody.t-group tr.g-head th { padding-top: 18px; font-size: 12px; font-weight: 600; color: var(--text); }
table.sources tbody.t-group tr.g-head th, table.sources tbody.t-group tr.g-head td { border-bottom: 0; padding-bottom: 2px; }
/* A fact that belongs to every source at once (the daily API quota) is written
   in the shared label column rather than three times over. */
table.sources .row-note { display: block; color: var(--muted); font-weight: 400; font-size: 12px; font-variant-numeric: tabular-nums; }

/* The one cell that carries colour: a fetcher's last error. `.warn` on its own
   is not a colour anywhere in this stylesheet (it qualifies .status, .tag and
   a table.calls cell), so the table says what amber means inside it. */
table.sources .warn { color: var(--warn); }

/* How much of the count above it is text a model could read: the qualifier on
   the figure, so it sits under the figure rather than in a row of its own that
   two of the three sources would answer "all of it". */
table.sources .sub-n { margin-top: 2px; color: var(--muted); font-size: 12px; }

table.sources tr.r-activity td { border-bottom: 0; padding-top: 4px; }
table.sources tr.r-activity details.activity { margin-top: 12px; }
table.sources .tally { font-size: 13px; }
/* A breakdown's count sits just off its name rather than at the far edge of a
   340px cell, where the eye loses the line between the two. */
table.sources .tally { max-width: 22em; }

details.activity { margin-top: 16px; padding-top: 10px; border-top: 1px solid var(--border); }
details.activity > summary { margin-top: 0; color: var(--text); font-size: 13px; font-weight: 600; }
details.activity > summary:hover { color: var(--accent); }

.login { width: min(400px, 100%); }
.login h1 { margin-bottom: 2px; }

/* ── Bits ─────────────────────────────────────────────────────────────────── */
.status { font-size: 17px; font-weight: 600; margin: 4px 0 10px; }
.status.ok { color: var(--ok); }
.status.warn { color: var(--warn); }
.status.error { color: var(--error); }

.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 10px 0 0; font-size: 13px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }

/* A breakdown with too many buckets to read as a sentence: name left, count
   right, one per line. display: contents on the li puts both spans straight
   into the grid, which is what keeps the counts in a column. */
.tally { display: grid; grid-template-columns: 1fr max-content; gap: 1px 12px; margin: 0; padding: 0; list-style: none; }
.tally li { display: contents; }
.tally .n { text-align: right; }

.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }

.button {
    font: inherit;
    font-size: 14px;
    padding: 7px 14px;
    border-radius: 7px;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    cursor: pointer;
    text-decoration: none;
    display: inline-block;
}
.button:hover { border-color: var(--accent); }
.button:disabled { opacity: .55; cursor: default; }
.button.primary { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: var(--btn-primary-fg); font-weight: 600; }
.button.primary:hover { background: var(--btn-primary-hover); border-color: var(--btn-primary-hover); }
/* Red at rest, not only on hover: rejecting is the one destructive thing on the
   review page. Mixed from --error rather than named, so the wash and the edge
   follow the one red without a second pair of literals to keep in step. */
.button.danger { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
.button.danger:hover { background: color-mix(in srgb, var(--error) 12%, transparent); border-color: var(--error); }
.button.block { display: block; width: 100%; text-align: center; }

button.link { background: none; border: 0; color: var(--muted); cursor: pointer; font: inherit; font-size: 13px; padding: 0; text-decoration: underline; }

input[type="text"], input[type="password"], input[type="search"], select {
    font: inherit;
    font-size: 14px;
    padding: 7px 10px;
    border: 1px solid var(--border);
    border-radius: 7px;
    background: var(--surface);
    color: var(--text);
    width: 100%;
}
select, input[type="search"] { width: auto; min-width: 220px; }
label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 4px; }
.check { display: flex; align-items: center; gap: 6px; margin: 0; }
.check input { width: auto; }
.stack { display: block; }
/* The submit button sits directly under the last input, which reads as one
   glued control without a gap. */
.stack .button { margin-top: 14px; }

.alert { padding: 10px 12px; border-radius: 7px; font-size: 14px; }
.alert.error { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
.alert.ok { background: color-mix(in srgb, var(--ok) 12%, transparent); color: var(--ok); }
.alert.info { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); }

.divider { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }

code { font-family: var(--mono); font-size: 13px; }
code.block { display: block; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; overflow-x: auto; }

.result, .logview {
    font-family: var(--mono);
    font-size: 12.5px;
    line-height: 1.5;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 7px;
    padding: 12px;
    margin-top: 14px;
    overflow-x: auto;
    white-space: pre-wrap;
    word-break: break-word;
}
.logview { max-height: 70vh; overflow-y: auto; white-space: pre; word-break: normal; margin-top: 16px; }
.logview .lvl-error { color: var(--error); }
.logview .lvl-warn { color: var(--warn); }
.logview .lvl-debug { color: var(--muted); }

details summary { cursor: pointer; color: var(--accent); font-size: 14px; margin-top: 8px; }

/* ── A whole card folded shut ─────────────────────────────────────────────── */
/* Not the accent-coloured "show me the raw JSON" summary above: this one is the
   card's own h2, so a closed card looks like any other card's heading with a
   disclosure triangle beside it, and the space it saves is real. The h2 goes
   inline so the marker, the heading and the one-line answer sit on one line;
   inline also drops its bottom margin, which the summary supplies instead when
   the card is open. */
details.fold > summary { color: var(--text); font-size: inherit; margin: 0; }
details.fold[open] > summary { margin-bottom: 10px; }
details.fold > summary > h2 { display: inline; margin: 0; }
details.fold > summary:hover > h2 { color: var(--accent); }
details.fold > summary .sub { margin-left: 10px; font-size: 13px; font-weight: 400; color: var(--muted); }
/* The open and close are animated where the browser can (Chromium 131 and up,
   which is what this is read in); everywhere else they snap, which is what
   <details> has always done. */
details.fold { interpolate-size: allow-keywords; }
details.fold::details-content {
    block-size: 0; overflow: clip;
    transition: block-size .2s ease, content-visibility .2s allow-discrete;
}
details.fold[open]::details-content { block-size: auto; }
/* Folded shut outside a card, as a page section: the h2 inside the summary
   gives up its own margins to sit inline with the marker, so the details
   itself carries the space a .section-h2 would have had above it. */
details.fold.section-fold { margin: 26px 0 0; }

/* One prompt version per column, so a change can be read against the one it
   replaced. The colour is the verdict and the hover is the reasoning: a cell
   holding "Yes" says nothing on its own, since whether that is right depends
   on what the person typed two columns to the left. */
.rtw-t td { white-space: nowrap; }
.rtw-t td[title] { cursor: help; }
.rtw-t .rtw-agreed { color: var(--ok); }
.rtw-t .rtw-disagreed { color: var(--error); font-weight: 600; }
.rtw-t .rtw-extra { color: var(--accent); }
.rtw-t .rtw-none { color: var(--muted); }

table.calls { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
table.calls th, table.calls td { text-align: left; padding: 6px 10px 6px 0; border-bottom: 1px solid var(--border); }
table.calls th { color: var(--muted); font-weight: 500; }
table.calls td.url { font-family: var(--mono); font-size: 12px; word-break: break-all; }
/* A column of figures, in either table: right aligned and tabular-lined, so it
   can be read down its length rather than one row at a time. */
table.calls th.num, table.calls td.num,
table.records th.num, table.records td.num { text-align: right; font-variant-numeric: tabular-nums; padding-right: 14px; }
/* A figure that is off: the difference column on the OpenAI reconciliation,
   where a day the ledger and the bill disagree on has to be findable by eye
   down a column whose other rows are all zero. Only .status and .tag carried a
   warn variant before, and neither is a table cell. */
table.calls td.warn { color: var(--warn); }

/* ── Probe verdicts ───────────────────────────────────────────────────────── */
.verdicts { list-style: none; margin: 8px 0 0; padding: 0; font-size: 14px; }
.verdict { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
.verdict .mark { font-family: var(--mono); width: 1em; flex: none; }
.verdict.ok .mark { color: var(--ok); }
.verdict.error .mark { color: var(--error); }
.verdict.error { color: var(--error); }
.verdict.muted { color: var(--muted); }

.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; background: var(--muted); }
.dot.ok { background: var(--ok); }
.dot.error { background: var(--error); }

.probe-steps .card h2 { display: flex; align-items: center; font-weight: 600; }
.probe-steps .result { max-height: 60vh; overflow-y: auto; }

/* ── A comment, drawn whole ───────────────────────────────────────────────── */
/* Under an opened row on the comment archive, and inside the bundle preview on
   Extraction and a record's page. It keeps a readable measure for the same
   reason `.eb-text` does: a write-up set across an 1850 table is prose in a
   1,500px line. `.card.comment` and its four verdict borders went with the
   card list on 2026-09-09. */
.comment-body { max-width: 100ch; white-space: pre-wrap; word-break: break-word; font-size: 14px; }

/* ── An email's body, paragraph by paragraph ───────────────────────────────── */
/* The whole body is always shown; the filter's verdict is an annotation on it,
   never a cut. A read paragraph looks like ordinary text so that scanning finds
   what a model actually sees; a dropped one is dimmed and named, and keeps its
   left border so a long thread reads as one struck-through block rather than as
   forty separate judgements. Not struck through with a line: these run to
   thousands of characters and line-through makes them unreadable, and being
   unreadable is exactly wrong on the page whose job is checking the rule. */
.email-body { margin-top: 8px; }
.eb { --eb-gutter: 150px; --eb-gap: 8px; display: flex; gap: var(--eb-gap); align-items: baseline; padding: 3px 0 3px 9px; border-left: 2px solid transparent; }
/* A block keeps a readable measure, the same 100 characters a wide page's
   paragraphs keep. It ran the full width of whatever held it until
   2026-09-08, which was tolerable in a 1500 card and is not on an 1850
   table: an email body is prose, and a 1,500px line loses the reader on the
   way back to the start of the next one. */
.eb-text { margin: 0; max-width: 100ch; white-space: pre-wrap; word-break: break-word; font-size: 14px; }
.eb-read { border-left-color: var(--accent); }
.eb-cut { border-left-color: var(--border); opacity: .55; }
.eb-cut .eb-text { font-size: 13px; }
.eb-rule {
    flex: none; width: var(--eb-gutter); text-align: right; padding-top: 1px;
    font-size: 11px; font-weight: 600; color: var(--warn);
}
/* A read paragraph has no rule to name, but it still starts where every other
   paragraph starts. The gutter is empty rather than absent: the body is one
   column of text down the page, and a kept paragraph that began 158px to the
   left of the quoted one under it read as a different document rather than as
   the same email with an annotation beside it. */
.eb-read .eb-text { margin-left: calc(var(--eb-gutter) + var(--eb-gap)); }
/* Under 700px the rule sits on its own line above the text, so there is no
   gutter to line up with and the indent would be most of the screen. */
@media (max-width: 700px) {
    .eb { display: block; }
    .eb-rule { width: auto; text-align: left; display: block; }
    .eb-read .eb-text { margin-left: 0; }
}

/* ── (?) help marks ───────────────────────────────────────────────────────── */
button.help {
    display: inline-flex; align-items: center; justify-content: center;
    width: 17px; height: 17px; padding: 0; margin-left: 6px;
    border: 1px solid var(--border); border-radius: 50%;
    background: var(--surface); color: var(--accent);
    font-family: inherit; font-size: 11px; font-weight: 600; line-height: 1;
    cursor: pointer; vertical-align: 2px; flex: none;
}
button.help:hover { border-color: var(--accent); }
.help-pop {
    position: fixed; z-index: 60;
    background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
    padding: 10px 12px; font-size: 13px; line-height: 1.5; color: var(--text);
    font-weight: 400; font-style: normal; text-align: left;
    box-shadow: 0 8px 28px var(--shadow);
    white-space: pre-line;
}

/* ── Fetcher activity feed ────────────────────────────────────────────────── */
.feed { list-style: none; margin: 10px 0 0; padding: 0; font-size: 13px; max-height: 45vh; overflow-y: auto; }
.feed li { padding: 7px 0; border-bottom: 1px solid var(--border); }
.feed li:last-child { border-bottom: none; }
.feed .when { color: var(--muted); font-variant-numeric: tabular-nums; }
.feed .line { margin: 1px 0 0; }
.feed .line.error { color: var(--error); }

/* ── Expandable analysis rows on /extraction ──────────────────────────────── */
/* A provider heading inside table.calls, on the same reasoning as the field
   groups in table.values: a rule and a muted label rather than a filled band,
   which would compete with the figures the grouping exists to organise. The
   header cell is already muted and ruled by the base th rule above, so all
   this adds is the air that separates one group from the one before it. */
/* The cost table's last five columns are two groups (every source, comments
   only) spanned by a first header row. A rule down the left of each group is
   what separates them: a reader comparing two figures has to see which pair
   belongs together, and the gap alone was not enough at these column widths. */
table.calls th.group-start, table.calls td.group-start { border-left: 1px solid var(--border); padding-left: 14px; }
/* A spanning heading is centred over its columns, overriding the right
   alignment the figures beneath it want: right aligned it sat above the last
   column of the group and read as a label for that one column alone.
   `:not(:last-child)` is what says "a spanning row": a heading row is spanning
   only when a second row of real column headings sits under it. Without it the
   rule reached the single heading row of every other table.calls on the site,
   centring headings over left-aligned columns and taking the rule off under
   them. */
table.calls thead tr:first-child:not(:last-child) th { padding-bottom: 2px; font-size: 12px; border-bottom: none; text-align: center; }
table.calls thead tr:first-child:not(:last-child) th:empty { border: none; }

table.calls tbody.t-group > tr > th { padding-top: 16px; font-size: 12px; font-weight: 600; }
table.calls tbody.t-group:first-of-type > tr > th { padding-top: 6px; }
table.calls tr.expandable { cursor: pointer; }
table.calls tr.expandable:hover td { background: var(--bg); }
table.calls tr.detail > td { padding: 0 0 12px; }
table.calls tr.detail .result { margin-top: 6px; max-height: 50vh; overflow-y: auto; }

/* ── Flow strips ──────────────────────────────────────────────────────────── */
/* A row of stage boxes joined by arrows: the pipeline on the overview, and the
   filter funnel on the comment archive. A box is a link, a button or a plain
   div depending on the page, so the look is shared and the element is not.

   A grid rather than a wrapping flex row, because the file funnel has eight
   stages and wraps: flex sizes each line on its own, so the three that fell to
   the second line stretched to share the full width and came out half as wide
   again as the five above them. The columns repeat in pairs, a stage then the
   arrow after it, which is exactly the order the children come in, so a line
   always breaks after an arrow and a stage is never dealt an arrow's column. A
   short line simply leaves its trailing columns empty. auto-fit, not auto-fill:
   a strip that fits on one line (the overview's four) collapses the columns it
   does not use and fills the width as it always did. */
/* The floor is what decides when the funnel wraps, and a wrap leaves a dangling
   arrow at the end of the row and one orphaned stage under it. 150px is the
   longest funnel (the email filter's six stages) inside `.page.wide`: six boxes,
   five arrows and eleven gaps come to 1,098px against the 1,152px available.
   The boxes are 1fr above the floor, so a shorter funnel still fills its row. */
.flow {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr) 22px);
    align-items: stretch; gap: 8px; margin: 14px 0 6px;
}
/* The same sum inside `.page.widest`, where the longest funnel is the file
   archive's ten stages: ten boxes, nine arrows and nineteen gaps come to
   1,772px against the 1,802 available, so the floor drops to 140 and the row
   holds all ten. At 150 it fitted nine and left the tenth stranded under a
   dangling arrow, which is the wrap this floor exists to prevent. */
.page.widest .flow { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr) 22px); }
.flow-stage {
    display: flex; flex-direction: column; gap: 2px; padding: 10px 14px;
    text-align: left; background: var(--surface); border: 1px solid var(--border);
    border-top: 3px solid var(--muted); border-radius: var(--radius);
    cursor: pointer; font: inherit; color: var(--text); text-decoration: none;
}
div.flow-stage { cursor: default; }
.flow-stage:hover { border-color: var(--accent); }
.flow-stage.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.flow-stage.warn { border-top-color: var(--warn); }
.flow-stage.error { border-top-color: var(--error); }
.flow-title { font-size: 13px; font-weight: 600; }
.flow-n { font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; }
.flow-note { line-height: 1.35; }
.flow-arrow { align-self: center; text-align: center; color: var(--muted); font-size: 18px; }
/* A record's own strip: the same four boxes counting one record. A stage
   with nothing to do here (no extractor for this kind, nothing readable)
   goes quiet rather than amber: nobody is needed, it is simply not built. */
.record-flow { margin: 10px 0 16px; }
.flow-stage.quiet { border-top-color: var(--border); color: var(--muted); }
.flow-stage.quiet .flow-n { color: var(--muted); }

/* The name, and whose record it is pushed to the far end of the same line.
   Baseline-aligned rather than centred, so the recruiter's name sits on the
   name's own line and not in the middle of a 22px heading; it wraps under
   only on a narrow window, which is where a heading and a name cannot share
   a line honestly. Italic where nobody owns the record, so an absence does
   not read as somebody's name. */
.record-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; }
.record-owner { color: var(--muted); font-size: 14px; white-space: nowrap; }
.record-owner.none { font-style: italic; }

/* Where a record stands with the extractor, under its name. The state word
   carries the only colour: amber for anything waiting on the machine, green
   for read and up to date, muted for a record nothing can read yet. The
   sentence after it stays body text, because it is the half that is read. */
.record-status { margin: 6px 0 4px; }
.record-status p { margin: 0 0 3px; }
.record-status-line { font-size: 14px; }
.status-word { margin-right: 2px; }
.status-word.state-due, .status-word.state-settling { color: var(--warn); }
.status-word.state-up_to_date { color: var(--ok); }
.status-word.state-no_extractor, .status-word.state-nothing { color: var(--muted); }

/* The CRM's values are two narrow columns of eleven rows, so the table keeps
   its own width inside a full-width section rather than stretching its labels
   half a screen away from the numbers they name. */
/* The CRM's values, and beside them the photograph it holds. The picture is
   not one of the extractor's fields and never will be, no model reading it
   and none proposing it, but it is the one thing on the card a reader takes
   in without reading, and whether there is one at all decides whether a
   headshot found in a CV ever reaches Review. The card widens by exactly the
   picture and its gap, so the fields keep the width they were set for.

   Where the CRM holds none, a dashed frame reads "empty", the same word and
   the same muted grey a field with nothing in it uses two columns away: the
   caption is the label and stays put, the frame is the value and varies. An
   absence stated is read; an absence left blank is missed, and this is a card
   whose whole job is to say what is there. */
.record-held { display: flex; align-items: flex-start; gap: 16px; max-width: 756px; }
.record-held > .values { flex: 1; }
.held-photo { flex: none; width: 120px; margin: 0; text-align: center; }
.held-photo-img { display: block; max-width: 120px; max-height: 150px; margin: 0 auto; border: 1px solid var(--border); border-radius: 6px; }
.held-photo-img.empty { display: flex; width: 120px; height: 150px; align-items: center; justify-content: center; border-style: dashed; color: var(--muted); font-size: 13px; }
.held-photo figcaption { margin-top: 6px; }

/* ── A record's archive: one table over everything collected ──────────────── */
/* Read and rejected items in one date order, because a rule can only be judged
   against the items beside it. A rejected row is struck through in red where
   it stands: it is one line, so a line through it stays readable, which is
   why this and the paragraph-level `.eb-cut` (thousands of characters, dimmed
   instead) do not draw rejection the same way.

   The two character columns are the point of the table, so they are tabular
   and right aligned, and the row's own state never touches them: a struck-out
   number is unreadable and the size of what was thrown away is exactly what a
   person is here to see. */
table.archive { table-layout: fixed; }
table.archive th { font-weight: 500; }
table.archive td, table.archive th { padding-right: 12px; }
table.archive .ai-when { width: 14em; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
table.archive .ai-kind { width: 8em; white-space: nowrap; }
/* The headshot found in a file, beside its kind tag: a thumbnail one line
   tall, the picture at full size while the pointer rests on it. The preview
   takes no pointer of its own, as on the file archive, or it would sit
   between the cursor and the rows it covers. The edge says what became of
   it: green once written, red once rejected, plain otherwise. */
.ai-shot { position: relative; display: inline-block; vertical-align: middle; margin-left: 8px; }
.ai-shot-thumb { display: block; height: 22px; width: 18px; object-fit: cover; border: 1px solid var(--border); border-radius: 3px; }
.ai-shot.approved .ai-shot-thumb { border-color: var(--ok); }
.ai-shot.rejected .ai-shot-thumb { border-color: var(--error); opacity: .6; }
.ai-shot-full { display: none; pointer-events: none; position: absolute; left: 0; top: 100%; z-index: 5; width: 200px; height: auto; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px var(--bg); }
.ai-shot:hover .ai-shot-full { display: block; }
.ai-shot:hover .ai-shot-thumb { border-color: var(--accent); }
table.archive .ai-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
table.archive th.num, table.archive td.num { width: 8.5em; }
table.archive .ai-status { width: 17em; color: var(--muted); }
table.archive .tag { margin-left: 0; }

/* The strike lands on the date and the origin only. text-decoration cannot be
   cancelled by a descendant, so putting it on the row would have drawn a line
   through the kind tag and, worse, through the two numbers, which are the
   whole reason a rejected row is on screen at all. */
table.archive tr.ai-stripped td, table.archive tr.ai-cut td { color: var(--muted); }
table.archive tr.ai-stripped .ai-when, table.archive tr.ai-stripped .ai-what { text-decoration: line-through; text-decoration-color: var(--error); }
table.archive tr.ai-cut .ai-when, table.archive tr.ai-cut .ai-what { text-decoration: line-through; text-decoration-color: var(--warn); }
table.archive tr.ai-stripped .ai-status { color: var(--error); }
table.archive tr.ai-cut .ai-status { color: var(--warn); }
table.archive tr.ai-read .ai-n-read { color: var(--ok); }
/* The open row sinks towards --bg, the same way the hover on every table.calls
   inside a card does: this table is in a card too, so one rule covers both. */
table.archive tr.open td { background: var(--bg); }

/* The totals are the answer to "how much of this does the AI actually read?",
   so they sit under the columns they add up rather than in a sentence
   somewhere. Heavier than the rows and ruled off from them. */
table.archive tfoot th {
    text-align: left; padding: 8px 12px 8px 0; font-weight: 600;
    border-top: 1px solid var(--border); border-bottom: none;
}
table.archive tfoot th.num { text-align: right; font-variant-numeric: tabular-nums; }
table.archive tfoot th.muted { font-weight: 500; }

/* An opened row: what the rule said, then the item itself. */
table.archive tr.detail > td { padding: 4px 0 16px; }
.ai-verdict { margin: 0 0 6px; font-size: 13px; color: var(--muted); }
.ai-verdict b { color: var(--error); }

/* ── Comments: the extraction filter ────────────────────────────────────────── */
/* The funnel's boxes are buttons that preset the kind filter below, so it is
   not an illustration but a control over the same data. Red for what Vincere
   wrote, amber for picks, muted grey for house notes: dropping those was a
   decision someone made, so there is nothing there to act on. */
.cf-stage-all { border-top-color: var(--muted); }
.cf-stage-system { border-top-color: var(--error); }
.cf-stage-picklist { border-top-color: var(--warn); }
.cf-stage-house { border-top-color: var(--muted); }
.cf-stage-extract { border-top-color: var(--accent); }

/* Rule tiles. Sized so the whole rule set fits on one screen without scrolling,
   because comparing them against each other is the point of the page. */
.cf-rules { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 8px; margin: 12px 0 4px; }
.cf-rule {
    display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; text-align: left;
    background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--muted);
    border-radius: var(--radius); cursor: pointer; font: inherit; color: var(--text);
}
.cf-rule:hover { border-color: var(--accent); }
.cf-rule.active { border-color: var(--accent); border-left-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.cf-rule-extract { border-left-color: var(--accent); }
.cf-rule-system { border-left-color: var(--error); }
.cf-rule-picklist { border-left-color: var(--warn); }
.cf-rule-house { border-left-color: var(--muted); }
.cf-rule-n { font-size: 21px; font-weight: 650; font-variant-numeric: tabular-nums; }
.cf-rule-label { font-size: 13px; font-weight: 600; }
.cf-rule-note { line-height: 1.35; }
.cf-rule-share { font-variant-numeric: tabular-nums; }

/* Per-comment badges. The left border repeats the verdict so a long scroll
   stays readable without reading each badge. Accent for "kept", not green:
   green would say verified-good, and kept only means unrecognised. */
.cf-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 0 0 7px; }
.tag, .cf-tag { padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 600; border: 1px solid var(--border); color: var(--muted); }
.tag { margin-left: 2px; vertical-align: 2px; white-space: nowrap; }
/* Only for something a person should act on: a paused or failing fetcher is
   otherwise invisible while its system block is closed. */
.tag.warn { border-color: var(--warn); color: var(--warn); }
.cf-tag-extract { border-color: var(--accent); color: var(--accent); }
.cf-tag-system { border-color: var(--error); color: var(--error); }
.cf-tag-picklist { border-color: var(--warn); color: var(--warn); }
.cf-tag-house { border-color: var(--muted); color: var(--muted); }
/* An archive browser is a table, one item a row and one fact a column, so the
   list scans down a column for the same thing (Kevin, 2026-09-05: the file
   archive was a card per document, three lines each, and the same fact sat
   somewhere different on every card; a table is also half the height). The
   file archive went first; the email archive followed on 2026-09-08 for the
   same reasons, so the shared columns are styled once here under
   `table.browse` and each page adds only the columns that are its own.

   Every column but the title's is a fixed width and clips with the whole
   value on hover, so the file name or the subject takes what is left. The two
   character counts sit in one cell as read over held, the read count green,
   because the gap between them is what the column is for. The headshot, which
   only files have, is a thumbnail two lines tall with its score beside it;
   the full-size picture is the same bytes, shown over the table while the
   pointer rests on the thumbnail, so the row stays short and checking a find
   is a hover rather than a tab. */
/* The list is a card, like every other section of the app, so the table has
   an edge and a floor instead of floating on the page's own colour (Kevin,
   2026-09-05). Tighter than a card of prose, the rows carrying their own
   padding. The headings follow the scroll, because the whole archive is one
   table and column headings that scroll away are no use by row 300; they sit
   on the card's colour, and their rule is a shadow rather than a border,
   since a collapsed border does not travel with a sticky cell. */
.cf-list { padding: 2px 16px 6px; }
.cf-list table.calls { margin-top: 0; }
table.browse thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); border-bottom: none; box-shadow: inset 0 -1px 0 var(--border); }
table.browse td { vertical-align: middle; }
table.browse .cf-verdict { width: 14em; white-space: nowrap; }
table.browse .cf-type, table.browse .cf-who, table.browse .cf-from,
table.browse .cf-by, table.browse .cf-act { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The document name, or an email's subject, wraps rather than being cut at the
   column's edge: it is what a row is found by, and the ellipsis fell in the
   middle of the name a person was scanning for. Two lines at most, so a row is
   still a row and the column keeps its rhythm, with the ellipsis after them and
   the whole name on hover as before. The break may land inside a word, because
   a file name is often one long word with underscores where the spaces would be
   (Kevin, 2026-09-08). */
table.browse .cf-title { white-space: normal; }
table.browse .cf-title > span {
    display: -webkit-box; -webkit-box-orient: vertical;
    -webkit-line-clamp: 2; line-clamp: 2;
    overflow: hidden; overflow-wrap: anywhere;
}
/* A name that is one machine-made token: break on the character the line ends
   on. overflow-wrap only breaks inside a word when there is nowhere else to
   break, so the hyphen in "1715337391037-e=1772064000&v=beta&t=..." was taken
   first and left most of the line empty with the rest still not fitting on
   the next. The page script marks these rows, since break-all would split an
   ordinary word as readily as a hash (Kevin, 2026-09-08). */
table.browse .cf-title > span.cf-dense { word-break: break-all; }
table.browse .cf-title .cf-orig { font-weight: 650; cursor: help; }
/* The comment archive's is the same column and takes what is left the same
   way, but on one line. A file name and a subject are titles, and cutting one
   loses the word being scanned for, which is why they wrap to two; a comment
   is prose, so two lines of it is not a title but the opening of a paragraph.
   It made almost every row twice as tall for a preview nobody reads to the
   end of (Kevin, 2026-09-09). One line, clipped like every other fixed
   column, with the opening on hover and the whole thing one click away. */
table.browse .cf-line { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
table.browse .cf-type { width: 10em; }
table.browse .cf-ext { width: 4.5em; }
table.browse .cf-when { width: 9em; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
table.browse th.num, table.browse td.num { width: 8.5em; white-space: nowrap; }
table.browse .cf-read { color: var(--ok); }
table.browse .cf-kind { width: 6.5em; color: var(--muted); }
table.browse .cf-who { width: 19em; }
table.browse .cf-shot { width: 5.5em; white-space: nowrap; }
table.browse .cf-shot .ph { position: relative; display: inline-block; vertical-align: middle; margin-right: 6px; }
table.browse .ph-thumb { display: block; height: 26px; width: auto; max-width: 26px; object-fit: cover; border: 1px solid var(--border); border-radius: 4px; }
/* The preview takes no pointer of its own: it hangs over the rows below
   the thumbnail, and while it could be hovered it kept itself alive under
   the cursor and swallowed the click meant for the next row (Kevin,
   2026-09-05). Untouchable, it is gone the moment the pointer leaves the
   thumbnail, and everything under it stays reachable. */
table.browse .ph-full { display: none; pointer-events: none; position: absolute; right: 0; top: 100%; z-index: 5; max-width: none; max-height: 360px; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px var(--bg); }
table.browse .ph:hover .ph-full { display: block; }
table.browse .ph:hover .ph-thumb { border-color: var(--accent); }
/* The score carries the words, so hovering the picture shows the picture
   and nothing else. cursor: help says the number is worth resting on, as on
   the review card's photo and its basis chip. */
table.browse .ph-score { font-size: 12px; font-variant-numeric: tabular-nums; cursor: help; }
/* The email archive's own two columns. The sender is an address, so it clips
   with the whole of it on hover like every other fixed column; the direction
   is one word and sits muted beside it, because which way a mail went is
   context for the sender rather than a fact to scan for. */
table.browse .cf-from { width: 20em; }
table.browse .cf-dir { width: 5.5em; color: var(--muted); }
/* The comment archive's own two. The recruiter and the KPI action a comment was
   logged under were two of five facts on a meta line; as columns they are
   scannable, which is what the line never was. A third, a green mark on any
   comment matching the audit's keyword test, went on 2026-09-09: it lit up on
   45% of the archive, and the banner already names the comments that matter. */
table.browse .cf-by { width: 12em; }
table.browse .cf-act { width: 9em; color: var(--muted); }

/* ── Extraction: the next analysis, laid out before the button ───────────── */
/* The write-up on the left, the CRM's current values on the right: the two
   things the model is shown, side by side, so "what will it read?" needs no
   scrolling. Below 900px they stack. */
.sub-h { margin: 14px 0 6px; font-size: 13px; font-weight: 600; color: var(--muted); }
.sub-h button.help { vertical-align: 0; }
label.inline { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.preview { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 20px; margin-top: 6px; }
@media (max-width: 900px) { .preview { grid-template-columns: minmax(0, 1fr); } }
.write-up { padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; max-height: 50vh; overflow-y: auto; }
table.values { width: 100%; border-collapse: collapse; font-size: 13px; }
table.values td { padding: 3px 8px 3px 0; border-bottom: 1px solid var(--border); }
table.values td.v { text-align: right; font-variant-numeric: tabular-nums; }
table.values td.v.empty { color: var(--muted); }
/* One tbody per subject (compensation, availability), because thirteen fields
   in one run read as a wall. The heading is the only thing separating them:
   a rule above and the muted weight of a label, not a filled band, which would
   compete with the values it is there to organise. */
table.values tbody.v-group th {
    padding: 12px 0 3px; text-align: left; font-size: 12px; font-weight: 600;
    color: var(--muted); border-bottom: 1px solid var(--border);
}
table.values tbody.v-group:first-child th { padding-top: 2px; }

/* The bundle, one row per item: date, kind, size, then who or what. The sizes
   are the column a reader scans, so they are tabular and right aligned with a
   unit of fixed width behind them, which keeps the digits in line. The last
   column takes the slack and truncates: a long CV filename must not push the
   numbers around. */
table.bundle-list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.bundle-list td { padding: 3px 10px 3px 0; vertical-align: baseline; }
table.bundle-list td:last-child { padding-right: 0; }
.bi-date { white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--muted); }
.bi-chars { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.bi-what { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.queue { list-style: none; margin: 4px 0 0; padding: 0; font-size: 13px; }
.queue li { padding: 3px 0; }

/* ── Records: the directory ──────────────────────────────────────────────── */
/* One table per kind of record on the same sheet the tabs open into. The
   counts are right-aligned tabular figures so a column of them scans; the
   name is the only link, and the state reads as words, not a badge. */
.records-body { padding: 14px 18px 18px; }
.records-body .actions { margin-top: 8px; }
.records-body [data-filter] { min-width: 220px; }
.records-scroll { overflow-x: auto; }
table.records td.date { white-space: nowrap; color: var(--muted); }
/* The recruiter who owns the record. Muted like the dates: it is what the
   table is filtered and scanned by, never what it is read for, and a column
   of names in body colour would compete with the names in the first column
   that are the point of the row. */
table.records td.owner { white-space: nowrap; color: var(--muted); }
table.records td.state { white-space: nowrap; }
table.records td.state.ok { color: var(--ok); }
table.records td.state.muted { color: var(--muted); }
.outcome-line { margin: 10px 0 2px; }
.record + .record { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--border); }
table.changes td em { font-style: italic; color: var(--text); }
table.calls tr.detail .preview { margin-top: 12px; }

/* ── Review: one card per candidate, one open at a time ───────────────────── */
/* Closed, a card is the name and a chip per proposed field, nothing else.
   Open, the body is the rest of the same card: a muted origin line, then one row
   per field the extractor owns, in the panels of the Vincere screen, with
   the rows the run left alone folded away. No column headings: the values
   say what they are. The proposed value is the hover target for the source
   text, and the dotted underline is what says so. */
/* Cards sit under the prompt that produced them when more than one is on
   the page: a heading in the page's own type, not a card, so the cards stay
   the only boxes. */
.rv-prompt { margin: 22px 0 6px; }
.rv-prompt:first-child { margin-top: 6px; }
.rv-prompt h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; font-weight: 600; }
.rv-prompt:not(.current) h2 { color: var(--muted); }
.rv-candidate { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin: 10px 0; }
.rv-candidate.open { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
/* The name is never squeezed to make room for the chips: it keeps its one
   line, and a row of chips too wide to sit beside it drops to a line of its
   own underneath, still right-aligned. */
.rv-head {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; width: 100%; padding: 12px 16px;
    background: none; border: 0; border-radius: var(--radius); font: inherit; color: var(--text);
    text-align: left; cursor: pointer;
}
.rv-head:hover { background: var(--bg); }
.rv-candidate.open > .rv-head { border-radius: var(--radius) var(--radius) 0 0; }
.rv-caret { flex: none; width: 1em; color: var(--muted); font-size: 12px; }
.rv-name { flex: none; white-space: nowrap; font-weight: 650; font-size: 15px; }
.rv-id { flex: none; white-space: nowrap; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
/* Whose candidate this is in Vincere, next to the id and as quiet as it: the
   name is looked for when a reviewer is working through one colleague's
   people, and must never compete with the candidate's own name. Italic where
   there is no owner, so an absence does not read as somebody's name. */
.rv-owner { flex: none; white-space: nowrap; color: var(--muted); font-size: 13px; }
.rv-owner.none { font-style: italic; }
/* Quieter than the id beside it: the age answers a question the eye asks of
   the whole list at once, and never competes with the name. */
.rv-when { flex: none; white-space: nowrap; color: var(--muted); font-size: 12px; }
.rv-chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-left: auto; }
.rv-chip { padding: 1px 9px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); font-size: 12px; white-space: nowrap; }
.rv-chip b { font-weight: 650; }
/* 'new' fills an empty field; 'update' overrules a value a colleague typed,
   which is the one to look at twice. The same two colours carry through to
   the proposed number on the line. */
.rv-chip.new { border-color: var(--ok); }
.rv-chip.new b { color: var(--ok); }
.rv-chip.update { border-color: var(--warn); }
.rv-chip.update b { color: var(--warn); }
.rv-chip.approved b { color: var(--ok); }
.rv-chip.rejected b { color: var(--error); }
/* 'inferred' cuts across new and update: the model read a step past the
   sentence rather than off it, so the chip and the row both carry a dashed
   edge, the one visual that means "look twice" without claiming a third
   colour the two kinds already own. On the chip the edge is the whole of
   it; the word is beside the proposed value once the card is open, which is
   the thing it qualifies. */
.rv-chip.inferred { border-style: dashed; }
.tag.rv-basis { margin-left: 8px; border-style: dashed; border-color: var(--accent); color: var(--accent); font-weight: 500; cursor: help; }
/* The flex gap already spaces it from the value it follows. */
.rv-value .rv-basis { margin-left: 0; }
.rv-row.inferred .rv-new { border-bottom: 1px dashed var(--accent); }

/* The body keeps the card's own surface, the same as the head above it, so an
   open candidate is one panel raised off the page with a rule across it, and
   the card's border is the only edge it has. It was tinted a step darker than
   the page for part of 2026-09-04, which gave the open card a floor but made
   it the darkest thing on screen, reading as a hole rather than as the piece
   being worked on. */
.rv-body { padding: 12px 16px 14px; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); }
.rv-run + .rv-run { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
/* The origin sentence on the left, the two controls on the right, on one
   line: what to show and where the rest of the record is. */
.rv-top { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin: 0 0 4px; color: var(--muted); font-size: 13px; }
.rv-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.rv-page { white-space: nowrap; }
/* The item count in the origin line is a hover target for the list of what
   the run read, marked the way the proposed values are. */
.rv-items { border-bottom: 1px dotted var(--accent); cursor: help; }
.rv-pop table.bundle-list { margin-top: 4px; }

/* One table, every field in screen order. The label and the held value are
   the same two columns whether the row is a change or not, so revealing the
   unchanged rows slots them in between the changes rather than under them.
   The label, the arrow and the buttons take the width they need; what the
   CRM holds and what the text says share the rest in proportion to what is
   in them, which is the table's own auto layout and deliberately not a
   fixed split: a fixed 30% for the held value left a gulf between "empty"
   and the proposed value on nearly every row. */
table.rv-fields { width: 100%; border-collapse: collapse; font-size: 14px; }
table.rv-fields td { padding: 7px 8px; border-top: 1px dashed var(--border); vertical-align: middle; }
table.rv-fields td:first-child { padding-left: 4px; }
table.rv-fields td:last-child { padding-right: 4px; }
/* The panel heading, on the same terms as table.values: a rule and a muted
   label, never a filled band. The first row under it needs no dashed rule
   of its own. */
table.rv-fields tr.rv-group th {
    padding: 14px 4px 3px; text-align: left; font-size: 12px; font-weight: 600;
    color: var(--muted); border-bottom: 1px solid var(--border);
}
table.rv-fields tbody:first-child tr.rv-group th { padding-top: 4px; }
table.rv-fields tr.rv-group + tr td { border-top: 0; }
td.rv-field { width: 1%; white-space: nowrap; font-weight: 500; }
/* The rules behind a reasoned answer, on the two right-to-work lines only.
   Sized down and spaced off the label so it reads as a footnote to the field
   rather than as part of its name. */
.rv-rules { margin-left: 8px; font-weight: 400; }
td.rv-cur { font-variant-numeric: tabular-nums; }
.rv-cur.empty { color: var(--muted); }
.rv-row.update td.rv-cur { color: var(--muted); text-decoration: line-through; }
/* The arrow has a column of its own, so it always points at the proposed
   value beside it and never at the end of a line the value has wrapped off. */
td.rv-arrow { width: 1%; padding-left: 2px; padding-right: 2px; color: var(--muted); text-align: center; }
td.rv-prop { font-variant-numeric: tabular-nums; }
.rv-value { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.rv-new { font-weight: 650; font-size: 15px; border-bottom: 1px dotted var(--accent); cursor: help; }
/* The headshot in place of a value: small enough to sit in the row, large
   enough to tell a face from a logo, on the same hover as a value. */
.rv-photo { display: block; max-width: 120px; max-height: 150px; border: 1px solid var(--border); border-radius: 6px; cursor: help; }
.rv-row.new .rv-new { color: var(--ok); }
.rv-row.update .rv-new { color: var(--warn); }
.rv-qual, .rv-said { color: var(--muted); font-size: 13px; }
.rv-qual:empty { display: none; }
/* Pushed to the far side of the proposed column, so it makes a column of its
   own down the card instead of sitting at a different offset on every row,
   and the value keeps the tag and the qualifier for company. Quiet until
   pointed at: the change of mind is the rare path, approving what was read
   the common one. */
.rv-change { margin-left: auto; padding: 1px 8px; font-size: 12px; color: var(--muted); }
.rv-change:hover { color: var(--text); }
/* The two buttons sit in a column of their own, so they line up down the
   card whatever the values beside them do. The state ("Writing…", a
   failure) goes under them rather than pushing them about. */
td.rv-acts { width: 1%; white-space: nowrap; text-align: right; }
.rv-buttons { display: inline-flex; gap: 6px; }
.rv-state { display: block; margin-top: 3px; }
.rv-state:empty { display: none; }
td.rv-acts.decided { white-space: normal; max-width: 26em; }
.rv-input {
    width: 120px; font: inherit; font-size: 14px; padding: 3px 8px;
    border: 1px solid var(--border); border-radius: 6px;
    background: var(--surface); color: var(--text); font-variant-numeric: tabular-nums;
}
/* Amber once the reviewer has typed over the model's number, so an edited
   line cannot be mistaken for an approval of what the model said. */
.rv-input.edited { border-color: var(--warn); box-shadow: 0 0 0 1px var(--warn); }
/* A Yes/No picker holds two short words, so it sizes to them rather than to
   the width a salary needs. */
select.rv-input { width: auto; min-width: 5em; }
/* A name or an address needs the room a salary does not. */
.rv-input.rv-text { width: min(28em, 100%); font-variant-numeric: normal; }
.rv-notes { margin: 10px 4px 0; font-size: 13px; color: var(--muted); }
/* The approve-all button under the buttons column it acts on. */
.rv-foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 10px; padding-right: 4px; }
@media (max-width: 760px) {
    .rv-body { overflow-x: auto; }
}

/* The source text under the pointer: what the item is, then why the value,
   then the whole text with the sentence marked and scrolled to, scrolling
   on its own when the write-up is long. */
.rv-pop { overflow-y: auto; white-space: normal; padding: 10px 14px 12px; }
.rv-pop-head { margin: 0 0 6px; color: var(--muted); font-size: 12px; }
.rv-pop-text { white-space: pre-wrap; word-break: break-word; font-size: 13.5px; }
.rv-pop-miss { margin: 0 0 8px; color: var(--warn); }
.rv-pop-why { margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border); color: var(--muted); }
/* A tint alone was easy to scroll past in a CV; the halo is what makes the
   sentence findable at a glance. */
mark.rv-mark {
    background: color-mix(in srgb, var(--accent) 30%, transparent); color: inherit; padding: 0 2px; border-radius: 3px;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}
.button.compact { padding: 4px 10px; font-size: 13px; }

/* ── Candidate field bench ─────────────────────────────────────────────────── */
/* Two name columns, because the CRM's name for a field and the API's name for
   it are both load-bearing and neither is guessable from the other. The pair
   is the content of the page, so they get the width and the inputs stay
   narrow. Fixed layout so the permanent and contract tables, which are two
   separate tables in two cards, still line up as one grid down the page. */
.fb-table { table-layout: fixed; width: 100%; }
.fb-table th:nth-child(1), .fb-table td:nth-child(1) { width: 30%; }
.fb-table th:nth-child(2), .fb-table td:nth-child(2) { width: 30%; }
.fb-table th:nth-child(3), .fb-table td:nth-child(3) { width: 16%; }
.fb-table th { text-align: left; }
.fb-table td { vertical-align: middle; }
.fb-ui { font-weight: 600; }
.fb-ui .muted { font-weight: 400; }
.fb-now { font-family: var(--mono); font-size: 13px; }
.fb-table input[type="text"],
.fb-table select { width: 100%; max-width: 15em; }
.fb-warn { color: var(--warn); font-weight: 600; }

/* The literal request body. It is the last thing read before a write that
   cannot be undone, so it is monospaced and does not wrap into ambiguity. */
.fb-json {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 10px 12px;
    font-family: var(--mono);
    font-size: 12.5px;
    overflow-x: auto;
    margin: 8px 0 0;
}

/* The annual salary that rides along with a monthly one. It is not a separate
   decision, so it does not get a row of its own; it sits under the proposed
   value and stays quiet. */
.rv-companion { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; }
.rv-companion b { color: var(--text); font-weight: 650; }

/* ── The field mapping table on /extraction ───────────────────────────────── */
/* One table per panel of the candidate screen, in the screen's own order, so
   it can be read against the CRM in another tab. The screen's name for a field
   leads because that is the one a recruiter can check; our own label for it
   used to sit alongside and was simply a second name for the same thing. */
/* Fixed layout so the two panels, which are two separate tables, line up as
   one grid down the page rather than each sizing to its own longest cell. */
table.ft { margin-bottom: 18px; table-layout: fixed; width: 100%; }
/* The two name columns hold a screen label and an API id, both short, and
   both a fixed size: sized as a percentage they grew with the page while the
   meaning, the one column with a sentence in it, wrapped to four lines beside
   them. 185px fits the longest id (salary_months_per_year) and the longest
   label. The third column is left unsized so it takes whatever remains.
   The widths sit on a colgroup rather than on the cells because a
   fixed-layout table takes them from its first row alone, and the first row
   here is the panel heading spanning all three: widths on the second row's
   cells were read by nobody, which is how the columns came to sit at an equal
   third each whatever this rule said. */
table.ft col.ft-name { width: 185px; }
table.ft td:nth-child(2) code { white-space: nowrap; }
/* A screen box the extractor declines, kept in its place so the gap is
   explained; dimmed so it does not read as a field the model can name. */
tr.ft-excluded td { opacity: .6; }
table.ft th.ft-block {
    font-size: 14px; padding-top: 12px;
    border-bottom: 1px solid var(--border); color: var(--text);
}
/* Left aligned, against the centring a spanning heading gets from table.calls:
   there the heading labels a group of columns and belongs over them, here it
   names the panel the rows below sit in, so it reads as a section title and
   lines up with the first column under it. Spelled out to the same depth as
   the rule it overrides, which outranks a plain table.ft th.ft-block. */
table.ft thead tr:first-child:not(:last-child) th.ft-block { text-align: left; }
.ft-ui { font-weight: 500; }
/* Currency, salary type and months per year are just boxes on the screen but
   not in the model's answer: they are written alongside a value and never
   proposed alone. Screen order mixes them in, so the row says so instead. */
.ft-qual {
    margin-left: 8px; padding: 1px 6px; border-radius: 999px;
    border: 1px solid var(--border); background: var(--bg);
    color: var(--muted); font-size: 11px; font-weight: 400; white-space: nowrap;
}

/* ─── Bundles ─────────────────────────────────────────────────────────────────
   An item in a candidate's bundle, on the entity page and inside an expanded
   run: a muted line naming the kind, date and origin, then the text. The kind
   tags borrow the semantic colours so a CV and a comment read apart at a
   glance. */
.bundle-item { margin: 0 0 12px; }
.bundle-item > p { margin: 0 0 4px; }
.tag.item-comment { border-color: var(--accent); color: var(--accent); }
.tag.item-file { border-color: var(--ok); color: var(--ok); }
.tag.item-email { border-color: var(--warn); color: var(--warn); }
.tag.ok { border-color: var(--ok); color: var(--ok); }
/* The Fieldwork log missing from a record entirely: worse than "not yet",
   which is normal for a few seconds after an approval. */
.tag.error { border-color: var(--error); color: var(--error); }
.rv-chip.write_failed { border-color: var(--error); color: var(--error); }
table.calls tr.failed td { color: var(--error); }

/* A short number or id box beside its label, where a full-width input would
   read as a form field rather than a setting. */
input.narrow { width: 70px; }

/* ─── Photos ──────────────────────────────────────────────────────────────────
   /review/photos: the headshots waiting on Review as a grid of faces, because
   "is this a person?" is answered by looking, many at a time. A tile is the
   picture alone until the pointer rests on it (or a key moves focus into it),
   and then a band over its lower edge names the candidate and offers the two
   decisions. A replacement tile is the held photo and the new one side by
   side, with the dates under both always on show. The pictures are all the
   same shape (the crop is a portrait, a little taller than wide) so a row
   lines up and an odd one is the thing the eye catches. */
.ph-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.ph-head h2 { margin: 0; }
.ph-head h2 .sub { margin-left: 10px; font-size: 13px; font-weight: 400; color: var(--muted); }
.ph-lock { margin: 6px 0 0; }
.ph-progress { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 12px 0 0; }

.ph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin-top: 14px; }
.ph-grid.pairs { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.ph-grid > p { grid-column: 1 / -1; margin: 0; }

.ph-tile { position: relative; min-width: 0; }
.ph-frame { position: relative; border-radius: 6px; }
/* Square, as the headshot is cut since 2026-09-22: the tile shows exactly
   what goes to Vincere, which draws it as a circle as wide as the picture. */
.ph-img {
    display: block; width: 100%; aspect-ratio: 1 / 1; object-fit: cover;
    border: 1px solid var(--border); border-radius: 6px; background: var(--bg);
}
.ph-missing { display: flex; align-items: center; justify-content: center; padding: 8px; text-align: center; font-size: 12px; color: var(--muted); }

.ph-pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 6px; }
.ph-side { margin: 0; min-width: 0; }
.ph-arrow { align-self: center; color: var(--muted); }
.ph-date { margin-top: 4px; font-size: 12px; line-height: 1.35; color: var(--muted); }
.ph-date.new { color: var(--text); }

.ph-over {
    position: absolute; left: 0; right: 0; bottom: 0;
    display: none; flex-direction: column; gap: 4px;
    padding: 6px; border-radius: 0 0 6px 6px;
    background: color-mix(in srgb, var(--bg) 88%, transparent);
    font-size: 12px; line-height: 1.3;
}
/* A pair carries its dates under the pictures, and the dates are what that
   tile is read for, so its band drops from the top edge instead. */
.ph-tile.replace .ph-over, .ph-tile.notphoto .ph-over, .ph-tile.same .ph-over { bottom: auto; top: 0; border-radius: 6px 6px 0 0; }
.ph-tile:hover .ph-over, .ph-tile:focus-within .ph-over { display: flex; }
.ph-name { color: var(--text); font-weight: 600; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ph-name:hover { text-decoration: underline; }
.ph-src { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ph-acts { display: flex; gap: 4px; }
.ph-acts .button { flex: 1; padding: 3px 4px; font-size: 12px; }

/* What the page has done to a tile since it loaded, marked on the tile's
   edge so a grid of written photos reads as done without a word on each. */
.ph-tile.busy .ph-img { opacity: .5; }
.ph-tile.written .ph-img { outline: 2px solid var(--ok); outline-offset: -2px; }
.ph-tile.failed .ph-img { outline: 2px solid var(--error); outline-offset: -2px; }
.ph-badge {
    position: absolute; top: 4px; right: 4px; padding: 1px 6px; border-radius: 999px;
    background: var(--surface); border: 1px solid var(--border); font-size: 11px; color: var(--muted);
}
.ph-tile.written .ph-badge { color: var(--ok); border-color: var(--ok); }
.ph-tile.failed .ph-badge { color: var(--error); border-color: var(--error); }
.ph-tile.set_aside .ph-img { opacity: .5; }
/* The folded group of the rule's verdicts: its heading is the summary, with
   its own caret since a flex summary loses the browser's marker. */
details.ph-section > summary { list-style: none; cursor: pointer; }
details.ph-section > summary::-webkit-details-marker { display: none; }
.ph-caret { display: inline-block; margin-right: 8px; color: var(--muted); transition: transform .15s; }
details[open] > summary .ph-caret { transform: rotate(90deg); }

/* ── Writes ────────────────────────────────────────────────────────────────── */
/* One row per write (/writes). The facts of fixed shape (when, who, the
   candidate, the field, the two tags) take the width they need and never
   wrap; what the CRM held and what was written share the rest, each in its
   own column with the arrow between, so a photo pair sits side by side and
   the arrows line up down the table (Kevin, 2026-09-22). The held column is
   a set share of the width and right aligned, so the old value and the new
   one meet at the arrow: left to the table's own layout, a long address in
   one row made the column wide and left "empty" and a held photo stranded
   far from the arrow in every other. A picture keeps its whole shape in a
   fixed square: what Vincere held may be a strip or a logo, and on this page
   it is the record of what was replaced, not a thumbnail. */
table.calls.writes td { vertical-align: middle; }
table.calls.writes .w-fit { width: 1%; white-space: nowrap; }
table.calls.writes .w-was { width: 22%; text-align: right; }
table.calls.writes .w-was .w-value { justify-content: flex-end; }
table.calls.writes .w-was .w-photo { margin-left: auto; }
table.calls.writes .w-arrow { width: 1%; padding: 6px 12px; color: var(--muted); }
table.calls.writes td.w-val { overflow-wrap: anywhere; }
table.calls.writes td.w-when { color: var(--muted); }
/* A candidate's writes of one sitting are one tbody: a faint line between its
   own rows and the full one under the last, so the full lines are what
   separate one candidate from the next. Dashed at full strength was tried
   first and at reading distance looked the same as solid. */
table.calls.writes tbody.w-sitting > tr > td { border-bottom: 1px solid color-mix(in srgb, var(--border) 35%, transparent); }
table.calls.writes tbody.w-sitting > tr:last-child > td { border-bottom: 1px solid var(--border); }
table.calls.writes tr.open td { background: var(--bg); }
table.calls.writes tr.detail > td { background: var(--bg); padding: 0 12px 14px; }
.w-value { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; }
.w-value .tag { margin-left: 0; }
.w-held { color: var(--muted); }
.w-held.replaced { text-decoration: line-through; }
.w-new { font-weight: 600; }
.w-qual { color: var(--muted); font-size: 12px; }
.w-also { display: block; margin-top: 3px; font-size: 12px; color: var(--muted); }
.w-who, .w-auto, .w-hover, .w-edited, .w-photo[title] { cursor: help; }
.w-auto { font-style: italic; color: var(--muted); }
.w-photo {
    display: block; width: 96px; height: 96px; box-sizing: border-box; object-fit: contain;
    border: 1px solid var(--border); border-radius: 6px; background: var(--bg);
}
.w-photo.none {
    display: flex; align-items: center; justify-content: center; padding: 6px;
    border-style: dashed; background: none; color: var(--muted); font-size: 12px; text-align: center;
}
.kv.w-detail { margin-top: 12px; }
.kv.w-detail dd { max-width: 100ch; }

/* ── Jobs ──────────────────────────────────────────────────────────────────── */
/* Everything that runs in the background (/jobs). What is running now is a
   card per job with its steps as a checklist: done steps ticked with their
   counts and times, the current one with a bar under it, the ones to come
   muted. The two tables below share one set of column widths, so the jobs on
   a timer and the one-off jobs line up down the page as if they were one
   table with a break in it. */
.job-live { padding: 14px 20px; }
.job-live-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.job-live-elapsed { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.job-dot { flex: none; align-self: center; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: run-pulse 1.4s ease-in-out infinite; }
.job-dot.waiting { background: var(--muted); animation: none; }
@media (prefers-reduced-motion: reduce) { .job-dot { animation: none; } }

.job-steps { list-style: none; margin: 10px 0 0; padding: 0; font-size: 13px; }
.job-steps li {
    display: grid; grid-template-columns: 18px minmax(0, 1fr) 220px 70px;
    column-gap: 10px; align-items: baseline; padding: 4px 0;
}
.job-steps .step-count, .job-steps .step-time { font-variant-numeric: tabular-nums; }
.job-steps .step-time { text-align: right; color: var(--muted); }
.job-steps li.done .step-mark { color: var(--ok); }
.job-steps li.current .step-label { font-weight: 600; }
.job-steps li.current .step-mark::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.job-steps li.todo, .job-steps li.skipped { color: var(--muted); }
.job-steps li.skipped .step-label { text-decoration: line-through; text-decoration-color: var(--border); }

/* The bar sits under the current step's text, from the label column to the
   end. A <progress> rather than a styled div: an element's inline width is
   refused by the page's content security policy, and without a value the
   browser draws the "working, total unknown" animation itself. */
progress.job-bar {
    grid-column: 2 / -1; width: 100%; height: 6px; margin-top: 5px;
    appearance: none; border: 0; border-radius: 3px; overflow: hidden;
    background: var(--border); color: var(--accent);
}
progress.job-bar::-webkit-progress-bar { background: var(--border); border-radius: 3px; }
progress.job-bar::-webkit-progress-value { background: var(--accent); border-radius: 3px; }
progress.job-bar::-moz-progress-bar { background: var(--accent); border-radius: 3px; }

table.calls.jobs { table-layout: fixed; margin-top: 0; }
table.calls.jobs td, table.calls.jobs th { vertical-align: baseline; }
table.calls.jobs col.j-name { width: 250px; }
table.calls.jobs col.j-when { width: 160px; }
table.calls.jobs col.j-last { width: 220px; }
table.calls.jobs col.j-next { width: 160px; }
table.calls.jobs col.j-act { width: 110px; }
table.calls.jobs td.j-act { text-align: right; padding-right: 0; }
table.calls.jobs .job-name { font-weight: 600; font-size: 14px; }
table.calls.jobs .sub, table.calls.jobs td.sub-cell { color: var(--muted); }
table.calls.jobs .sub { font-size: 12px; margin-top: 1px; }
table.calls.jobs tr.open td { background: var(--bg); }
table.calls.jobs tr.detail > td { background: var(--bg); padding: 0 12px 12px; }
.job-state { color: var(--accent); }
.job-failed, .job-error { color: var(--error); }
.job-paused { color: var(--warn); }
.job-runs { max-height: 50vh; margin-top: 0; }
