/* scva -- site-wide stylesheet.

   One hand-written stylesheet, no framework.
   Structure:
     1. Design tokens (colour, type, space) -- light theme, then a dark
        override keyed on <html data-theme="dark">. static/js/theme.js
        sets that attribute before first paint from the header toggle's
        stored choice, or from prefers-color-scheme when there is none;
        nothing here reads the media query directly, so the stylesheet
        has one notion of "dark" and the script owns the decision.
     2. Reset / base element styles.
     3. Layout: header, nav, main.
     4. Components: cards, stat rows, forms, buttons, placeholders.
     5. Responsive: the nav wraps at tablet width rather than breaking.

   Tokens worth knowing about for later screens:
     --color-pass / --color-fail / --color-review are reserved for verdict
     badges -- the review screen will want them next to a video,
     so they're chosen to hold up against dark surfaces, not just white.
     --focus-ring is the one focus style for the whole app; the review
     screen is keyboard-driven end to end, so this is deliberately
     visible rather than the browser default.
*/

:root {
    /* Form controls and scrollbars follow the same switch as the tokens:
       light until the script says otherwise, dark when it does. */
    color-scheme: light;

    /* Type scale -- 1.2 ratio (minor third), 16px base. */
    --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    --text-xs: 0.8rem;
    --text-sm: 0.9rem;
    --text-base: 1rem;
    --text-md: 1.2rem;
    --text-lg: 1.44rem;
    --text-xl: 1.9rem;

    /* Space scale -- 4px base. */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-5: 1.5rem;
    --space-6: 2rem;
    --space-7: 3rem;

    --radius: 8px;
    --border-width: 1px;
    --max-width: 72rem;

    /* Palette -- light. */
    --color-bg: #f7f7f5;
    --color-surface: #ffffff;
    --color-surface-raised: #ffffff;
    /* The ground for something recessed into a surface -- a disabled
       field, a plain tag. Opaque, because the page carries a tiled
       background image (body::before) that shows straight through a
       translucent grey and makes the control look like a hole. */
    --color-surface-sunken: #efede7;
    --color-border: #dcdad4;
    /* A step darker than --color-border, for a box that has to out-weigh
       the rules drawn inside it. */
    --color-result-border: #b9b5a9;
    --color-text: #1c1b19;
    --color-text-muted: #6b6860;
    --color-accent: #1f5c4d;
    /* The rule tag. Pale yellow rather than the accent green
       it started as: the accent is what every button on the site is
       painted with, so a green chip beside them read as one more control
       to press. Yellow is the one warm hue not already spoken
       for -- amber is awaiting-review, orange is skipped, and both of
       those are saturated fills on the progress bar rather than a pale
       tint behind small text. */
    --color-rule-bg: #faf4d9;
    --color-rule-border: #ddcd93;
    --color-rule-text: #5c4708;
    --color-accent-contrast: #ffffff;
    /* The word clouds on Reports cycle through these, by rank. Five
       hues in the palette's register -- the accent green and the rule
       chip's amber, then a slate blue, a plum and a teal -- none of
       them a verdict colour, because a red "discount" would say the
       word failed. Decoration, not identity: the count beside each
       term is the fact (Tim, 2026-09-15: one hue faded by count read
       as one colour). */
    --color-cloud-1: #1f5c4d;
    --color-cloud-2: #8a5a0e;
    --color-cloud-3: #2f5d8a;
    --color-cloud-4: #7a3e6b;
    --color-cloud-5: #1f6a7a;
    /* The ground a job card is drawn on, on the screens that list jobs --
       the jobs list's rows and the review queue's per-job blocks (Tim,
       2026-09-05). White, as the rest of the site's cards are, and told
       apart from the page by a border tinted towards the accent and a
       two-layer shadow -- a tight one that seats the card and a wide soft
       one that lifts it. The hover deepens both rather than washing the
       ground: the card does not change colour, it comes forward. */
    --color-job-card: #ffffff;
    --color-job-card-border: rgba(20, 45, 38, 0.14);
    --color-job-card-border-hover: rgba(20, 45, 38, 0.24);
    --shadow-job-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 3px 10px rgba(0, 0, 0, 0.035);
    --shadow-job-card-hover: 0 2px 4px rgba(0, 0, 0, 0.07), 0 6px 16px rgba(0, 0, 0, 0.05);
    --color-header-bg: #14140f;
    --color-header-text: #f2f1ec;
    --color-header-text-muted: #b9b6ab;

    /* The decision card's ground, tinted by what was decided. Pale
       enough that the verdict word stays the loudest thing on the card
       -- the tint says which way it went before the word is read, it
       does not replace it. */
    --decision-pass: #eef6f1;
    --decision-fail: #fbeeed;
    --decision-review: #f6f0e3;
    --color-pass: #1f7a4d;
    --color-fail: #a3312b;
    --color-review: #9a6a12;

    --focus-ring: #2f6fed;

    --shadow-card: 0 1px 2px rgba(20, 20, 15, 0.06), 0 1px 1px rgba(20, 20, 15, 0.04);
}

:root[data-theme="dark"] {
    color-scheme: dark;
    /* The page sits a step above the cards here, the reverse of the
       light theme: at #161613 under #1f1f1b cards the two bled into
       one dark field. Cards are the darker panels on a mid-dark page. */
    --color-bg: #24241f;
    --color-surface: #181815;
    --color-surface-raised: #262620;
    --color-surface-sunken: #2a2925;
    /* No tint in the dark: the light values are near-whites and would
       be a hole in the page here. */
    --color-job-card: #181815;
    --color-job-card-border: #3a392f;
    --color-job-card-border-hover: #59564a;
    --shadow-job-card: var(--shadow-card);
    --shadow-job-card-hover: 0 1px 4px rgba(0, 0, 0, 0.4);
    --color-border: #3a392f;
    --color-result-border: #59564a;
    --color-text: #ecebe4;
    --color-text-muted: #a6a397;
    --color-accent: #5fbf9d;
    --color-rule-bg: #302b18;
    --color-rule-border: #564e2c;
    --color-rule-text: #e2cf8c;
    --color-accent-contrast: #0c1512;
    --color-cloud-1: #5fbf9d;
    --color-cloud-2: #d9b45e;
    --color-cloud-3: #7fb0e0;
    --color-cloud-4: #c98bc0;
    --color-cloud-5: #62c0d0;
    --color-header-bg: #0d0d0b;
    --color-header-text: #f2f1ec;
    --color-header-text-muted: #918e82;

    --decision-pass: #16241f;
    --decision-fail: #2a1b1a;
    --decision-review: #262015;
    --color-pass: #4fbf85;
    --color-fail: #e08079;
    --color-review: #e0b155;

    --focus-ring: #7fa8ff;

    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 1px rgba(0, 0, 0, 0.3);
}

/* -- Reset / base ------------------------------------------------------ */

*,
*::before,
*::after {
    box-sizing: border-box;
}

html {
    font-size: 100%;
    /* Reserve the scrollbar's width whether or not this page needs one.
       Without it every navigation between a page that scrolls and one
       that does not shifts the whole layout sideways by the scrollbar's
       width -- /status/ scrolls, /jobs/start/ does not, and moving
       between them jumped the header and the page with it.

       Not visible in headless Chromium, which draws overlay scrollbars
       that take no space; it is a desktop-browser fault, which is where
       it was reported from. */
    scrollbar-gutter: stable;
}

body {
    margin: 0;
    font-family: var(--font-sans);
    font-size: var(--text-base);
    line-height: 1.5;
    background: var(--color-bg);
    color: var(--color-text);
}

/* Social-media icon tile behind every page. A pseudo-element rather than
   a background on body so the image can be faded with opacity instead of
   pre-baking the transparency into the PNG -- the strength is one number
   here. Sits above --color-bg and below all content; cards and surfaces
   paint over it. */
body::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: -1;
    background: url("../img/bg-tile.png") repeat;
    background-size: 420px;
    opacity: 0.05;
    pointer-events: none;
}

/* The tile's icons are near-black; on the dark ground they would
   vanish. Invert them to faint light lines instead. */
[data-theme="dark"] body::before {
    filter: invert(1);
    opacity: 0.06;
}

h1, h2, h3 {
    line-height: 1.25;
    margin: 0 0 var(--space-3);
}

h1 { font-size: var(--text-xl); }
h2 { font-size: var(--text-md); }
h3 { font-size: var(--text-base); font-weight: 600; }

p { margin: 0 0 var(--space-4); }

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

/* One focus style for the whole app, established early because the
   keyboard-driven review screen depends on it. Uses :focus-visible so a
   mouse click doesn't leave a ring behind, but a Tab press always
   does. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
    outline: 3px solid var(--focus-ring);
    outline-offset: 2px;
    border-radius: 2px;
}

.skip-link {
    position: absolute;
    left: -999px;
    top: auto;
    background: var(--color-accent);
    color: var(--color-accent-contrast);
    padding: var(--space-2) var(--space-4);
    z-index: 100;
    border-radius: 0 0 var(--radius) 0;
}

.skip-link:focus {
    left: 0;
    top: 0;
}

/* -- Header / nav -------------------------------------------------------- */

.site-header {
    background: var(--color-header-bg);
    color: var(--color-header-text);
    padding: var(--space-3) var(--space-5);
}

/* The row's gap and the nav links' side padding were both a step
   larger until the theme toggle arrived (TASK-315). Measured at the
   72rem container with the admin nav, the role pill and the Setup menu,
   the row had 9px to spare; a 28px button wrapped onto a line of its
   own on every desktop. One step down on each buys ~90px and keeps the
   header a single row with room for a five-digit build number. */
.site-header__row {
    max-width: var(--max-width);
    margin: 0 auto;
    display: flex;
    align-items: center;
    gap: var(--space-4);
    flex-wrap: wrap;
}

.site-brand {
    font-size: var(--text-md);
    font-weight: 700;
    letter-spacing: 0.02em;
    color: var(--color-header-text);
    text-decoration: none;
    margin-right: auto;
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

/* The logo is a square tile; height-matched to the wordmark's line so the
   header row keeps its height. */
.site-brand__logo {
    width: 1.75rem;
    height: 1.75rem;
    display: block;
    flex: none;
}

.site-nav ul {
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
}

.site-nav a,
.site-nav__group > summary {
    display: inline-block;
    color: var(--color-header-text-muted);
    text-decoration: none;
    padding: var(--space-2);
    border-radius: var(--radius);
    font-size: var(--text-sm);
    font-weight: 600;
}

.site-nav a:hover,
.site-nav__group > summary:hover,
.site-nav__group[open] > summary {
    color: var(--color-header-text);
    background: rgba(255, 255, 255, 0.08);
}

.site-nav a[aria-current="page"],
.site-nav__group > summary[aria-current="page"] {
    color: var(--color-header-bg);
    background: var(--color-header-text);
}

/* The Setup menu. `<details>` because the browser already does
   disclosure, keyboard and screen-reader announcement -- see the comment
   in base.html. Positioned absolutely over the page rather than pushing
   the header taller, which would move every screen down when it opens.

   The summary's look is folded into the `.site-nav a` / `.site-nav
   a:hover` rules above rather than repeated here, so a later change to
   how nav links look keeps applying to it. What is left below is only
   what a summary needs beyond a link: a pointer cursor, no list marker,
   and the browser's own disclosure triangle suppressed. */
.site-nav__group {
    position: relative;
}

.site-nav__group > summary {
    cursor: pointer;
    list-style: none;
}

.site-nav__group > summary::-webkit-details-marker {
    display: none;
}

/* `.site-nav ul` (above) already lays out the top-level row at the same
   specificity as a bare `.site-nav__group > ul` would have, so this adds
   the `.site-nav` ancestor class to outrank it deliberately rather than
   by accident of which rule is declared last. */
.site-nav .site-nav__group > ul {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    display: block;
    min-width: 12rem;
    margin-top: var(--space-1);
    padding: var(--space-2);
    background: var(--color-header-bg);
    border-radius: var(--radius);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.site-nav__group > ul > li {
    display: block;
}

.site-nav__group > ul a {
    display: block;
}

@media (max-width: 900px) {
    /* The nav already wraps at this width rather than collapsing, so an
       overlay would cover the page content underneath it. Inline here.
       Same reasoning as the base rule above: the `.site-nav` ancestor
       class keeps this pinned above `.site-nav ul`'s own max-width: 900px
       rule further down this stylesheet, regardless of which is declared
       first. */
    .site-nav .site-nav__group > ul {
        position: static;
        min-width: 0;
        box-shadow: none;
        padding: 0 0 0 var(--space-3);
    }
}

.site-account {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--text-sm);
    margin-left: auto;
}

.site-account__who {
    color: var(--color-header-text);
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
}

.site-account__role {
    font-size: var(--text-xs);
    color: var(--color-header-text-muted);
    border: var(--border-width) solid var(--color-header-text-muted);
    border-radius: 999px;
    padding: 0 var(--space-2);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

a.site-account__who {
    text-decoration: underline;
}

/* Which build is running. Chrome, not content: the same muted
   treatment as the role pill it sits beside, so it reads as a footnote to
   the header rather than an eighth navigation item. Tabular figures stop
   the row shifting sideways as the build number gains a digit. The header
   row has ~44px of slack with the admin nav, which is why this shows the
   short form and puts `build 87 · a3f9c21` in the title.

   The negative margin halves the row's 16px gap for this one item, which
   is what buys room for a five-digit build number. It also reads better:
   the build belongs with the account block, not floating apart from it. */
.site-version {
    margin-left: calc(var(--space-2) - var(--space-4));
    font-size: var(--text-xs);
    color: var(--color-header-text-muted);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Light or dark. An icon button beside the version mark, drawn from the
   same muted header colour so it reads as chrome rather than as a
   navigation item. It carries both icons and shows the one for the mode
   a press would switch *to* -- the sun in the dark, the moon in the
   light -- which is also what its label says; theme.js writes that
   label. The pair is chosen by the same attribute the tokens key on, so
   the icon can never disagree with the page. */
.theme-toggle {
    /* Same halved gap as the version mark, for the same reason: it is
       chrome, and belongs with the account block. */
    margin-left: calc(var(--space-2) - var(--space-4));
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: none;
    border-radius: var(--radius);
    background: none;
    color: var(--color-header-text-muted);
    cursor: pointer;
    flex: none;
}

.theme-toggle:hover {
    color: var(--color-header-text);
    background: rgba(255, 255, 255, 0.08);
}

.theme-toggle svg {
    width: 1.1rem;
    height: 1.1rem;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.theme-toggle__sun {
    display: none;
}

[data-theme="dark"] .theme-toggle__sun {
    display: block;
}

[data-theme="dark"] .theme-toggle__moon {
    display: none;
}

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

.link-button:hover {
    color: var(--color-header-text);
}

/* -- Main layout --------------------------------------------------------- */

.site-main {
    max-width: var(--max-width);
    margin: 0 auto;
    padding: var(--space-6) var(--space-5) var(--space-7);
}

.page-header {
    margin-bottom: var(--space-5);
}

.page-header h1 {
    margin-bottom: 0;
}

/* -- The job screen's heading, which is also its rename ------------------
   One row: the <h1> holding the name and the icon that opens it for
   editing, then the owner mark beside it. Editing swaps the whole row
   for a form of the same shape -- the field is inside an <h1> of its own
   -- so opening it moves nothing on the page but the box that appears
   around the name. */
.job-title {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--space-2) var(--space-3);
}

/* How you refer to a job out loud, not what you read the heading for --
   the same rule as .job-row__number and .job-card__number (TASK-356). */
.job-title__number {
    font-variant-numeric: tabular-nums;
    font-weight: 400;
    color: var(--color-text-muted);
    margin-right: var(--space-1);
}

/* Bare, and muted until it is wanted: the eye is here for the name, and
   a filled accent button beside a 2rem heading reads as the loudest
   thing on the screen. Overrides the site's default button fill. */
.job-title__edit {
    background: none;
    border: none;
    padding: 0;
    margin-left: var(--space-2);
    line-height: 0;
    vertical-align: middle;
    color: var(--color-text-muted);
    border-radius: var(--radius);
}

.job-title__edit:hover {
    color: var(--color-accent);
    filter: none;
}

/* The field takes the rest of the row, and the heading's own type. */
.job-title--editing h1 {
    flex: 1 1 24rem;
    min-width: 0;
}

.job-title__input {
    font: inherit;
    width: 100%;
    padding: var(--space-1) var(--space-2);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    background: var(--color-surface);
    color: var(--color-text);
}

.job-title__actions {
    display: inline-flex;
    gap: var(--space-2);
}

/* What the form refused, beside the box it refused: the heading has no
   room above it for a message, and the page's own message strip is for
   what happened, not for a field that is still open. */
.job-title__error {
    font-size: var(--text-sm);
    font-weight: 400;
    color: var(--color-fail);
}

/* 60rem, not the 48rem reading measure below: New Job's is the one lede
   long enough to wrap, and 48rem broke it mid-sentence. Every other
   page's lede is short enough that the wider cap changes nothing. */
.lede {
    font-size: var(--text-md);
    color: var(--color-text-muted);
    max-width: 60rem;
}

/* -- What just happened ---------------------------------------------------
   The bar templates/_messages.html renders, once, above every page's
   content. It is a whole sentence rather than a badge, so it takes the
   same 48rem reading measure as .placeholder below, rather than the card
   width.

   No new colours: the left edge and the tint are the verdict palette the
   rest of the app already reads -- pass for a success, fail for an
   error, review for a warning -- so "it worked" here is the same green
   as "it passed" there. Info stays neutral, because it is a statement of
   fact ("Nothing in this job stopped.") rather than an outcome.

   The edge is 4px on one side of an otherwise ordinary card border, so
   the colour is reinforcement and the words are the message -- nothing
   here depends on telling green from red. */
.messages {
    list-style: none;
    margin: 0 0 var(--space-5);
    padding: 0;
    display: grid;
    gap: var(--space-2);
    max-width: 48rem;
}

.message {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-left-width: 4px;
    border-left-color: var(--color-text-muted);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-3) var(--space-4);
}

.message--success {
    background: var(--decision-pass);
    border-left-color: var(--color-pass);
}

.message--warning {
    background: var(--decision-review);
    border-left-color: var(--color-review);
}

.message--error {
    background: var(--decision-fail);
    border-left-color: var(--color-fail);
}

/* -- Components ----------------------------------------------------------- */

/* htmx's own indicator rules, word for word what htmx 2.0.10 would write
   into a `<style>` element at load. base.html tells it not to
   (`includeIndicatorStyles: false`), because `style-src 'self'` refuses
   that element and the refusal was a console error on every screen
   (TASK-225). Nothing in the templates carries `htmx-indicator` today --
   the one indicator, #results on the job screen, keys on `htmx-request`
   further down -- but the class is htmx's documented contract, and the
   next template to use it should find it working. */
.htmx-indicator {
    opacity: 0;
    visibility: hidden;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
    opacity: 1;
    visibility: visible;
    transition: opacity 200ms ease-in;
}

.placeholder {
    border: var(--border-width) dashed var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-5);
    color: var(--color-text-muted);
    max-width: 48rem;
}

.placeholder p {
    margin: 0;
}

.card {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-5);
    margin-bottom: var(--space-5);
    max-width: 40rem;
}

.card__title {
    margin-bottom: var(--space-1);
}

.card__subtitle {
    color: var(--color-text-muted);
    margin-bottom: var(--space-4);
}

.card__meta {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin: var(--space-4) 0 0;
}

/* A card that is nothing but its meta line -- the status page's empty
   states -- where the top margin would hold space for a title that is
   not there. */
.card__meta--bare {
    margin-top: 0;
}

/* One row, however many stats there are.
   `repeat(4, ...)` was fine until a fifth arrived and Failed dropped to a
   line of its own. Auto-flow columns take their count from the content,
   so adding or removing a stat needs no modifier class and no second
   place to keep in step. */
.stat-row {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: var(--space-3);
    margin: 0;
}

.stat dt {
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
    margin-bottom: var(--space-1);
}

.stat dd {
    margin: 0;
    font-size: var(--text-lg);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

/* Clear of the heading, so the button is not sitting on the title. */
.outputs__get {
    margin: var(--space-4) 0 0;
}

.output-list {
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2) var(--space-4);
    margin: var(--space-2) 0 0;
    padding: 0;
}

.output-list a {
    font-size: var(--text-sm);
    font-weight: 600;
}

/* -- Job detail ------------------------------------------------------------
   Wider than a status card: the failure list carries a post, a reason and
   an action on one line, which doesn't fit the 40rem reading measure the
   status cards use. */

/* One wide, so a card is the same size whichever screen it is on.
   There were three: 40rem plain, 56rem wide, and 72rem hand-set on the
   jobs list and the review queue -- which is the number the page
   container itself uses, so those two were the ones telling the truth.
   The status page's cards were the 40rem odd ones out and read as a
   different app. */
.card--wide {
    max-width: var(--max-width);
}

.stage {
    display: grid;
    grid-template-columns: 1fr minmax(6rem, 12rem) 3rem;
    align-items: center;
    gap: var(--space-3);
}

/* --three and --four are gone: .stat-row counts its own children now.
   Below this width five stats cannot sit side by side without the labels
   colliding, so they wrap into a grid instead -- a wrapped row reads,
   a squeezed one does not. */
@media (max-width: 52rem) {
    .stat-row {
        grid-auto-flow: row;
        grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
    }
}

.stat--pass { color: var(--color-pass); }
.stat--review { color: var(--color-review); }
.stat--fail { color: var(--color-fail); }
.stat--error { color: var(--band-errors); }

/* "3 of 4", with the denominator subordinate to the number.
   A bare 3 is not a number anybody can size, and a 3 and a 4 at the same
   weight are two numbers to read rather than one fact. Regular weight,
   muted, and back to lining figures at body size -- tabular-nums on the
   dd keeps the counts in a column, which the trailing phrase would
   otherwise stretch. */
.stat__of {
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--color-text-muted);
    white-space: nowrap;
}

/* Failures. One row per stopped post: what it was, why it stopped, and
   the one action available for it. */

/* **Columns, not a block per post**. This was a two-part flex
   row -- everything about the post stacked on the left, the button on
   the right -- so three stopped posts read as three paragraphs and the
   only thing that lined up between them was the button.

   Six tracks, identical on every row and on both cards, so the accounts
   sit in a column and the URLs sit in a column. `minmax(0, ...)` on the
   three that hold text of unpredictable length is what keeps them there:
   without it a yt-dlp message a paragraph long sets its track's width
   from its own content and the row stops matching the row above it.

   The tracks are the list's, and each row lays out on them as a
   subgrid. When every row was a grid of its own, the three `auto`
   tracks were sized per row from that row's content -- "Reel" against
   "Unknown type", a Retry button against none -- so the fr tracks
   beside them landed at a different x on every row and the columns
   did not line up (Tim, 2026-09-16). One set of tracks, sized once
   from the widest cell in the whole list, is what a column means. */
.failure-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns:
        auto            /* platform mark */
        minmax(0, 1fr)  /* account */
        auto            /* content type */
        minmax(0, 1.1fr)/* where the post is */
        minmax(0, 1.7fr)/* what went wrong */
        auto;           /* Retry */
    column-gap: var(--space-3);
}

/* Baseline alignment rather than centre, because the error cell can wrap
   to several lines and the first line of it is what the other five cells
   belong beside. */
.failure {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: baseline;
    row-gap: var(--space-1);
    padding: var(--space-3) 0;
    border-top: var(--border-width) solid var(--color-border);
}

.failure:first-child {
    padding-top: 0;
    border-top: none;
}

/* The mark is 16px and aria-hidden; the platform is readable from the
   URL beside it, so this column is recognition rather than information. */
.failure__platform {
    line-height: 1;
}

.failure__account {
    font-weight: 600;
    overflow-wrap: anywhere;
}

/* Same treatment as the results list: an account nobody could identify
   is a fact about the row, not a name, so it is not set like one. */
.failure__account--unknown {
    font-weight: 400;
    color: var(--color-text-muted);
}

.failure__where {
    font-size: var(--text-sm);
    overflow-wrap: anywhere;
}

/* The tool's own words, quoted rather than paraphrased. Monospace
   because it is output, not prose. A block inside the error cell, so it
   wraps in that column rather than across the row. */
.failure__detail {
    display: block;
    margin: var(--space-1) 0 0;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    overflow-wrap: anywhere;
}

.failure__reason {
    margin: 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* Inside the error cell rather than a seventh column: both
   cards share this row shape and only the Errors card has a stage to
   name, so a column of its own would be empty on every Skipped row. */
.failure__stage {
    display: inline-block;
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-weight: 700;
    color: var(--color-fail);
    margin-right: var(--space-2);
}

.failure__action {
    justify-self: end;
}

/* One button for both cards, sitting directly under the pair it acts
   on. It is outside either card because it means both; the pull upwards
   is what stops it reading as a stray control floating in the gap
   before Outputs. */
.failure__retry-all {
    margin: calc(-1 * var(--space-2)) 0 var(--space-5);
}

/* Rows an upload arrived with that never became posts. The same list
   shape as `.failure` above, one track shorter and with no action
   column: there is no post to retry, and what fixes one of these is the
   file. Four tracks, so the sheet names line up under each other and the
   row numbers under those -- the two things somebody scans this list
   with the spreadsheet already open beside them. */
.unread-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.unread {
    display: grid;
    grid-template-columns:
        auto            /* sheet */
        auto            /* row number */
        minmax(0, 1fr)  /* the cell as it was typed */
        minmax(0, 1.4fr); /* what was wrong with it */
    align-items: baseline;
    gap: var(--space-1) var(--space-3);
    padding: var(--space-2) 0;
    border-top: var(--border-width) solid var(--color-border);
}

.unread:first-child {
    padding-top: 0;
    border-top: none;
}

.unread__sheet {
    font-weight: 600;
}

.unread__row {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    white-space: nowrap;
}

/* The client's own cell, quoted rather than paraphrased -- monospace for
   the same reason `.failure__detail` is: it is content, not prose. */
.unread__value {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: var(--text-xs);
    overflow-wrap: anywhere;
}

/* A cell with nothing in it has no text to set. This is the app saying
   so, so it is set as the app's words and not as the client's. */
.unread__value--empty {
    font-family: inherit;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

.unread__note {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

.badge {
    display: inline-block;
    font-size: var(--text-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    border-radius: 999px;
    padding: var(--space-1) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    color: var(--color-text-muted);
}

.badge--requested {
    border-color: var(--color-review);
    color: var(--color-review);
}

/* -- the whole caption, on hover ------------------------------------------ */

/* A caption is cut at 140 so a card is not mostly caption, and it opens
   in place when you hover or focus it.

   **Not a floating bubble**. It was one: absolutely
   positioned, `visibility: hidden` when closed. Both halves of that were
   wrong inside `.review-decide`, which scrolls -- laid out but invisible,
   it gave the panel a horizontal scrollbar for content nobody could see,
   and when shown it was clipped by the same scroll box. Text that grows
   where it stands has neither problem, and needs four rules rather than
   fifteen. */
/* A caption clamped to a number of lines rather than cut at a character
   count (2026-08-20). The review screen's right-hand column is a fixed
   height and must not need a scrollbar before the reviewer has touched
   anything, and two lines is what it can spare.

   Lines, not characters, because how many characters make two lines
   depends on the caption -- a count that guessed would be wrong for every
   caption that is not average. The whole thing stays in the markup and
   opens on hover or focus, so nothing is hidden from a reader who wants
   it; the partial gives this a `tabindex` so that works without a mouse. */
.caption-clamp {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--caption-lines, 2);
    line-clamp: var(--caption-lines, 2);
    overflow: hidden;
}

.caption-clamp:hover,
.caption-clamp:focus-visible {
    -webkit-line-clamp: unset;
    line-clamp: unset;
    overflow: visible;
}

.full-text {
    cursor: help;
    text-decoration: underline dotted var(--color-border);
    text-underline-offset: 3px;
}

.full-text__all { display: none; }

/* TASK-348 (on TASK-346's hovers) gave the timestamp a dotted underline
   plus a help cursor, so "there is more here" was visible before the
   reader tried hovering. TASK-355.4 removed the underline at Tim's
   request as visual noise; the help cursor and the local-time title on
   hover remain. */
.stamp {
    cursor: help;
}

.category__mark[title],
.reviewed-mark[title],
.job-state[title] {
    cursor: help;
}

.full-text:hover .full-text__short,
.full-text:focus-visible .full-text__short { display: none; }

.full-text:hover .full-text__all,
.full-text:focus-visible .full-text__all { display: inline; }

/* -- rule and content-type tags ------------------------------------------- */

/* Two marks, so a rule name and a content type are recognisable as what
   they are wherever they turn up rather than reading as prose. One
   treatment each, never one per value: the point is to mark the
   category, and a palette of five rule colours would ask a reader to
   learn five things to gain nothing.

   They are told apart by three channels, not just colour: the rule tag
   is squared off, tinted and semibold; the type tag is a plain
   uppercase pill in the muted grey. Either alone survives being read
   in monochrome or by someone who cannot separate the hues. */
.tag {
    display: inline-block;
    /* A tag that is a grid item stretches to its track by default, so
       the rule name on the job screen filled a 14rem column while the
       same tag elsewhere shrank to its words. No effect
       outside a grid. */
    justify-self: start;
    font-size: var(--text-xs);
    padding: 0.05rem var(--space-2);
    border: var(--border-width) solid;
    white-space: nowrap;
    vertical-align: baseline;
}

/* A rule is the thing the app is *about*, so its tag carries the accent
   and a squared corner -- it is not a status, and a pill would put it in
   the same family as the verdict badges, which is exactly what it is
   not. */
.tag--rule {
    border-radius: 3px;
    font-weight: 600;
    border-color: var(--color-rule-border);
    background: var(--color-rule-bg);
    color: var(--color-rule-text);
}

/* A content type is a fact about the post, in the same register as the
   verdict badges beside it. */
.tag--type {
    border-radius: 999px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    border-color: var(--color-border);
    color: var(--color-text-muted);
}

/* Wrapping is better than clipping in a narrow column. */
.tag--wrap { white-space: normal; overflow-wrap: anywhere; }

/* What the post is about. A plain bordered tag rather than `.tag--rule`'s
   accent or `.tag--type`'s pill, so a category reads as its own third
   thing rather than being mistaken for the rule it sits beside or the
   type it sits after. */
.tag--category {
    background: var(--color-surface-sunken);
    color: var(--color-text);
    border-color: var(--color-border);
}

/* What the model thought, where it was not sure enough to export it.
   Shown so there is something to correct, dimmed so it does not read as
   a decided answer. */
.tag--category-unsure {
    opacity: 0.65;
    border-style: dashed;
}

/* "None identified" -- a confident, uncorrected empty answer
   (core/classify/product_category.py's prompt asks for exactly this on
   a lifestyle shot, a sponsorship card or a bare logo), rendered rather
   than left as a labelled row with nothing in it. Italic rather than a
   colour or a border change of its own, so it reads as a caption about
   the row -- "nothing to show" -- and not as a category alongside MX,
   VD and DA. */
.tag--category-none {
    font-style: italic;
    color: var(--color-text-muted);
}

.category {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    flex-wrap: wrap;
}

/* The same amber this app already uses for "awaiting review" -- an
   unsure category is exactly that: an answer nobody has looked at yet. */
.category__mark {
    display: inline-flex;
    color: var(--color-review);
}

.category__mark--set {
    color: var(--color-text-muted);
}

/* Bare and muted, the same treatment `.job-title__edit` uses for the
   job rename -- the eye belongs on the tags beside it, not on a filled
   button the size of the badge it sits next to. */
.category__edit {
    background: none;
    border: none;
    padding: 0;
    line-height: 0;
    vertical-align: middle;
    color: var(--color-text-muted);
    border-radius: var(--radius);
}

.category__edit:hover {
    color: var(--color-accent);
    filter: none;
}

/* The category while it is being set: the taxonomy's checkboxes, then
   Save and Cancel, in the same inline run of tags used to fill --
   swapped in for `.category` by `core.views.post_category`
   (`_category_form.html`), so this keeps that class and adds its own
   rather than being a shape of its own. */
.category--editing {
    display: inline-flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
}

.category-choices {
    display: inline-flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    border: none;
    padding: 0;
    margin: 0;
}

.category-choice {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    white-space: nowrap;
}

.category__actions {
    display: inline-flex;
    gap: var(--space-2);
}

/* What the form refused, beside the boxes it refused -- the same
   placement `.job-title__error` uses beside the name field. */
.category__error {
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--color-fail);
}

/* -- Filter bar --------------------------------------------------------------
   Every filter and search on the site wears this one box (Tim, 2026-09-15):
   the job screen's results, the log, the review queue, uploads and the jobs
   board. It was four forms with three looks -- the results had a recessed
   strip, the queue had loose widgets on the page background, the jobs board
   had pills -- and a reader had to work out each time that a select and a
   button belonged together. One recessed strip, one label style, one
   select chrome. */

.filter-bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2) var(--space-3);
    margin: 0 0 var(--space-4);
    padding: var(--space-3) var(--space-4);
    background: color-mix(in srgb, var(--color-surface) 55%, transparent);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    font-size: var(--text-sm);
    max-width: var(--max-width);
}

.filter-bar label {
    color: var(--color-text-muted);
    font-weight: 600;
}

/* `appearance: none` rather than just a background colour: with the
   control left at `auto`, Chromium paints its own light chrome over the
   author background, so a select that computes to #1f1f1b still renders
   white on the dark theme. The arrow is two gradients rather than an
   SVG data URI -- the app runs under a strict CSP and this needs no
   opinion from it. */
.filter-bar select,
.filter-bar input[type="text"],
.filter-bar input[type="search"] {
    font: inherit;
    font-size: var(--text-sm);
    padding: var(--space-1) var(--space-2);
    border: var(--border-width) solid var(--color-border);
    border-radius: 6px;
    background-color: var(--color-surface);
    color: var(--color-text);
}

.filter-bar select {
    appearance: none;
    padding-right: var(--space-6);
    max-width: 16rem;
    background-image:
        linear-gradient(45deg, transparent 50%, currentColor 50%),
        linear-gradient(135deg, currentColor 50%, transparent 50%);
    background-position:
        calc(100% - 1rem) calc(50% + 1px),
        calc(100% - 0.7rem) calc(50% + 1px);
    background-size: 5px 5px;
    background-repeat: no-repeat;
}

/* The uploads list names jobs, which run long. */
.filter-bar select[name="job"] {
    max-width: 28rem;
}

.filter-bar button,
.filter-bar .button {
    font-size: var(--text-sm);
    padding: var(--space-1) var(--space-3);
}

/* Pushed to the far end: Clear undoes the row rather than continuing
   it, so it should not sit in the run of controls that build a filter. */
.filter-bar__clear {
    margin-left: auto;
    font-weight: 600;
}

/* -- Review queue ----------------------------------------------------------
   A block of similar work per card: same account, same rule. The facts on
   the right (verdict, confidence, views) are what decides whether to open
   an item; the sentence on the left is what it was flagged for. */

.queue-count {
    color: var(--color-text-muted);
    margin-bottom: var(--space-4);
}

.queue-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    flex-wrap: wrap;
}

/* -- Review screen ---------------------------------------------------------
   One post per screen, no scrolling to decide: a two-column grid sized
   to what's left of the viewport under the header, with the media on the
   left and everything a decision needs on the right. Both columns
   scroll internally if a post carries unusually long
   evidence, so the *page* never does -- the decision controls stay in the
   same place on every post, which is what makes several hundred decisions
   in a sitting bearable. */

.review-screen {
    display: grid;
    grid-template-columns: minmax(0, 1.1fr) minmax(22rem, 0.9fr);
    gap: var(--space-5);
    /* Header (~4.5rem) plus the page's own vertical padding. */
    height: calc(100dvh - 12rem);
    min-height: 26rem;
}

.review-media {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-5);
    display: flex;
    flex-direction: column;
    min-height: 0;
    overflow-y: auto;
}

/* The right-hand column is three cards now, not one panel. It
   keeps the scrolling -- the page itself must never scroll, so that a
   reviewer's controls stay in the same place on every post -- and the
   cards inside it carry the surface. */
.review-decide {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    min-height: 0;
    overflow-y: auto;
}

.review-card {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-4) var(--space-5);
}

/* The evidence is what varies in length, so it takes the remaining
   height and scrolls; the other three cards stay their own size. The
   verdict card is last and must never be pushed off the bottom -- it is
   the one being used several hundred times a sitting. */
/* Four labelled rows and nothing else since the evidence moved to its
   own card, so it takes the height it needs. It used to take
   whatever was left and scroll inside itself, which is what a card
   holding the evidence, the frames and the judged panel had to do. */
.review-card--decision {
    flex: 0 0 auto;
}

.review-card--verdict {
    flex: 0 0 auto;
}

/* Which way it went, before a word is read. Only the three a reviewer
   acts on: an errored or unrun check has no verdict to colour, and
   tinting it would say one was reached. */
.decision--pass { background: var(--decision-pass); }
.decision--fail { background: var(--decision-fail); }
.decision--needs_review { background: var(--decision-review); }

.review-card__title {
    margin: 0 0 var(--space-2);
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
}

/* The four questions every rule answers, set apart from the rule's own
   detail below them so a reviewer knows which half of the card will be
   in the same place on the next post. */
.review-standard {
    margin: 0 0 var(--space-4);
    display: grid;
    gap: var(--space-2);
}

.review-standard > div {
    display: grid;
    grid-template-columns: 9rem minmax(0, 1fr);
    gap: var(--space-1) var(--space-3);
    align-items: baseline;
}

/* The same label as `.review-head` and `.review-post` above it: three
   cards in a column, one way of reading a labelled row. */
.review-standard dt {
    color: var(--color-text-muted);
    font-weight: 700;
    font-size: var(--text-sm);
}

.review-standard dd {
    margin: 0;
}

/* No rule above it: inside the evidence card the frames are one more
   thing the card is showing, not a section of their own. */
.review-detail__title {
    margin: var(--space-3) 0 var(--space-2);
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
}

.review-media__frame {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    text-align: center;
    gap: var(--space-2);
    background: var(--color-bg);
    border: var(--border-width) dashed var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-5);
}

.review-media__kind {
    margin: 0;
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-weight: 700;
    color: var(--color-text-muted);
}

.review-media__note {
    margin: 0;
    max-width: 28rem;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* The queue-position badge floats over the top right of both columns.
   It is progress through the sitting, not part of the decision, and
   inside the Decision panel it pushed the post's identity down the card
   and read as the first fact about the post.

   Absolute rather than in flow so the two columns keep their full
   height: .review-screen is sized against the viewport so the page never
   scrolls, and a badge in the flow above it would take that height from
   the cards. */
.review-layout {
    position: relative;
}

.result-actions {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    margin-top: var(--space-4);
}

.review-progress {
    position: absolute;
    top: calc(-1 * var(--space-3));
    right: var(--space-3);
    z-index: 2;
    margin: 0;
    padding: var(--space-1) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: 999px;
    background: var(--color-surface);
    box-shadow: var(--shadow-card);
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Rule, verdict, confidence -- three labelled rows rather than one run
   of text. The card used to open with the rule as quiet grey
   meta and then "Fail 0.83 confident" on one line, the score hung off
   the verdict word; read out it came to "DECISION Lettermark Fail 0.83
   confident Why No S...", four facts with nothing between them.

   A label column, so each fact is named and the values line up under
   each other -- the same shape as the detail rows below, which is the
   point: the head is the summary of the same kind of thing. */
/* The same grid, labels and sizes as `.review-post` in the card above.
   These two lists sit one under the other in the same column, and this
   one had its own uppercase micro-label and its own type scale -- so
   two adjacent cards asking the reader to learn two ways of reading a
   labelled row.

   9rem and the muted 700 label come from `.review-post`, which shares
   them with `.review-standard` for the same reason. */
.review-head {
    display: grid;
    grid-template-columns: 9rem minmax(0, 1fr);
    /* The row gap the Post card beside it uses, so the stacked cards
       breathe alike. */
    gap: var(--space-2) var(--space-3);
    align-items: baseline;
    margin: 0;
    font-size: var(--text-sm);
}

.review-head__row { display: contents; }

.review-head dt {
    color: var(--color-text-muted);
    font-weight: 700;
}

.review-head dd {
    margin: 0;
    min-width: 0;
    overflow-wrap: anywhere;
}

/* The verdict keeps its colour and its weight and gives up its size.
   Weight as well as colour, so it is still the answer on the card for a
   reader who cannot see the red. */
/* The verdict word on the Decision card. Upper case because the same
   verdict is upper case everywhere it is a badge -- the result rows on
   the job screen and the review queue, the stat row's bands -- and one
   word for the verdict in two cases reads as two different things. The
   tracking is what stops upper case from reading as shouting at the size
   this is set. */
.review-verdict {
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.review-confidence {
    font-variant-numeric: tabular-nums;
}

/* The Job card is one row and has nothing under it, so it wants the
   labelled shape without the rule that separates a head from a body. */
.review-head--plain {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: 0;
}

/* This post's specifics, behind a press. Closed by default:
   the verdict, the confidence and the why are the decision, and on a
   post where the three agree a reviewer never opens this. */
/* Its own card now, opened by its title. Inside the Decision
   card, opening it grew that card and the decision needed a scrollbar --
   which is the thing it was added to prevent. */
.evidence {
    font-size: var(--text-sm);
}

/* The summary is the card's heading, so it reads like one rather than
   like a link. The marker is what says it opens. */
.evidence > summary {
    cursor: pointer;
    margin: 0;
}

.evidence > summary:hover,
.evidence > summary:focus-visible {
    text-decoration: underline;
}

.evidence[open] > summary {
    margin-bottom: var(--space-3);
}

.evidence .review-standard {
    margin-bottom: 0;
}

.review-card--evidence {
    padding: var(--space-4) var(--space-5);
}

/* A timestamp that jumps the player rather than a timestamp to scrub for
   by hand. It reads as text and behaves as a control, which
   is what it is: the number is the information, the jump is the
   convenience. */
/* A timestamp that jumps the player. The dashed underline read as a
   border round a box, so it is a plain link now -- which is what it
   behaves like. */
.seek {
    font: inherit;
    font-variant-numeric: tabular-nums;
    color: var(--color-accent);
    background: none;
    border: 0;
    padding: 0;
    margin-right: var(--space-2);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}

/* A bolded match inside a keyword finding is the words the rule caught,
   not emphasis on a sentence, so it is marked rather than merely bold. */
.evidence b {
    background: var(--color-rule-bg);
    box-shadow: 0 0 0 2px var(--color-rule-bg);
    border-radius: 2px;
}

/* "Not scored" is a fact about the rule, not a missing value, so it is
   worded rather than blank -- but it is not a number, so it does not
   get a number's weight. */
.review-confidence--none {
    color: var(--color-text-muted);
}

/* "Exact" stands where a number stands, so it is set like one rather
   than like prose. This is the one screen that prints the word -- the
   post cards print a percentage or nothing -- and the hover on it is
   what makes it a claim about the rule rather than a weaker score than
   0.83. */
.review-confidence--exact {
    font-weight: 700;
    color: var(--color-text);
}

/* Scoped to the review screen's headline verdict. Unqualified,
   these also coloured every row of the job screen's results list, which
   carries the same `verdict--*` modifier -- a whole row in red rather
   than a badge. */
.review-verdict.verdict--fail { color: var(--color-fail); }
.review-verdict.verdict--needs_review { color: var(--color-review); }
.review-verdict.verdict--pass { color: var(--color-pass); }

/* The mark that says a person decided this verdict rather than the
   machine, on both the job screen's rows and the Decision card.

   Inherits `currentColor`, so it takes the verdict's own colour and the
   badge reads as one thing rather than as a badge with a sticker on it.
   Muted a little, because the verdict is the answer and this is who
   gave it -- and lifted back to full strength when the person overruled
   the check, which is the case worth noticing. */
.reviewed-mark {
    display: inline-flex;
    align-items: center;
    margin-left: 0.3em;
    vertical-align: -0.15em;
    opacity: 0.65;
}

.reviewed-mark--overruled {
    opacity: 1;
}

/* Its counterpart: nobody has decided this yet. A clipboard with a pen
   against `.reviewed-mark`'s person -- a decision nobody has written,
   beside the person who wrote it. Full opacity,
   where the reviewed mark is muted: this is the mark that is asking for
   something and the other one has finished.

   Its glyph is 15px to the person's 13 and carries a cropped viewBox,
   because interior detail needs the room a silhouette does not -- see
   `_review_due_mark.html`. `vertical-align` is a shade deeper to match,
   so the two sit on the same line as a row swaps one for the other. */
.review-due-mark {
    display: inline-flex;
    align-items: center;
    margin-left: 0.3em;
    vertical-align: -0.2em;
}

/* Side by side, not stacked. Two full-width boxes cost the
   card most of its height for a choice with two options, and pushed the
   feedback field and the button below the fold on a post with much
   evidence. */
.review-choices {
    border: none;
    margin: 0 0 var(--space-3);
    padding: 0;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-2);
}

.review-choices legend {
    grid-column: 1 / -1;
}

.review-choices legend {
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
    padding: 0;
    margin-bottom: var(--space-2);
}

.review-choice {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    /* Tighter than it was: the box is a hit target for a keyboard-first
       screen, so it needs to be comfortably clickable rather than
       large. */
    padding: var(--space-2);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    cursor: pointer;
    font-size: var(--text-sm);
    min-width: 0;
}

.review-choice:has(input:checked) {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 1px var(--color-accent);
}

/* The key badge. Same shape everywhere it appears so a reviewer learns
   one vocabulary: a letter in a box means "press this". */
.review-choice__key {
    flex: none;
    display: inline-block;
    min-width: 1.6rem;
    text-align: center;
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    border: var(--border-width) solid var(--color-border);
    border-radius: 4px;
    padding: 0 var(--space-1);
    color: var(--color-text-muted);
    background: var(--color-bg);
}

.review-note {
    display: block;
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
    margin-bottom: var(--space-1);
}

.review-form input[type="text"] {
    width: 100%;
    font: inherit;
    padding: var(--space-2) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    background: var(--color-surface);
    color: var(--color-text);
    margin-bottom: var(--space-4);
}

.review-actions {
    display: flex;
    gap: var(--space-3);
    flex-wrap: wrap;
}

.button--quiet {
    background: none;
    color: var(--color-text);
    border: var(--border-width) solid var(--color-border);
}

.review-nav {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    flex-wrap: wrap;
    margin-top: auto;
    padding-top: var(--space-5);
    font-size: var(--text-sm);
}

/* The link's underline ran across the key badge as well as the word,
   because a text decoration is drawn across an inline-block rather than
   skipping it. Underlining the label alone says the same
   thing without boxing the letter twice. */
.review-nav a,
.review-nav button {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    text-decoration: none;
}

.review-nav a[href]:hover .review-nav__label,
.review-nav a[href]:focus-visible .review-nav__label {
    text-decoration: underline;
}

/* An inert nav item -- the key exists, there's just nowhere to go. */
.review-nav a:not([href]) {
    color: var(--color-text-muted);
    cursor: default;
}

.link-button--dark {
    color: var(--color-accent);
}

.link-button:disabled {
    color: var(--color-text-muted);
    cursor: default;
    text-decoration: none;
}

/* -- Reports ---------------------------------------------------------------
   Server-rendered ranking bars in HTML -- no chart library, no build
   step. Every chart is a single series, so nothing here uses colour to
   tell things apart: the label and the value sit beside the fill as
   text. Most rankings carry the accent, but a fail-oriented ranking
   (`.bars--fail`) carries the verdict red instead, so a card counting
   breaches doesn't borrow the same hue as a card counting clean posts.
   The word clouds are the one exception, and a decorative one -- they
   cycle `--color-cloud-*` by rank, with the count still written beside
   every term. Bars are capped in thickness with a rounded
   data-end and a square baseline. Light and dark come from the tokens at
   the top of this file, so there is one stylesheet rather than a theme
   fork. */

.hero {
    display: flex;
    align-items: baseline;
    gap: var(--space-4);
    margin: 0;
    flex-wrap: wrap;
}

/* One number and a sentence. The full card padding was sized for a card
   with a chart in it, and left this one standing half empty. */
.card--compact {
    padding-top: var(--space-4);
    padding-bottom: var(--space-4);
}

.card--compact .card__title {
    margin-bottom: var(--space-2);
}

.hero__value {
    font-size: 2rem;
    line-height: 1;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--color-pass);
}

.hero__detail {
    color: var(--color-text-muted);
}

.bars {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: var(--space-3);
}

.bar {
    display: grid;
    /* 16rem, not 14: a flag and a platform mark now lead the label, and
       the longest account name on the list wrapped its icons onto a
       line of their own at 14. */
    grid-template-columns: minmax(8rem, 16rem) minmax(0, 1fr) minmax(9rem, auto);
    align-items: center;
    gap: var(--space-4);
}

.bar--empty {
    display: block;
    color: var(--color-text-muted);
    font-size: var(--text-sm);
}

.bar__label {
    font-size: var(--text-sm);
    font-weight: 600;
    overflow-wrap: anywhere;
}

.bar__track {
    display: block;
    height: 1rem;
    background: var(--color-bg);
    border-radius: 2px;
}

.bar__fill {
    display: block;
    height: 100%;
    background: var(--color-accent);
    /* Square where it grows from the baseline, rounded at the data end. */
    border-radius: 0 4px 4px 0;
    min-width: 2px;
}

/* Rankings of failures carry the verdict red instead of the accent, so
   a full-width bar reads as "this is the bad list" without a label.
   Mixed against the card's own surface rather than used at full
   strength, as the result-card tints further down do -- straight --color-fail
   painted the length of a bar shouts in a way a single badge does not,
   and 55% keeps enough of it to still read as red once mixed with the
   surface, in both themes. */
.bars--fail .bar__fill {
    background: color-mix(in srgb, var(--color-fail) 55%, var(--color-surface));
}

.bar__value {
    font-size: var(--text-sm);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    text-align: right;
}

.bar__of {
    display: inline-block;
    margin-left: var(--space-2);
    font-weight: 400;
    color: var(--color-text-muted);
}

/* The two word clouds on Reports. A list, so with the styles off it is
   a ranking and reads worst first; sized from `--cloud-weight`, the
   term's share of the top count, between the small text and twice it.
   The count sits beside every term because size is a picture to compare
   and never the only way to read it -- the same rule as the bars. */
.cloud {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--space-2) var(--space-4);
}

.cloud__term {
    font-size: calc(var(--text-sm) * (1 + var(--cloud-weight, 0)));
    font-weight: 600;
    line-height: 1.2;
    overflow-wrap: anywhere;
    color: var(--color-cloud-1);
}

/* Hues by rank, round and round: a cloud in one colour is a list in
   big type. Colour is not a series here -- the count beside each term
   is the fact -- and it is never a verdict colour (see the tokens). */
.cloud__term:nth-child(5n + 2) { color: var(--color-cloud-2); }
.cloud__term:nth-child(5n + 3) { color: var(--color-cloud-3); }
.cloud__term:nth-child(5n + 4) { color: var(--color-cloud-4); }
.cloud__term:nth-child(5n + 5) { color: var(--color-cloud-5); }

.cloud__count {
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

/* What tells two accounts sharing a display name apart. Set
   below the name rather than beside it: it answers "which of the two is
   this", so it is secondary to the name, and inline it would push the
   label column wide enough to squeeze the bars it sits next to. */
.category-icon {
    vertical-align: -0.15em;
    margin-right: 0.3rem;
    color: var(--color-text-muted);
}

/* Inside a category tag the mark scales with the tag's smaller type
   rather than keeping the 16px the ranking's labels wear. */
.tag--category .category-icon {
    width: 1.1em;
    height: 1.1em;
    margin-right: 0.2rem;
}

/* A region's flag sits in the line with its name, where the language
   flag elsewhere is a block of its own. */
.bar__label .flag {
    display: inline-block;
    vertical-align: -0.15em;
    margin-right: 0.15rem;
}

.bar__qualifier {
    display: block;
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--color-text-muted);
}

/* --- Reach of failed posts -----------------------------------------------
   Two tables on one card: the sums, failed against passed, and the five
   failed posts seen most. Both are `.matrix` tables, with the numbers
   right-aligned and tabular so the two rows of the first compare down
   the column, which is the only comparison the card is for. Both span
   the card, so the last column's numbers end where the bar values end
   on the cards below rather than a few inches in from the edge. */
.matrix.reach-grid,
.matrix.reach-top {
    max-width: none;
    width: 100%;
    table-layout: fixed;
}

.reach-grid th:last-child,
.reach-grid td:last-child,
.reach-top th:last-child,
.reach-top td:last-child {
    padding-right: 0;
}

/* The row label column takes what the number columns leave, with no
   left padding, so the labels start where the bar labels start. It
   had a 16rem width of its own until 2026-09-16 (Tim): under auto
   layout the slack then went to Views and Likes, which put the sums
   a good way left of the same two columns on the posts table under
   it. Fixed layout and no width here, and both tables' number
   columns are the 9rem `.reach-grid__cell` sets below. */
.reach-grid thead th:first-child,
.reach-grid tbody th {
    padding-left: 0;
    padding-right: var(--space-4);
}

.reach-grid tbody th {
    font-size: var(--text-sm);
    font-weight: 600;
    text-transform: none;
    letter-spacing: 0;
    color: var(--color-text);
    white-space: nowrap;
    border-bottom: 0;
    vertical-align: top;
}

/* One width on both tables, so the sums and the five posts' figures
   end on the same two lines down the card (Tim, 2026-09-16). 9rem holds
   a nine-digit sum with its commas and the "134 of 281 posts" beneath. */
.reach-grid th.reach-grid__cell,
.reach-grid td.reach-grid__cell,
.reach-top th.reach-grid__cell,
.reach-top td.reach-grid__cell {
    width: 9rem;
    text-align: right;
    white-space: nowrap;
    vertical-align: top;
    font-variant-numeric: tabular-nums;
    font-weight: 700;
}

/* The eye and heart mark, ahead of the word rather than replacing it --
   this heading is quoted to clients and reads as a word first. */
.reach-grid th.reach-grid__cell svg,
.reach-top th.reach-grid__cell svg {
    vertical-align: -0.15em;
    margin-right: var(--space-1);
    opacity: 0.75;
}

/* The count beneath a sum is not bold; the sum is the fact and the
   count qualifies it. */
.reach-grid td.reach-grid__cell .bar__qualifier {
    font-weight: 400;
}

.reach-top thead th:first-child,
.reach-top td.reach-top__still {
    padding-left: 0;
    padding-right: var(--space-3);
    width: 4rem;
}

.reach-top td {
    vertical-align: top;
}

/* A poster frame, narrow enough that five rows fit on a screen. The
   frame keeps its own shape -- these are 9:16 reels and 1:1 feed posts
   -- and a post with no stored media gets an empty box the same width,
   so the column does not collapse on that row. */
.reach-top__image {
    display: block;
    width: 4rem;
    height: auto;
    min-height: 3rem;
    border-radius: calc(var(--radius) / 2);
    background: var(--color-bg);
}

.reach-top__image--none {
    height: 4rem;
}

/* Wide enough that a poster's name and the link each sit on one or
   two lines. A width, not a min-width: the table's layout is fixed,
   which reads the first row's widths and nothing else. */
.reach-top__post {
    width: 20rem;
    font-size: var(--text-sm);
    font-weight: 600;
}

.reach-top__post .platform-icon {
    vertical-align: -0.15em;
    margin-right: 0.3rem;
}

.reach-top__poster--unknown {
    color: var(--color-text-muted);
    font-weight: 400;
}

.reach-top__link {
    display: block;
    font-size: var(--text-xs);
    font-weight: 400;
    overflow-wrap: anywhere;
}

/* The rules column takes the slack -- the one column with no width,
   under fixed layout -- and tags wrap within whatever is left. */
.reach-top__rules {
    min-width: 12rem;
}

.reach-top__rules .tag {
    margin: 0 var(--space-1) var(--space-1) 0;
}

/* --- Account and region rankings -----------------------------------------
   Four `.matrix` tables where there were bars until 2026-09-16: with a
   platform, a brand and a region to carry beside the name, a bar with
   a caption was hiding the facts that tell two identically-named
   accounts apart, and a bar drawn from the rate kept being read as a
   count. Full width like the reach tables, so the numbers end where
   the bar values end on the cards around them.

   Fixed layout, not auto: each table sizes its own columns from its
   own content by default, so "Region" landed at a different x on the
   fail table and the pass table whenever their longest account name
   differed. Every column but the name gets an explicit width instead,
   so Region and the counts sit at the same place on all four tables
   regardless of which rows are in them, and the name column -- the one
   column with no width set -- takes whatever is left. */
.matrix.ranking {
    max-width: none;
    width: 100%;
    table-layout: fixed;
}

.ranking th:first-child,
.ranking td:first-child {
    padding-left: 0;
}

.ranking th:last-child,
.ranking td:last-child {
    padding-right: 0;
}

.ranking__name {
    font-weight: 600;
}

.ranking__brand {
    width: 9rem;
}

/* Wraps rather than the name column's nowrap+ellipsis: a region is
   sometimes a handle-shaped string with nowhere to break (real data --
   "samsungmagyarorszag" -- not a display bug), and a fixed-width column
   would otherwise clip it silently. */
.ranking__region {
    width: 11rem;
    overflow-wrap: anywhere;
}

.ranking .platform-icon,
.ranking .flag {
    display: inline-block;
    vertical-align: -0.15em;
    margin-right: 0.3rem;
}

.ranking th.ranking__number,
.ranking td.ranking__number {
    width: 7rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* The verdict mark ahead of the word, as the reach tables' eye and
   heart are -- and on the verdict's own colour, so a column headed
   "Failed" is red before it is read. The word stays the heading's
   muted grey. */
.ranking th.ranking__number svg {
    vertical-align: -0.15em;
    margin-right: var(--space-1);
}

.ranking th.ranking__number--fail svg { color: var(--color-fail); }
.ranking th.ranking__number--pass svg { color: var(--color-pass); }

/* --- Every aspect ratio seen, as a table ---------------------------------
   Rows are platform and post type, columns the ratios seen, tallest on
   the left and widest on the right. A count the format rule allows sits
   on the pass colour and one it does not on the fail colour, and the
   latter is bracketed too, so the table is legible in black and white. */
/* Both class names, or `.matrix`'s own width and 40rem cap, declared
   later in this file, win the tie and squeeze the row-label column.
   Full width, like the other cards' tables: the row-label column stays
   pinned to 16rem below, and the ratio columns share what is left. */
.matrix.ratio-grid {
    max-width: none;
    width: 100%;
}

/* The row-label column is the bar label's 16rem plus the bar's 1rem
   gap, with no left padding, so the labels start where the bar labels
   start and the first ratio column starts where the bar tracks do. */
.ratio-grid thead th:first-child,
.ratio-grid tbody th {
    width: 16rem;
    box-sizing: content-box;
    padding-left: 0;
    padding-right: var(--space-4);
}

.ratio-grid th.ratio-grid__cell,
.ratio-grid td.ratio-grid__cell {
    text-align: right;
    white-space: nowrap;
    vertical-align: bottom;
    font-variant-numeric: tabular-nums;
    /* Columns size to their widest entry with the spacing in the padding,
       not a min-width: right-aligned numbers in a column wider than they
       are would float clear of where the column starts. */
    padding-left: var(--space-5);
    padding-right: var(--space-3);
}

/* After the rule above, which it ties on specificity: the first ratio
   column has no left padding so its widest entry starts on the bar line. */
.ratio-grid th:nth-child(2),
.ratio-grid td:nth-child(2) {
    padding-left: 0;
}

.ratio-grid tbody th {
    font-size: var(--text-sm);
    font-weight: 600;
    text-transform: none;
    letter-spacing: 0;
    color: var(--color-text);
    white-space: nowrap;
    border-bottom: 0;
}


.ratio-grid td.ratio-grid__cell--allowed {
    color: var(--color-pass);
    font-weight: 700;
}

.ratio-grid td.ratio-grid__cell--outside {
    color: var(--color-fail);
    font-weight: 700;
}

/* The shape itself, dashed, above its name: the longer side is 24px
   and the other follows the ratio, so 9:16 is a tall box and 16:9 a
   wide one. */
/* Box and name stack as one block, centred on each other, and the block
   sits to the right with the numbers beneath it. */
.ratio-head {
    display: inline-flex;
    flex-direction: column;
    align-items: center;
}

.ratio-box {
    display: block;
    margin: 0 0 var(--space-1);
    border: 1px dashed var(--color-text-muted);
    border-radius: 1px;
    box-sizing: border-box;
}

.ratio-grid td.ratio-grid__cell--none::before {
    content: "\2013";
    color: var(--color-border);
}

.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* -- Jobs list ------------------------------------------------------------- */

.status-filter {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-bottom: var(--space-5);
}

.status-filter a {
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-2);
    font-size: var(--text-sm);
    font-weight: 600;
    text-decoration: none;
    padding: var(--space-2) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: 999px;
    background: var(--color-surface);
    color: var(--color-text);
}

.status-filter a[aria-current="true"] {
    background: var(--color-accent);
    border-color: var(--color-accent);
    color: var(--color-accent-contrast);
}

.status-filter__count {
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
}

.status-filter a[aria-current="true"] .status-filter__count {
    color: inherit;
}

.job-cards {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Width comes from `.card--wide` now, which is this number:
   both are a list of boxes you scan down, and different margins on the
   two screens read as two different apps rather than two views of one. */

.job-card__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    /* Clear of the progress bar underneath, which was sitting right
       against the job's name. */
    margin-bottom: var(--space-4);
}

.job-card .card__title {
    margin-bottom: 0;
    min-width: 0;
    overflow-wrap: anywhere;
}

/* The one thing the subtitle carried that the name does not, so it moved
   up here. Tabular and muted: it is how you refer to a job out
   loud, not what you read the card for. */
.job-card__number {
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    font-weight: 400;
}

/* Who a job belongs to (core/_owner.html): the person mark and an
   initialled name, muted and small wherever it sits -- inside a card
   title it must read as an aside on the name, not more of it. Inline
   flex so the icon centres on the text's own line. */
.owner {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-sm);
    font-weight: 400;
    color: var(--color-text-muted);
    white-space: nowrap;
}

.owner__icon {
    flex: none;
}

/* Breathing room after the job name it follows -- in a card title and
   in the job screen's Name row. Not on `.owner` itself: as a labelled
   value of its own (the review screen's Job card) it starts its cell,
   and a left margin there would indent it out of the label column. */
.card__title .owner,
.job-facts dd .owner {
    margin-left: var(--space-2);
}

/* Whose queue the card sits in, on the review queue: the card's top
   right corner, over the empty end of the identity line, rather than in
   the flow of the verdicts. The job screen puts no owner on an entry --
   it names one once in Job Details rather than on every one of 9,551
   rows -- so nothing is drawn there.

   The identity line reserves the width rather than wrapping under it:
   an account name long enough to reach the pin would otherwise run
   underneath it. */
.result__owner {
    position: absolute;
    top: var(--space-4);
    right: var(--space-4);
}

.result:has(.result__owner) .result__account {
    padding-right: 9rem;
}

/* Status badges reuse the verdict tokens: a failed job is the same red as
   a failed check, and an awaiting-review job the same amber as one -- the
   colour means the same thing in both places. The label is always there,
   so the colour is reinforcement rather than the message. */
.badge--running { border-color: var(--color-accent); color: var(--color-accent); }
/* In flight wears the band the count of in-flight posts is drawn in, so
   the badge and the stat that explain each other share a colour. */
.badge--in_flight { border-color: var(--band-awaiting-batch); color: var(--band-awaiting-batch); }
.badge--complete { border-color: var(--color-pass); color: var(--color-pass); }
.badge--failed { border-color: var(--color-fail); color: var(--color-fail); }
.badge--awaiting_review { border-color: var(--color-review); color: var(--color-review); }

.review-count {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
    margin: 0 0 var(--space-4);
}

.review-count__number {
    font-size: var(--text-xl);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--color-accent);
}

/* -- Forms ------------------------------------------------------------- */

.form {
    max-width: 32rem;
}

/* A form that is itself a card (New Job) takes the card's width, not
   the form's, so it lines up with every other card on the site. */
.card.form {
    max-width: none;
}

.field {
    margin-bottom: var(--space-4);
}

.field label {
    display: block;
    font-size: var(--text-sm);
    font-weight: 600;
    margin-bottom: var(--space-1);
}

.field input,
.field select,
.field textarea {
    width: 100%;
    font: inherit;
    padding: var(--space-2) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    background: var(--color-surface);
    color: var(--color-text);
}

/* A checkbox is not a text input and must not be stretched to the width
   of the form. `.field input` sets width: 100% for every input in a
   field, and there was no checkbox in this application until now. */
.field--check label {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    cursor: pointer;
}

/* The tick boxes are grouped under one legend that reads like the
   labels above it. The fieldset's own box is removed; the card is the
   box. */
.field--options {
    border: 0;
    padding: 0;
    margin: 0 0 var(--space-4);
    min-width: 0;
}

.field--options legend {
    font-size: var(--text-sm);
    font-weight: 600;
    padding: 0;
    margin-bottom: var(--space-2);
}

.field--options .field--check:last-child {
    margin-bottom: 0;
}

/* Under a heading the options are items, not labels: at the label weight
   the heading had nothing to stand out from. */
.field--options .field--check label {
    font-weight: 400;
}

.field--check input[type="checkbox"] {
    width: auto;
    flex: 0 0 auto;
    margin: 0;
    padding: 0;
}

.form-error {
    color: var(--color-fail);
    font-size: var(--text-sm);
    margin-bottom: var(--space-4);
}

/* form-error doubles as a <ul> when a field carries more than one error
   (StartJobForm.clean_pasted_urls reports them by line, with the
   offending text: each bad line is its own list item) -- this only adds
   the list affordances the plain-paragraph case doesn't need. */
ul.form-error {
    padding-left: var(--space-5);
}

ul.form-error li {
    margin-bottom: var(--space-1);
}

.hint {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin: var(--space-1) 0 0;
}

button,
.button {
    font: inherit;
    font-weight: 600;
    font-size: var(--text-sm);
    background: var(--color-accent);
    color: var(--color-accent-contrast);
    border: none;
    border-radius: var(--radius);
    padding: var(--space-2) var(--space-4);
    cursor: pointer;
}

button:hover,
.button:hover {
    filter: brightness(1.08);
}

/* A `<span>` wearing `.button`, for the moment there is nothing to press
   -- Start reviewing on an empty queue. Present rather than gone, so the
   page beneath it does not move when a filter empties the queue (Tim,
   2026-09-16); `display: inline-block` holds the same box a link would. */
.button--disabled {
    display: inline-block;
    background: var(--color-surface);
    color: var(--color-text-muted);
    border: var(--border-width) solid var(--color-border);
    cursor: default;
}

.button--disabled:hover {
    filter: none;
}

/* -- Login ----------------------------------------------------------------
   The one screen with no work on it, so it gets a composition rather
   than a column: a centred card on a faint wash of the accent, the
   brand mark above the form. Every colour is an existing token -- the
   card is the same surface, border and shadow as .card, plus one
   accent keyline along the top. */

.login {
    display: flex;
    justify-content: center;
    /* Pushes the card toward the optical centre of what the header
       leaves free, collapsing gracefully on short viewports. */
    padding: clamp(var(--space-2), 8vh, 5rem) var(--space-2) var(--space-6);
    background:
        radial-gradient(
            36rem 24rem at 50% 20%,
            color-mix(in srgb, var(--color-accent) 9%, transparent),
            transparent 70%
        );
}

.login-card {
    width: min(24rem, 100%);
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-top: 3px solid var(--color-accent);
    border-radius: var(--radius);
    /* The card token's shadow plus a longer, softer drop: this card
       floats alone on the page, so it can carry more depth than a card
       sitting in a stack. */
    box-shadow:
        var(--shadow-card),
        0 16px 40px -20px rgba(20, 20, 15, 0.35);
    padding: var(--space-6) var(--space-6) var(--space-5);
    text-align: center;
}

.login-card__mark {
    width: 64px;
    height: auto;
    margin-bottom: var(--space-3);
}

.login-card__title {
    font-size: var(--text-lg);
    font-weight: 700;
    line-height: 1.25;
    margin: 0 0 var(--space-1);
}

.login-card__subtitle {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin: 0 0 var(--space-5);
}

/* The card centres its chrome; the form inside stays a form. */
.login-card .form {
    max-width: none;
    text-align: left;
}

.login-card__submit {
    width: 100%;
    padding: var(--space-3) var(--space-4);
    font-size: var(--text-base);
    margin-top: var(--space-2);
}

/* The one line under the form: "Forgot your password?", or the way
   back to the login page from the reset screens. */
.login-card__aside {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin: var(--space-4) 0 0;
}

/* The reset screens say one thing each; the copy reads as a paragraph,
   not as a subtitle. */
.login-card__copy {
    font-size: var(--text-sm);
    text-align: left;
    margin: 0 0 var(--space-4);
}

@media (prefers-reduced-motion: no-preference) {
    .login-card {
        animation: login-rise 0.35s ease-out both;
    }
}

@keyframes login-rise {
    from {
        opacity: 0;
        transform: translateY(12px);
    }
}

/* -- Responsive ----------------------------------------------------------
   Laptop (~1280px+) is the primary target; nothing above should break
   between that and tablet width. The nav wraps onto its own line rather
   than collapsing into a hidden menu -- no JS, and every destination
   stays visible and reachable at any width. */

@media (max-width: 900px) {
    .site-header__row {
        gap: var(--space-3);
    }

    .site-nav {
        order: 3;
        width: 100%;
    }

    .site-nav ul {
        justify-content: flex-start;
    }

    .stat-row {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }

    .stage {
        grid-template-columns: 1fr 4rem;
        row-gap: var(--space-1);
    }



    /* The six columns collapse to three. Six tracks in the
       width left below 900px gives the error column about four
       characters, so the two cells that hold sentences take the full
       width and the row becomes four short lines instead. `.failure` is
       a grid now, so the `flex-direction: column` it used to share with
       `.queue-item` here did nothing. */
    .failure {
        grid-template-columns: auto minmax(0, 1fr) auto;
        gap: var(--space-1) var(--space-2);
    }

    .failure__where,
    .failure__reason,
    .failure__action {
        grid-column: 1 / -1;
    }

    .failure__action {
        justify-self: start;
    }

    /* Same collapse for the unread rows: the sheet and its row number
       stay side by side, because together they are one address, and the
       cell and the sentence take the full width under them. */
    .unread {
        grid-template-columns: auto minmax(0, 1fr);
        gap: var(--space-1) var(--space-2);
    }

    .unread__value,
    .unread__note {
        grid-column: 1 / -1;
    }

    /* Below tablet width the two review columns stack, and the screen is
       allowed to scroll: "no scrolling to decide" is a promise about the
       laptop this is worked on, not about a phone nobody reviews 350
       posts from. */
    .review-screen {
        grid-template-columns: 1fr;
        height: auto;
    }

    /* The bar label goes above its own bar rather than shrinking to a
       column too narrow to read an account name in. */
    .bar {
        grid-template-columns: 1fr auto;
        gap: var(--space-1) var(--space-3);
    }

    .bar__track {
        grid-column: 1 / -1;
        order: 3;
    }
}

@media (max-width: 480px) {
    .stat-row {
        grid-template-columns: 1fr;
    }
}

/* --- What a job's checks found ------------------------------------------
   The job screen showed progress and failures and never a verdict, so the
   paste-a-URL flow ended on a page that never said what the app decided. */

/* Title and subtitle on the left, how many posts on the right. The count
   used to sit on a line of its own between the filter and the list,
   where it read as a third control rather than as the answer to "how
   big is this". */
.results__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3) var(--space-5);
    flex-wrap: wrap;
    margin-bottom: var(--space-4);
}

.results__heading .card__subtitle {
    margin-bottom: 0;
}

/* The strip is the list toolbar (`.filter-bar--toolbar`, further down);
   the results section beneath it is what dims. */

/* While a request is in flight the list dims: htmx puts
   `htmx-request` on the indicator, which is the section itself. A
   click that changes nothing for 200ms reads as ignored; one that
   greys the list reads as working. The strip stays at full strength so
   the control just used does not fade under the pointer. `.list-swap`
   is the same contract on the jobs list, the review queue, the account
   list and the logs (TASK-358): the region their toolbar swaps, with
   the toolbar itself left out of the dimming. */
#results.htmx-request .results,
.list-swap.htmx-request > :not(.filter-bar) {
    opacity: 0.45;
    transition: opacity 120ms ease-in;
}

.results {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: var(--space-4);
}

/* Filtered, the list never shrinks below a viewport: a page that gets
   shorter than the reader has scrolled is pulled back up by the browser
   and the filter bar moves under the pointer. `align-content: start`
   keeps the cards at the top of the room rather than spread down it. */
.results--filtered {
    min-height: 100vh;
    align-content: start;
}

/* No scroll anchoring across the swap. The section replaces itself
   whole, and Chrome's anchoring then picks something below it -- the
   Output card -- to hold still, so the page scrolled to keep a card
   the reader was not looking at where it had been (measured 2026-09-15:
   scrollY 668 -> 1410 on one badge click). Anchor to nothing in here
   and the scroll position simply stays. */
#results {
    overflow-anchor: none;
}

/* The review queue's job blocks. The results list on a job screen sits
   inside a `card card--wide`, and the queue's cards sat straight on the
   page background, so the same card read as two different objects
   depending on which screen it was drawn on. Gathering the queue by job
   (2026-09-04) gave each block the same ground.

   Only the title's link is restyled: a job name is a heading first and a
   way back to the job second, so it takes the heading's colour and
   underlines on hover rather than arriving blue. */
.results-group__title a {
    color: inherit;
    text-decoration: none;
}

.results-group__title a:hover,
.results-group__title a:focus-visible {
    text-decoration: underline;
}

/* A result is a box, not a rule-separated row. On a page of 25 the
   hairline borders left it ambiguous which verdicts belonged to which
   post -- the gap between two results looked the same as the gap
   between a post and its own checks. */
.result {
    /* A stronger edge than the app's default hairline. These
       boxes contain their own internal rules -- one per verdict row -- so
       at the same weight as those the box stopped reading as a box and a
       page of results became one undifferentiated grid. The outline needs
       to beat what it holds. */
    border: 2px solid var(--color-result-border);
    border-radius: var(--radius);
    background: var(--color-bg);
    /* The verdict rows carry a coloured left edge that would otherwise
       square off the bottom corners. */
    overflow: hidden;
    /* The ground the review queue's owner mark is pinned to. */
    position: relative;
}

/* The post's own answer: a tinted header and a badge saying what it adds
   up to (2026-08-20). The page is read to find which posts are a problem,
   and answering that meant reading every verdict row on every card.

   `color-mix` against the card's own ground rather than fixed pale hexes,
   so the tints follow --color-pass/--color-fail into dark mode instead of
   staying pastel on a dark card. 12% is enough to read as a state at a
   glance and light enough to leave the text on it at full contrast.

   `.result__post` is `_post_card.html`'s own class, and that partial is
   what both the job screen and the review queue render, so this one
   rule tints the same post the same way on both (TASK-355.2). The fail
   tint dropped from 12% to 7% there: Tim found the pink too strong once
   the badge already carries the red emphasis, and a paler card lets the
   badge read as the emphasis rather than competing with it. Pass stays
   at 12% -- only the fail state was flagged.

   Three states. "Awaiting review" is untinted on purpose: it is the
   absence of an answer, and giving it a colour of its own would make a
   page of undecided posts look decided. */
.result--compliant .result__post {
    background: color-mix(in srgb, var(--color-pass) 12%, var(--color-surface));
}

.result--breach .result__post {
    background: color-mix(in srgb, var(--color-fail) 7%, var(--color-surface));
}

/* Pushed to the end of the identifying line, so it lands in the same place
   on every card and the eye can run down it. */
.result__compliance {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    margin-left: auto;
    font-size: var(--text-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--color-text-muted);
}

.result--compliant .result__compliance { color: var(--color-pass); }
.result--breach .result__compliance { color: var(--color-fail); }
.result--error .result__compliance { color: var(--band-errors); }
/* Pending keeps the muted default: nothing has been decided, and a
   colour would say something had. */

.result__post {
    display: grid;
    gap: var(--space-1);
    padding: var(--space-4);
}

/* Who posted it, what kind it is, how many saw it -- the line that
   identifies the post while scanning. */
.result__account {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin: 0;
    min-width: 0;
    font-size: var(--text-md);
    font-weight: 600;
    line-height: 1.3;
}

/* The same three figures as the queue card, on the line that identifies
   the post (2026-08-20). Drawn from the same partial, so the marks cannot
   drift apart between the two screens. Inline after the type, so the
   group sits in the same place here as on the queue card. */
.result__engagement {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    white-space: nowrap;
    font-size: var(--text-sm);
    font-weight: 400;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

.result__engagement svg { flex: none; opacity: 0.75; }

.result__account .platform-icon {
    width: 20px;
    height: 20px;
    flex: none;
    margin-right: 0;
    vertical-align: baseline;
}

.result__name {
    min-width: 0;
    overflow-wrap: anywhere;
}

.result__name--unknown {
    color: var(--color-text-muted);
    font-weight: 400;
}

/* Look comes from `.tag--type` now; this only positions it. */
.result__type {
    white-space: nowrap;
}

.result__what {
    margin: 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    overflow-wrap: anywhere;
}

/* Long, and not being read -- it is there to be clicked and to confirm
   which post this is. Same treatment as the queue card's URL. */
.result__where {
    margin: 0;
    font-size: var(--text-sm);
    min-width: 0;
}

.result__where a {
    display: inline-block;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}

/* The decisions, on the raised surface so they read as the content of
   the box rather than more of its header.

   **Two widths, declared here and used by every row below.** A score
   that starts wherever its own badge happens to end is a ragged column
   down a card (Tim, 2026-09-04, with a screenshot of three PASS rows
   whose numbers were a few pixels apart) -- the FAIL badge carries a
   review mark inside it and is wider than PASS by exactly that mark.
   Sibling grids cannot share track sizes, so the width is stated rather
   than measured.

   `--verdict-badge` is sized for the badges that are actually drawn,
   not for the longest one that could be. Measured 2026-09-04: Pass 822,
   Error 302, Fail 90, Not checked 9, Not applicable 7 of 1,221 rows.
   5rem clears "Fail" carrying its review mark, which is the widest of
   the first three and half again the width of "Pass" -- and "Not
   applicable" no longer counts, because the badge abbreviates it to
   "N/A" (`verdict_badge`, core/templatetags/post_display.py).

   That leaves "Not checked", nine rows, wider than this. It is not
   sized for: at 8.5rem, which is what it measures, every one of the
   other 1,212 rows carried 4rem of nothing between the verdict and its
   score, which is what Tim asked twice to close. Those nine overflow
   their cell into the score cell beside it, which is always empty on
   that verdict -- see `.verdict__open` below -- so the badge is the
   only thing out of line and the rule tags and reasons stay in their
   columns.

   `--verdict-score` is the mark, three digits and the per-cent sign,
   which is "100%" -- what a measured rule reads since today. */
.verdicts {
    --verdict-badge: 5rem;
    --verdict-score: 3.6rem;
    --verdict-rule-shift: 1.5rem;
    list-style: none;
    margin: 0;
    padding: 0;
    background: var(--color-surface);
    border-top: var(--border-width) solid var(--color-border);
}

/* Badge, rule, reason -- full width, because the reason is a sentence.
   Boxed into the old 26rem side column it had about 5rem to wrap in and
   came out as a vertical ribbon two words wide; here it gets whatever
   the card has left, which is around 30rem. */
.verdict {
    font-size: var(--text-sm);
    border-top: var(--border-width) solid var(--color-border);
    /* No colour spine. It carried the same fact as the badge
       beside it, and a column of colour down the left of a 25-row list
       competed with the badges for the reader's eye rather than helping
       them. The badge still carries the verdict in colour and in words. */
}

/* The row itself is the link: a page of results is 25 rows,
   and a trailing "open" on each would be 25 more tab stops to reach the
   one you want. The grid moved here with it, so the spine and the
   dividers stay on the <li> and the hit area is the whole row. */
.verdict__open {
    display: grid;
    /* 8.5rem is what "Needs review" and "Not checked" actually measure at
       text-xs uppercase with the tracking on -- at 7rem they overflowed
       the track and butted straight into the rule name. */
    /* Three tracks: the decision, the rule, the reason. The score is
       inside the first of them, beside the badge, because it qualifies
       that verdict rather than the row (Tim, 2026-09-04).

       The first track is the badge column and the score column plus the
       gap between them, so every rule tag on the page starts at the same
       x whether or not its check had a score to give.

       **A fixed length, not `minmax(..., max-content)`.** It was the
       latter, so the one badge wider than its column -- "Not checked",
       nine rows of 1,221 -- pushed its own rule tag and reason along
       with it, and the reason wrapped to two lines where every other row
       fitted on one. The badge overflows its cell now and the rest of
       the row does not move.

       Nothing collides, and structurally: a badge only outgrows the
       column when its verdict is Not checked, and only Pass and Fail
       rows carry a score at all -- 428 and 39 of the 467 stored
       confidences on 2026-09-04, none on any other verdict, because a
       rule that did not run measured nothing. So the overflow runs into
       an empty score cell, which is 3.6rem plus the gap and wider than
       the 3.5rem "Not checked" needs. */
    /* The rule tag sits hard against the confidence number because the
       middle track is wider than any rule name needs (Tim, 2026-09-16).
       Shifting `--verdict-rule-shift` from the first track into the
       second's cap opens a gap before the tag without moving where the
       reason column starts. */
    grid-template-columns:
        calc(var(--verdict-badge) + var(--space-2) + var(--verdict-score) + var(--verdict-rule-shift))
        minmax(0, calc(14rem - var(--verdict-rule-shift)))
        minmax(0, 1fr);
    gap: var(--space-2) var(--space-4);
    align-items: baseline;
    padding: var(--space-3) var(--space-4);
    color: inherit;
    text-decoration: none;
}

.verdict__open:hover,
.verdict__open:focus-visible {
    background: var(--color-bg);
}

/* The rule name is what a reader is scanning for, so that is what
   underlines rather than the whole row going blue. */
.verdict__open:hover .verdict__rule,
.verdict__open:focus-visible .verdict__rule {
    text-decoration: underline;
}

.verdict:first-child {
    border-top: 0;
}

/* Neutral until a verdict says otherwise, so an unrecognised one is
   legible rather than invisible. */
/* The one badge shape in this app: a verdict on the job screen and in the
   review queue, a job's state and its outcome counts on a job row
   (core/_job_row.html). They were three shapes until 2026-09-04, and the
   job row's own list had two of them -- a tinted chip on Fail, bare text
   on Pass -- so two counts of the same kind read as two kinds of thing.

   `--badge` is the colour it is tinted with; unset means the neutral
   badge, which is what Complete, Queued and Fetched want. The verdict
   rules below set no `--badge` and paint the three properties directly,
   because they were here first and say the same thing. */
.job-results__badge {
    --badge: var(--color-text-muted);
    /* How much of `--badge` the fill and the edge take. Fail, Awaiting
       review and Errors turn both up (Tim, 2026-09-04): they are what
       somebody has to do something about, and the alternative -- a second
       treatment for the loud three -- would be two components where the
       app has one.

       Both trimmed a further third (TASK-357, Tim): a jobs-list row is
       read in bulk, and the badges do not need to carry as much weight
       as they do standing alone in the Results tiles. */
    --badge-fill: 10%;
    --badge-edge: 28%;
    display: inline-flex;
    align-items: center;
    /* No `gap`: the fetched badge's "34", its "/" and its "103" are three
       flex items, and a gap between them set the count as "34 / 103". The
       one thing that does need clearing is the live mark, which asks for
       it itself. */
    font-variant-numeric: tabular-nums;
    background: color-mix(in srgb, var(--badge) var(--badge-fill), var(--color-surface));
    border-color: color-mix(in srgb, var(--badge) var(--badge-edge), var(--color-surface));
    color: var(--badge);
}

.verdict__badge,
.job-results__badge {
    padding: 0.05rem 0.4rem;
    border-radius: 4px;
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    white-space: nowrap;
    border: var(--border-width) solid var(--color-border);
}

/* What each badge is tinted with: the job screen's Results tiles'
   colours, so one number is one colour wherever it is drawn. */
.job-results__item--pass .job-results__badge { --badge: var(--color-pass); }
.job-results__item--fail .job-results__badge { --badge: var(--color-fail); }
.job-results__item--review .job-results__badge { --badge: var(--color-review); }
/* Not checked is neutral, as its tile on the job screen is: no verdict,
   and neither of its halves' colours (skipped orange, errors violet)
   may claim the sum. */
.job-results__item--unchecked .job-results__badge { --badge: var(--color-text-muted); }

/* The two that are asking for something. */
.job-results__item--fail .job-results__badge,
.job-results__item--review .job-results__badge {
    --badge-fill: 16%;
    --badge-edge: 50%;
}

.verdict__badge {
    justify-self: start;
    padding: 0.05rem 0.4rem;
    border-radius: 4px;
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    white-space: nowrap;
    background: var(--color-bg);
    border: var(--border-width) solid var(--color-border);
    color: var(--color-text-muted);
}

/* Tinted ground with the verdict colour as the text, rather than white
   on a solid fill. The solid version hard-coded `#fff`, which is fine
   against the light theme's #1f7a4d and close to unreadable against the
   dark theme's #4fbf85 -- and the dark palette exists precisely because
   these badges have to hold up on a dark surface. */
.verdict--pass .verdict__badge {
    background: color-mix(in srgb, var(--color-pass) 14%, var(--color-surface));
    border-color: color-mix(in srgb, var(--color-pass) 40%, var(--color-surface));
    color: var(--color-pass);
}

.verdict--fail .verdict__badge {
    background: color-mix(in srgb, var(--color-fail) 14%, var(--color-surface));
    border-color: color-mix(in srgb, var(--color-fail) 40%, var(--color-surface));
    color: var(--color-fail);
}

.verdict--needs_review .verdict__badge {
    background: color-mix(in srgb, var(--color-review) 14%, var(--color-surface));
    border-color: color-mix(in srgb, var(--color-review) 40%, var(--color-surface));
    color: var(--color-review);
}

/* A rule still to run (TASK-328): a hollow badge in the muted ink, and
   a row that is not a link -- no hover wash, no underline, the default
   cursor -- because there is no result behind it to open. */
.verdict--pending .verdict__badge {
    border-style: dashed;
    color: var(--color-text-muted);
}

.verdict__open--still:hover,
.verdict__open--still:focus-visible {
    background: transparent;
}

.verdict__open--still:hover .verdict__rule {
    text-decoration: none;
}

.verdict__rule {
    font-weight: 600;
    overflow-wrap: anywhere;
}

.verdict__why {
    color: var(--color-text-muted);
    overflow-wrap: anywhere;
}

/* The verdict and its score, as one object inside the row's first
   track -- two columns of their own, so the numbers line up down the
   card however wide each badge is. The badge does not fill its column;
   it keeps the width of its own word, because it is a tinted chip and a
   chip stretched to 10.5rem for the word "PASS" is a pale block.

   `baseline` rather than `center`: the badge is uppercase at text-xs and
   the score is text-sm, and centring them left the number sitting a
   pixel high against the word. */
.verdict__decision {
    display: grid;
    /* Fixed, for the reason `.verdict__open`'s first track is: a badge
       wider than its column overflows rather than widening the row. */
    grid-template-columns: var(--verdict-badge) var(--verdict-score);
    align-items: baseline;
    gap: var(--space-3);
    min-width: 0;
}

/* How sure the check was: the dial, then the number, muted so the
   verdict beside it stays the thing being read. Tabular figures so a
   page of them keeps one rhythm as the numbers change length.

   Absent, not blank, on a rule that measured the file, on one that
   neither scores nor measures, and on a row a person has decided -- see
   `_confidence_shown` in views.py. There is no column to hold open: the
   badge it follows is as wide as its own word. */
.verdict__confidence {
    display: inline-flex;
    align-items: center;
    gap: 0.2rem;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.verdict__confidence svg {
    flex: none;
    opacity: 0.8;
}

/* Below this the three tracks stop being readable, so the reason takes
   a line of its own under the badge and the rule rather than squeezing
   them -- it is the longest and the least useful at a glance. */
@media (max-width: 48rem) {
    .verdict__open {
        grid-template-columns:
            calc(var(--verdict-badge) + var(--space-2) + var(--verdict-score))
            minmax(0, 1fr);
        row-gap: var(--space-1);
    }

    /* The reason spans both tracks on a second row; the decision and the
       rule keep the first between them, which is the group that has to
       stay together. */
    .verdict__why {
        grid-column: 1 / -1;
    }
}

/* The post itself, on the screen where somebody judges it.
   Capped by height rather than width: these are 9:16 reels, and letting
   one run to its natural height would push the decision controls off the
   fold -- which is the thing the review screen set out to avoid. */
.review-media__player {
    display: block;
    max-width: 100%;
    max-height: 60vh;
    width: auto;
    height: auto;
    margin: 0 auto;
    border-radius: 4px;
    background: var(--color-bg);
}

.review-post {
    display: grid;
    /* The same label column as `.review-standard` below it, so the two
       lists in the right-hand column line up rather than each choosing
       their own indent. */
    grid-template-columns: 9rem minmax(0, 1fr);
    /* The same row gap as `.review-standard`: at half of it the rows
       read as one block rather than a list (Tim, 2026-09-15). */
    gap: var(--space-2) var(--space-3);
    margin: 0;
    font-size: var(--text-sm);
    align-items: baseline;
}

/* The account line is a heading inside the queue card, where it is the
   card's title. In the Post card it is one field among four, so it is
   set at the size of the rest of them. */
.review-post .queue-card__account {
    font-size: var(--text-sm);
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
}

.review-post dt {
    color: var(--color-text-muted);
    font-weight: 700;
}

.review-post dd {
    margin: 0;
    min-width: 0;
    overflow-wrap: anywhere;
}

/* Which language a post is shown in.
 *
 * The choice is an attribute on <html>, set by static/js/language.js
 * from the reader's stored preference. With the script blocked there is
 * no attribute and neither rule matches, so both languages show, one
 * under the other -- the source words are never hidden by markup alone.
 */
/* Inline, so the language that is showing runs on from the flags that
 * turn it -- as a block it started a line of its own and left the pair
 * stranded above the caption they belong to.
 */
.lang {
    display: inline;
}

html[data-lang="en"] .lang--source,
html[data-lang="source"] .lang--en {
    display: none;
}

/* With the script blocked there is no `data-lang` and neither rule above
 * matches, so both languages show -- and then they need their own lines,
 * because two of them running together as one paragraph is unreadable.
 */
html:not([data-lang]) .lang {
    display: block;
}

/* The flags that open a caption. Small and quiet, and inline with the
 * words they act on rather than beside the label: the queue card hides
 * every label it has, which left the pair looking attached to nothing.
 */
.lang-toggle {
    display: inline-flex;
    gap: 0.25rem;
    /* The gap goes on the right: the pair opens the caption now rather
     * than trailing a label, so a left margin indented the first flag
     * out of line with the account row above it on every card. */
    margin-right: 0.4rem;
    vertical-align: middle;
}

.lang-toggle__flag {
    background: none;
    border: 1px solid transparent;
    border-radius: 2px;
    cursor: pointer;
    line-height: 0;
    padding: 1px;
}

.lang-toggle__flag[aria-pressed="true"] {
    border-color: var(--color-accent);
}

/* No separate :focus-visible rule here: `.lang-toggle__flag` is a
   <button>, and the one focus style for the whole app (above) already
   covers it. */

.lang-toggle__code {
    color: var(--color-text-muted);
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.04em;
}

.flag {
    border: 1px solid var(--color-border);
    display: block;
}

/* On the review screen the labels are shown, so the icon would be saying
   "Likes" a second time beside the word. The number alone there. */
.review-post .queue-card__engagement svg { display: none; }

.queue-card__account { font-weight: 600; }

/* The icons carry their own brand colours now, so `color` no
   longer applies -- only the unknown-platform fallback still inherits
   it, which is the one case where a neutral outline is right. */
.platform-icon {
    vertical-align: -0.15em;
    margin-right: 0.15rem;
    color: var(--color-text-muted);
    border-radius: 3px;
}

/* A full post URL is long and is not the thing being read -- it is there
   to be clicked and to confirm which post this is. */
.queue-card__url a {
    display: inline-block;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}

.button--small {
    padding: 0.3rem 0.75rem;
    font-size: var(--text-sm);
}

/* The account is what identifies the post at a glance while scanning the
   page, so it leads at a size that can be read without stopping. */
/* text-md, which is what the job results list leads with. At
   text-lg the account was the biggest thing on the screen after the page
   title, and the same account name repeats down a list -- the size was
   being spent on the fact that changes least. */
.queue-card__account {
    font-size: var(--text-md);
    font-weight: 600;
    line-height: 1.25;
}

.queue-card__account .platform-icon {
    width: 22px;
    height: 22px;
    vertical-align: -0.2em;
    margin-right: 0.3rem;
}

/* The four summary stats, centred in equal shares of the card.
   They were left-aligned inside auto-flowed columns, so each number sat
   at a different distance from its own label and the row read as four
   things placed rather than one row measured.

   This is deliberately the same data as the progress list above it, seen
   the other way round: that one is how far the job got, this is what it
   came to. Keeping both means the second has to look like a summary, not
   like a repeat. */
.stat-row--summary .stat {
    text-align: center;
}

/* The job's own facts, as labelled rows. Same shape as a
   queue card's fields, so a reader moving between the two screens is not
   re-learning where to look. */
/* Same *styling* too, not just the same shape. Two cards sat
   on one screen carrying the same kind of content -- a labelled list of
   facts -- at two sizes and two label weights: text-sm with a 400 label
   here, text-base with a 700 label on a review card. The gaps already
   agreed; type and weight now do as well.

   The label column is 8rem rather than the review card's 6.5rem because
   the labels differ: "Last changed" measures 121px bold at text-base, so
   at the old 7rem it wrapped to two lines the moment the type grew. That
   is fit, not a second opinion about spacing. */
.job-facts dl {
    display: grid;
    /* 9rem: "Monthly spend" is the widest label now and wrapped at 8. */
    grid-template-columns: 9rem minmax(0, 1fr);
    gap: var(--space-2) var(--space-4);
    margin: 0;
    font-size: var(--text-base);
    align-items: baseline;
}

/* Three groups -- Job, Configuration, Usage -- stacked with a gap wide
   enough to read as a break between lists rather than a row that
   wrapped. The group's label is the review card's title, exactly --
   bold small caps (Tim, 2026-09-15) -- so it is a heading of a
   different rank from the bold muted `dt`s under it. */
.job-facts {
    display: grid;
    gap: var(--space-4);
    /* A breath under the title, so the first group label is not read
       as its subtitle; and the least the footer's auto margin can be. */
    margin: var(--space-2) 0 var(--space-4);
}

.job-facts__label {
    margin: 0 0 var(--space-2);
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
}

/* The log link is a way out of the card, not a fact about the job, so
   it sits under a rule at the foot. The card is flexed so the footer
   holds the bottom edge when the card is stretched to the right
   column's height, rather than trailing the last group. */
.card--facts {
    display: flex;
    flex-direction: column;
}

.job-facts__footer {
    margin-top: auto;
    padding-top: var(--space-3);
    border-top: 1px solid var(--color-border);
    font-size: var(--text-sm);
}

/* Bold and muted, the review card's treatment, borrowed here: at 400
   against a 400 value the label and the thing it labels carry the same
   weight and the eye has nothing to run down the left edge of the
   card. */
.job-facts dt {
    color: var(--color-text-muted);
    font-weight: 700;
}

.job-facts dd {
    margin: 0;
    min-width: 0;
    overflow-wrap: anywhere;
}

/* Paging, as a footer to the list. Previous and Next keep their box when
   there is nowhere to go rather than disappearing, so the control that
   is still live does not move between pages -- the same reasoning as the
   review screen's inert nav items. */
.pagination {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    margin-top: var(--space-4);
    padding-top: var(--space-4);
    border-top: var(--border-width) solid var(--color-border);
    font-size: var(--text-sm);
}

.pagination__step {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-weight: 600;
    text-decoration: none;
    padding: var(--space-1) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: 999px;
}

.pagination__step svg {
    flex: none;
}

/* The header's copy sits on the title's line, right-aligned, without
   the rule and the top margin the foot's copy uses to separate itself
   from the last card. */
.pagination--top {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
    justify-content: flex-end;
}

.pagination__step:hover {
    background: var(--color-bg);
}

.pagination__step--spent {
    color: var(--color-text-muted);
    border-color: transparent;
}

/* "26-50 of 9,551": wide enough for the month-sized range so the
   steps either side do not shuffle as the numbers change. */
.pagination__where {
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
    min-width: 9rem;
    text-align: center;
}

/* -- the frame a verdict points at ---------------------------------------- */

/* Thumbnails wrap rather than scroll: a result has one or two moments,
   occasionally four, and a horizontal scroller for three items hides
   the third behind a gesture. */
.moments {
    list-style: none;
    margin: 0 0 var(--space-4);
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3);
}

.moment__jump {
    display: grid;
    grid-template-columns: auto auto;
    grid-template-areas: "still still" "label at";
    gap: var(--space-1) var(--space-2);
    align-items: baseline;
    padding: var(--space-2);
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    cursor: pointer;
    font: inherit;
    text-align: left;
    color: inherit;
}

.moment__jump:hover,
.moment__jump:focus-visible {
    border-color: var(--color-text-muted);
    background: var(--color-bg);
}

.moment__still {
    grid-area: still;
    /* The frame decides its own height: these are 9:16 reels and 1:1
       feed posts, and forcing either into the other's box would be
       cropping the evidence. */
    width: 7rem;
    height: auto;
    border-radius: calc(var(--radius) / 2);
    /* A frame that has not arrived yet, or 404s, leaves a box the size
       of the one that would have been there rather than collapsing the
       row it sits in. */
    background: var(--color-bg);
    min-height: 4rem;
}

.moment__label {
    grid-area: label;
    font-weight: 600;
    font-size: var(--text-sm);
}

.moment__at {
    grid-area: at;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

/* -- the progress bar ----------------------------------------------------- */

/* One band per state, the bands summing to the job's posts. Five separate
   tracks answered "how far has fetching got" five times and never
   answered "where are the posts".

   **The colours are a status palette, not a categorical one**, and the
   order they stack in is part of the design. Every band also carries a
   text label in the row beneath, a 2px gap either side, and a name in
   the readout -- colour is never the only channel here.

   **Skipped is orange, not a third grey**. It was #a8a498
   against in-progress's #b9b5a9: ΔE2000 4.8, which is two swatches of
   the same grey. Skipped is not undecided, it is one of the four ways a
   post ends, so it belongs with the outcome colours rather than the
   neutrals -- and it sits between pass and failed in the stack, where
   green | orange | red reads as a progression. Its worst separation
   against any other band, across normal, protanopic and deuteranopic
   vision, goes 4.8 -> 12.6 in light and 8.0 -> 11.8 in dark.

   The dark value is darker than the light one, which inverts the pattern
   the rest of this palette follows. That is deliberate: dark awaiting is
   a pale amber (#e0b155), deuteranopia collapses amber and orange, and
   lightness is the only channel left to separate them by.

   Known and accepted, pre-dating this: pass-vs-failed at 4.4 dark for
   deuteranopia. (A waiting band for the retired waiting-on-a-human
   stage went with TASK-363; it had never rendered on real data.)

   **Errors is violet**. The band that was called failed is now
   two: `failed` keeps the red, because a rule failing a post is what the
   red already meant to a reader, and the pipeline breaking gets a colour
   of its own. Violet rather than a sixth warm tone -- red, orange and
   amber are already three, and a fourth is where protanopia and
   deuteranopia stop telling them apart. It sits last in the stack, so it
   never touches orange.

   Measured: the violet's worst separation against any other band is
   ΔE2000 28.9 light and 26.9 dark, across normal, protanopic and
   deuteranopic vision -- comfortably the easiest band in the set to pick
   out, which is right for the one that means something broke. Simulated
   with Vienot's model; the four figures quoted above this paragraph came
   from a different simulation and are not on the same scale.

   The remaining neutrals are deliberately grey: a post that has not been
   decided yet has no verdict colour to wear, and giving it one would say
   the job had got further than it has. */
:root {
    --band-not-started: #e8e6e0;
    --band-to-check: #b9b5a9;
    /* A fourth tone in the neutral family, between to-check and errors
       (L* roughly 73, 54, 30), so the two bands that both mean "fetched,
       no verdict yet" sit next to each other and are still told apart at
       a glance. Deliberately not a colour: a post out at the provider
       has no verdict, and giving it one would say the job had got
       further than it has. */
    --band-awaiting-batch: #86826f;
    --band-awaiting: #9a6a12;
    --band-pass: #1f7a4d;
    --band-skipped: #e8813a;
    --band-failed: #a3312b;
    --band-errors: #6b3f8f;
}

:root[data-theme="dark"] {
    --band-not-started: #2b2b25;
    --band-to-check: #59564a;
    --band-awaiting-batch: #74705f;
    --band-awaiting: #e0b155;
    --band-pass: #4fbf85;
    --band-skipped: #dc6c28;
    --band-failed: #e08079;
    --band-errors: #b98fd6;
}

/* Underscore, matching `Job.state_breakdown`'s keys (`not_started`,
   `to_check`): the classic templates' stat row emits `band--{{ key }}`
   on its swatches. The custom property keeps its hyphen; only the class
   has to agree with the key. The stacked bar these once painted is gone
   from the job screen (TASK-338); the swatches and the In flight badge
   still wear them. */
.band--not_started { background: var(--band-not-started); }
.band--to_check { background: var(--band-to-check); }
.band--awaiting_batch { background: var(--band-awaiting-batch); }
.band--awaiting { background: var(--band-awaiting); }
.band--pass { background: var(--band-pass); }
.band--skipped { background: var(--band-skipped); }
.band--failed { background: var(--band-failed); }
.band--errors { background: var(--band-errors); }

/* -- Job screen: the overview band ----------------------------------------
   Job Details on the left; on the right, the Progress card over the Post
   Results card (TASK-338). The facts card is tall and the two live
   cards are short, so side by side the three fill one band of the
   screen, and the three questions a reader arrives with -- which job,
   what is it doing, what came of it -- sit above the fold. The
   right-hand pair is one polled element, swapped whole every two
   seconds while the job is live. */
.job-overview {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem);
    gap: var(--space-5);
    /* The facts card runs to the bottom of the pair beside it, but the
       pair does not run to the bottom of the facts (Tim, 2026-09-15):
       the tiles keep one size whatever the Progress card above them
       says, and background under the Results card is fine where tiles
       grown to fill it were not. */
    align-items: start;
    max-width: var(--max-width);
    margin-bottom: var(--space-5);
}

/* The grid sizes the cards; the 40rem reading measure and the bottom
   margin a card carries on its own are the grid's job here. A
   descendant rule, so every card in the band -- the facts, the pair,
   Output under them -- is covered without a rule per wrapper. */
.job-overview .card {
    max-width: none;
    margin-bottom: 0;
}


.job-overview > .card:has(.job-facts) {
    align-self: stretch;
}

/* The column and the polled pair inside it stack the same way, so the
   Outputs card under the pair keeps the pair's own rhythm. */
.job-side,
.job-live {
    display: grid;
    gap: var(--space-4);
    min-width: 0;
}


@media (max-width: 56rem) {
    .job-overview {
        grid-template-columns: minmax(0, 1fr);
    }
}

/* -- The Progress card: where the run is ---------------------------------
   Compact on purpose: a mark, the state, one line, and a bar only while
   the job is moving. `--run` is the state's colour and is the same token
   the job's status badge wears for that state, so the card and the pill
   in the facts beside it agree without a word. */
.progress-card {
    --run: var(--color-text-muted);
}

.progress-card--processing { --run: var(--color-accent); }
.progress-card--in_flight { --run: var(--band-awaiting-batch); }
.progress-card--completed { --run: var(--color-pass); }
.progress-card--failed { --run: var(--color-fail); }

.progress-card__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

/* A round tinted well for the mark, the same formula the outcome badges
   use for their fill: the state's colour at a few percent over the
   surface, so it reads as the app's own tint and not a new one. */
.progress-card__mark {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    color: var(--run);
    background: color-mix(in srgb, var(--run) 12%, var(--color-surface));
}

/* Processing wears the pulse the jobs list's badge uses, drawn larger
   here because it is the whole mark rather than a dot beside a word. */
.progress-card__pulse {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: currentColor;
}

/* In flight pulses too (Tim, 2026-09-15): the job is alive, just not
   here. The whole well breathes rather than the plane alone, since the
   plane is an outline and a ring around an outline reads as a second
   shape. One declaration for both, and off under reduced motion. */
.progress-card__pulse,
.progress-card--in_flight .progress-card__mark {
    animation: job-state-pulse 1.6s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
    .progress-card__pulse,
    .progress-card--in_flight .progress-card__mark { animation: none; }
}

.progress-card__words {
    min-width: 0;
}

.progress-card__state {
    margin: 0;
    font-size: var(--text-md);
    line-height: 1.2;
}

.progress-card__line {
    margin: var(--space-1) 0 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* One bar, one fill: posts in a state they will not leave, over the job.
   The track is the page ground, as the old bar's was; the fill is the
   state's colour, so a failed job's bar is red up to where it stopped. */
.progress-card__track {
    height: 0.6rem;
    margin-top: var(--space-4);
    border-radius: 999px;
    overflow: hidden;
    background: var(--color-bg);
}

.progress-card__bar {
    height: 100%;
    width: calc(var(--share) * 1%);
    border-radius: inherit;
    background: var(--run);
    transition: width 400ms ease-out;
}

.progress-card__readout {
    display: flex;
    justify-content: space-between;
    gap: var(--space-3);
    margin: var(--space-2) 0 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

.progress-card__percent {
    font-weight: 700;
    color: var(--color-text);
}

/* The open batches, as the Job Details card lays out its facts: a
   label column and a value column, small and muted. */
.progress-card__flight {
    display: grid;
    grid-template-columns: max-content minmax(0, 1fr);
    gap: var(--space-1) var(--space-3);
    margin: var(--space-3) 0 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    cursor: default;
}

.progress-card__flight dt {
    font-weight: 600;
}

.progress-card__flight dd {
    margin: 0;
}

/* A poll that has stopped coming: the review tint, since it is a
   thing for someone to look at rather than a failure of the run. */
.progress-card__stale {
    padding: 0 var(--space-2);
    border-radius: var(--radius);
    background: var(--decision-review);
    color: var(--color-text);
}

/* The error, as a line a reader cannot miss and not as a number: the
   decision-fail tint the verdict cards use, with the text in the fail
   colour. */
.progress-card__error {
    margin: var(--space-3) 0 0;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius);
    background: var(--decision-fail);
    color: var(--color-fail);
    font-size: var(--text-sm);
    overflow-wrap: anywhere;
}

/* -- The Results card: what the posts came to ------------------------------
   `.card--compact`, like the two cards it sits between. A row of tiles
   under the title, each a small tinted panel: the outcome's mark and word, its count large, its share small.
   The tint is the outcome badge's own fill formula (a few percent of the
   outcome colour over the surface) so the tiles read as the badges the
   jobs list wears, enlarged, rather than as a second palette. */

/* Two by two, not four across. The column the card sits in is 26rem at
   most, and four tiles in it left "Awaiting review" as "AWAITI..." --
   the one label a reviewer is looking for. Two rows of two also make
   the card about as tall as the facts beside it. A fifth tile (Errors,
   only when there are any) takes the next row alone, which is right for
   the one outcome that means something broke. */
.result-tiles {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

.result-tile {
    --tile: var(--color-text-muted);
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "mark label" "count count" "share share";
    align-items: center;
    /* Start, not centre: Awaiting review has no share row, and centred
       it sat lower than its neighbours' label and count (Tim,
       2026-09-15). Every tile's label and count now sit on the same
       lines, whether or not a share follows. */
    align-content: start;
    gap: 0 var(--space-1);
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius);
    border: var(--border-width) solid color-mix(in srgb, var(--tile) 24%, var(--color-surface));
    background: color-mix(in srgb, var(--tile) 8%, var(--color-surface));
    color: var(--tile);
    min-width: 0;
}

.result-tile--pass { --tile: var(--color-pass); }
.result-tile--fail { --tile: var(--color-fail); }
.result-tile--review { --tile: var(--color-review); }
/* Not checked is neutral on purpose: the posts in it have no verdict,
   and the two colours the jobs list gives their halves (skipped orange,
   errors violet) would each claim the tile for one of them. */
.result-tile--unchecked { --tile: var(--color-text-muted); }

.result-tile__mark {
    grid-area: mark;
    flex: 0 0 auto;
}

.result-tile__label {
    grid-area: label;
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    white-space: nowrap;
}

.result-tile__count {
    grid-area: count;
    margin-top: var(--space-1);
    font-size: var(--text-lg);
    font-weight: 700;
    line-height: 1.1;
    font-variant-numeric: tabular-nums;
    color: var(--color-text);
}

/* The one tile that goes somewhere: the waiting count opens the job's
   review queue. Underlined only on hover, so the tile is a number first
   and a link second, like the count on the jobs list row. */
a.result-tile__count {
    color: var(--color-text);
    text-decoration: none;
}

/* The platforms row: each platform behind its mark, on one line where
   they fit. */
.job-facts__platforms {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1) var(--space-3);
}

.job-facts__platform {
    white-space: nowrap;
}

.result-tile__share {
    grid-area: share;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

/* The split's two halves go to their cards, like the count above them.
   Every link on a tile is underlined only on hover, so the tile reads
   as a number first and a link second, like the count on a job row. */
.result-tile__share a {
    color: inherit;
    text-decoration: none;
}

a.result-tile__count:hover,
a.result-tile__count:focus-visible,
.result-tile__share a:hover,
.result-tile__share a:focus-visible {
    text-decoration: underline;
}

/* The swatch is what makes the stat row a legend: identity arrives as a
   coloured mark beside the words, never as coloured words. */
.stat__swatch {
    display: inline-block;
    width: 0.6rem;
    height: 0.6rem;
    margin-right: 0.4em;
    border-radius: 2px;
    vertical-align: baseline;
}

/* To fetch is the palest band in the set, and pale is right on the bar,
   where it sits in a filled track. As a 0.6rem chip on the card's own
   surface it all but disappears -- in either scheme, since the dark
   value is as close to the dark surface as the light one is to the
   light. A hairline, on this swatch only: the rest are already legible
   and a border on all of them would draw the legend more strongly than
   the bar it is a legend for. */
.stat__swatch.band--not_started {
    box-shadow: inset 0 0 0 1px var(--color-border);
}

/* -- a verdict that came from a model ------------------------------------- */

/* Marked, not decorated. This rule has never been scored against
   Samsung's own verdicts, and until it is, a screen that let one of its
   verdicts look like a measured one would be overstating what the tool
   knows. */
.judged {
    margin: 0 0 var(--space-4);
    padding: var(--space-3);
    border: var(--border-width) solid var(--color-review);
    border-radius: var(--radius);
    background: var(--color-bg);
}

.judged .review-standard {
    margin: 0;
}

/* The transcript can be a paragraph. It is evidence rather than prose to
   read end to end, so it is bounded and scrolls inside itself rather
   than pushing the decision controls down the card. */
.judged__transcript {
    display: block;
    max-height: 7rem;
    overflow-y: auto;
    font-size: var(--text-sm);
}

/* -- the rules reference -------------------------------------------------- */

/* One card per rule. They were rows inside a shared ruleset card,
   divided by a hairline, and a rule whose body is a ten-row matrix ran
   straight into the next rule's title -- at a glance there was no
   telling where one rule ended. A hairline cannot bound a box whose
   height varies by a factor of ten; a border and a gap can.

   The identity column went with it. A 15rem sidebar holding a name and
   a "Change" link cost the matrix a third of its width to save nothing,
   so the body runs the full card and the name sits in a header above
   it. */

.ruleset {
    margin-bottom: var(--space-7);
}

.ruleset__head {
    display: flex;
    align-items: baseline;
    gap: var(--space-3);
    flex-wrap: wrap;
    margin-bottom: var(--space-4);
}

.ruleset__name {
    margin: 0;
    font-size: var(--text-lg);
}

.ruleset__meta {
    margin: 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

.ruleset__empty {
    margin: 0;
    padding: var(--space-5);
    border: var(--border-width) dashed var(--color-border);
    border-radius: var(--radius);
    color: var(--color-text-muted);
    font-size: var(--text-sm);
}

.rule {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    margin-bottom: var(--space-4);
    /* The page is reached by fragment -- "Change settings" re-renders it
       with one card open and lands on that card. Without this the card
       butts against the top of the viewport and reads as cut off. */
    scroll-margin-top: var(--space-5);
}

.rule:last-child { margin-bottom: 0; }

/* The card open for editing has to be findable after the jump, so it
   carries the accent the form inside it does. */
.rule--editing {
    border-color: var(--color-accent);
    box-shadow: 0 0 0 1px var(--color-accent);
}

.rule__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    flex-wrap: wrap;
    padding: var(--space-4) var(--space-5);
    border-bottom: var(--border-width) solid var(--color-border);
}

.rule__name {
    margin: 0;
    font-size: var(--text-md);
    line-height: 1.2;
}

/* Beside the name it acts on, and saying what it changes. It was a bare
   "Change" under a column of figures, naming neither. */
.rule__edit {
    flex: none;
    font-size: var(--text-sm);
    padding: var(--space-1) var(--space-3);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    text-decoration: none;
}

.rule__edit:hover,
.rule__edit:focus-visible {
    border-color: var(--color-accent);
    text-decoration: underline;
}

.rule__editing {
    flex: none;
    font-size: var(--text-sm);
    font-weight: 600;
    color: var(--color-accent);
}

.rule__body { padding: var(--space-5); }

/* The admin's words, set as prose above the settings the page works out
   for itself. Slightly larger than the derived lines below
   it, because it is what a reader came for and those are the detail
   behind it. */
.rule__described {
    font-size: var(--text-sm);
    margin-bottom: var(--space-4);
    max-width: 44rem;
}

.rule__described p {
    margin: 0 0 var(--space-2);
    color: var(--color-text-muted);
}

/* The first paragraph is the one-line overview: the rule in a glance,
   set in the text colour and a shade heavier, the detail muted under it. */
.rule__described .rule__lead {
    color: var(--color-text);
    font-weight: 600;
}

.rule__described ul {
    margin: 0 0 var(--space-2);
    padding-left: 1.1rem;
}

.rule__described li {
    margin-bottom: var(--space-1);
}

.rule__described > :last-child {
    margin-bottom: 0;
}

.rule__lines {
    margin: 0;
    padding-left: 1.1rem;
    font-size: var(--text-sm);
}

.rule__lines li { margin-bottom: var(--space-1); }
.rule__lines li:last-child { margin-bottom: 0; }

.rule__more {
    margin-top: var(--space-2);
    font-size: var(--text-sm);
}

.rule__more:first-child { margin-top: 0; }

.rule__more summary {
    cursor: pointer;
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

.rule__more ul {
    margin: var(--space-2) 0 0;
    padding-left: 1.1rem;
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

/* The matrix is ten rows of three short values: dense, tabular, and
   numerically aligned so the shapes column reads as a column. It had
   0.2rem of vertical air and no left padding, which ran the columns
   together -- banded rows and real padding on both axes instead. */
.matrix__wrap { overflow-x: auto; }

.matrix {
    border-collapse: collapse;
    font-size: var(--text-sm);
    width: 100%;
    max-width: 40rem;
}

.matrix th,
.matrix td {
    text-align: left;
    padding: var(--space-2) var(--space-5);
}

.matrix th {
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
    font-weight: 600;
    border-bottom: 2px solid var(--color-border);
}

/* Banding rather than a rule per row: ten hairlines in a box that is
   itself bounded by a hairline is a lot of lines for three columns. */
.matrix tbody tr:nth-child(even) { background: var(--color-bg); }

.matrix td:last-child {
    font-variant-numeric: tabular-nums;
}

/* -- changing a rule ------------------------------------------------------ */

.rule-form {
    margin-top: var(--space-4);
    padding-top: var(--space-4);
    border-top: var(--border-width) solid var(--color-border);
}

.rule-form:first-child {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
}

.rule-field { margin-bottom: var(--space-4); }

.rule-field label {
    display: block;
    font-weight: 600;
    font-size: var(--text-sm);
    margin-bottom: var(--space-1);
}

.rule-field input[type="text"],
.rule-field input[type="number"],
.rule-field textarea {
    width: 100%;
    max-width: 34rem;
    font-family: inherit;
    font-size: var(--text-sm);
}

.rule-field textarea {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.rule-field__help {
    margin: var(--space-1) 0 0;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    max-width: 34rem;
}

.rule-field--wrong label { color: var(--color-fail); }

.rule-field--wrong ul.errorlist {
    margin: var(--space-1) 0 0;
    padding-left: 1.1rem;
    color: var(--color-fail);
    font-size: var(--text-sm);
}

.rule-form__actions {
    display: flex;
    align-items: center;
    gap: var(--space-4);
}

/* -- Accounts (TASK-347) -----------------------------------------------------
   The add row wears the filter-bar box so the two strips at the top of the
   screen match, and its fields are .rule-field laid side by side rather
   than stacked: a row of five short inputs, not a form of long ones. */

.account-add {
    align-items: flex-start;
}

/* The fields share the row's width rather than sitting at a fixed
   12rem each and leaving the box two-thirds empty; the select and the
   button keep their own size. */
.account-add .rule-field {
    margin-bottom: 0;
    flex: 1 1 10rem;
}

.account-add .rule-field:has(select),
.account-add .account-add__submit {
    flex: 0 0 auto;
}

.account-add .rule-field input[type="text"] {
    width: 100%;
    max-width: none;
}

/* The button lines up with the inputs, not the labels above them. */
.account-add__submit {
    padding-top: calc(var(--text-sm) + var(--space-1) + 0.25rem);
}

/* Six columns rather than three: let it out past .matrix's 40rem cap. */
.matrix--accounts {
    max-width: 64rem;
}

.account-remove {
    margin: 0;
}

/* Narrow screens: the card keeps its padding but loses the horizontal
   generosity, and the matrix scrolls inside its own wrapper rather than
   pushing the page sideways. */
@media (max-width: 46rem) {
    .rule__head,
    .rule__body { padding: var(--space-4); }

    .matrix th,
    .matrix td { padding: var(--space-2) var(--space-4); }
}

/* The prompt a judged rule sends. Pre, because it is text with
   its own line breaks and paragraph structure that the model receives
   verbatim -- reflowing it would show something other than what is sent.
   Wrapped rather than scrolled sideways: it is prose, not code. */
.rule__prompt-body {
    margin: var(--space-2) 0 0;
    padding: var(--space-3);
    background: var(--color-surface-sunken);
    border-radius: var(--radius-sm, 4px);
    font-family: inherit;
    font-size: var(--text-sm);
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* The prompt's three labelled sections. The heading says whose text a
   section is and where it is edited; quieter than the rule name so the
   sections read as parts of one prompt, not three rules. */
.rule__section-head {
    margin: var(--space-4) 0 0;
    font-size: var(--text-sm);
    font-weight: 600;
    color: var(--color-text-muted);
}

.rule__section-note {
    margin: var(--space-2) 0 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* A form field switched off, not removed (the New Job spreadsheet
   upload during the beta). Needed because `.field input` above sets its
   own background and colour, which overrides the browser's own disabled
   styling -- without this the field looks perfectly usable and only
   refuses when clicked. */
.field input:disabled,
.field input.field__input--off {
    background: var(--color-surface-sunken);
    color: var(--color-text-muted);
    cursor: not-allowed;
}

/* -- Footer -------------------------------------------------------------- */

/*
 * The footer sits at the bottom of a short page rather than half way up
 * one: body becomes the column and .site-main takes the slack. Both the
 * header and the footer keep a full-width black band with the max-width
 * applied to the row inside, which is what .site-header already does --
 * so the two ends of the page match at any window width.
 */
body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

.site-main {
    flex: 1 0 auto;
    /* `width: 100%` because of the `margin: 0 auto` that centres it: a
       flex item with an auto cross-axis margin is never stretched, so
       once body became a flex column for the footer above, .site-main
       went shrink-to-fit and every page was only as wide as its widest
       content. Invisible on a table, obvious on the login form, whose
       two inputs collapsed to the width of the word "Password:" until an
       error message gave the column something longer to size to. */
    width: 100%;
}

.site-footer {
    background: var(--color-header-bg);
    color: var(--color-header-text-muted);
    padding: var(--space-4) var(--space-5);
    font-size: var(--text-sm);
}

.site-footer__row {
    max-width: var(--max-width);
    margin: 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4) var(--space-5);
    flex-wrap: wrap;
}

.site-footer__product {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--color-header-text);
    font-weight: 600;
}

/* Wordmark is white-on-transparent, so it only reads against the footer's
   dark background. Height-matched to the "By" text rather than the row. */
.site-footer__logo {
    height: 1rem;
    width: auto;
    display: block;
}

.site-footer__links {
    display: flex;
    gap: var(--space-5);
    flex-wrap: wrap;
}

.site-footer__links a {
    color: var(--color-header-text-muted);
}

.site-footer__links a:hover,
.site-footer__links a:focus {
    color: var(--color-header-text);
}

/* The two cards on /docs/, the only page that offers a choice of guide. */
.docs-choices {
    list-style: none;
    padding: 0;
    margin: 0;
    display: grid;
    gap: var(--space-4);
}

.docs-choices li {
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-4);
    box-shadow: var(--shadow-card);
}

.docs-choices a {
    font-size: var(--text-md);
    font-weight: 600;
}

.docs-choices p {
    margin: var(--space-2) 0 0;
    color: var(--color-text-muted);
    max-width: 44rem;
}

/* ---------------------------------------------------------------------
   The log (/logs/)

   A dense monospaced list, because it is read by scanning down the left
   edge for a time and across for a level; a proportional font makes both
   harder. The level is a tint rather than a word in the row: four
   severities across a few hundred lines is a pattern to be seen, not a
   column to be read.
   --------------------------------------------------------------------- */
.log-tail,
.log-history {
    list-style: none;
    margin: 0;
    padding: 0;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--text-xs);
}

.log-tail {
    /* Bounded, so a running job does not push the history off the page.
       The list scrolls; the page does not. */
    max-height: 26rem;
    overflow-y: auto;
}

.log-row {
    display: flex;
    gap: var(--space-3);
    align-items: baseline;
    flex-wrap: wrap;
    padding: var(--space-1) var(--space-2);
    border-bottom: var(--border-width) solid var(--color-border);
}

.log-row > .stamp { flex: 0 0 auto; color: var(--color-text-muted); }
.log-row__step {
    flex: 0 0 5.5rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
}
.log-row__message { flex: 1 1 20rem; }
.log-row__detail { flex: 0 0 auto; color: var(--color-text-muted); }
.log-row__detail pre { margin: var(--space-1) 0 0; white-space: pre-wrap; }

.log-row--debug { color: var(--color-text-muted); }
.log-row--warning { background: var(--decision-review); }
.log-row--error { background: var(--decision-fail); font-weight: 600; }
.log-row--empty { color: var(--color-text-muted); font-style: italic; }

.log-poll {
    display: flex;
    gap: var(--space-3);
    align-items: baseline;
    padding: var(--space-2);
    color: var(--color-text-muted);
}

/* No filter-row or paginator rules here. The log wears `.filter-bar`
   and `.pagination` -- the components the results list already defines,
   down to the `select` chrome that the dark theme needs and the spent
   step that keeps the control from moving between pages. A third set of
   both was written first, and one of its classes was never styled at
   all. */

/* -- Job rows: the compact jobs board ------------------------------------
   TASK-269. The jobs list and the status page's two lists were cards: a
   heading, then six stat columns given equal weight whether they had
   happened or not. Six jobs filled a laptop screen, and finding the one
   that needed a person meant reading every number on every card.

   These are the same facts at operations density -- three lines a job,
   about 84px -- and deliberately the same design: the tokens, the badge
   shape, the owner mark, the verdict colours and the band colours are all
   the ones already here. Nothing below invents a colour.

   The old cards are still styled by everything above; they render under
   `?ui=classic` (templates/core/jobs_classic.html). Nothing here needs
   removing to go back to them. */

/* Filters on the left, find and sort on the right, one line where there
   is room for one. The box itself is the shared `.filter-bar`. */
/* -- The list toolbar -------------------------------------------------------
   Pills with counts on the left, the finer controls on the right, at the
   pills' size so the whole strip holds one line at 1280. Born on the jobs
   list (Tim, 2026-09-15) and the bar on every list since 2026-09-16: the
   review queue, the job screen's results and the account list draw it
   too. `core/_filter_pills.html` is the left half. */
.filter-bar--toolbar {
    justify-content: space-between;
    padding: var(--space-2) var(--space-3);
}

/* The word at the head of every bar (Tim, 2026-09-16, TASK-358): a
   strip of pills and a search box did not say what it was for, and the
   Filter button that used to say so has gone -- every control applies
   itself now. Muted and small-capped so it reads as a heading for the
   row rather than another control in it. */
.filter-bar__title {
    font-size: var(--text-xs);
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-right: var(--space-2);
}

/* The pills and the title share the left half of the toolbar, so the
   title needs a row to sit in beside them. */
.filter-bar__lead {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

/* The same chips, tightened: six of them plus All is the widest thing on
   the page, and at the card list's spacing they took a whole line of
   their own before the first job. */
.status-filter--tight {
    gap: var(--space-1);
    margin-bottom: 0;
}

.status-filter--tight a {
    padding: var(--space-1) var(--space-2);
    font-size: var(--text-xs);
}

.filter-bar__controls {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

/* 6rem (Tim, 2026-09-15): the pills, the search and the sort are meant
   to share one line, and the field was the thing pushing the form under
   the pills. A job number or a word of a name fits. */
.filter-bar__text {
    min-width: 6rem;
    width: 6rem;
}

/* Sized to its options, capped: the browser sizes a select to its
   longest option plus its own chrome, which is right for the job
   screen's Rule list and was 10rem of pill for the jobs list's one-word
   sort default -- so the sort states its own width below. */
.filter-bar__select {
    max-width: 16rem;
}

/* Wide enough for "Needs attention" at this size, and no wider. */
.filter-bar__select--sort {
    width: 8rem;
}

.filter-bar--toolbar .filter-bar__text,
.filter-bar--toolbar .filter-bar__select,
.filter-bar--toolbar .filter-bar__label,
.filter-bar--toolbar .button {
    font-size: var(--text-xs);
}

/* The job filter dropdown (core/_job_filter_dropdown.html, Tim,
   2026-09-16): a `<details>`, styled as the closed control the
   `.filter-bar__select` chrome above already establishes, opening onto
   a panel styled after the Setup menu's (`.site-nav__group > ul`
   above) -- one disclosure pattern for the whole app rather than two. */
.job-filter {
    position: relative;
}

.job-filter > summary {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    cursor: pointer;
    list-style: none;
    font: inherit;
    font-size: var(--text-xs);
    font-weight: 600;
    padding: var(--space-1) var(--space-2);
    border: var(--border-width) solid var(--color-border);
    border-radius: 6px;
    background-color: var(--color-surface);
    color: var(--color-text);
    min-width: 12rem;
}

.job-filter > summary::-webkit-details-marker {
    display: none;
}

/* `core/_chevron_icon.html`, an inline Lucide `chevron-down` -- not a
   CSS-drawn arrow. Two gradient tiles positioned to abut (TASK-358,
   then TASK-355.3's fix to it) needed to land on the same device pixel
   twice, and broke a second time on ordinary zoom (Tim, 2026-09-16): a
   visible seam down the middle. A vector path has no seam to misalign.
   Shared with `.dropdown-select__button` below. */
.dropdown-chevron {
    margin-left: auto;
    flex-shrink: 0;
}

.job-filter[open] > summary {
    border-color: var(--color-accent);
}

.job-filter__count {
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
}

/* Positioned and shadowed exactly as `.site-nav__group > ul` is -- the
   one dropdown panel this app draws, reused rather than restyled. */
.job-filter ul {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    display: block;
    min-width: 100%;
    max-height: 20rem;
    margin-top: var(--space-1);
    padding: var(--space-2);
    overflow-y: auto;
    list-style: none;
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.job-filter ul li {
    display: block;
}

.job-filter ul a {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-2);
    border-radius: 6px;
    text-decoration: none;
    font-size: var(--text-sm);
    color: var(--color-text);
    white-space: nowrap;
}

/* Not a wash of --color-surface: the panel is already --color-surface,
   so mixing it in was a no-op and the row never lit (Tim, 2026-09-16
   -- the same trap `.dropdown-select__listbox [role="option"]:hover`
   below fell into). The accent at the listbox's hover strength, so the
   two dropdowns light the same way. */
.job-filter ul a:hover {
    background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}

.job-filter ul a[aria-current="true"] {
    background: var(--color-accent);
    color: var(--color-accent-contrast);
}

.job-filter ul a[aria-current="true"] .job-filter__count {
    color: inherit;
}

/* -- Verdict/Rule filter dropdowns (TASK-355.3) ---------------------------
   The Verdict and Rule selects on the job screen (`.filter-bar__select`
   above) pick a plain word off a native `<option>`, which cannot carry
   the verdict badge or the rule tag the rest of the page draws them
   with. static/js/filter_dropdown.js layers a button and a listbox over
   each `<select>` once it runs; the panel and its chevron echo
   `.job-filter` just above rather than invent a second dropdown shape,
   though this one holds a value instead of navigating, so its options
   are `role="option"` rather than links and there is no `<details>`.

   The `<select>` stays in the markup as the value the form submits and
   as the whole control on a page with no JS -- `.dropdown-select__native
   --js` is the class the script adds once it has taken over, and is the
   only thing here that assumes JS ran. */
.dropdown-select {
    position: relative;
    display: inline-flex;
}

.dropdown-select__native--js {
    display: none;
}

.dropdown-select__button {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    cursor: pointer;
    font: inherit;
    padding: var(--space-1) var(--space-2);
    border: var(--border-width) solid var(--color-border);
    border-radius: 6px;
    background-color: var(--color-surface);
    color: var(--color-text);
}

.dropdown-select__button[aria-expanded="true"] {
    border-color: var(--color-accent);
}

.dropdown-select__button:focus-visible,
.dropdown-select__listbox [role="option"]:focus-visible {
    outline: 2px solid var(--focus-ring);
    outline-offset: 2px;
}

/* Lines up with a badge/tag's own text, which starts one `--space-2`
   in from its pill's edge, not the option row's edge "Any" otherwise
   sits flush against. */
.dropdown-select__any {
    display: inline-block;
    padding-left: var(--space-2);
    color: var(--color-text-muted);
}

/* Positioned and shadowed exactly as `.job-filter ul` -- the one
   dropdown panel this app draws. */
.dropdown-select__listbox {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    /* The button itself can be as narrow as "Any ▾" -- wide enough for
       that is not wide enough for a rule's full label, so the panel
       needs its own floor rather than inheriting the button's width
       (TASK-355.3 follow-up: rule pills were wrapping one letter per
       line at 100%). */
    min-width: max(100%, 16rem);
    max-width: 22rem;
    max-height: 20rem;
    margin: var(--space-1) 0 0;
    padding: var(--space-1);
    overflow-y: auto;
    list-style: none;
    background: var(--color-surface);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.dropdown-select__listbox [role="option"] {
    display: block;
    padding: var(--space-1) var(--space-2);
    border-radius: 6px;
    cursor: pointer;
}

/* Mixing --color-surface into a backdrop that is already --color-surface
   is a no-op -- the wash was invisible regardless of opacity. Tint with
   the accent instead, at half the selected-row strength below, so
   hover and "this is the current choice" stay visually distinct. */
.dropdown-select__listbox [role="option"]:hover {
    background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}

/* A tint rather than `.job-filter`'s solid accent fill: the option
   underneath is a badge or a tag with a tint of its own, and a solid
   accent ground behind either would fight it for the reader's eye
   rather than just marking which row is current. */
.dropdown-select__listbox [aria-selected="true"] {
    background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}

.job-rows {
    list-style: none;
    margin: 0 0 var(--space-5);
    padding: 0;
    display: grid;
    gap: var(--space-2);
    max-width: var(--max-width);
}

/* Two lines, and a left edge that says which rows matter.

   Spacing and type follow the job screen's post card (`.result`, Tim,
   2026-09-04): `--space-4` in from the sides, `--space-1` between the
   lines, and an identity line at `--text-md`/600 that wraps rather than
   truncates. It is a lighter box than a post card -- a hairline, not the
   2px `.result` wears -- because a post card contains its own internal
   rules and has to out-weigh them, and this contains none.

   `position: relative` is load-bearing: the title's stretched link covers
   the row from here. */
.job-row {
    position: relative;
    display: grid;
    gap: var(--space-1);
    padding: var(--space-2) var(--space-4);
    background: var(--color-job-card);
    border: 1px solid var(--color-job-card-border);
    border-left: 3px solid var(--job-row-edge, var(--color-job-card-border));
    border-radius: var(--radius);
    box-shadow: var(--shadow-job-card);
    transition: border-color 120ms ease, box-shadow 120ms ease;
}

/* The row is one link, so it says so under the pointer and under the
   keyboard: the border deepens and the shadow lifts, leaving the ground
   alone. Subtle on purpose -- a list of fifty rows, one of them coming
   forward.

   `border-left-color` is restated because the shorthand above it would
   otherwise paint over the status edge, and a row stops saying it is
   running for exactly as long as the pointer is on it. The fallback is
   what a row with no status modifier gets, so those darken with the rest
   of the box. */
.job-row:hover,
.job-row:focus-within {
    border-color: var(--color-job-card-border-hover);
    border-left-color: var(--job-row-edge, var(--color-job-card-border-hover));
    box-shadow: var(--shadow-job-card-hover);
}

.job-row:focus-within {
    outline: 2px solid var(--focus-ring);
    outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
    .job-row { transition: none; }
}

/* The review queue's block head: the same row, saying which job this is
   and nothing else. The block is already a `card card--wide`, and a
   bordered row inside a bordered card is two boxes saying one thing. It
   keeps `position: relative`, because the title's stretched link is
   measured from it and must cover the row and not the block. */
.job-rows--head {
    margin-bottom: var(--space-4);
}

/* The review queue's job card: the block is the job there, so it takes the
   same ground the jobs list's rows do (Tim, 2026-09-05). Selected by the
   head it contains rather than a modifier class, because that head is
   already what makes the block a job's. */
.card--wide:has(.job-rows--head) {
    background: var(--color-job-card);
    border-color: var(--color-job-card-border);
    box-shadow: var(--shadow-job-card);
}

/* The heading row inside it is the only bit of the block that goes
   anywhere, so the block follows the pointer the jobs list's rows do --
   but it is a container of post cards, not one link, so it lifts only
   when that head is actually under the pointer or the keyboard. */
.card--wide:has(.job-rows--head:hover),
.card--wide:has(.job-rows--head:focus-within) {
    border-color: var(--color-job-card-border-hover);
    box-shadow: var(--shadow-job-card-hover);
}

.job-row--heading {
    padding: 0;
    background: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
}

.job-row--heading:hover,
.job-row--heading:focus-within {
    background: none;
    box-shadow: none;
}

/* Which rows are somebody's problem, said before anything is read.
   Complete and queued keep the plain border: most rows are complete, and
   a list where every row is marked marks nothing. */
.job-row--running { --job-row-edge: var(--color-accent); }
.job-row--awaiting_review { --job-row-edge: var(--color-review); }
.job-row--failed { --job-row-edge: var(--color-fail); }
/* In flight was the one live status with no edge (TASK-357): a row
   sitting with the provider read the same as a finished one until
   the pointer found its pill. Same colour the pill and the job
   screen's progress bar already use for it. */
.job-row--in_flight { --job-row-edge: var(--band-awaiting-batch); }

.job-row__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
}

/* The post card's identity line, and the same job: name first, then the
   facts that tell two jobs of the same name apart. Wraps rather than
   truncating -- on a narrow screen these are short facts, not one long
   one. */
.job-row__title {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0 var(--space-2);
    margin: 0;
    min-width: 0;
    font-size: var(--text-md);
    font-weight: 600;
    line-height: 1.3;
    overflow-wrap: anywhere;
}

/* The whole row, clickable, without a click handler: an anchor whose
   ::after covers the row. A keyboard still lands on one link per job, and
   the review pill inside sits above this on the z-axis. */
.job-row__open {
    color: inherit;
    text-decoration: none;
}

.job-row__open::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
}

.job-row__open:hover .job-row__name {
    text-decoration: underline;
}

/* One ring, not two. The row draws the focus ring (`:focus-within`
   above), so the link inside it drawing its own put a box round the
   title inside a box round the row. */
.job-row__open:focus-visible {
    outline: none;
}

/* How you refer to a job out loud, not what you read the row for. */
.job-row__number {
    font-variant-numeric: tabular-nums;
    font-weight: 400;
    color: var(--color-text-muted);
    margin-right: var(--space-1);
}

/* When, how big and whose -- beside the name, and subordinate to it. */
/* Centred, not baselined (Tim, 2026-09-04): the owner mark is an
   inline-flex box whose first item is an SVG, and a box like that has no
   real baseline -- the browser synthesises one from its bottom edge, so
   the icon and the name sat a few pixels off the words either side of
   them. Every item on this line is one line of text of one size, so
   centring them is the same alignment for the text and the right one for
   the mark. */
.job-row__meta {
    display: inline-flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0 var(--space-1);
    /* One size down from the title (TASK-357), so the date/size/owner
       line reads as a caption rather than a second line of the same
       weight. */
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--color-text-muted);
}

/* The dots between the facts. Lighter than the words they separate and
   darker than the card's own rules: at the border's colour they vanished
   on white, and the line read as four facts with holes between them. */
.job-row__sep {
    color: var(--color-text-muted);
    opacity: 0.55;
}

/* The right of the first line: how far the run has got, then where it
   is. Both are about the *run*, which is what keeps them off the second
   line -- that one is only ever verdicts about posts, so every job's
   pills start at the same edge. */
.job-row__state {
    flex: none;
    display: flex;
    align-items: center;
    gap: var(--space-4);
}

/* Where the *job* is: `.badge`, the pill the job screen's Status row
   draws. What is set here is only how it sits in the row's head. */
.job-state {
    flex: none;
    display: inline-flex;
    align-items: center;
}

/* Running, said without reading: a dot that pulses with a halo. The halo
   is the same colour as the badge's own text, so nothing new is
   introduced -- it is the mark breathing. */
.job-state__live {
    margin-right: 0.35em;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    animation: job-state-pulse 1.6s ease-in-out infinite;
}

@keyframes job-state-pulse {
    0% {
        opacity: 1;
        box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent);
    }
    70% {
        opacity: 0.55;
        box-shadow: 0 0 0 4px transparent;
    }
    100% {
        opacity: 1;
        box-shadow: 0 0 0 0 transparent;
    }
}

@media (prefers-reduced-motion: reduce) {
    .job-state__live { animation: none; }
}

/* The second line: what the posts came to, and nothing else. */
.job-row__outcome {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-1) var(--space-3);
}

/* What the posts came to: one badge each, zeroes omitted. */
.job-results {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-1) var(--space-2);
}

.job-results__item {
    display: inline-flex;
    align-items: center;
}

/* A job with no rows yet. The one thing on this line that is a sentence
   rather than a badge, so it is set as the app talking. */
.job-row__nothing {
    margin: 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* The one badge on the row that goes somewhere other than the job -- this
   job's review queue -- so it is the one thing lifted above the stretched
   link that covers the row. */
.job-results__item--review > a {
    position: relative;
    z-index: 1;
    display: inline-flex;
    text-decoration: none;
}

.job-results__item--review > a:hover .job-results__badge {
    text-decoration: underline;
}

/* What a finished run produced -- the status page only. */
.job-row__outputs {
    margin: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0 var(--space-1);
    font-size: var(--text-xs);
    color: var(--color-text-muted);
}

.job-row__outputs a {
    position: relative;
    z-index: 1;
    font-weight: 600;
}

/* Narrow screens wrap the row's lines; they do not go back to being a
   card. Everything above already wraps, so this is the toolbar, which is
   the one fixed width. */
@media (max-width: 40rem) {
    .filter-bar--toolbar {
        flex-direction: column;
        align-items: stretch;
    }

    .filter-bar__controls {
        flex-wrap: wrap;
    }

    .filter-bar__text {
        flex: 1 1 8rem;
        min-width: 0;
    }

    .job-row__head {
        flex-wrap: wrap;
    }
}
