/* ==========================================================================
   SIGNAL, Plate edition: the one shared stylesheet for SES Tools and SES
   Applications.

   Two byte-identical copies exist (static/ui-core.css and
   applications/static/css/ui-core.css); tests/test_ui_sync.py enforces the
   byte equality. Edit ONE copy, then overwrite the other verbatim.

   THE SYSTEM IN ONE PARAGRAPH
   Everyone who uses this product reads the UK safety sign code every working
   day, so the sign code IS the semantic system. Red prohibits, yellow warns,
   blue instructs a person to act, green confirms. A status is a legal
   instruction, not a tint. The material is a SIGN: a black rail across the top,
   a flat cool grey ground, and white plates on it. No texture, no pencil, no
   radius, and nothing on the page lifts off it. Colour is a solid block, and
   the only other places it appears are the FIELD under one (rule 5) and the
   one data colour, violet, on a chart's marks (rule 1). One family, Archivo, at 800 for anything that leads, so every page has one
   subject. (The drafting-board edition, with its dot grid, hand-drawn frames
   and warm board, was replaced on 2026-09-03; the archive under static/archive/
   keeps it.)

   THE GROUND HAS A RANGE, and this is what makes rule 1 affordable. Rule 1
   sends everything undecorated to the neutrals, so the neutrals have to be able
   to say something: --board, --board-alt and --board-quiet are three steps down
   from the plate, --ink through --ink-3 three steps up from the page, --line
   through --line-3 three weights of rule. They were collapsed onto one grey and
   one secondary ink from 2026-09-03 to 2026-09-11, and a rule that says "use
   the neutrals" pointed at nothing: a worked queue was drawn exactly like an
   untouched one, and any page wanting a quiet distinction had to spend a safety
   colour on it or go without.

   THE FIVE RULES, each of them load bearing:
   1. One meaning per colour, and nothing decorative may borrow one. A green
      rule drawn for looks makes 220 unreviewed sites read as a clean audit.
      The one exception is the mark itself: the four inks appear together, in
      the sign code's order, under the wordmark, and never beside a status.
      Four colours are the sign code and each means a status. ONE more,
      violet (--data), means "this data" and is never a status: it fills a
      mark (a bar, a line, a map region, a legend swatch) and never a badge,
      a button, a row, a plate or a word. See the token block.
   2. Hazard banding (45 degrees, ink and yellow) means OUT OF PLAY: a disabled
      control and an out-of-scope row, nowhere else. Use it as decoration once
      and a genuinely dead control stops reading as dead.
   3. A plate states, a button acts. Here that is .badge and .btn: the same
      rectangle, a different element, never swapped. If a status becomes
      clickable, every status on the page starts inviting a click.
   4. Focus is a black rule inside a yellow field, everywhere, and it is the
      only ring in the build. Every control already has a 2px ink border, so a
      coloured ring vanishes into it; the yellow is what survives the ground.
   5. A FIELD IS NOT A STATUS. The four --*-field tokens are the ground a status
      stands on, and a field may never be the only thing a row says: its colour
      is drawn at full strength in the same row too, as the rule in the first
      cell, the rim, or the plate. Otherwise a 4% red becomes a quiet way of
      saying the loud thing red is for, which is rule 1 going out the back door.
      The fields were #FFFFFF from 2026-09-03 to 2026-09-11 on the grounds that
      half-strength red is pink: true of red as a STATUS, and the wrong
      conclusion for the ground under one. The retirement was invisible, so
      seven pages went on asking for a tint and painting white on white.
   (The OLD rule 5, that a status never rests on colour alone, was retired on
   2026-09-07 on Asad's call: this estate does not cater for a colour-blind
   read. Plates still carry their words; a box may now carry only its colour.)

   ONE SHADOW, AND IT IS NOT FOR THE PAGE. --shadow stays at none: a plate is
   printed on the sign. --lift is for the five things that are not on the page
   at all but over it, listed where the token is declared, and a sixth user is
   the token becoming decoration.

   PORTABILITY: not one url reference anywhere, which a test enforces (the two
   copies must be byte-portable, and fonts are declared by each service's own
   fonts.css). Every rule, arrow and band below is drawn with gradients.
   Nothing external, and light theme only: no dark palette, no colour-scheme
   media query, no theme attribute, no switcher. Tests pin all of it.
   ========================================================================== */

:root {
  /* ---- The ground. It carries NO meaning at all ---------------------------
     This is the rule that keeps the grammar alive: if a neutral could mean
     something, a colour would stop meaning anything. Nothing on this list is
     ever a status, and no status is ever built from it. Cool, flat, untextured:
     the page is a grey wall and everything with an edge is a white plate on it.
     THE RAMP IS THE ESCAPE HATCH, so it has to have somewhere to go. Rule 1
     sends everything undecorated to the neutrals, and from 2026-09-03 to
     2026-09-11 the neutrals were three names for one grey (--board, --board-alt
     and --board-quiet all #ECEEEE) and two names for one secondary ink. A
     worked queue was drawn exactly like an untouched one, and the rule that
     says "use the neutrals" pointed at nothing. Each step below is now a step:
     the grounds go DOWN from the plate, the inks go UP from the page. */
  --board: #ECEEEE;        /* the ground under everything */
  --board-alt: #E3E6E6;    /* a quiet panel, a recessed track, a control you cannot use */
  --board-quiet: #D8DBDB;  /* a row whose decision is made: it sinks, it does not go green */
  --paper: #FFFFFF;        /* a plate: anything with an edge, and the ONLY surface you may type on or press */
  --rail: #101214;         /* the black rail across the top of every page */
  --rail-alt: #2A2D30;     /* a hovered rail item */

  /* ---- Ink. Measured against the surface each one actually sits on -------
     Three steps, and the second one moved DOWN to make room rather than the
     third moving up: a lighter third grey would have taken 35 caption and
     timestamp call sites below 4.5:1 on the ground. Every step here passes
     on all four surfaces (paper, board, board-alt, board-quiet). */
  --ink: #101214;          /* every word, every control edge, the rail. 16.1:1 on the ground, 18.7:1 on paper */
  --ink-2: #4C5154;        /* secondary. 6.5:1 on the ground, 7.6:1 on paper */
  --ink-3: #5E6366;        /* tertiary: a caption, a timestamp. 5.2:1 on the ground, 6.1:1 on paper */
  --line: #E2E4E4;         /* a rule between rows. It separates, it never encloses, and it never carries meaning */
  --line-2: #CBCFCF;       /* a rule on a control that is waiting */
  --line-3: #ABB0B0;       /* a rule that DIVIDES: a section break, the edge of a tile with nothing in it */

  /* ---- The four safety colours, one meaning each ------------------------- */
  /* PROHIBITS: expired, no match, failed, every discrepancy in a pack, Kill. */
  --red: #BE0F2B;          /* 6.4:1 white on it; 6.4:1 as words on paper, 5.5:1 on the ground */
  --red-field: #FBEFF1;    /* THE FIELD a red row stands on. See THE FIELD CONTRACT below */
  --red-wash: var(--red-field);
  /* WARNS: due within 30 days, low and medium confidence, a value a person
     must read for themselves. Yellow ALWAYS carries black text. */
  --yellow: #FFC20E;       /* 11.6:1 with ink on it. It is never a text colour: see --warn */
  --yellow-field: #FFF8E4;
  --yellow-wash: var(--yellow-field);
  /* INSTRUCTS A PERSON: a decision is needed, a run is in flight, a question
     is unanswered, a link. The one primary button. */
  --blue: #0057B8;         /* 6.9:1 white on it; 6.9:1 as words on paper, 5.9:1 on the ground */
  --blue-dark: #003F85;    /* pressed and hovered blue fills */
  --blue-field: #EDF2F9;
  --blue-wash: var(--blue-field);
  /* CONFIRMS: verified, complete, in compliance. Nothing green asks anything
     of you, which is why green fills and rims but never calls to action. */
  --green: #00703C;        /* 6.2:1 white on it */
  --green-text: #00602A;   /* green AS WORDS, one step darker: 7.6:1 on paper, 6.5:1 on the ground */
  --green-field: #ECF4EF;
  --green-wash: var(--green-field);

  /* THE FIELD CONTRACT, which is what keeps rule 1 intact.
     A field is NOT a status. It is the ground a status stands on, and it may
     never appear without that status drawn at full strength in the same row:
     the rule inside the first cell, the plate in the last one, or both. So
     there is no such thing as a pink row with nothing red in it, and a field
     can never be read on its own. tests/test_signal_vocabulary.py pins this
     at every call site.

     The four were #FFFFFF from the Plate edition (2026-09-03) to 2026-09-11,
     retired on the grounds that half-strength red is pink. That is true of red
     as a STATUS and it was the wrong conclusion for the ground under one: the
     retirement was invisible, so seven live pages went on asking for a tint and
     silently painting white on white for eight days, among them the SIA table
     whose own comment reads "the row states it twice: the wash, and a rule
     inside the first cell". Lightness sits at 96 to 98 so --ink clears 15:1 and
     --ink-3 clears 4.9:1 on all four.
     The `-wash` names are kept pointing here because seven pages say them. */

  /* ---- The data colour: violet, and it is NEVER a status ------------------
     Rule 1 sent everything drawn from data to the neutrals, so a chart could
     rank things and never pick one out. The sign code has room for exactly
     one more hue and it is this one: purple is not in the UK safety sign code
     at all, and #955BE3 sits 18.7 (OKLab x100) from the nearest safety colour,
     blue, where 15 is the floor for "never mistaken at a glance". Nothing
     darker is offered because every darker violet drifts under that floor
     towards blue, and there is no second data hue because the next candidate,
     magenta, is only 10.6 from this one (measured 2026-09-30).

     It fills a MARK and nothing else: a bar, a line, a map region, a legend
     swatch. Never a badge, a button, a row, a stat plate or a word, and a test
     reads every rule in the build for it. That boundary is the whole reason it
     is allowed: the old --violet (Glass, until 2026-07-26) was removed because
     it had become a fifth STATUS ("unknown") with no meaning in the sign code.
     This is the hue back as the opposite of a status, and named for its job.

     --data is the one solid, 4.3:1 on paper, the week being read or the
     series being compared. --data-1 to --data-5 are an even ramp from the
     field up to it, for a magnitude (a map, a heat strip); the lightest is
     1.3:1 on paper and never carries a line or a word. */
  --data: #955BE3;
  --data-1: #E8DFFC;
  --data-2: #D2BFF8;
  --data-3: #BE9DF7;
  --data-4: #AB79F5;
  --data-5: #955BE3;

  /* Out of play. The one pattern in the build, and rule 2 owns it. */
  --hazard: repeating-linear-gradient(45deg, var(--ink) 0 7px, var(--yellow) 7px 14px);

  /* ---- Status aliases -----------------------------------------------------
     The six-family palette (ok/warn/danger/info/neutral/unknown) collapses onto
     the four meanings. The NAMES survive because roughly 60 rules in per-page
     CSS across both services still say var(--danger) and var(--ok); the values
     are the sign code. A `-tint` is the field a status sits on, which is paper
     now, and a `-bd` is its rim.

     --warn is the trap worth naming: yellow cannot be a text colour at any
     weight that also stays yellow, so the WARN TEXT TOKEN IS INK. A warning in
     words sits beside a yellow rule or on a yellow plate with a word; it is
     never amber lettering on a white page. --unknown ("undetermined") is the
     same shape: an undetermined value is one a person must read. */
  --ok: #00602A;      --ok-tint: #FFFFFF;      --ok-bd: #00703C;
  --warn: #101214;    --warn-tint: #FFFFFF;    --warn-bd: #FFC20E;
  --danger: #BE0F2B;  --danger-tint: #FFFFFF;  --danger-bd: #BE0F2B;
  --info: #0057B8;    --info-tint: #FFFFFF;    --info-bd: #0057B8;
  --neutral: #5E6366; --neutral-tint: #ECEEEE; --neutral-bd: #E2E4E4;
  --unknown: #101214; --unknown-tint: #FFFFFF; --unknown-bd: #101214;

  /* --amber is the old name for safety yellow, and --violet is GONE. A fifth
     hue with no meaning is exactly what rule 1 forbids. */
  --amber: #FFC20E;

  /* ---- Text and rim aliases --------------------------------------------- */
  --text: #101214;          /* 16.1:1 on the ground */
  --text-muted: #5E6366;    /* 5.2:1 on the ground, 6.1:1 on paper */
  --text-dim: #5E6366;
  --text-faint: #6E7377;    /* placeholders and out-of-month days: 5.4:1 on paper, 4.6:1 on the ground */
  --text-inverse: #FFFFFF;  /* words on a filled safety colour or on the rail */
  --border: #E2E4E4;        /* a rule that separates */
  --border-soft: #E2E4E4;
  --border-strong: #101214; /* the edge of anything you can press or type into, which is how a control is told from a label */
  /* DEPRECATED rim aliases, kept because ~40 per-page rules in both services
     still write `border: 1px solid var(--glass-bd-hi)` and an undefined variable
     invalidates the whole shorthand. */
  --glass-bd: var(--line);
  --glass-bd-hi: var(--ink);

  /* ---- No brand colour --------------------------------------------------
     There is no brand colour in this system: the identity is the black rail
     and the four-colour mark. --brand and its three siblings survived as
     aliases of the MANDATORY BLUE ("a person must act") until 2026-10-01, when
     the last three rules that said them were moved onto the token they meant
     and the names were deleted, so a decorative rule can no longer borrow the
     instruction by reaching for a friendlier name. */

  /* ---- The mark -----------------------------------------------------------
     The four inks as one bar under the wordmark, in the sign code's order.
     Drawn as a gradient so it needs no image, and it is the ONE place the four
     appear together. */
  --mark-bar: linear-gradient(90deg, var(--red) 0 25%, var(--yellow) 25% 50%, var(--blue) 50% 75%, var(--green) 75% 100%);

  /* ---- Shape --------------------------------------------------------------
     NO radius, anywhere: a sign is cut, not moulded. The names survive because
     ~50 rules in per-page CSS still name them, and they all resolve to zero,
     which is the point. */
  --r: 0;
  --r-card: var(--r); --r-ctl: var(--r); --r-icon: var(--r); --r-pill: var(--r);

  /* ---- Space --------------------------------------------------------------
     --pad is the page gutter every full-width row shares. */
  --pad: clamp(14px, 3.2vw, 48px);
  --sp-1: 6px;   /* inside a plate, and between two plates */
  --sp-2: 8px;   /* between controls in a row */
  --sp-3: 12px;  /* the width of a band's colour rule; between things in a row */
  --sp-4: 24px;  /* the block padding of a plate */
  --sp-5: 28px;  /* between the two columns of a form */
  --sp-6: 40px;  /* between page sections */

  /* ---- Type ---------------------------------------------------------------
     THE LADDER: 72 / 56 / 48 / 22 / 18 / 17 / 15 / 13 / 12. Every font-size
     in this file is one of them. There is deliberately NO 14px reading size:
     body copy and every table cell are 15px; 12px is the stamped size (plates,
     column heads, field labels), 13px is a control label and a mono code. */
  --t-figure: 72px;   /* the one number a page is about */
  --t-display: 56px;  /* the number on a stat plate */
  --t-title: 48px;    /* the page title */
  --t-head: 22px;     /* a section heading, and a count in a row of counts */
  --t-sub: 18px;      /* a tool name, an empty state, the sentence a big number sits in */
  --t-num: 17px;      /* a number read down a column or typed into a box */
  --t-body: 15px;     /* everything unmarked */
  --t-ctl: 13px;      /* a button label: uppercase, like every stamped instruction */
  --t-mark: 12px;     /* plates, flags, table heads, field labels, group names */

  /* ONE family. Archivo is a variable face with a width axis, self-hosted by
     each service's fonts.css; the three names survive because per-page CSS
     says all of them, and they resolve to the same face. The stamped voice is
     weight and tracking, never a second family. */
  --font-sans: 'Archivo', 'Helvetica Neue', Arial, sans-serif;
  --font-cond: 'Archivo', 'Helvetica Neue', Arial, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* ---- Lift, focus and motion ---------------------------------------------
     NOTHING ON THE PAGE LIFTS. A plate is printed on the sign, not floating
     above it, and --shadow, --shadow-soft and --shadow-float stay at none so
     the ~30 rules that still name them keep drawing flat.

     What --lift is for is the other thing: an element that is not on the page
     at all, but over it. Five of those exist and they are the whole list,
     pinned by tests/test_signal_vocabulary.py:

       .ses-cal     a popover with no backdrop of its own
       .ses-toast   floats over whatever is underneath it
       .modal       a panel on a dimmed board
       .ses-cmd     the same panel, for the command palette
       table.data thead th   the sticky head, once rows are under it

     The first two had nothing at all saying "above", which is why the calendar
     needs its card lifted to z-index 60 to stop the next opaque band in
     document order painting over it. A rim cannot say depth; only a shadow can.
     (No brace in this comment on purpose: /ui reads the token block by cutting
     at the first one, so a brace here hides every token below it.)
     --lift-edge is separate because the sticky head needs one downward edge and
     not a halo, and a token tuned per use is how a shadow becomes decoration. */
  --shadow: none;
  --shadow-soft: none;
  --shadow-float: none;
  --lift: 0 1px 0 rgba(16, 18, 20, 0.06), 0 6px 20px rgba(16, 18, 20, 0.11);
  --lift-edge: 0 8px 12px -7px rgba(16, 18, 20, 0.55);
  --focus-ring: 0 0 0 4px var(--yellow);
  /* Three durations and three curves, plus one spring and one stamp. A sign is
     stamped, not floated: arrivals hit and settle, leavings are quicker than
     arrivals, and nothing a person does fifty times a day animates at all. */
  --dur-fast: 120ms;
  --dur-base: 200ms;
  --dur-slow: 320ms;
  --ease-arrive: cubic-bezier(0.05, 0.7, 0.1, 1);
  --ease-leave: cubic-bezier(0.3, 0, 0.8, 0.15);
  --ease-move: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-spring: linear(0, 0.009, 0.035 2.1%, 0.141 4.4%, 0.281 6.5%, 0.723 12.8%, 0.938 16.3%, 1.017, 1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.161, 1.154 29.9%, 1.129 32.8%, 1.051 39.6%, 1.017 43.1%, 0.991, 0.977 51%, 0.974 53.8%, 0.975 57.1%, 0.997 69.8%, 1.003 76.9%, 1);
  --ease-stamp: linear(0, 0.4 20%, 1.08 55%, 0.985 75%, 1);
  /* The old pair, kept for the per-page rules that name them. */
  --ease: var(--ease-arrive);
  --speed: var(--dur-fast);
}

/* ---- Base ---------------------------------------------------------------- */
* { box-sizing: border-box; }
html { height: 100%; }
body {
  margin: 0;
  min-height: 100%;
  font-family: var(--font-sans);
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--text);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  /* This product is nothing but numbers: dates, counts, scores, hours, licence
     numbers. Tabular figures are on globally so a column of them lines up. */
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
  /* The ground, flat. It carried a dot grid and a stock texture until Plate;
     both went because on a page that is 60% empty, texture is what the eye
     lands on. */
  background: var(--board);
}
h1 { font-size: var(--t-title); font-weight: 800; margin: 0 0 var(--sp-2); letter-spacing: -0.035em; line-height: 1; }
h2 { font-size: var(--t-head); font-weight: 800; margin: 0 0 var(--sp-2); letter-spacing: -0.025em; line-height: 1.2; }
h3 { font-size: var(--t-sub); font-weight: 800; margin: 0 0 var(--sp-2); letter-spacing: -0.02em; line-height: 1.25; }
p { margin: 0 0 var(--sp-3); }
/* A link instructs you to go somewhere, so it is blue, and the underline is the
   shape that carries it where colour cannot.
   The next note belongs to `code` two lines down, and sits up here rather than
   directly above it on purpose: /ui reads a declaration by anchoring on the END
   of the previous rule, so a comment wedged between the two renders that size on
   the style guide as "not set". 0.92em is an optical correction and not a ninth
   step of the ladder: a mono face at the same nominal size as the sans beside it
   reads larger. */
a { color: var(--blue); text-decoration: underline; text-underline-offset: 2px; }
a:hover { color: var(--blue-dark); }
code { font-family: var(--font-mono); font-size: 0.92em; }
.lead { color: var(--ink-2); font-size: var(--t-sub); margin: 0 0 var(--sp-4); }
.small { font-size: var(--t-mark); }
.muted { color: var(--ink-2); }
.mono { font-family: var(--font-mono); }

/* RULE 4. An outline (not a box-shadow) is what survives forced-colors mode,
   and the yellow field is what survives the board: every control here already
   has a 1px ink border, so a 1px coloured ring would be invisible. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 0;
  box-shadow: var(--focus-ring);
}

/* The 45 degree band, as a utility. RULE 2: out of play, and nothing else.
   It is a background IMAGE, so a component can wear it along one edge without
   giving up its own background-color. */
.hazard {
  background-image: var(--hazard);
  background-repeat: no-repeat;
  background-position: bottom left;
  background-size: 100% 7px;
}

/* ---- Layout -------------------------------------------------------------- */
/* ---- How wide a surface gets --------------------------------------------
   A max-width is a READING measure. It is right for prose and for a form, where
   a line past about 75 characters is harder to read, and it is wrong for data: a
   six column table of expiries gets nothing worse at 1900px.

   The rule is that width buys CONTENT, never air. A surface that goes wide is
   expected to spend the room on more of the thing (two tables side by side, more
   rows, a fuller table), not on stretching one column across a 2000px screen.
   `--wide` therefore has no ceiling at all: the page gutter is the only inset,
   and what the extra room is spent on is each surface's own decision. */
.container { max-width: 1160px; margin: 0 auto; padding: var(--sp-4) var(--pad) 64px; }
.container--narrow { max-width: 720px; }
.container--wide { max-width: none; }

/* ---- Banner (the masthead) -----------------------------------------------
   The black rail. Ink, edge to edge, sixty pixels, and everything on it is
   white. It is the one thing every page shares, so it is the brand. */
.app-banner {
  position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  min-height: 60px; padding: 0 var(--pad);
  background: var(--rail);
  color: var(--text-inverse);
}
.app-banner-left { display: flex; align-items: center; gap: 16px; min-width: 0; }
.app-banner-brand {
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
  gap: 4px; text-decoration: none; flex: 0 0 auto;
}
.app-banner-brand:hover { text-decoration: none; }
/* THE WORDMARK. SES at 800, TOOLS at 400 and quieter, set as one word in
   capitals, with the four-colour rule under it. The rule is the mark: it is
   the one place the four inks appear together, in the sign code's order, and
   it never sits beside a status. */
/* `line-height: 1` is the ink copy's alignment, not a typographic choice.
   The word's own strut is taller than the letters it wraps, so an absolutely
   positioned copy pinned to `inset: 0` starts at the top of the box while the
   title sits inside it, and the relay lands its letters a few pixels above the
   ones they are meant to replace. Collapsing the strut makes the box the
   letters. */
.app-banner-word {
  position: relative; display: inline-block; font-size: 17px; line-height: 1;
  transform-origin: 0% 100%;
}
.app-banner-title {
  font-size: 17px; font-weight: 800; color: var(--text-inverse);
  letter-spacing: -0.02em; text-transform: uppercase; white-space: nowrap; line-height: 1;
}
/* THE SECOND COPY OF THE WORDS, drawn FOUR TIMES over the first and clipped a
   quarter each, invisible at rest. It is what the idle relay reveals, and it is
   four layers rather than one because of what the relay does to them: a quarter
   FADES, and four separate opacities fade independently while one moving clip
   can only jump. The whole `aria-hidden` stack is heard once, as nothing: a
   screen reader reads the wordmark off the copy underneath.

   Each layer carries the WHOLE gradient across the whole word and then hides
   three quarters of itself, so every layer's colours sit exactly where the
   mark's do and no layer has to know which quarter it is beyond its clip. */
.app-banner-ink {
  position: absolute; inset: 0; pointer-events: none;
  font-size: 17px; font-weight: 800; letter-spacing: -0.02em;
  text-transform: uppercase; white-space: nowrap; line-height: 1;
}
.app-banner-ink > i {
  position: absolute; inset: 0; font-style: normal; opacity: 0;
  background-image: var(--mark-bar);
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.app-banner-ink > i:nth-child(1) { clip-path: inset(0 75% 0 0); }
.app-banner-ink > i:nth-child(2) { clip-path: inset(0 50% 0 25%); }
.app-banner-ink > i:nth-child(3) { clip-path: inset(0 25% 0 50%); }
.app-banner-ink > i:nth-child(4) { clip-path: inset(0 0 0 75%); }
/* THE MARK, as four real segments rather than one gradient. The gradient was
   right until the relay needed to lift one quarter at a time. `flex: 1` keeps
   each segment a quarter of the mark's width, which is the same quarter the
   gradient above clips its letters at, so a segment and the letters over it
   always carry the same ink with no arithmetic anywhere. */
.app-banner-mark { display: flex; width: 100%; height: 3px; }
.app-banner-mark > i { flex: 1 1 0; }
.app-banner-mark > i:nth-child(1) { background: var(--red); }
.app-banner-mark > i:nth-child(2) { background: var(--yellow); }
.app-banner-mark > i:nth-child(3) { background: var(--blue); }
.app-banner-mark > i:nth-child(4) { background: var(--green); }

/* ---- THE IDLE RELAY ---------------------------------------------------------
   Every few seconds the rule hands its ink up to the letters, a segment at a
   time, and the letters give it back. One gesture read in two places: the
   segment lifts, and the quarter of the wordmark above it takes that segment's
   colour just as the segment lands.

   IT IS FOUR FADES, NOT FOUR JUMPS. The first build stepped one clip across
   the word with `steps(1, end)`, which put four hard cuts 70ms apart and then
   held for half a second: four states in a fifth of a second reads as a
   stutter, not a hand-over. Each quarter now crosses on its own opacity over
   260ms, and the four crossings are 120ms apart, so the ink arrives at reading
   speed. Opacity and transform are the two things a compositor can animate
   without touching the page again, which is why this costs nothing on a phone.

   The stagger is the whole point. A segment starts lifting, and 90ms later the
   letters above it begin to take its colour: cause, then effect, four times.
   Level the delays and it is four things twitching at once.

   NOT GUARDED HERE FOR REDUCED MOTION. The block at the foot of this file lands
   every animation on its last keyframe, and the last keyframe of the reveal is
   `opacity: 0`: a person who asked for less movement gets no relay at all,
   which is the right answer and costs no extra rule. */
@keyframes ses-mark-lift {
  0%, 100% { transform: translateY(0); }
  40%      { transform: translateY(-3px); }
}
@keyframes ses-mark-relay {
  0%    { opacity: 0; }
  29%   { opacity: 1; }
  71%   { opacity: 1; animation-timing-function: var(--ease-leave); }
  100%  { opacity: 0; }
}
/* THE VOCABULARY AND NOTHING ELSE. `--ease-stamp` is the small overshoot a
   segment needs to read as lifting rather than sliding, and it is the curve
   this build already stamps a plate with; `--ease-arrive` carries the ink in
   and `--ease-leave` takes it back, quicker than it came. None of the three is
   a new curve, which tests/test_motion_never.py is right to insist on: a file
   with six easings has no easing at all. */
[data-fidget-run] .app-banner-ink > i {
  animation: ses-mark-relay 900ms var(--ease-arrive) both;
}
[data-fidget-run] .app-banner-ink > i:nth-child(1) { animation-delay: 90ms; }
[data-fidget-run] .app-banner-ink > i:nth-child(2) { animation-delay: 210ms; }
[data-fidget-run] .app-banner-ink > i:nth-child(3) { animation-delay: 330ms; }
[data-fidget-run] .app-banner-ink > i:nth-child(4) { animation-delay: 450ms; }
[data-fidget-run] .app-banner-mark > i {
  animation: ses-mark-lift 380ms var(--ease-stamp) both;
}
[data-fidget-run] .app-banner-mark > i:nth-child(2) { animation-delay: 120ms; }
[data-fidget-run] .app-banner-mark > i:nth-child(3) { animation-delay: 240ms; }
[data-fidget-run] .app-banner-mark > i:nth-child(4) { animation-delay: 360ms; }
.app-banner-brand:hover .app-banner-title { color: var(--text-inverse); }
/* THE WORDMARK TAKES ITS BOARD'S COLOUR so the rail and the band agree. Only
   where a board exists: the portal is not a board and its mark stays paper.
   Blue is drawn at brand strength on Asad's call (2026-09-11); it measures
   2.73:1 on the rail, which is below the 3:1 large-text floor, and the lighter
   #4D9BF0 that clears it at 6.49:1 is the change to make if it reads badly in a
   bright office. Nothing else in the build depends on that number. */
[data-board] .app-banner-title,
[data-board] .app-banner-brand:hover .app-banner-title { color: var(--band); }
[data-board] .app-banner-accent { color: var(--band); opacity: 0.72; }
/* THE SECOND WORD TAKES INK AS WELL. `Tools` is its own span so it can be set
   lighter, and a span with a colour of its own paints over the clipped
   background instead of letting it through: the relay ran across `SES` and
   stopped dead at the space. Transparent hands it back to the layer's
   gradient. It sits below the board rules because a board must not paint this
   one: on Blue and Yellow the copy underneath is the band's colour, and the
   copy over it is the mark's. */
.app-banner-ink .app-banner-accent { color: transparent; opacity: 1; }
.app-banner-accent { font-weight: 400; color: rgba(255, 255, 255, 0.7); }
.app-banner-nav {
  display: flex; align-items: center; gap: 0; min-width: 0;
  overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
}
.app-banner-nav::-webkit-scrollbar { display: none; }
.app-banner-tool {
  display: inline-flex; align-items: center; min-height: 60px; padding: 0 14px;
  font-family: var(--font-sans); font-size: var(--t-ctl); font-weight: 600;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.62); text-decoration: none; white-space: nowrap;
  transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease);
}
.app-banner-tool:hover { background: var(--rail-alt); color: var(--text-inverse); text-decoration: none; }
.app-banner-tool:active { background: var(--paper); color: var(--ink); }
/* The page you are on: a white plate cut into the black rail. */
.app-banner-tool.active { color: var(--ink); background: var(--paper); }

/* ---- HOW MUCH OF THE NAV FITS ----------------------------------------------
   These rules were an inline <style> string built in shared_ui.py, which put
   the ONE navigation control that actually ships outside the design system and
   off the style guide. They are components; they live here.

   THERE ARE NO WIDTHS HERE, and that is the point. `fitMasthead()` in
   ui-core.js walks four levels, most generous first, writing each onto the
   header as `data-fit` and reading .app-banner-nav's scrollWidth against its
   clientWidth. It stops at the first level that does not overflow, so the
   masthead answers "how much fits" by measuring the box it is actually in:

     data-fit="all"      the version plate and every pill
     data-fit="plate"    the plate goes first, because nobody navigates by it
     data-fit="near"     then every pill outside the area you are working in
     data-fit="button"   then the nav itself, for the control that opens Cmd+K

   The last level is taken WITHOUT measuring, because a hidden nav reports 0/0
   and 0 <= 0 reads as "it fits".

   THE DEFAULT, with no attribute at all, is the `near` level: that is what
   every screen in this office lands on, so the frame before the deferred script
   runs already shows the right thing on a 1280 to 1600 window and merely widens
   on a 1920. The base rules below are that level; `all` and `plate` turn the
   plate and the far pills back on.

   The three numbers that were here (1720, 1640, 1260) were measured by hand in
   Chromium against a THIRTEEN tool run, and the set before them (1560) against
   twelve. Both went stale the moment another tool arrived, and stale is not
   loud: the strip scrolls with its scrollbar suppressed, so a pill that no
   longer fits is eaten in silence, and on that tool's own page it can be the
   ACTIVE one. Nothing here is a number any more, so there is nothing left to
   re-measure when a fourteenth tool is added.

   THE PILLS ARE NEVER REMOVED FROM THE MARKUP, only hidden. ui-core.js builds
   the Cmd+K palette from these same links, and a pill dropped from the DOM
   would silently drop a tool from the palette, which is the one thing that has
   to stay complete. `.near` is written by render_banner and means "same area as
   the page you are on". */
/* The name of the tool you are in. Off above 640px, where the pills or the
   Tools button already answer the question. */
.app-banner-here { display: none; }
/* The base state IS the `near` level, for the pre-script frame. `:has`, because
   a banner with no nav at all (the home page asks for one without) has nothing
   to fit and never gets an attribute: it keeps its plate. */
.app-banner:has(.app-banner-nav) .app-banner-ver { display: none; }
.app-banner-tool:not(.near):not(.active) { display: none; }
/* Stated the other way round so the phone block far below, which takes this
   control away at its own low specificity, still wins: on a phone the way to
   another tool is the home wall the brand beside it already reaches. */
.app-banner-more { display: inline-flex; }
.app-banner:not([data-fit="button"]) .app-banner-more { display: none; }
/* THE RAIL IS SCOPED TO THE AREA YOU ARE IN, at every level. Thirteen pills
   in one strip made every page a search; HR pages show the four HR tools,
   Operations pages the nine, and the area switch at the right reaches the
   other half. The `all` and `plate` levels differ from `near` only in whether
   the version plate is drawn: the far pills stay hidden (and stay in the DOM,
   for the palette). */
.app-banner[data-fit="all"] .app-banner-ver { display: block; }
.app-banner[data-fit="plate"] .app-banner-ver { display: none; }
.app-banner[data-fit="button"] .app-banner-nav { display: none; }
/* The area switch: the OTHER area's name, as a control on the rail. It goes
   to the home wall anchored at that area, which is where every tool is. */
.app-banner-area {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 34px; padding: 0 12px;
  font-family: var(--font-sans); font-size: var(--t-ctl); font-weight: 600;
  letter-spacing: 0.02em; color: rgba(255, 255, 255, 0.7);
  text-decoration: none; white-space: nowrap;
  border: 1.5px solid rgba(255, 255, 255, 0.25); border-radius: var(--r);
}
.app-banner-area:hover { color: var(--ink); background: var(--paper); border-color: var(--paper); text-decoration: none; }
/* The switch is a `plate`-level luxury: at `near` the pills need the room
   more, and Cmd+K and the brand still reach the other area. */
.app-banner-area { display: none; }
.app-banner[data-fit="all"] .app-banner-area,
.app-banner[data-fit="plate"] .app-banner-area { display: inline-flex; }
.app-banner-more {
  align-items: center; gap: var(--sp-1);
  min-height: 34px; padding: 0 var(--sp-3); margin: 0;
  font-family: var(--font-sans); font-size: var(--t-ctl); font-weight: 600;
  letter-spacing: 0.02em; text-transform: none; color: var(--text-inverse);
  background: transparent; border: 1.5px solid rgba(255, 255, 255, 0.4);
  border-radius: var(--r); cursor: pointer;
}
.app-banner-more:hover { background: var(--paper); border-color: var(--paper); color: var(--ink); }
/* The shortcut is named ON the control. It has opened the palette since the
   palette shipped and the button never said so, which is the difference between
   a keyboard route people use and one only its author knows about. */
.app-banner-more kbd {
  font-family: var(--font-mono); font-size: 11px; font-weight: 400;
  letter-spacing: 0; text-transform: none;
  padding: 1px 4px; border: 1px solid rgba(255, 255, 255, 0.4); border-radius: var(--r);
  background: transparent; color: rgba(255, 255, 255, 0.7);
}
.app-banner-more:hover kbd { border-color: var(--ink); color: var(--ink); }

.app-banner-right { display: flex; align-items: center; gap: var(--sp-2); flex: 0 0 auto; }
.app-banner-ver {
  font-family: var(--font-mono); font-size: var(--t-mark); color: rgba(255, 255, 255, 0.5);
  background: transparent; border: 0;
  border-radius: var(--r); padding: 3px 0; white-space: nowrap;
}
.app-banner-logout { margin: 0; }
.logout-btn {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 34px; padding: 0 14px;
  font-family: var(--font-sans); font-size: var(--t-ctl); font-weight: 600;
  letter-spacing: 0.02em; text-transform: none; color: var(--text-inverse);
  text-decoration: none;
  background: transparent; border: 1.5px solid rgba(255, 255, 255, 0.4); border-radius: var(--r);
  cursor: pointer;
  transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease);
}
.logout-btn:hover { background: var(--paper); border-color: var(--paper); color: var(--ink); }
.logout-btn:active { transform: scale(0.96); }
.logout-btn svg { flex: 0 0 auto; }

/* ---- Subbar (Applications' second-level nav) -----------------------------
   THE BOARD BAND. One step under the masthead, in the colour of the board it
   belongs to, with the page you are on cut out of it as a white plate. It is
   the masthead's own device one level down: up there the active tool is a white
   plate in the black rail, and asking a person to learn a second answer to
   "where am I" forty pixels lower was the whole fault with the grey version.

   THE BAND IS AN IDENTITY, NOT A STATUS, which is a deliberate waiver of the
   rule that blue instructs and yellow warns. Site Linkage holds the same waiver
   for INDEL purple and S.E.S gold. It is safe here for one reason and it has to
   stay true: the band never changes. A page cannot turn its band red, so
   nothing on this bar can ever be mistaken for a ruling about the record below
   it, and a red row on a blue-banded page is still the only red on the screen.

   `--band` and its two inks are set by `[data-board]` on the body. Yellow takes
   ink and blue takes paper, because yellow is never a text colour: without the
   pair moving together somebody eventually writes white on yellow. */
[data-board="blue"] {
  --band: var(--blue); --band-on: var(--text-inverse); --band-dim: rgba(255, 255, 255, 0.68);
}
[data-board="yellow"] {
  --band: var(--yellow); --band-on: var(--ink); --band-dim: rgba(16, 18, 20, 0.60);
}
/* A page with no board at all (the portal's own tools) gets the old bar, so
   this block can never leave a subbar unpainted. */
.app-subbar {
  position: sticky; top: 0; z-index: 49;
  display: flex; align-items: stretch; justify-content: space-between; gap: 16px;
  min-height: 40px; padding: 0 var(--pad);
  background: var(--band, var(--paper));
  border-bottom: var(--band-edge, 2px solid var(--ink));
}
[data-board] .app-subbar { --band-edge: 0; }
.app-banner + .app-subbar { top: 60px; }
.app-subbar-title {
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 800;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--ink); white-space: nowrap; flex: 0 0 auto;
}
.app-subbar-nav {
  display: flex; align-items: stretch; gap: 0; min-width: 0; flex: 1 1 auto;
  overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
}
.app-subbar-nav::-webkit-scrollbar { display: none; }
/* A LINK ON THE BAND IS FULL HEIGHT, because the plate has to be cut out of the
   band rather than sit on it. `align-self: stretch` is what does that, and it
   is why the bar's own `align-items` is `stretch` and not `center`. */
.app-subbar-link {
  display: inline-flex; align-items: center; gap: 6px; margin: 0;
  align-self: stretch; min-height: 34px; padding: 0 14px;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; line-height: 1.4;
  color: var(--band-dim, var(--ink-2)); text-decoration: none; white-space: nowrap;
  background: transparent; border: none;
  -webkit-appearance: none; appearance: none; cursor: pointer;
  transition: color var(--speed) var(--ease), background-color var(--speed) var(--ease);
}
.app-subbar-link:hover { color: var(--band-on, var(--ink)); text-decoration: none; }
.app-subbar-link.active { background: var(--paper); color: var(--ink); }
/* The bar off a board keeps the rule it always had, so no page loses its
   active state to a variable that is not set. */
.app-subbar-link { border-bottom: 2px solid transparent; }
:not([data-board]) > * > .app-subbar .app-subbar-link.active { border-bottom-color: var(--ink); }
.app-subbar-right { display: flex; align-items: center; gap: var(--sp-2); flex: 0 0 auto;
  padding-left: var(--sp-2); }
.app-subbar-user {
  font-family: var(--font-mono); font-size: var(--t-mark); color: var(--band-dim, var(--ink-2));
  white-space: nowrap; max-width: 40vw; overflow: hidden; text-overflow: ellipsis;
}
.app-subbar form { margin: 0; }

/* ---- Topbar (the page title strip) ---------------------------------------
   It is sticky under the banner, which is a behaviour worth keeping: a bulk run
   is paused and killed from here while the results scroll. Sticky means it MUST
   be opaque. It had no background at all before, and only got away with it
   because the page behind it was a pale wash; over a ruled board, rows would
   scroll straight through the title. */
.topbar {
  position: sticky; top: 60px; z-index: 4;
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-4) 0 var(--sp-3);
  background: var(--board);
}
/* The topbar heading is the page's title, so it is an h1: it is the only thing
   naming the page, and a screen reader user navigating by heading needs it at
   the top level. h2 is still styled because the /ui specimen sits inside a page
   that already has its own h1. */
.topbar h1, .topbar h2 { margin: 0; font-size: var(--t-title); font-weight: 800; letter-spacing: -0.035em; line-height: 1; }
/* A title with a piece of live data beside it, kept out of the h1 so the phone
   can drop one and keep the other. Baseline, so the two read as one line. */
.topbar-titles { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
/* The subtitle is its own line under the title, in the secondary ink: the
   title names the page, the line under it says what the page is about. It was
   run on after the title at the baseline, which read as one long heading. */
.topbar h1 .sub, .topbar h2 .sub { display: block; color: var(--ink-2); font-weight: 500; font-size: var(--t-sub); letter-spacing: 0; line-height: 1.3; margin-top: 8px; }

/* ---- Buttons -------------------------------------------------------------
   RULE 3: a button ACTS. It is the same rectangle as a .badge and one element
   apart from it, so the two must never be swapped.
   A bare <button> gets the whole treatment, which is why every button-shaped
   thing that is NOT a button (a sort header, a calendar day, a nav item) has to
   strip it back explicitly further down. */
button, .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  min-height: 44px; padding: 0 18px;
  font-family: var(--font-sans); font-size: var(--t-ctl); font-weight: 700; line-height: 1;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--ink);
  background-color: var(--paper);
  border: 2px solid var(--ink);
  border-radius: var(--r);
  cursor: pointer; text-decoration: none; white-space: nowrap;
  transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
              border-color var(--speed) var(--ease);
}
button:hover:not(:disabled), .btn:hover:not(:disabled) {
  background-color: var(--ink); color: var(--text-inverse); text-decoration: none;
}
/* A button gives: the interface says it heard you. It answers with the fill
   the hover already promised, which is also the only answer a finger ever
   gets, since a finger never hovers. By default it does NOT shrink: nothing
   on a sign lifts, sinks or scales (2026-09-08, it was scale(0.96) for a
   year). The one press that moves is opt-in, .btn--press in THE MOVES below,
   for a primary ACTION a person takes once and then waits on (Check, Send,
   Start), on Asad's call of 2026-10-22 from the live mockups. The sign still
   does not shrink by default, a nav pill never takes it, nor does a red
   button. */
button:active:not(:disabled), .btn:active:not(:disabled) {
  background-color: var(--ink); color: var(--text-inverse); text-decoration: none;
}

/* Blue instructs: this is the button a person is meant to press next. */
.btn--primary { background-color: var(--blue); border-color: var(--blue); color: var(--text-inverse); }
.btn--primary:hover:not(:disabled), .btn--primary:active:not(:disabled) { background-color: var(--blue-dark); border-color: var(--blue-dark); color: var(--text-inverse); }
/* Green confirms, so it rims rather than fills: a green button must not be the
   loudest thing on a page, because nothing green asks anything of you. */
.btn--ok { border-color: var(--green); color: var(--green-text); }
.btn--ok:hover:not(:disabled), .btn--ok:active:not(:disabled) { background-color: var(--green); border-color: var(--green); color: var(--text-inverse); }
/* Red prohibits: Kill, Delete, Discard. */
.btn--danger { background-color: var(--red); border-color: var(--red); color: var(--text-inverse); }
.btn--danger:hover:not(:disabled), .btn--danger:active:not(:disabled) { background-color: #96091F; border-color: #96091F; color: var(--text-inverse); }
.btn--danger-ghost { color: var(--red); border-color: var(--red); background-color: transparent; }
.btn--danger-ghost:hover:not(:disabled), .btn--danger-ghost:active:not(:disabled) { background-color: var(--red); border-color: var(--red); color: var(--text-inverse); }
/* Quiet: Cancel, Undo, Log out. A --line rim instead of an ink one is exactly
   the difference between "a control" and "a control you can ignore". */
.btn--ghost { background-color: transparent; border-color: var(--line-2); color: var(--ink-2); }
.btn--ghost:hover:not(:disabled), .btn--ghost:active:not(:disabled) { background-color: var(--ink); border-color: var(--ink); color: var(--text-inverse); }
.btn--sm { min-height: 36px; padding: 0 14px; font-size: var(--t-mark); }
.btn--full { width: 100%; }
.btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.btn-row--split { justify-content: space-between; }

/* RULE 2, finished. A disabled control is NOT YET; an OUT OF PLAY control wears
   the band, and only that one does.

   The band was on every disabled button for a year, which put yellow and black
   tape across the primary action of six tools before the user had done anything
   wrong: a Check button waiting for a share code is not out of play, it is
   waiting. This file had already made exactly that argument for inputs, in
   writing, at the `:disabled` field rule below ("a form with six dead fields on
   it would read as a building site"), and never carried it up to the control the
   eye lands on first. Use the band once as decoration and a genuinely dead
   control stops reading as dead, which is what rule 2 exists to prevent.

   A not-yet control still reads as dead: board fill, secondary ink, a soft rim
   and not-allowed. What it no longer does is shout. */
button:disabled, .btn:disabled, .btn.is-disabled,
button[aria-disabled="true"], .btn[aria-disabled="true"] {
  cursor: not-allowed;
  color: var(--text-faint);
  background-color: var(--board-alt);
  border-color: var(--line-2);
}

/* OUT OF PLAY: opt in, and mean it. Not "you have not filled this in yet" but
   "this cannot happen at all": a download with no file behind it, a control on a
   tool that has been switched off, an action this account cannot take. The band
   is drawn as a background image along the bottom edge rather than as an ::after
   with extra padding, so a button does not change height the moment it becomes
   pressable again. */
/* The tape is APPLIED, left to right, when a control stops being possible,
   and it comes off the same way when a file arrives behind it: the band is
   always drawn, at zero width while the control can act, and its width is
   what transitions. It never marches; a moving band is a barrier gate, and
   this is a sticker. */
.btn--out {
  background-image: var(--hazard);
  background-repeat: no-repeat;
  background-position: bottom left;
  background-size: 0% 7px;
  transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
              border-color var(--speed) var(--ease), transform 160ms var(--ease-arrive),
              background-size var(--dur-base) var(--ease-arrive);
}
.btn--out:disabled, .btn--out.is-disabled, .btn--out[aria-disabled="true"],
button.btn--out:disabled, button.btn--out[aria-disabled="true"] {
  background-size: 100% 7px;
}
button:disabled:hover, .btn:disabled:hover, .btn.is-disabled:hover,
button[aria-disabled="true"]:hover, .btn[aria-disabled="true"]:hover {
  background-color: var(--board-alt); color: var(--text-faint); border-color: var(--line-2);
}
button:disabled:active, .btn:disabled:active, .btn[aria-disabled="true"]:active { transform: none; }

/* ---- Badges (plates) -----------------------------------------------------
   RULE 3 again: a plate STATES a fact and never takes a click.
   A plate is always a word, and the four meanings are told apart by fill as
   well as hue (red, blue and green fill with white on them, yellow fills with
   ink, neutral and undetermined are rimmed). */
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 24px; padding: 0 9px; border-radius: var(--r); white-space: nowrap;
  font-family: var(--font-sans); font-size: var(--t-mark); font-weight: 800; line-height: 1.1;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--ink);
  background-color: var(--paper);
  border: 1.5px solid var(--ink);
}
.badge--ok { background-color: var(--green); border-color: var(--green); color: var(--text-inverse); }
.badge--warn { background-color: var(--yellow); border-color: var(--ink); color: var(--ink); }
.badge--danger { background-color: var(--red); border-color: var(--red); color: var(--text-inverse); }
.badge--info { background-color: var(--blue); border-color: var(--blue); color: var(--text-inverse); }
.badge--neutral { background-color: var(--paper); border-color: var(--ink-2); color: var(--ink-2); }
/* Undetermined: a person has to read it. It is the outlined plate, ink on
   paper, so it cannot be mistaken for a decided "due in 30 days". */
.badge--unknown { background-color: var(--paper); border-color: var(--ink); color: var(--ink); }

/* The sign-in card: narrow, centred, on a page with nothing else on it. A
   modifier and not a local `.card { width: ... }` override, which is what the
   portal's login page used to do and which silently re-widthed every card on
   that page. Both sign-in screens and the admin gate share it. */
.card--auth {
  width: min(400px, calc(100vw - 2 * var(--sp-4)));
  margin-top: clamp(1.5rem, 8vh, 5rem);
  margin-left: auto; margin-right: auto;
}

/* Back navigation. Quieter than a button, which is the point, because going
   back is never the action a page is for. */
.back-link {
  display: inline-block; margin-bottom: var(--sp-2);
  font-size: var(--t-body); color: var(--ink-2); text-decoration: none;
}
.back-link:hover { color: var(--ink); text-decoration: underline; }

/* ---- Cards (a band, the full width of whatever holds it) -----------------
   There is no pane in this system. A region is a BAND: the board shows through
   it, 1px rules close it, and an 8px rule down the left edge is the largest
   carrier of colour on any page. A colourless band keeps a --line rule, so the
   left edge is a consistent place to look for a status whether or not there is
   one to see. */
/* A FRAME is drawn by hand; the LEFT BAND is not. That band carries status (a
   page gives it a safety colour), so it stays a printed rule in whatever
   colour the page sets. Drawing
   it in graphite darkened it AND put it in competition with the meanings it
   exists to hold. Only the three neutral edges are pencil.

   The strokes are painted into the ring a TRANSPARENT border still reserves, so
   the box does not move by a pixel and no padding needs compensating. */
.card {
  background-color: var(--paper);
  border: 0;
  border-left: var(--sp-3) solid var(--paper);
  border-radius: var(--r);
  padding: var(--sp-4);
  margin-bottom: var(--sp-3);
}
/* The decorative tier: a tile in a wall of them. An ink rule along the top
   and no colour rule at all, because a home tile has no status to carry. */
.card--deco { border-left: 0; border-top: 4px solid var(--ink); }
.card--flush { padding: 0; overflow: hidden; }
.card > h3:first-child { margin-top: 0; }
/* A whole-card link (the portal tool tiles): hovering turns the ground to
   paper and the left rule to ink. It does not underline everything written on
   it, and it does not lift, because nothing in this build lifts.
   The display is set through `:where(a)`, which weighs nothing, so it lands at
   (0,1,0): a page that lays its own tiles out with `.tool-card { display: flex }`
   still wins on source order. Written as `a.card` it would have outweighed every
   such rule in both services and flattened the tiles into a stack. */
:where(a).card { display: block; }
a.card { text-decoration: none; transition: background-color var(--speed) var(--ease), border-color var(--speed) var(--ease); }
a.card:hover, a.card:hover * { text-decoration: none; }
a.card:hover { background: var(--paper); border-top-color: var(--blue); }
a.card:active { transform: scale(0.99); }

/* ---- Card heading ------------------------------------------------------------
   ONE heading for the top of a card. Six dialects had grown instead: h3 with a
   leading icon, h2 with a numbered step circle, a plain h2, no heading at all,
   uppercase micro-caps, and Applications' own. They disagreed on size, weight,
   colour and the gap to the body, so two cards side by side looked like two
   products. Put this class on whichever heading level the page's outline needs
   (it sets the type itself, so h2 and h3 land identically).

   It is SMALLER than the body copy under it and that is deliberate: this is the
   signage voice, a stamped group name, not a title competing with the numbers
   inside the band. */
.card-title {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 var(--sp-3);
  font-family: var(--font-sans); font-size: var(--t-head); font-weight: 800; line-height: 1.2;
  letter-spacing: -0.025em; text-transform: none; color: var(--ink);
}
/* The icon is a quiet marker beside the words, never a second heading: muted,
   cap-height, and it never shrinks when the text wraps. */
.card-title > svg { width: 18px; height: 18px; flex: 0 0 auto; color: var(--ink-2); }
/* The Plate edition set the card heading at 22px sentence case. It was a 12px
   stamped label for a year, and beside a 56px figure or a 22px table title it
   read as a caption for the card rather than its name. The stamped voice keeps
   its place on labels (.setup-label, .stat-label, th) where it labels a value,
   never where it names a thing. */

/* ---- Panel (a surface nested INSIDE a card) ----------------------------------
   Three tools had each rolled the same recipe by hand (the document scanner's
   file rows, the SES exporter's note and its debug error block). One class, so a
   run of them reads as one family.
   The three tones, always in this order: board (the page), rail (a nested
   surface), paper (something you may press or type on). A panel is the rail
   tone, so it can never be mistaken for an input. */
.panel {
  background-color: var(--board);
  border: 0;
  border-radius: var(--r);
  padding: var(--sp-2) var(--sp-3);
}

/* ---- Stat (a number worth reading across the room) ----------------------- */
.stat {
  display: flex; flex-direction: column;
  background: var(--paper);
  border: 0;
  border-top: 6px solid var(--ink);
  border-radius: var(--r);
  padding: 20px var(--sp-4) var(--sp-4);
}
/* The label reads FIRST, whatever order the page wrote the three lines in: a
   plate is read "hours this week: 4,120", not "4,120: hours this week". Pages
   wrote both orders and the wall of plates disagreed with itself. */
.stat > .stat-label { order: -1; margin-top: 0; margin-bottom: 14px; }
.stat > .stat-num { margin-top: 0; }
.stat > .stat-note, .stat > .stat-sub { margin-top: 12px; font-family: var(--font-sans); font-size: var(--t-ctl); color: var(--ink-2); }
/* Status on a stat is the plate itself: a warning plate is yellow with ink
   on it, a prohibiting plate is red with white on it, and a confirming or an
   instructing one keeps its paper and takes the colour on its top rule. The
   number's own colour never changes: a red number is unreadable at 56px and
   says nothing a plate does not. */
.stat--info { border-top-color: var(--blue); }
.stat--warn { background: var(--yellow); border-top-color: var(--ink); }
.stat--ok { border-top-color: var(--green); }
/* Red belongs to a count that is an ALLEGATION rather than a shortfall: guards
   nobody can account for on any portal, records that may be two people under
   one licence. Two tools wrote this same rule into their own page after finding
   it missing here, which is how a component ends up with two definitions and
   one of them drifting. Neither draws it at zero: a red tile reading nothing is
   an alarm about nothing. */
.stat--danger { background: var(--red); border-top-color: #8A0B1F; color: var(--text-inverse); }
/* EVERYTHING on a red plate is white. The label and the no-value word were,
   and a page's own note under the figure was not: 12px secondary grey on the
   prohibition red, unreadable, and it shipped. A descendant rule here outranks
   a page's bare .stat-note, so a page cannot put grey on red by accident. */
.stat--danger :is(.stat-label, .stat-note, .novalue) { color: var(--text-inverse); }
.stat--warn :is(.stat-label, .stat-note) { color: var(--ink); }
.stat-num { font-size: var(--t-display); font-weight: 800; line-height: 0.9; letter-spacing: -0.04em; margin-top: 14px; }
.stat-label {
  margin-top: var(--sp-2);
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2);
}

/* ---- Forms ----------------------------------------------------------------
   Paper is the only surface you may type on, and a 1px ink edge is what tells a
   control from a label. Both halves matter: drop the ink edge and a field
   becomes a piece of paper lying on the board with no affordance at all. */
label {
  display: block; margin-bottom: 8px;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2);
}
.field { margin-bottom: var(--sp-3); }
/* `search` belongs in this list with every other text-like type. Leaving it out
   once already cost a tool a verbatim copy of this whole block in its own page
   CSS, which is exactly the drift this stylesheet exists to prevent. */
input[type="text"], input[type="password"], input[type="date"], input[type="email"],
input[type="tel"], input[type="number"], input[type="search"], input[type="file"],
select, textarea, .field-input {
  width: 100%;
  min-height: 44px;
  padding: 8px 14px;
  font-family: var(--font-sans); font-size: 16px; line-height: 1.5;
  color: var(--ink);
  background-color: var(--paper);
  border: 2px solid var(--ink);
  border-radius: var(--r);
  /* Focus LANDS: the yellow field grows out from the black rule rather than
     switching on. 120ms, so tabbing through a form never waits on it. */
  transition: background-color var(--speed) var(--ease), border-color var(--speed) var(--ease),
              box-shadow var(--dur-fast) var(--ease-arrive);
}
textarea, textarea.field-input { min-height: 88px; resize: vertical; }
/* Safari draws a search box as a native capsule that ignores the fill, the rim
   and the radius until the appearance is reset. Kept out of the list above so
   the file input keeps its native button. */
input[type="search"] { -webkit-appearance: none; appearance: none; }
/* The select loses its native arrow with the appearance, so one is drawn from
   two gradients: a triangle, no image file, no url reference. */
select {
  appearance: none; -webkit-appearance: none; padding-right: 34px;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ink) 50%),
    linear-gradient(135deg, var(--ink) 50%, transparent 50%);
  background-size: 7px 7px, 7px 7px;
  background-position: calc(100% - 20px) 19px, calc(100% - 13px) 19px;
  background-repeat: no-repeat;
}
input:hover:not(:disabled), textarea:hover:not(:disabled), select:hover:not(:disabled) { background-color: #FFFFFF; }

/* UNANSWERED, and this is the polarity change: the old system lit up a field
   once it was FILLED, which meant a blank form was a page of quiet slots and
   the work left to do was invisible. Blue instructs, so an unanswered field
   carries a 3px blue underline and the form shows you what it still wants.

   The underline is an inset box-shadow and not a border, so nothing moves by
   3px at the moment a value lands.

   "Unanswered" is `:placeholder-shown`, which needs a placeholder to exist at
   all. Applications' form builder gives every text widget a blank one; the
   portal's pages are hand-written and mostly have none, so ui-core.js fits a
   single-space placeholder to anything text-like that is missing one (including
   controls JS inserts later). A native date input never matches
   :placeholder-shown at all, so it is judged on `[data-filled]`, which
   ui-core.js keeps in step with its value.

   Two specificity traps, both of which have bitten once:
   1. This has to OUTWEIGH the base block above. Every selector here lands on
      the same (0,1,1) and wins on source order.
   2. It must never apply to a locked field. That is handled by MATCHING, not by
      arithmetic: `:not(.is-locked)` cannot be outranked. The allow list, the
      placeholder test and the date test all sit inside `:where()`, which weighs
      nothing, so none of them can push this rule above `.is-locked`, above the
      invalid rule, above the disabled rule or above focus. Every selector here
      lands on exactly (0,1,1). */
input:where([type="text"], [type="password"], [type="email"], [type="tel"], [type="number"], [type="search"]):not(.is-locked):where(:placeholder-shown),
input:where([type="date"]):not(.is-locked):not(:where([data-filled])),
textarea:not(.is-locked):where(:placeholder-shown) {
  box-shadow: inset 0 -3px 0 var(--blue);
}
/* A select is judged on its chosen option, which needs `:has()`. Two traps, and
   they pull in opposite directions:

   1. It gets its OWN rule because a browser that does not understand a selector
      drops the whole comma-separated list it appears in, so leaving this
      alongside the inputs above meant one unsupported selector turned the state
      off for every field on the page rather than just for selects. Left
      unwrapped, `:not(:has(...))` simply never matches without :has() support,
      which fails safe (a select that never looks unanswered).
   2. That unwrapped :has() drags the selector to (0,3,2), well above the focus,
      invalid and disabled rules below, which no ordering can fix. So the
      select's underline is drawn as a third BACKGROUND LAYER (the first two are
      its arrow) rather than as the inset box-shadow the inputs use. The focus
      ring is a box-shadow, so the two can no longer collide at all.
      Wrapping the :has() in `:where()` to cheapen it is NOT the fix: `:where()`
      is a forgiving list, so on a browser without :has() it would match nothing,
      the :not() would then match everything, and every select on the page would
      claim to be unanswered. */
select:not(.is-locked):not(:has(option:checked:not([value=""]))) {
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ink) 50%),
    linear-gradient(135deg, var(--ink) 50%, transparent 50%),
    linear-gradient(var(--blue), var(--blue));
  background-size: 7px 7px, 7px 7px, 100% 3px;
  background-position: calc(100% - 20px) 19px, calc(100% - 13px) 19px, bottom left;
  background-repeat: no-repeat;
}

/* INVALID. Keyed on aria-invalid and not on :invalid, which is a trap: :invalid
   matches every empty required field before anyone has typed a character, so a
   fresh form would open painted red. Red prohibits, and the field's message has
   to be words beside it. */
input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"],
.field-input[aria-invalid="true"] {
  border-color: var(--red);
  background-color: var(--paper);
  box-shadow: inset 8px 0 0 var(--red);
  /* Wrong says no: two cycles of 4px and it stops dead. A shake is a head
     shake, not a bounce, and it is the only time red moves. */
  animation: wrong-shake 240ms var(--ease-arrive) 1;
}
@keyframes wrong-shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-4px); }
  50% { transform: translateX(4px); }
  75% { transform: translateX(-3px); }
}

/* DISABLED. Deliberately NOT hazard banded, though rule 2 would allow it: the
   band marks a control you would otherwise press, and a form with six dead
   fields on it would read as a building site. It goes to the board tone
   instead, which is the one thing here you cannot type on. */
input:disabled, select:disabled, textarea:disabled,
.field-input[aria-disabled="true"] {
  color: var(--text-faint);
  background-color: var(--board-alt);
  border-color: var(--line-2);
  box-shadow: none;
  cursor: not-allowed;
}

/* Focus is LAST on purpose, and it repeats the global ring rather than
   inheriting it: the rules above weigh (0,1,1) and would otherwise take the
   yellow field off any field that is unanswered, invalid or being corrected. */
input:focus-visible, select:focus-visible, textarea:focus-visible, .field-input:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 0;
  border-color: var(--ink);
  background-color: #FFFFFF;
  box-shadow: var(--focus-ring);
}
/* Firefox dims a placeholder to 54% opacity of its own accord, which would undo
   the contrast fix; opacity:1 is the whole reason this line is not one word. */
input::placeholder, textarea::placeholder { color: var(--text-faint); opacity: 1; }
.field .help, .help { font-size: var(--t-body); color: var(--ink-2); margin-top: 6px; }
.row-flex { display: flex; gap: var(--sp-2); align-items: flex-end; flex-wrap: wrap; }
.row-flex > input { flex: 1 1 auto; width: auto; }
.dob-row { display: flex; gap: var(--sp-2); }
.dob-row .field { flex: 1; margin-bottom: 0; }

/* ---- Two-answer toggle ------------------------------------------------------
   Two halves of one stamped rectangle. The chosen half fills with ink, and an
   EMPTY toggle (nobody has been asked yet) carries the same 3px blue underline
   as any other unanswered field, which is the state the pages that feed HMRC
   forms depend on being able to tell apart from "No".

   Applications builds these from a Django RadioSelect, which puts the widget's
   class on the wrapping div AND on each radio, so every rule is scoped to the
   div; a hand-written page has to wrap the pair in `div.field-toggle` the same
   way. Only ever use it for a genuine pair: three or more choices is a segmented
   control, not a toggle.

   The radios are hidden ONLY where :has() actually works (@supports selector),
   so a browser without it shows an ordinary pair of radios rather than a
   selection nobody can see. */
div.field-toggle {
  position: relative; display: inline-flex;
  background: var(--paper);
  border: 2px solid var(--ink); border-radius: var(--r);
  overflow: hidden;
}
div.field-toggle:not(:has(input:checked)) { box-shadow: inset 0 -3px 0 var(--blue); }
div.field-toggle > div { flex: 1 1 0; position: relative; }
div.field-toggle > div + div { border-left: 2px solid var(--ink); }
div.field-toggle label {
  display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
  margin: 0; min-height: 40px; padding: 0 20px; cursor: pointer; white-space: nowrap;
  font-family: var(--font-cond); font-size: var(--t-ctl); font-weight: 700;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-2);
  transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease);
}
div.field-toggle label:hover { background: var(--board-alt); color: var(--ink); }
div.field-toggle input { accent-color: var(--blue); margin: 0; }
div.field-toggle > div:has(input:checked) label { background: var(--ink); color: var(--paper); }
div.field-toggle > div:has(input:focus-visible) label { outline: 3px solid var(--ink); outline-offset: -3px; }
@supports selector(:has(*)) {
  /* Focusable but not drawn: the filled half carries the state. */
  div.field-toggle input { position: absolute; opacity: 0; pointer-events: none; }
}

/* A PAIR WHOSE ANSWERS ARE SENTENCES opts into this. The toggle is built to sit
   on one line and its labels never wrap, which is right for Yes/No and wrong
   for a pair that has to state two positions: the applicant's Working Time
   Directive answer measured 967px of min-content, and because a card is a grid
   item that will not shrink below its content, the whole phone page went 625px
   wide with nothing out there to see. Stacked, the halves keep the stamped
   rectangle and the filled-half state; only the axis and the wrapping change. */
div.field-toggle--wrap { display: grid; grid-template-columns: 1fr; width: 100%; }
div.field-toggle--wrap > div + div { border-left: 0; border-top: 2px solid var(--ink); }
div.field-toggle--wrap label {
  justify-content: flex-start; text-align: left; white-space: normal;
  padding: 10px 14px; letter-spacing: 0.04em;
}

/* ---- Date field (three boxes plus a calendar popover) -----------------------
   A box each for day, month and year, no punctuation between them: three boxes
   say "three things" more clearly than one box with slashes in it. The three
   boxes ARE the value; the calendar only fills them.

   This is the answer to `input[type=date]`, which hands the whole interaction
   to a native picker that looks different on every platform. Applications drives
   it from apps/core/widgets.py plus static/js/date-pills.js; a portal page
   adopting it needs the same markup and script. */
.ses-date { position: relative; display: inline-block; }
.ses-date-boxes { display: inline-flex; align-items: center; gap: var(--sp-2); }
.ses-date input.date-pill {
  font-size: var(--t-num); font-weight: 700; text-align: center;
  padding: 9px 4px; width: auto;
}
.ses-date input.d-day, .ses-date input.d-month { width: 3.6rem; }
.ses-date input.d-year { width: 5rem; }
.ses-date input.date-pill::placeholder { letter-spacing: 0.06em; }
/* A part completing is the moment focus hands over, so the two read as one
   event. Colour only: nothing moves, per the motion rule. */
.ses-date input.done { background-color: var(--paper); box-shadow: inset 0 -3px 0 var(--green); }
/* Out of range: the box that is wrong turns, not the whole row. Weighs (0,2,1),
   above the focus rule, so a bad box stays red while it is being corrected. */
.ses-date input.bad {
  border-color: var(--red); color: var(--red); background-color: var(--paper);
  box-shadow: inset 0 -3px 0 var(--red);
}
.ses-date-msg { display: none; margin: 7px 0 0; font-size: var(--t-body); font-weight: 700; color: var(--red); }
.ses-date-msg.show { display: block; }
.ses-date-open {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; min-height: 44px; margin-left: 2px; padding: 0; flex: 0 0 auto;
  border: 2px solid var(--ink); border-radius: var(--r);
  background-color: var(--paper); color: var(--ink); cursor: pointer;
}
.ses-date-open:hover { background-color: var(--ink); color: var(--paper); }
.ses-date-open svg { width: 17px; height: 17px; }
/* The calendar is absolutely positioned inside whatever card it opens from, so
   the NEXT card down the page (an ordinary opaque band in document order) would
   paint over it and take its clicks. The card is lifted while a calendar inside
   it is open and dropped again when it closes. This used to be blamed on
   a backdrop filter making a stacking context; the filter is gone and the lift is
   still needed, because painting order alone is enough to lose a popover. */
.card.has-open-cal { position: relative; z-index: 60; }

.ses-cal {
  position: absolute; z-index: 40; top: calc(100% + 6px); left: 0; width: 286px;
  padding: var(--sp-3);
  background: var(--paper);
  border: 2px solid var(--ink);
  border-radius: var(--r);
  box-shadow: var(--lift);
  transform-origin: top left;
  animation: cal-open var(--dur-base) var(--ease-arrive);
}
/* No JS change needed: an animation restarts every time the element leaves
   display: none, which is how cal.hidden = false already opens it. */
@keyframes cal-open { from { opacity: 0; transform: translateY(-4px) scale(0.98); } }
.cal-head { display: flex; align-items: center; gap: 4px; margin-bottom: var(--sp-2); }
.cal-title {
  flex: 1; text-align: center; font-size: var(--t-body); font-weight: 700; color: var(--ink);
}
/* Stripped back from the button capsule above: inside the calendar these are
   chrome, not calls to action. */
.cal-nav {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-height: 0; padding: 0;
  border: 1px solid transparent; border-radius: var(--r);
  background-color: transparent; color: var(--ink);
  font-size: var(--t-body); line-height: 1; letter-spacing: 0;
}
.cal-nav:hover:not(:disabled) { background-color: var(--board); color: var(--ink); border-color: var(--line-2); }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-dow {
  text-align: center; padding-bottom: 4px;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-2);
}
.cal-day {
  aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
  min-height: 0; padding: 0;
  font-family: var(--font-sans); font-size: var(--t-num); font-weight: 400; letter-spacing: 0;
  text-transform: none; color: var(--ink);
  border: 1px solid transparent; border-radius: var(--r); background-color: transparent;
}
.cal-day:hover:not(:disabled) { background-color: var(--board); color: var(--ink); }
.cal-day.out { color: var(--text-faint); }
/* Today is a rim, the selected day is a fill: two different shapes, so they can
   both be true at once and still be told apart. */
.cal-day.today { border-color: var(--ink); font-weight: 700; }
.cal-day.sel { background-color: var(--ink); color: var(--paper); font-weight: 700; }
.cal-foot {
  display: flex; justify-content: space-between; gap: var(--sp-2);
  margin-top: var(--sp-3); padding-top: var(--sp-2); border-top: 1px solid var(--line);
}
/* No rule for the footer buttons: they carry .btn .btn--sm .btn--ghost now, and
   an element selector here outweighed the variant, so Today and Clear kept a
   look of their own however they were classed. */

/* ---- Tables ----------------------------------------------------------------
   15px cells, because a table IS the product here and 13px was the single
   biggest reason every page read flat. Zebra rows so 538 lines stay countable,
   a rail-toned head, and an ink rule under the head that says where the data
   starts. */
/* ONE table height for the estate. 68vh was the system's answer and it was not
   convincing enough to be used: six pages invented five more (calc(100vh-250px)
   twice, calc(100vh-300px), 70vh three times, 21rem), so a table's height
   depended on which tool you were in. min() of the two takes the shorter, which
   is the one that actually keeps the head and the toolbar on screen. */
.table-wrap { overflow-x: auto; overflow-y: auto; position: relative;
  max-height: min(68vh, calc(100vh - 260px)); border-radius: var(--r); }
/* `position: relative` is load bearing. `.sr-only` is absolutely positioned,
   and with no positioned ancestor a clipped word inside a row resolves against
   the body and escapes the scroll: 2,611 rows of colour-only boxes on the
   Mitie list, each with its word for a screen reader, made a 130,000px
   document over a 1,343px body, and a person could scroll into the void
   (2026-09-08). The wrap is the containing block now, so what scrolls inside
   it stays inside it. */
table.data { width: 100%; border-collapse: collapse; font-size: var(--t-body); }
table.data thead th {
  position: sticky; top: 0; z-index: 2;
  height: 36px; padding: 0 14px; text-align: left; white-space: nowrap;
  background: var(--paper);
  box-shadow: var(--lift-edge);
  font-family: var(--font-cond); font-size: 11px; font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2);
  border-bottom: 2px solid var(--ink);
}
table.data td { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }

/* A NUMBER READ DOWN A COLUMN. --t-num was written for exactly this and three
   tools used it, so on every other table a count, a site name and a date all
   weighed 15px and the eye landed on the badge, because the badge was the only
   thing with a fill. Right aligned and tabular so the digits stack, one step up
   in size and weight so the column reads as data rather than as more words. */
table.data td.num, table.data th.num {
  font-size: var(--t-num); font-weight: 700; text-align: right;
  font-variant-numeric: tabular-nums;
}
table.data th.num { font-size: 11px; }

/* The tight row, for a table that is a QUEUE rather than a report: 538 supplier
   sites, 160 bulk rows. 32px rows against 45px, which is nine more rows on a
   1000px screen. The floor is 6px of block padding: at 15px type a 32px row is
   still a comfortable target and a 28px one is not, so do not go lower. */
table.data.data--tight td { padding: 6px var(--sp-3); line-height: 1.35; }
/* Both stripes are OPAQUE. Left transparent, the odd rows let the board's
   machine rule show through behind the numbers while the even rows hid it, so a
   159 row table alternated in texture as well as in tone. The rule is the page's
   ground, not something to read data through. */
table.data tbody tr { background: var(--paper); transition: background-color var(--dur-fast) var(--ease); }
table.data tbody tr:nth-child(even) { background: var(--paper); }
/* The only hover in the build, and only under a fine pointer: a 3px ink rule
   on the left and the row goes to the ground, so a person tracking down 400
   rows knows which one they are on. */
@media (hover: hover) and (pointer: fine) {
  table.data tbody tr:hover td { background: var(--board); }
  table.data tbody tr:hover td:first-child { box-shadow: inset 3px 0 0 var(--ink); }
}

/* ---- THE REVIEW QUEUE ------------------------------------------------------
   A table somebody works DOWN, one decision at a time, for an hour: 538 supplier
   sites of which about 220 need a real answer. The Invoice Audit worked this out
   and kept it to itself, so the page both audits now redirect to had none of it.

   Three states, and the order of these rules is load bearing. A decided row is
   quiet, NOT green: green confirms, and 220 green rows would read as a clean
   audit rather than as a finished queue, which is rule 1 exactly. The row the
   person is on is drawn on paper with an ink rule top and bottom and the blue
   left rule that means "a decision is needed here", so it is found again after
   a scroll without hunting. Hover comes first and focus-within last, because
   the pointer is over one row while the keyboard is on another and the keyboard
   is the one that must win. */
table.data tbody tr[data-decided] td { background: var(--board-quiet); color: var(--ink-2);
  transition: background-color var(--dur-base) var(--ease-arrive), color var(--dur-base) var(--ease-arrive); }
table.data tbody tr[data-decided]:hover td { background: var(--board-quiet); }
table.data tbody tr:focus-within td {
  background: var(--paper);
  box-shadow: inset 0 1px 0 var(--ink), inset 0 -1px 0 var(--ink);
}
table.data tbody tr:focus-within td:first-child {
  box-shadow: inset 0 1px 0 var(--ink), inset 0 -1px 0 var(--ink),
              inset var(--sp-2) 0 0 var(--blue);
}

/* The keys, stated once beside the queue rather than in a paragraph about it.
   A key is a control, so it is drawn like one: the mono face, an ink rim. */
.keyhint { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: center;
  color: var(--ink-2); font-size: var(--t-mark); }
.keyhint kbd {
  font-family: var(--font-mono); font-size: 11px; line-height: 1;
  padding: 3px 5px; border: 1.5px solid var(--ink); border-radius: var(--r);
  background: var(--paper); color: var(--ink); box-shadow: 0 2px 0 var(--ink);
}
table.data td.code, table.data .mono { font-family: var(--font-mono); font-size: 0.92em; }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { background: var(--board); color: var(--ink); }
th.sortable.sorted-asc::after { content: " \25B2"; font-size: 0.8em; }
th.sortable.sorted-desc::after { content: " \25BC"; font-size: 0.8em; }
/* The sort control inside a sortable header. A real <button> in the cell, not a
   role and tabindex bolted onto the th: the cell stays a columnheader, which is
   what carries aria-sort and the arrow, and the button brings the focus stop and
   Enter/Space for free. Clicks bubble to the th, so a table's existing sort
   wiring needs no change. Everything below strips the rectangle ui-core gives a
   bare button back to the header text it replaced, because the arrow lives on
   the th and the button must not introduce a box of its own. */
th.sortable > .th-sort {
  display: inline-flex; align-items: center; min-height: 24px; font: inherit; color: inherit; text-align: inherit;
  text-transform: inherit; letter-spacing: inherit;
  background: none; border: 0; border-radius: 0; padding: 0;
  cursor: pointer;
  white-space: normal;    /* buttons are kept on one line above; headers wrap */
}
th.sortable > .th-sort:hover { background: none; color: inherit; }
th.sortable > .th-sort:active { transform: none; }
.cell-nowrap { white-space: nowrap; }

/* ---- Messages, results, empty states --------------------------------------
   A message is a band: a 12px rule down the left carries the meaning and the
   words carry it again. A band ARRIVES: six pixels of rise and a fade over
   200ms on the arrival curve, once, when it is first painted. */
@keyframes band-rise { from { opacity: 0; transform: translateY(6px); } }
.message {
  animation: band-rise var(--dur-base) var(--ease-arrive) both;
  display: block; padding: 14px 20px; margin-bottom: var(--sp-3);
  font-size: var(--t-body); color: var(--ink);
  background: var(--paper);
  border: 0;
  border-left: var(--sp-3) solid var(--ink-2);
  border-radius: var(--r);
}
/* A hidden message stays hidden. `.message` sets display: flex, which is
   more specific than the browser's own [hidden] rule, so a page that hid its
   failure box with the attribute found it drawn anyway. */
.message[hidden], .result[hidden], .run-band[hidden] { display: none; }
.message--ok { border-left-color: var(--green); }
.message--warn { border-left-color: var(--yellow); }
.message--danger { border-left-color: var(--red); }
.message--info { border-left-color: var(--blue); }
.message strong { font-weight: 700; }

.result {
  display: none; margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-4);
  font-size: var(--t-body);
  background: var(--paper);
  border: 0;
  border-left: var(--sp-3) solid var(--ink-2);
  border-radius: var(--r);
}
.result.visible { display: block; animation: band-rise var(--dur-base) var(--ease-arrive) both; }
.result.ok, .result.valid { border-left-color: var(--green); }
.result.error, .result.invalid { border-left-color: var(--red); }
.result.unknown { border-left-color: var(--yellow); }
.result-field { display: flex; gap: var(--sp-3); padding: 7px 0; font-size: var(--t-body); border-top: 1px solid var(--line); }
.result-field:first-child { border-top: 0; }
.result-label {
  flex: 0 0 130px;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-2);
  padding-top: 2px;
}

/* The word and the way out, nothing else. No fabricated rows, ever. */
.empty-state {
  text-align: center; color: var(--ink-2); padding: var(--sp-4);
  background-color: var(--paper);
  border: 2px dashed #B9BEBE; border-radius: var(--r);
}
.empty-title { font-size: var(--t-head); font-weight: 800; letter-spacing: -0.025em; color: var(--ink); margin-bottom: var(--sp-1); }

/* ---- Dropzone --------------------------------------------------------------
   Paper, because you may drop on it, and a DASHED ink edge, because dashed is
   the one shape in the build that means "this is not filled in yet". */
.dropzone {
  background: var(--paper);
  border: 2px dashed var(--ink-2);
  border-radius: var(--r);
  padding: var(--sp-5) var(--sp-4);
  text-align: center; cursor: pointer;
  transition: background-color var(--speed) var(--ease), border-color var(--speed) var(--ease);
}
.dropzone:hover { border-color: var(--ink); }
/* The same 1px press every button and .btn carries; a role=button div was the
   one control on the board that hovered and focused but never dipped. */
.dropzone:active { transform: scale(0.99); }
/* Blue instructs: while a file is over it, the zone is asking you to let go. */
.dropzone.drag-over { border-style: solid; border-color: var(--blue); background: var(--paper); box-shadow: inset 0 0 0 2px var(--blue); }
.dropzone:focus-visible { outline: 2px solid var(--ink); outline-offset: 0; box-shadow: var(--focus-ring); }
.dropzone.compact { padding: var(--sp-2) var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); text-align: left; }
.dropzone-title { font-size: var(--t-sub); font-weight: 700; }
.dropzone-sub { font-size: var(--t-mark); color: var(--ink-2); }
/* Green confirms: the file is in. The border goes solid as well, so the state
   does not rest on the colour. */
.dropzone.has-file { border-style: solid; border-color: var(--green); background: var(--paper); }
.dropzone.has-file .dropzone-sub { color: var(--green-text); }
.dropzone.uploading { pointer-events: none; border-color: var(--line-2); background: var(--board); }
.dropzone .dz-name { font-weight: 700; }
.dropzone .dz-hint { font-size: var(--t-mark); color: var(--ink-2); }

/* ---- Check many: the drop hero ---------------------------------------------
   The second path on a checker page, standing in the column a result lands in:
   check one on the left, check many here. One element whose `data-state` says
   which part is showing (idle, reading, staged, line), so the script changes a
   word and never builds markup. Dashed while it waits, the dropzone's grammar;
   blue while a file is over it, because blue asks you to let go; solid ink once
   a sheet is in it; a slim line while the run band below does the talking.

   It MOVES, on Asad's call (2026-10-16, from the live mockup), where the rest
   of the board does not: a file over it swells it and hops the arrow, a drop
   ripples out from where it landed and the zone takes the file in, the loaded
   sheet rises in, and its buttons press in. Every curve one of the stated
   tokens, the press and the stamp shared with THE MOVES section since
   2026-10-22, and the reduced-motion block below collapses it all. */
.drop-hero {
  position: relative; display: grid; justify-items: center; align-content: center; gap: var(--sp-2);
  min-height: 300px; padding: var(--sp-5); text-align: center;
  background: var(--paper); color: var(--ink-2);
  border: 2px dashed var(--line-3); cursor: pointer; overflow: hidden;
  transition: border-color var(--speed) var(--ease), background-color var(--speed) var(--ease),
              transform var(--dur-slow) var(--ease-spring);
}
.drop-hero:focus-visible { outline: 2px solid var(--ink); outline-offset: 0; box-shadow: var(--focus-ring); }
.drop-hero-title { margin: 0; font-size: var(--t-head); font-weight: 800; letter-spacing: -0.01em; color: var(--ink); }
.drop-hero-what { margin: 0; max-width: 42ch; }
.drop-hero-foot { margin: 0; font-size: var(--t-mark); color: var(--ink-2); }
.drop-hero .btn-row { justify-content: center; margin-top: var(--sp-2); }
.drop-hero-arrow { width: 44px; height: 44px; color: var(--ink-2); transition: color var(--speed) var(--ease); }
.drop-hero-arrow .shaft { transform-origin: 12px 16px; transition: transform var(--dur-slow) var(--ease-spring); }
.drop-hero[data-state="idle"]:hover .drop-hero-arrow { color: var(--ink); }
.drop-hero[data-state="idle"]:hover .drop-hero-arrow .shaft { transform: translateY(-2px); }
/* One part at a time. */
.drop-hero-idle, .drop-hero-over, .drop-hero-reading, .drop-hero-staged, .drop-hero-line { display: none; }
.drop-hero[data-state="idle"]:not(.is-over) > .drop-hero-idle,
.drop-hero[data-state="idle"].is-over > .drop-hero-over,
.drop-hero[data-state="reading"] > .drop-hero-reading { display: grid; justify-items: center; gap: var(--sp-2); }
.drop-hero[data-state="staged"] > .drop-hero-staged { display: grid; gap: var(--sp-3); }
.drop-hero[data-state="line"] > .drop-hero-line { display: flex; }
.drop-hero[data-state="staged"] > .drop-hero-arrow, .drop-hero[data-state="line"] > .drop-hero-arrow { display: none; }
/* A file over it: blue asks you to let go. */
.drop-hero.is-over { border-style: solid; border-color: var(--blue); background: var(--blue-field); color: var(--ink); transform: scale(1.012); }
.drop-hero.is-over .drop-hero-arrow { color: var(--blue); }
.drop-hero.is-over .drop-hero-arrow .shaft { animation: drop-hop 700ms var(--ease-spring) infinite; }
@keyframes drop-hop { 0%, 100% { transform: translateY(0); } 45% { transform: translateY(-6px); } }
/* The instant of the drop: the zone takes the file in, and a square ripples
   out from where it landed (a square: nothing on this board is round). */
.drop-hero.is-gulp { animation: drop-gulp 380ms var(--ease-stamp); }
@keyframes drop-gulp { 0% { transform: scale(1.012); } 40% { transform: scale(0.985); } 100% { transform: scale(1); } }
.drop-hero-ripple {
  position: absolute; width: 24px; height: 24px; pointer-events: none;
  border: 3px solid var(--blue); animation: drop-ripple 560ms var(--ease-arrive) forwards;
}
@keyframes drop-ripple { from { transform: translate(-50%, -50%) scale(0.2); opacity: 1; } to { transform: translate(-50%, -50%) scale(16); opacity: 0; } }
.drop-hero[data-state="reading"] { cursor: progress; }
/* A sheet is in it: solid ink, the file card, the next press. */
.drop-hero[data-state="staged"] { border: 2px solid var(--ink); justify-items: stretch; text-align: left; min-height: 260px; cursor: default; }
.drop-hero[data-state="staged"] .btn-row { justify-content: flex-start; margin-top: 0; }
.drop-hero-staged > * { animation: drop-rise 420ms var(--ease-arrive) both; }
.drop-hero-staged > :nth-child(2) { animation-delay: 70ms; }
.drop-hero-staged > :nth-child(3) { animation-delay: 140ms; }
.drop-hero-staged > :nth-child(4) { animation-delay: 210ms; }
@keyframes drop-rise { from { opacity: 0; transform: translateY(8px); } }
.drop-hero-file { display: flex; align-items: center; gap: var(--sp-3); }
.drop-hero-file > svg { flex: 0 0 auto; color: var(--green-text); }
.drop-hero-name { font-size: var(--t-head); font-weight: 800; color: var(--ink); overflow-wrap: anywhere; }
.drop-hero-holds { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 4px; color: var(--ink-2); }
.drop-hero-holds b { color: var(--ink); font-size: var(--t-sub); font-variant-numeric: tabular-nums; }
.drop-hero-step { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; padding: var(--sp-2) 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
/* While the run band talks, and after it has finished: one slim line. */
.drop-hero[data-state="line"] { min-height: 0; padding: var(--sp-3) var(--sp-4); justify-items: stretch; text-align: left; cursor: default; }
.drop-hero-line { align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.drop-hero-line .btn { margin-left: auto; }
.drop-hero-line b { color: var(--ink); }
/* A browser that cannot do the run: the zone stays, its press is taped out. */
.drop-hero.is-out { border-style: solid; border-color: var(--line-2); cursor: default; }
/* Its buttons press in and spring back, and a press that comes alive stamps:
   both were scoped here until 2026-10-22, when they became the shared
   .btn--press and .is-stamped in THE MOVES below. The hero's buttons carry
   the class, and nothing here says the rule a second time. */

/* ---- Progress ---------------------------------------------------------------
   A run in flight is an instruction to wait, so the fill is blue. 1px ink around
   it, paper behind it, and the count lives ABOVE the bar, never inside it. */
.progress { height: 10px; background: var(--line); border: 0; border-radius: var(--r); overflow: hidden; }
/* The fill SCALES rather than resizing. Animating width relayouts the element's
   ancestors on every frame of the transition, and on the share code and audit
   pages this bar sits directly above a table that can hold hundreds of rows, so
   each tick during a twenty minute run relayouts all of them. A transform is
   composited and touches nothing around it.
   Driven by --p (0 to 1), which is what the tools set; the origin is the left
   edge so the fill grows from the start of the track. */
.progress-bar {
  height: 100%; width: 100%; background: var(--ink);
  transform-origin: left center;
  transform: scaleX(var(--p, 0));
  transition: transform 0.3s linear;
  /* Constant motion says still alive: white stripes at 22% marching over the
     ink. Never yellow, because hazard banding means out of play and a running
     job is the opposite of that. */
  background-image: repeating-linear-gradient(45deg, transparent 0 8px, rgba(255, 255, 255, 0.22) 8px 16px);
  background-size: 23px 100%;
  animation: run-stripes 700ms linear infinite;
}
@keyframes run-stripes { to { background-position: 23px 0; } }
/* The segment bar is a summary of a table that carries the words: it is never
   the only place a status appears. */
.progress-segs { display: flex; height: 10px; background: var(--line); border: 0; border-radius: var(--r); overflow: hidden; }
.progress-seg { flex: 1 0 auto; }
.progress-seg.ok, .progress-seg.valid { background: var(--green); }
.progress-seg.no_match, .progress-seg.failed, .progress-seg.error, .progress-seg.not_found,
.progress-seg.suspended, .progress-seg.revoked, .progress-seg.expired { background: var(--red); }
.progress-seg.unknown { background: var(--yellow); }
.progress-seg.skipped { background: var(--line); }
.progress-seg.checking, .progress-seg.active { background: var(--blue); animation: seg-active 1.2s ease-in-out infinite; }
@keyframes seg-active { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }

/* Work in flight with no known total: a nib travelling between two ticks.

   It replaced three dots pulsing in --blue, which was a waiting indicator
   wearing "act on this", rule one broken by the component that means the least.
   A constant rate is also what SAYS the total is unknown, where a pulse says
   nothing at all; a run with a real total fills .progress instead. The 400ms
   delay stays, so a reply that beats it never flashes a spinner.

   The field and the run are ONE pair of tokens: a shorter field has to shorten
   the sweep with it, or the nib walks out past its own tick. */
.spinner, .spinner-dot {
  --nib-field: 60px;
  --nib-run: calc(var(--nib-field) - 1px);
  --nib-tick: rgba(16, 18, 20, 0.35);
  --nib: var(--ink);
  position: relative; display: inline-block; vertical-align: middle;
  width: var(--nib-field); height: 10px;
  background:
    linear-gradient(var(--nib-tick), var(--nib-tick)) 0 0 / 1px 100% no-repeat,
    linear-gradient(var(--nib-tick), var(--nib-tick)) 100% 0 / 1px 100% no-repeat;
}
/* Sharecode and SIA inject bare dots with no .spinner wrapper, so the first dot
   draws the field and its siblings vanish; inside a real .spinner they are dead
   weight and are hidden. */
.spinner .spinner-dot, .spinner-dot + .spinner-dot { display: none; }
/* Inside a control the nib takes the CONTROL's colour and a short field.
   Graphite measures 1.50:1 on .btn--primary and 1.68:1 on .btn--danger, which is
   no indicator at all on the busy state of every synchronous POST in
   Applications, the pack build included; and a 60px field grew a pressed button
   by 70px under the finger. currentColor is what the per-page rule this
   supersedes always used. */
.btn .spinner, .btn .spinner-dot, button .spinner, button .spinner-dot {
  --nib-field: 26px; --nib-tick: currentColor; --nib: currentColor;
}
.spinner::before, .spinner-dot::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 1px;
  background: var(--nib); opacity: 0;
  animation: ses-nib-sweep 900ms linear 400ms infinite,
             ses-nib-appear 1ms linear 400ms both;
}
@keyframes ses-nib-sweep { from { transform: translateX(0); } to { transform: translateX(var(--nib-run, 59px)); } }
@keyframes ses-nib-appear { to { opacity: 1; } }

/* Is the far end answering. A plate that keeps a square marker: red is the only
   state here that stops you. */
.live-banner {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 8px; border-radius: var(--r);
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 800;
  letter-spacing: 0.1em; text-transform: uppercase; min-height: 24px;
  color: var(--ink); background-color: var(--paper); border: 1.5px solid var(--ink);
}
.live-dot { width: 7px; height: 7px; flex: 0 0 auto; background: currentColor; }
.live-banner.ok { background-color: var(--green); border-color: var(--green); color: var(--text-inverse); }
.live-banner.ok .live-dot { animation: live-pulse 2s ease-in-out infinite; }
.live-banner.down { background-color: var(--red); border-color: var(--red); color: var(--text-inverse); }
.live-banner.checking { background-color: var(--blue); border-color: var(--blue); color: var(--text-inverse); }
@keyframes live-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

/* ---- Summary band (a stamped rail, and the dial and ledger under it) -------
   The whole banner, not just its innards, because BOTH services show it and a
   rail one of them drew for itself is a rail the other cannot have. The portal
   leads its home page with it; Applications carries the same one, with the same
   numbers, above its dashboard.

   `--band-pad` is the one thing a service sets. Full bleed on the portal, where
   the band spans a page whose container has no gutter of its own; zero inside
   Applications' container, which already provides one. Anything else about it
   is the same in both places, which is the point of it being here. */
.summary-band { margin: 0; }
.summary-band-head {
  display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap;
  margin: 0; padding: 10px var(--band-pad, var(--pad));
  background: var(--paper);
  border-top: 4px solid var(--ink); border-bottom: 1px solid var(--line);
}
.summary-band-title {
  margin: 0;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 800;
  line-height: 1.2; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
}
/* The stamp: when this was read. Mono, because it is a reading and not prose. */
.summary-band-when {
  margin-left: auto;
  font-family: var(--font-mono); font-size: var(--t-mark); color: var(--ink-2);
}
/* The band states one measurement over a wall of tools, so it is sized to be
   read past rather than looked at: a 96px ring, not 120, and the block padding
   of a table cell rather than of a tile. It stood 270px tall over the tools it
   was introducing, which is a third of a laptop screen spent on one number. */
.summary-band-body {
  display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
  padding: var(--sp-3) var(--band-pad, var(--pad));
  background: var(--paper);
}
.summary-band-body .dial-ring { width: 96px; height: 96px; }
.summary-band-body .dial-value { font-size: var(--t-head); }
.summary-band-body > .dial { flex: 0 0 auto; }
.summary-band-body > .ledger { flex: 1 1 380px; }

/* ---- Dial and ledger (how far along something is, and what makes that up) ---
   Two components that compose, and are useful apart: the dial is one figure
   inside a ring, the ledger is the ruled lines that account for it.

   THE DIAL IS PROGRESS, NOT A PIE. One arc, green, on the neutral board. A ring
   split into three saturated arcs is a chart borrowing the sign code, and one
   drawn mostly red is an alarm about the whole population rather than a measure
   of it. Red belongs on the tally line that names the work, where it means what
   red means everywhere else: somebody has to go and do something.

   THE FIGURE IS SET IN THE BODY SANS, like .stat-num, and in the font's own
   proportional figures. A display face on a leading number reads as decoration;
   tabular figures give every digit the width of a zero, which leaves it looking
   loose at this size. Tabular belongs in the ledger, where the counts form a
   column that has to line up, and it is set there.

   EVERY VALUE IS IN THE MARKUP AT ITS FINAL SIZE, and the entrance only says
   where each piece comes FROM: the arc carries its real dash pair inline, the
   figure its real number. So the component is correct before a frame runs and
   stays correct if none ever does, and the reduced-motion rule at the foot of
   this file (which lands every animation on its last keyframe) removes the
   motion without removing the answer. Do not invert this into a script that
   parks it at zero and releases it: that is a page which is blank whenever the
   script does not arrive, and which flashes its answer backwards when it does.

   Rule four is kept by the ledger, not by the dial: every line says its state in
   words beside its keyline, so the colour is a second channel and never the
   only one. A dial with no ledger beside it needs its figure to say what it
   counts.

   THE LABEL SITS UNDER THE RING, not inside it. A word set at the stamped size
   and the stamped tracking is about 85px wide; the widest line that will fit
   inside a 96px ring, low enough to be under the figure, is about 69. So
   "COMPLETE" was drawn straight through the stroke at every width, and the
   tracking was being wound in to hide it, which only made the collision
   smaller. Below the ring the word is measured against nothing, so no ring
   size and no label can bring it back. Only a child of .dial is laid out here:
   .dial-label is also borrowed on its own, as a row label, for the stamped
   voice alone. */
.dial { display: grid; justify-items: center; align-items: center; }
.dial > .dial-ring, .dial > .dial-figure { grid-row: 1; grid-column: 1; }
.dial > .dial-label { grid-row: 2; grid-column: 1; }
.dial-ring { transform: rotate(-90deg); }
.dial-ring-track { fill: none; stroke: var(--line); stroke-width: 8; }
.dial-ring-arc {
  fill: none; stroke: var(--green); stroke-width: 8; stroke-linecap: butt;
  animation: dial-draw 1.1s var(--ease) both;
}
/* THE CALL SITE WRITES A PERCENTAGE, and nothing else:
       <circle class="dial-ring-arc" cx="60" cy="60" r="52"
               pathLength="100" stroke-dasharray="39 100"></circle>
   `pathLength="100"` normalises the circle's length, so the dash is read in
   percent and no page has to know that this ring's circumference is 326.73.
   Measured in all three engines (Chromium, WebKit, Gecko): the painted arc is
   identical to the absolute pair, 1,037 pixels against 1,038, where an ignored
   pathLength would paint 317.

   A PRESENTATION ATTRIBUTE, never a style attribute. Applications serves
   style-src 'self' with no unsafe-inline, so `style="stroke-dasharray:..."`
   is dropped there and the arc draws the whole ring; a presentation attribute
   is not an inline style and is not blocked. A `from`-only keyframe still
   lands on it, animated and reduced, which is the other half of why this
   component can be shared at all. */
@keyframes dial-draw { from { stroke-dasharray: 0 100; } }
.dial-figure {
  display: flex; flex-direction: column; align-items: center; margin: 0;
  animation: dial-rise 0.5s var(--ease) 0.15s both;
}
.dial-value { font-size: var(--t-display); font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
.dial-label {
  margin-top: 3px;
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-2);
}
@keyframes dial-rise { from { opacity: 0; transform: translateY(6px); } }

.ledger { min-width: 0; }
.ledger-row {
  display: grid; grid-template-columns: var(--sp-2) 4.5rem 2.5rem 1fr;
  align-items: center; gap: var(--sp-3); padding: 7px 0;
  border-bottom: 1px solid var(--line);
  animation: dial-rise 0.45s var(--ease) both;
}
.ledger-row:last-of-type { border-bottom: 0; }
/* One line after another rather than all at once: the eye follows them down in
   the order they are meant to be read. */
.ledger-row:nth-of-type(1) { animation-delay: 0.3s; }
.ledger-row:nth-of-type(2) { animation-delay: 0.4s; }
.ledger-row:nth-of-type(3) { animation-delay: 0.5s; }
.ledger-key { display: block; height: 16px; }
.ledger-key--ok { background: var(--green); }
.ledger-key--warn { background: var(--yellow); }
.ledger-key--danger { background: var(--red); }
.ledger-count, .ledger-share { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
.ledger-count { font-size: var(--t-num); font-weight: 700; }
.ledger-share { font-size: var(--t-mark); color: var(--ink-2); }
.ledger-note {
  margin: var(--sp-2) 0 0; font-size: var(--t-body); color: var(--ink-2);
  animation: dial-fade 0.45s var(--ease) 0.5s both;
}
@keyframes dial-fade { from { opacity: 0; } }

/* ---- Modal ------------------------------------------------------------------
   Opaque paper on a dimmed board. No blur: the overlay darkens, which is the
   whole job, and a blur only ever hid the fact that the panel behind it was
   translucent too. */
body.modal-open { overflow: hidden; }
.modal-overlay {
  position: fixed; inset: 0; z-index: 100;
  display: flex; align-items: center; justify-content: center;
  background: rgba(16, 18, 20, 0.55);
  animation: modal-fade 0.12s var(--ease);
}
/* The close half of modal-fade: the promise resolves and focus returns to the
   invoker immediately, this class is added after, and the node is removed once
   it has finished, so the overlay is never destroyed on the same frame it was
   answered on. */
.modal-overlay.out { animation: modal-out 0.12s var(--ease) forwards; }
@keyframes modal-out { to { opacity: 0; } }
.modal {
  background: var(--paper);
  border: 0;
  border-top: 6px solid var(--ink);
  border-radius: var(--r);
  box-shadow: var(--lift);
  max-width: 440px; width: calc(100vw - 40px);
  padding: var(--sp-4);
}
.modal-message { margin-bottom: var(--sp-4); font-size: var(--t-body); }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); }

/* The clipboard SIA popup. A number, a verdict, and the two ways out: nothing
   here is a form, so it borrows .modal wholesale and only lays out the answer. */
.ses-sia-num { margin: 0 0 var(--sp-2); font-size: var(--t-sub); letter-spacing: 0.06em; }
.ses-sia-state {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  margin: 0 0 var(--sp-4); font-size: var(--t-body); color: var(--ink-2);
}
.ses-sia-line { display: block; width: 100%; }
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }

/* ---- Toasts (driven by ui-core.js window.sesToast) -----------------------
   A confirmation, never an error you have to act on. It fades rather than
   sliding: motion here is opacity only, never position. */
.ses-toasts { position: fixed; bottom: 18px; right: 18px; z-index: 300;
  display: flex; flex-direction: column; gap: var(--sp-2); pointer-events: none; }
.ses-toast {
  display: inline-flex; align-items: center; gap: 9px; align-self: flex-end;
  max-width: min(360px, calc(100vw - 36px));
  padding: 14px 18px; font-size: var(--t-body); font-weight: 600;
  color: var(--text-inverse); background: var(--ink);
  border: 0;
  border-left: var(--sp-3) solid var(--ink-2);
  border-radius: var(--r);
  box-shadow: var(--lift);
  animation: ses-toast-in var(--dur-slow) cubic-bezier(0.32, 0.72, 0, 1);
}
.ses-toast.out { animation: ses-toast-out 180ms var(--ease-leave) forwards; }
.ses-toast-dot { width: 8px; height: 8px; flex: 0 0 auto; background: rgba(255, 255, 255, 0.6); }
.ses-toast--ok { border-left-color: var(--green); } .ses-toast--ok .ses-toast-dot { background: var(--green); }
.ses-toast--warn { border-left-color: var(--yellow); } .ses-toast--warn .ses-toast-dot { background: var(--yellow); }
.ses-toast--danger { border-left-color: var(--red); } .ses-toast--danger .ses-toast-dot { background: var(--red); }
.ses-toast--info { border-left-color: var(--blue); } .ses-toast--info .ses-toast-dot { background: var(--blue); }
/* Enters and leaves by the same edge, so a swipe to dismiss is obvious. The
   leaving is nearly twice as fast: the system responds, the person decides. */
@keyframes ses-toast-in { from { opacity: 0; transform: translateY(120%); } to { opacity: 1; transform: translateY(0); } }
@keyframes ses-toast-out { to { opacity: 0; transform: translateY(120%); } }

/* ---- Command palette (Cmd/Ctrl+K, driven by ui-core.js) ------------------ */
.ses-cmd-overlay { position: fixed; inset: 0; z-index: 400; display: none;
  align-items: flex-start; justify-content: center; padding: 12vh 16px 16px;
  background: rgba(16, 18, 20, 0.55); }
.ses-cmd-overlay.open { display: flex; }
.ses-cmd { width: min(560px, 100%); overflow: hidden;
  background: var(--paper); border: 0; border-top: 6px solid var(--ink); border-radius: var(--r);
  box-shadow: var(--lift); }
/* The palette's search line is deliberately NOT a field: no rim of its own, no
   unanswered underline as you type. It is written as `input.ses-cmd-input` and
   not `.ses-cmd-input` so it weighs (0,1,1), the same as the shared
   `input[type=text]` rules, and wins on order; a bare class (0,1,0) loses to
   them and the palette grew a bordered box in the middle of the panel. */
input.ses-cmd-input { width: 100%; border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
  background: transparent; font-family: var(--font-sans); font-size: var(--t-sub); font-weight: 400;
  color: var(--ink); padding: 14px 16px; box-shadow: none; }
.ses-cmd-input:focus, .ses-cmd-input:focus-visible { outline: none; box-shadow: none; border-color: var(--ink); background: transparent; }
.ses-cmd-input::placeholder { color: var(--text-faint); opacity: 1; }
.ses-cmd-list { max-height: 320px; overflow-y: auto; padding: 0; }
.ses-cmd-item { display: flex; align-items: center; gap: var(--sp-3); padding: 11px 16px;
  cursor: pointer; font-size: var(--t-body); font-weight: 400; color: var(--ink);
  border-bottom: 1px solid var(--line); }
.ses-cmd-item.sel { background: var(--ink); color: var(--paper); }
.ses-cmd-ico { width: 28px; height: 28px; flex: 0 0 auto;
  background: var(--board); color: var(--ink); display: inline-flex; align-items: center; justify-content: center; }
.ses-cmd-item.sel .ses-cmd-ico { background: var(--paper); }
.ses-cmd-empty { padding: 18px; text-align: center; color: var(--ink-2); font-size: var(--t-body); }

/* ---- A value the system does not have ------------------------------------
   NEVER a zero, never a blank, and never a dash. Three em dashes stood at the
   top of SES Hours at 34px and weight 800, which renders as a redaction bar,
   and the same glyph stood for four different answers: still loading, the read
   failed, never captured, and genuinely zero. A person reading the page could
   not tell which, and one of those four is a number and three of them are not.

   .skel covers loading, so this covers the other three, and it says which one
   in the stamped voice rather than in a sentence: "NOT CAPTURED" is a state,
   not an explanation. In a table cell the word is too wide, so a cell keeps a
   plain hyphen and takes the secondary ink, which a filled cell never has.

   It is deliberately NOT a status colour. A missing value is not a warning
   until somebody decides it is one, and rule 1 says a colour that appears where
   nothing is wrong stops meaning anything. */
.novalue {
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-2);
}
.stat-num > .novalue { font-size: var(--t-sub); letter-spacing: 0.1em; }
td > .novalue, th > .novalue { letter-spacing: 0; }

/* ---- Skeleton (loading placeholder) --------------------------------------
   Board tones only. It stands where a row will land, and it never pretends to
   be a number. */
/* A placeholder is content that has not been drawn yet, so it is drawn: a
   graphite stroke laid down left to right, the way a hand blocks a line in
   before it writes one. It replaced a shimmer, which is a highlight sliding
   over a solid bar and says nothing at all about what is coming.

   The stroke varies in DENSITY rather than breaking into gaps. Gaps would read
   as a dashed rule, and dashed is already spoken for: it is the dropzone, and it
   means "not filled in yet" on an edge. A skeleton means the same thing about a
   whole line, and two shapes saying one thing is how a vocabulary goes soft.

   It cannot be read as progress. It loops, and a loop has no total; a run with a
   real total fills .progress instead. 1.3s is the tempo the shimmer already ran
   at, kept deliberately so this introduces no new one. */
.skel { height: 15px; border-radius: var(--r);
  background-image: repeating-linear-gradient(90deg,
    rgba(94, 99, 102, 0.55) 0 9px,
    rgba(94, 99, 102, 0.30) 9px 13px,
    rgba(94, 99, 102, 0.50) 13px 24px,
    rgba(94, 99, 102, 0.24) 24px 27px,
    rgba(94, 99, 102, 0.58) 27px 38px,
    rgba(94, 99, 102, 0.32) 38px 41px);
  background-repeat: no-repeat;
  background-size: 0 100%;
  animation: ses-sketch 1.3s cubic-bezier(0.62, 0.03, 0.2, 1) infinite; }
/* The tip travels, holds while the eye reads the line, then the graphite lifts
   and the next pass begins. */
@keyframes ses-sketch {
  0%   { background-size: 0 100%;    opacity: 0.25; }
  52%  { background-size: 100% 100%; opacity: 1; }
  76%  { background-size: 100% 100%; opacity: 1; }
  100% { background-size: 100% 100%; opacity: 0.25; }
}

/* ---- Utilities --------------------------------------------------------------- */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* A hidden CONTROL has to be written as `input.sr-only` (0,1,1) to hold its one
   pixel against the shared `input[type=file]` block above, which weighs the same
   and otherwise wins on order. The bare class only managed to clip what it could
   not shrink: every file picker behind a dropzone stayed a full-width 44px box,
   and ten of them on the applicant's document card hung the page 625px off the
   right edge of a phone, which scrolled sideways with nothing to see there. */
input.sr-only, select.sr-only, textarea.sr-only {
  width: 1px; min-height: 0; padding: 0; border: 0;
}
.stack { display: grid; gap: var(--sp-3); }
/* THE DESK: a form on the left and what it produced on the right, side by side
   above 1040px and stacked below. Every checker had its one form stretched to
   the full container, so a nine-character share code sat in a 1,300px field.
   The form column is a third, floored so its controls stay usable. */
.desk { display: grid; gap: var(--sp-4); align-items: start; }
@media (min-width: 1040px) { .desk { grid-template-columns: minmax(360px, 1fr) 2fr; } }
.desk > * { min-width: 0; }

/* ---- Segmented control -----------------------------------------------------
   One choice from a few, side by side, the chosen one filled ink. It is a row
   of buttons that ACT (they change the view), so each is a real <button>, and
   the group is one rectangle: the mockups drew TODAY | THIS WEEK | LAST WEEK as
   one plate, and three pages had each built their own. `aria-pressed` carries
   the state, so a screen reader hears it and the style keys on it. */
.seg { display: inline-flex; align-items: stretch; border: 2px solid var(--ink); background: var(--paper); max-width: 100%; }
.seg > button, .seg > a {
  min-height: 40px; padding: 0 16px; margin: 0;
  border: 0; border-right: 2px solid var(--ink); background: var(--paper); color: var(--ink);
  font-family: var(--font-cond); font-size: var(--t-ctl); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
  cursor: pointer; white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; gap: 8px;
  transition: background-color var(--dur-fast) var(--ease-arrive), color var(--dur-fast) var(--ease-arrive);
}
.seg > button:last-child, .seg > a:last-child { border-right: 0; }
.seg > button:hover, .seg > a:hover { background: var(--board); }
.seg > [aria-pressed="true"], .seg > [aria-current] { background: var(--ink); color: var(--paper); }
/* Pressed stays pressed under the pointer: the hover's grey under the pressed
   segment's white word made it unreadable the moment it was clicked. */
.seg > [aria-pressed="true"]:hover, .seg > [aria-current]:hover { background: var(--ink); }
.seg > button:disabled { color: var(--ink-2); cursor: not-allowed; background: var(--paper); }
/* A count on a segment: the figure in the body sans, so THIS WEEK 41 reads as
   a label and its number rather than one shouted word. */
.seg b { font-family: var(--font-sans); font-weight: 800; letter-spacing: 0; font-size: var(--t-body); }
/* On a phone the row becomes a grid of cells that wraps: four full-word
   segments do not fit 390px in a line, and a segment that clips its own word
   is worse than two rows. The ink shows through a 2px gap as the rule
   between cells, so the drawn rectangle is the same. */
@media (max-width: 640px) {
  /* Two columns, stated, rather than as many as fit: the 2px gap is drawn on
     the ink behind, so an empty cell is a black block, and auto-fit left one
     beside the third segment of three on every phone (the Expiry Dashboard's
     SIA, 2026-09-05). With the count known, an odd last segment takes the
     whole row and there is no cell left to show through. */
  .seg { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; background: var(--ink); }
  .seg > button, .seg > a { border-right: 0; justify-content: center; padding: 0 10px; }
  .seg > :last-child:nth-child(odd) { grid-column: 1 / -1; }
  /* THREE IS ONE ROW. Two and one left the third segment a full-width bar
     under the other two, which read as a second control (Expiry's SIA,
     Statistics' SOL, on Asad's phone 2026-10-17). Three equal cells, and a
     long word wraps inside its own cell rather than leaving the row. */
  .seg:has(> :nth-child(3):last-child) { grid-template-columns: repeat(3, 1fr); }
  .seg:has(> :nth-child(3):last-child) > :last-child { grid-column: auto; }
  .seg:has(> :nth-child(3):last-child) > button,
  .seg:has(> :nth-child(3):last-child) > a {
    white-space: normal; text-align: center; line-height: 1.15; padding: 4px 6px; }
}
.stats-bar { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }
.grid { display: grid; gap: var(--sp-4); }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.mt-0 { margin-top: 0; } .mt-1 { margin-top: var(--sp-1); } .mt-2 { margin-top: var(--sp-2); } .mt-3 { margin-top: var(--sp-3); }
.mb-0 { margin-bottom: 0; } .mb-2 { margin-bottom: var(--sp-2); }
.m-0 { margin: 0; }
.text-right { text-align: right; }
.inline-form { display: inline; }

/* ==========================================================================
   PAGE FURNITURE
   The chrome a tool page draws around its own content: the full-bleed setup
   rail under the masthead, the band a run reports itself in, and the summary
   a finished run leaves behind.

   These lived as copy-paste inside eight per-page style blocks until
   2026-08-11. Nothing was wrong with any single copy; the fault was the
   distribution. Six pages held it and four did not, and the four that did not
   were exactly the four that read as unfinished. Promoting them here is what
   makes tool number thirteen inherit the house instead of reinventing it.

   A page may still tune one of these for its own content (the exporter widens
   .run-main to 380px because its count line is longer). It may not define one
   from scratch: tests/test_ui_sync.py fails if it does.
   ========================================================================== */

/* Break out of .container's gutter and paint edge to edge, then put the gutter
   back inside so the content still lines up with everything above it. This is
   what lets a rail be a rail rather than a boxed strip. */
.bleed {
  margin-left: calc(var(--pad) * -1); margin-right: calc(var(--pad) * -1);
  padding-left: var(--pad); padding-right: var(--pad);
}
/* The same break-out with no gutter returned, for a child that supplies its own. */
.bleed-flush { margin-left: calc(var(--pad) * -1); margin-right: calc(var(--pad) * -1); }

/* THE SETUP RAIL. Every precondition a run needs, as plates on one strip, so
   "am I ready to run this" is answered in one horizontal scan without opening
   anything. On the cool board, because it is chrome and not content. */
.setup-strip {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
  background: var(--paper);
  border-top: 4px solid var(--ink); border-bottom: 0;
  padding-top: 14px; padding-bottom: 14px;
  margin-bottom: var(--sp-3);
}
.setup-label {
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 800;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink);
  flex: 0 0 auto;
}
.setup-entry { display: flex; align-items: center; gap: var(--sp-2); }
/* The strip's one action stands at its end, whatever fills the middle: the
   run, the compare, the check. Every tool put its own button in its own place
   until this rule; now the eye knows where the verb is on every page. */
.setup-strip > .btn--primary { margin-left: auto; }
/* A satisfied precondition, as a plate you can press to change your mind: the
   tick, the name of the thing, and its value. It is a button because it acts
   (it re-opens the control it stands for); this strips the stamped button
   voice back to a readable value, which .btn/button alone does not do. */
button.setup-plate {
  min-height: 44px; padding: 0 14px; gap: var(--sp-2);
  font-family: var(--font-sans); font-size: var(--t-body); font-weight: 400;
  letter-spacing: 0; text-transform: none; max-width: 100%;
}
.setup-k {
  font-family: var(--font-cond); font-size: var(--t-mark); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2);
  flex: 0 0 auto;
}
/* The value beside the stamped key. It carries its own left gap, because
   without one the condensed uppercase key and the sans value collide into a
   single word: "SPREADSHEETDrop or choose .xlsx". */
.setup-v {
  margin-left: var(--sp-2); font-weight: 700;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.setup-n { font-size: var(--t-mark); color: var(--ink-2); flex: 0 0 auto; }
.setup-tick { flex: 0 0 auto; color: var(--green); }
/* The satisfied state: the plate that was a question is now an answer, so it
   turns green rather than staying a plain button. Its own id-scoped rules
   (the blue inset underline some pages give an unanswered file plate) stay
   page-local: only a plate that starts life uncoloured may generalise here. */
.setup-plate.is-set {
  background-color: var(--paper); border-color: var(--green); box-shadow: none;
}
.setup-plate.is-set:focus-visible { box-shadow: var(--focus-ring); }
.setup-plate:hover .setup-k, .setup-plate:hover .setup-tick,
.setup-plate:hover .setup-n { color: var(--text-inverse); }

/* A file input standing on the setup rail as a precondition plate: dashed
   like any dropzone until a file is behind it, green-rimmed and ticked once
   one is, so the rail answers "am I ready" for a file the same way it does
   for a typed value. The blue inset underline belongs here, on the composite,
   not on .setup-plate generally: this is the one plate that is unanswered
   until something is dropped or picked, not merely unset. */
.dropzone.setup-plate {
  min-height: 44px; margin: 0; flex: 0 1 auto; max-width: 100%;
  box-shadow: inset 0 -3px 0 var(--blue);
}
.dropzone.setup-plate.has-file { box-shadow: none; }
.dropzone.setup-plate:focus-visible { box-shadow: inset 0 -3px 0 var(--blue), var(--focus-ring); }
.dropzone.setup-plate.has-file:focus-visible { box-shadow: var(--focus-ring); }
.dropzone.setup-plate .dropzone-title {
  font-size: var(--t-body); font-weight: 400;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dropzone.setup-plate.has-file .dropzone-title { font-weight: 700; }
.dropzone.setup-plate .setup-tick { display: none; }
.dropzone.setup-plate.has-file .setup-tick { display: inline-block; }

/* THE RUN BAND. A run in flight instructs a person to wait, so it is blue.
   The left rule and the flag change together, never one without the other. */
.run-band, .done-band, .pf-band {
  display: flex; align-items: flex-start; gap: clamp(14px, 2.4vw, 28px); flex-wrap: wrap;
  border-top: 0; border-bottom: 0;
  border-left: var(--sp-3) solid var(--blue);
  background: var(--paper);
  padding-top: var(--sp-4); padding-bottom: var(--sp-4);
  margin-bottom: var(--sp-3);
}
.run-flag {
  flex: 0 0 auto; min-width: 0; margin: 0; padding: 8px 12px;
  font-family: var(--font-cond); font-size: 14px; font-weight: 800;
  letter-spacing: 0.1em; text-transform: uppercase; line-height: 1.1;
  background: var(--blue); color: var(--text-inverse);
  animation: plate-stamp 180ms var(--ease-stamp) both;
}
/* A plate is stamped: it arrives by coming down onto the page, never by
   fading in. The overshoot is the impact. */
@keyframes plate-stamp { from { opacity: 0; transform: scale(1.18); } }
.run-band.is-throttled { border-left-color: var(--yellow); }
.run-band.is-throttled .run-flag { background: var(--yellow); color: var(--ink); }
.run-band.is-failed { border-left-color: var(--red); }
.run-band.is-failed .run-flag { background: var(--red); }
/* A finished run confirms, so it is green, and the three ways a finish can be
   less than clean each take their own colour and their own word. */
/* A run ending flips the whole band from the blue run colour to a green, red
   or yellow verdict on one frame, which reads as a glitch rather than an
   arrival. 200ms is a stated second duration, not --speed: on a viewport-wide
   surface --speed reads as a snap. Transform only, never opacity, the same
   idiom as dial-rise above: a fading RED band is a prohibition arriving
   decoratively, and rule 1 does not allow that. The global reduced-motion
   block (`*, *::before, *::after`) already collapses this. */
.done-band { border-left-color: var(--green); animation: band-land 200ms var(--ease); }
.done-band .run-flag { background: var(--green); }
@keyframes band-land { from { transform: translateY(-5px); } }
.done-band.is-flagged { border-left-color: var(--red); }
.done-band.is-flagged .run-flag { background: var(--red); }
/* RED NEVER BOUNCES, and a failed run's flag is the same .run-flag that
   stamped in blue, with its stamp still on it. The stamp comes off here, in
   the sheet, rather than being trusted to each page: a failure lands with the
   band and holds still. tests/test_motion_never.py reads this rule. */
.run-band.is-failed .run-flag, .done-band.is-flagged .run-flag { animation: none; }
.done-band.is-incomplete { border-left-color: var(--yellow); }
.done-band.is-incomplete .run-flag { background: var(--yellow); color: var(--ink); }
.run-band[hidden], .done-band[hidden], .done-stats[hidden] { display: none; }

.run-main { flex: 1 1 320px; min-width: 0; }
.run-count { margin: 0 0 10px; font-size: var(--t-sub); line-height: 1.2; }
.run-count b {
  font-size: 44px; font-weight: 800; letter-spacing: -0.04em;
  vertical-align: -4px; margin-right: 4px;
}
.run-count .run-sub { color: var(--ink-2); font-size: var(--t-body); }
.run-meta { margin: 10px 0 0; font-size: var(--t-body); color: var(--ink-2); }
/* The cap is what lets the row wrap at all: an item that will not shrink is as
   wide as its controls in one line, and three of them are wider than a phone. */
.run-act { flex: 0 0 auto; max-width: 100%; display: flex; gap: var(--sp-2); flex-wrap: wrap; }

.done-stats {
  display: flex; flex-wrap: wrap; gap: var(--sp-2) 24px;
  align-items: baseline; margin-top: var(--sp-3);
}
.done-stats span { font-size: var(--t-body); color: var(--ink-2); }
.done-stats b {
  font-size: var(--t-head); font-weight: 800; color: var(--ink);
  margin-right: 5px; letter-spacing: -0.01em;
}

/* A card that opens. The summary is the whole head, so the chevron is a
   drawing and not a control: the click target is the row. The chevron points
   right at rest (a polyline, not an arrowhead) and turns to face down, which
   is 90deg, not 180. Each page still resets its own heading element's margin
   inside .card-summary (sharecode's is an h2, SIA's is .card-title); that one
   line stayed page-local rather than being promoted with the rest. */
.card.collapsible > .card-summary {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-2); cursor: pointer; user-select: none;
}
.card.collapsible > .card-summary .chev {
  color: var(--ink-2); transition: transform var(--speed) var(--ease); flex: 0 0 auto;
}
.card.collapsible.open > .card-summary .chev { transform: rotate(90deg); }
.card.collapsible > .card-body { display: none; margin-top: var(--sp-3); }
.card.collapsible.open > .card-body { display: block; }

/* ---- THE MOVES A PAGE ASKS FOR BY CLASS ------------------------------------
   Everything above moves because of what it IS: a .run-flag stamps, a
   .done-band lands, the hero's sheet rises. The hero (2026-10-16) was the first
   page to want those same moves on markup of its own, and the pages that
   followed it on 2026-10-22 (the home tiles, the single check, the step-up
   code, the desk, the record, the links, the guard's form, the supplier's
   desk; Asad's call from four live mockups) would each have written the
   keyframes a second time. So the moves are stated here ONCE, as classes a
   page puts on a thing at the moment it arrives, changes or goes, each one a
   stated duration on a stated curve, reusing the keyframe its owner above
   declared wherever one exists.

   Three rules for anything that joins this section. A move that ARRIVES is
   written from-only, so an element the class never reaches sits at the
   answer, and so does every element under reduced motion (the foot lands an
   animation on its last frame; a keyframe whose last frame is not the answer
   is a bug, and the one here that ends elsewhere says why). Nothing red takes
   the stamp or the spring, and this sheet cannot see what colour a plate is,
   so the page keeps .is-stamped off a .badge--danger and draws a red rule
   still. And a page runs a move again by taking the class off, reading a
   layout value and putting it back (window.sesRerun in ui-core.js), never by
   cloning the node. Not one token is added for any of this: --rule and --fill
   are set at the call site, by a class in Applications, whose CSP drops a
   style attribute. */

/* Children in order, the hero's stagger with two more steps: six, 70ms apart,
   and a seventh and everything after it arrive with the sixth (n+6: with a
   plain sixth, a seventh child rose with the FIRST), because anything longer
   than that is a table, and a table snaps: never on a tbody or a tr, which
   the Never list reads only under table.data. */
.rise-stagger > * { animation: drop-rise 420ms var(--ease-arrive) both; }
.rise-stagger > :nth-child(2) { animation-delay: 70ms; }
.rise-stagger > :nth-child(3) { animation-delay: 140ms; }
.rise-stagger > :nth-child(4) { animation-delay: 210ms; }
.rise-stagger > :nth-child(5) { animation-delay: 280ms; }
.rise-stagger > :nth-child(n+6) { animation-delay: 350ms; }
/* The three general hooks, named for what has happened to the thing. They
   are said AFTER the stagger at the same strength, so a child of a staggered
   list that is given one takes it (and keeps its delay, which the nth-child
   rule above still owns). .is-landed is also said at the strength of
   .result.visible, the one band in the build with an arrival of its own,
   which otherwise wins and the hook does nothing on the band it was written
   for. */
.is-stamped { animation: plate-stamp 220ms var(--ease-stamp) both; }
.is-risen { animation: drop-rise 420ms var(--ease-arrive) both; }
.is-landed, .result.visible.is-landed { animation: band-land 200ms var(--ease-arrive) both; }

/* A row that is done leaves quicker than it came, up and out on the leaving
   curve. Its last frame is opacity 0, the one keyframe in this section whose
   end is not a resting state, BY DESIGN: the page removes the node on
   animationend (which fires at once under reduced motion) with a timeout as
   the fallback, so nothing is ever left standing invisible in the flow.
   pointer-events: none, because a thing on its way out cannot be pressed. */
.is-leaving { animation: row-leave var(--dur-fast) var(--ease-leave) both; pointer-events: none; }
@keyframes row-leave { to { opacity: 0; transform: translateY(-6px); } }

/* A card's top rule draws left to right: 6px of --rule, ink unless the call
   site says otherwise, across the box's top edge. On a .card it starts at the
   paper edge, out over the band of paper down its left, so the rule spans the
   card edge to edge as the still border it replaced did. From-only, so a card
   the class never reaches has its rule and a card under reduced motion has it
   too. A RED rule is drawn still: an expired page leaves is-drawn off, since a
   prohibition does not perform its arrival. */
.rule-draw { position: relative; }
.rule-draw::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 6px;
  background: var(--rule, var(--ink)); transform-origin: left center;
}
.card.rule-draw::before { left: calc(-1 * var(--sp-3)); }
.rule-draw.is-drawn::before { animation: rule-draw 420ms var(--ease-arrive) both; }
@keyframes rule-draw { from { transform: scaleX(0); } }

/* A tick draws its own stroke, and it is GREEN ONLY: a tick confirms, and
   nothing else is drawn with one. The stroke is read in a 60-unit dash, so
   the path says pathLength="60" (a presentation attribute, the dial's trick
   above, which Applications' CSP leaves alone) and a path of any length draws
   end to end. From-only: an undrawn tick is a whole tick. */
.tick { width: 56px; height: 56px; color: var(--green); }
.tick path { fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: square; stroke-linejoin: miter; }
.tick.is-drawn path { stroke-dasharray: 60; animation: tick-draw 360ms var(--ease-arrive) 120ms both; }
@keyframes tick-draw { from { stroke-dashoffset: 60; } }

/* One segment of a strip fills left to right: the tape's idiom above and the
   desk's Delete wipe, a fill always drawn at zero width whose WIDTH is what
   moves. --fill is the call site's colour, green unless it says. A transition
   and not a keyframe, so taking is-filled off empties it the same way. The
   thing it sits on sets its ground with background-color, never the
   background shorthand, which resets this image and its size out from under
   the class and leaves a segment that never fills. */
.seg-fill {
  background-image: linear-gradient(var(--fill, var(--green)), var(--fill, var(--green)));
  background-repeat: no-repeat; background-size: 0% 100%; background-position: left center;
  transition: background-size var(--dur-slow) var(--ease-arrive);
}
.seg-fill.is-filled { background-size: 100% 100%; }

/* A shortfall that is met is struck through: a 2px ink rule drawn through the
   words on the arriving curve, and the words step back to the second ink. The
   rule starts at zero width because an unmet line has nothing through it, and
   it ends drawn, which is the answer. */
.strike { position: relative; }
.strike::after {
  content: ""; position: absolute; left: 0; right: 0; top: calc(50% - 1px); height: 2px;
  background: var(--ink); transform-origin: left center; transform: scaleX(0);
}
.strike.is-struck { color: var(--ink-2); }
.strike.is-struck::after { animation: strike 260ms var(--ease-arrive) both; }
@keyframes strike { to { transform: scaleX(1); } }

/* A count plate stamps its new figure. The page puts is-flipped on when the
   figure CHANGED and not when a poll found the same number, and never on a
   red plate. */
.count-flip.is-flipped { animation: plate-stamp 200ms var(--ease-stamp) both; }

/* Copy fills green and its word stamps "Copied". Green on a BUTTON is the
   confirm the clipboard just gave, the same fill .btn--ok:hover already draws,
   and the script gives the word back after 1.5s so the button is a button
   again. Written at the strength of .btn--primary:hover, because Copy link is
   a primary button and its hover would otherwise paint over the confirm. The
   word lives in a <span> so it can stamp on its own. */
.btn.copy-ok.is-copied, .btn.copy-ok.is-copied:hover:not(:disabled) {
  background-color: var(--green); border-color: var(--green); color: var(--text-inverse);
}
.copy-ok.is-copied > span { animation: plate-stamp 180ms var(--ease-stamp) both; }

/* The QR plate appears in scan order. segno draws the whole code as ONE path,
   so a module cannot be delayed on its own; the plate is masked instead, and
   the mask's edge sweeps corner to corner over 400ms, top left to bottom
   right, which reads as the code being sketched. A gradient mask, never an
   image, so this file still names no url; the mask is twice the plate so the
   edge enters at one corner and leaves at the other with no dead travel, and
   it ends fully revealed, so a decoder reading the plate reads all of it. */
.qr-sketch.is-drawn .qr {
  -webkit-mask-image: linear-gradient(135deg, var(--ink) 50%, transparent 50%);
  mask-image: linear-gradient(135deg, var(--ink) 50%, transparent 50%);
  -webkit-mask-size: 200% 200%; mask-size: 200% 200%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  animation: qr-sketch 400ms var(--ease-arrive) both;
}
@keyframes qr-sketch {
  from { -webkit-mask-position: 100% 100%; mask-position: 100% 100%; }
  to { -webkit-mask-position: 0 0; mask-position: 0 0; }
}

/* THE PRESS, opt-in. A primary ACTION a person takes once and then waits on
   (Check, Send, Start, Choose a sheet) goes in 1px and 3% in 60ms and springs
   back on the stamp curve: the hero's rule since 2026-10-16, a class since
   2026-10-22. Every other button keeps the 2026-09-08 answer, the fill and
   nothing else. Never on a nav pill (a pill changes colour only, and a test
   reads it) and never on .btn--danger or .btn--danger-ghost, because red does
   not bounce; tests/test_motion_never.py reads every page for both. The
   transition restates the button's own and adds the tape's background-size,
   so a press that is also .btn--out keeps its tape. */
.btn--press {
  transition: background-color var(--dur-fast) var(--ease-arrive), color var(--dur-fast) var(--ease-arrive),
              border-color var(--dur-fast) var(--ease-arrive), transform 260ms var(--ease-stamp),
              background-size var(--dur-base) var(--ease-arrive);
}
.btn--press:active:not(:disabled):not([aria-disabled="true"]) { transform: translateY(1px) scale(0.97); transition-duration: 60ms; }

/* ---- THE TOUCH LAYER -------------------------------------------------------
   SIGNAL had ONE control size for its whole life and it was a mouse size. A
   measurement of all twelve tools at 390px found every interactive control
   under the 44px floor: .btn at 38, .btn--sm and the sign-out at 32, the
   masthead brand at 27, and Shift Watch's own icon buttons at 28, on the one
   tool that exists to be read on a phone at 6am. Nothing overflowed, nothing
   was clipped, nothing was crowded: the responsive work was all done and the
   HAND was never considered.

   KEYED ON THE INPUT DEVICE, NOT THE VIEWPORT, and that is the whole reason
   this is a separate block rather than more rules in the 640px one below. A
   width query gets a touch laptop wrong in one direction and an iPad at 1024
   wrong in the other, and it makes a narrow desktop window grow fingers it
   does not have. `pointer: coarse` asks the only question that matters: is the
   thing pointing at this a finger.

   The floor is 44px because that is what both Apple and WCAG 2.2 settle on,
   and it is a floor rather than a size: a control already taller keeps its
   height. Nothing here changes a colour, a rule, a plate or a meaning. The
   grammar is the grammar on every device; this is reach. */
@media (pointer: coarse) {
  /* Every control a finger can land on. `min-height` and never `height`, so a
     button that wraps to two lines still grows rather than clipping its own
     label, which is the trap in every "just set 44px" version of this.

     A FLOOR A PAGE MAY NOT LOWER, which is what the !important is for and the
     only place in this file that uses one. It is not a style preference being
     forced: eight per-page rules set a control to 30, 34 or 39px, every one of
     them written for a mouse and every one of them more specific than a bare
     element selector, so without this the layer loses to exactly the pages that
     most need it. A page may still make a control BIGGER. It may not make it
     smaller than the hand.

     The small button is a floor too. It was 40px here for one draft, on the
     reasoning that it should stay visibly lesser than an ordinary button, which
     is trading a person's reach for a hierarchy that type and fill already
     carry: .btn--sm keeps --t-mark and its quieter ground and is told apart by
     those, not by being harder to hit. */
  .btn, .btn--sm, button, .app-banner-brand, .dropzone, [role="button"] {
    min-height: 44px !important;
  }
  input:not([type="checkbox"]):not([type="radio"]), select, textarea {
    min-height: 48px !important;
  }
  /* A checkbox is drawn at its own size and cannot grow without going blurry,
     so the LABEL grows around it instead and the hit area comes with it. */
  label:has(input[type="checkbox"]), label:has(input[type="radio"]) {
    min-height: 44px; display: flex; align-items: center; gap: var(--sp-2);
  }

  /* A table row is a target too, on every page where tapping one opens it.
     This also overrides .data--tight on purpose: the tight row exists for a
     220-decision queue at a desk, and nobody works that queue on a phone. */
  table.data td, table.data.data--tight td { padding-top: 14px; padding-bottom: 14px; }

  /* THE FIRST COLUMN STAYS. A six column expiry table needs 567px inside a
     390px screen, so a finger scrolls sideways to reach the expiry date, which
     is the only reason that page exists, and by then the guard's name it
     belongs to has gone off the left edge. A row you can read half of is worse
     than a row you have to scroll, because it invites reading the wrong line.

     `background: inherit` and not a token: the zebra sets its colour on the
     row, so the pinned cell takes whichever stripe it is standing in and the
     alternation survives. A 1px inset rule on its trailing edge says the
     column is pinned rather than merely first. */
  table.data td:first-child {
    position: sticky; left: 0; z-index: 1;
    background: inherit;
    box-shadow: inset -1px 0 0 var(--line);
  }
  /* The head takes the rail EXPLICITLY and never `inherit`. A th carries its own
     background while the row behind it carries none, so an inherited one is
     transparent: the first shot of this showed "Licence" sliding under "Name"
     and the two words drawn on top of each other. z-index above the body cells
     so the pinned head also passes over them on a vertical scroll. */
  table.data thead th:first-child {
    position: sticky; left: 0; z-index: 3;
    background: var(--paper);
    box-shadow: inset -1px 0 0 var(--line);
  }

  /* There is more table to the right, said the way the masthead already says
     it about the nav: a fade at the edge, drawn as a mask with a gradient, so
     this file still references nothing external. Drawn only while there is
     something to scroll to. */
  .table-wrap {
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent);
    mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent);
  }
  .table-wrap:not(:has(table)) { -webkit-mask-image: none; mask-image: none; }

  /* No Cmd key on a phone. The shortcut is real and worth naming where it can
     be pressed; on a finger it is a promise the device cannot keep. */
  .app-banner-more kbd { display: none; }
}

/* ---- Responsive ----------------------------------------------------------------- */
@media (max-width: 640px) {
  /* SIXTEEN PIXELS IS NOT A PREFERENCE, IT IS THE LINE iOS DRAWS. Safari
     magnifies the viewport when a field under 16px takes focus, and it does not
     zoom back out: the page you were reading stays enlarged until you pinch it
     down by hand. Nine visible fields across this estate sat at
     `--t-body`, which is 15, so one tap into a filter, a share code, a licence
     number or a date left the whole product looking blown up. It is the reason
     the tools "looked zoomed in" on a phone, and no amount of trimming padding
     was ever going to touch it.

     The ladder is not changed and this is not a new size: 16px is the floor for
     a FIELD on a phone and nothing else moves. */
  input:not([type="checkbox"]):not([type="radio"]), select, textarea {
    font-size: 16px;
  }

  /* ---- THE PHONE SCALE ------------------------------------------------------
     THE TOUCH LAYER IS REACH AND THIS IS SCALE, and conflating the two is what
     made every page except one read as a desk page held close to the face.

     That layer sets a 44px floor on every control and 48px on every input,
     which is the right answer to "can a finger land on this" and no answer at
     all to "does the page fit". Measured at 390x844 on the tools that are
     mostly controls: Site Linkage's six filter buttons, its text field and its
     two count plates came to 430px before a single site was drawn; SES Export's
     two date rows are eight boxes and two calendar buttons at 48px each; the
     Invoice Audit's two drop zones took 220px between them. Under it sat base
     padding written for a mouse: 20px inside every card, 26px inside every drop
     zone, and a 14px gutter down both edges of the screen.

     A phone screen is 844px tall and the whole of it is the budget. 36px is
     still a deliberate target (it is the size this product's own segmented
     controls have used since Shift Watch came down to it), it is above the 32px
     that reads as cramped, and it is a size chosen for a screen rather than
     inherited from one twice as tall. The floor above stays exactly as it is
     for a touch laptop and an iPad, which have the room that argument assumed.

     Nothing here changes a colour, a plate, a rule or a meaning. Scale only. */
  .btn, .btn--sm, button, .dropzone, [role="button"] {
    min-height: 36px !important;
  }
  /* NOT THE BRAND. On a phone it is no longer a word in a bar, it is the
     stacked block naming the product and the page, and it is the only route
     back to the switcher: it keeps the full 44px floor. Left in the sweep above
     it inherited 36 and settled at 42 by content, which is the kind of number
     nobody chose. */
  .app-banner-brand { min-height: 44px !important; }
  input:not([type="checkbox"]):not([type="radio"]), select, textarea {
    min-height: 40px !important;
  }
  label:has(input[type="checkbox"]), label:has(input[type="radio"]) { min-height: 36px; }
  .btn { padding: 7px 12px; }
  .btn--sm { padding: 5px 10px; }
  input:not([type="checkbox"]):not([type="radio"]), select { padding: 7px 10px; }
  select { padding-right: 34px; }   /* the drawn arrow keeps its room, or it sits on the words */

  /* THE GUTTER AND THE PADDING INSIDE IT. 14px each side of the screen, then
     20px inside the surface standing in it, is 68px of a 390px width spent
     before any content: a third of the room on two nested margins. */
  :root { --pad: 12px; }
  .card { padding: var(--sp-3); margin-bottom: var(--sp-2); }
  .dropzone { padding: var(--sp-3); }
  .setup-strip { padding-top: 6px; padding-bottom: 6px; margin-bottom: var(--sp-3); }
  /* Six of these and two calendar buttons make a date range. At 17px bold with
     9px of padding they were the tallest thing on the exporter, twice over.
     SIXTEEN, not `--t-body`: a date pill is a field, and this selector is more
     specific than the floor above, so 15 here would put the zoom back on the
     one page that has eight of them. */
  .ses-date input.date-pill { font-size: 16px; padding: 6px 4px; }

  /* ---- A TABLE ON A PHONE IS A LIST OF CARDS -------------------------------
     Six columns need 567px. Pinning the first column keeps the name on screen
     and loses the status off the right edge, which is the one thing the row is
     for. Below this width a table that OPTS IN (`.data--cards`) becomes cards; Shift Watch and the public sheet draw their own phone layouts and keep the table: the first cell is
     the card's name, a cell holding a plate sits at the top right, and every
     other cell is a line under the name, each prefixed by its column head
     (copied onto the cell as data-label by ui-core.js). The head row goes;
     sorting on a phone is done by the filter strip above the table. */
  table.data, table.data.data--cards tbody, table.data.data--cards tr, table.data.data--cards td { display: block; }
  table.data.data--cards thead { display: none; }
  /* A page's own floor for its table (the expiries set 440px so six columns
     stay readable) is a floor for COLUMNS, and cards have none: it is lifted
     here or the cards run 50px past the phone's right edge. */
  table.data.data--cards { min-width: 0; }
  table.data.data--cards tbody tr {
    position: relative; margin: 0 0 var(--sp-2);
    padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + 8px);
    background: var(--paper);
    border-bottom: 0;
    box-shadow: inset 8px 0 0 var(--line-2);
  }
  table.data.data--cards tbody tr:nth-child(even) { background: var(--paper); }
  /* A row says its tone with `data-tone` (danger, warn) and the card takes it
     as a colour rule down the left, the same shape a band uses. */
  table.data.data--cards tbody tr[data-tone="danger"] { box-shadow: inset 8px 0 0 var(--red); }
  table.data.data--cards tbody tr[data-tone="warn"] { box-shadow: inset 8px 0 0 var(--yellow); }
  table.data.data--cards td { padding: 2px 0; height: auto; border-bottom: 0; font-size: var(--t-body); white-space: normal; }
  table.data.data--cards td:first-child {
    position: static; box-shadow: none; background: transparent;
    font-size: 16px; font-weight: 700; padding-right: 120px; margin-bottom: 4px;
  }
  /* ONE plate goes to the corner: the cell a page marks `data-corner`, or
     failing that the last cell that is nothing but a plate. A row with three
     plates (a person's two documents and the worst of them) keeps the first
     two inline with their labels; only the verdict stands in the corner. */
  table.data.data--cards td[data-corner]:has(> .badge:only-child),
  table.data.data--cards tr:not(:has(td[data-corner])) > td:last-child:has(> .badge:only-child) {
    position: absolute; top: var(--sp-3); right: var(--sp-3); padding: 0;
  }
  table.data.data--cards td[data-corner]::before { content: none; }
  table.data.data--cards td[data-label]:not(:first-child):not(:has(> .badge:only-child))::before {
    content: attr(data-label) " ";
    font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-2);
    margin-right: 6px;
  }
  table.data.data--cards td:empty { display: none; }
  table.data.data--cards td.num { text-align: left; font-size: var(--t-body); }
  .table-wrap:has(> table.data--cards) { max-height: none; overflow: visible; -webkit-mask-image: none; mask-image: none; }

  /* A TABLE ROW IS NOT A TAP TARGET ON MOST OF THESE PAGES. The touch layer
     holds every row at 14px top and bottom so a finger can hit one, which is
     right where tapping a row opens it and is 28px a row of nothing anywhere
     else. This is the same reading that took Shift Watch's list from 250px a
     row to 80. A control INSIDE a row keeps its own floor above. */
  table.data td, table.data.data--tight td { padding-top: 8px; padding-bottom: 8px; }

  /* ---- A PHONE HAS ONE WAY BACK AND IT IS THE HOME PAGE ---------------------
     Above this the masthead is a switcher, narrowing by measurement rather
     than by any width stated here: every pill while they fit, then the area you
     are working in, then one "Tools" button that opens the palette. On a phone
     that button was a second way to change tool from a
     page that already carries the brand as a link home, and it stood where the
     answer to "what am I looking at" belongs. The name takes its place, the
     home wall becomes the one switcher, and the page head below stops saying
     the name a second time thirty pixels lower.

     Both controls stay in the markup at every width. `ui-core.js` builds the
     Cmd+K palette out of the pills, so a link dropped from the DOM is a tool
     dropped from the palette. */
  .app-banner-more { display: none; }
  .app-banner-area { display: none; }
  /* THE MASTHEAD STACKS: the way back over where you are.
     Beside each other, the brand was set in sentence case and the tool in
     condensed capitals tracked at .1em, six pixels apart, which is the same
     kind of thing said twice in two accents. Stacked, the two are obviously
     different things: a small tracked over-line naming the product, and the
     page you are on at reading size under it.

     Both live inside the brand's own link, so the whole block is the way home
     rather than a 90px word beside a label. That costs 8px of bar height and
     it is the only place in the estate where a control is worth 54px. */
  .app-banner { min-height: 60px; }
  .app-banner-brand {
    flex-direction: column; align-items: flex-start; justify-content: center;
    gap: 1px; min-height: 44px;
  }
  /* THE SECOND COPY IS SET THE SAME WAY OR IT IS NOT THE SAME WORDS. The ink
     copy lies exactly on the title and the relay reveals it a quarter at a
     time, so every letter has to land on the letter under it: anything given
     to the title here is given to the ink too. It was left at 17px Archivo
     over an 11px condensed over-line, which read as a second wordmark falling
     on the first every few seconds. The colour is the one thing not shared,
     because the ink copy is clipped out of the mark and stays transparent. */
  .app-banner-word,
  .app-banner-title,
  .app-banner-ink {
    font-family: var(--font-cond); font-size: 11px; font-weight: 800;
    letter-spacing: 0.14em; text-transform: uppercase;
  }
  /* WHITE, NOT GREY (Asad, 2026-09-11). Set small and tracked, the over-line
     was held back a second time with 55% ink and read as switched off. The
     size and the tracking are the quieting; the colour does not have to be.
     Boards are untouched: `[data-board]` paints the wordmark its band colour
     at every width and outranks this. */
  .app-banner-title { color: var(--text-inverse); }
  .app-banner-accent { font-weight: 800; color: var(--text-inverse); }
  /* The four-colour rule stays under the stacked brand: it is the mark, and
     a phone is where the wordmark is smallest and needs it most. It was
     dropped here with the switcher and nobody meant to drop it. The selector
     named `::after` until the relay made the mark four real segments. */
  .app-banner-mark { margin-top: 2px; }
  .app-banner-here {
    display: block; font-family: var(--font-sans); font-size: 24px;
    font-weight: 800; letter-spacing: -0.03em; text-transform: none;
    color: var(--text-inverse); white-space: nowrap; overflow: hidden;
    text-overflow: ellipsis; max-width: 62vw;
  }
  /* SIGNING OUT IS A HOME PAGE ERRAND, like changing tool. It sat in the top
     right of all thirteen tools next to a name that was not there yet, and it
     is pressed once a month. The wall carries it at the foot; nothing else
     reaches /logout on a phone, so that control is not optional. */
  .app-banner-logout { display: none; }
  /* Account goes with it, to the same foot of the home wall. */
  .app-banner-me { display: none; }
  /* THE PAGE HEAD STOPS REPEATING THE MASTHEAD. `--tool` is on the heads whose
     h1 IS the tool's own name, which is twelve of the seventeen: the other five
     name a page INSIDE a tool ("Last failure", "Response times", a briefing's
     own date) and keep theirs, because the masthead cannot say those.
     A head that held nothing but the name is not a head at all once the name
     has gone, so it goes with it; a head that also carries a control, a link or
     a live status plate keeps the bar and loses only the title. */
  /* VISUALLY hidden, not `display: none`. The masthead's copy of the name is
     `aria-hidden` (it sits inside the link home), so this is the only place a
     screen reader can learn what page it is on. */
  .topbar--tool h1 {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
  }
  /* The subtitle goes rather than being hidden with it. `clip` hides a thing
     visually and does not shrink its BOX: the span kept its own 200px rectangle
     out at the right of a 1px parent, and the page-integrity sweep reads
     geometry, so four pages reported content past the viewport that no eye
     could see. It is also the one part of the head that is prose, which is the
     part a phone has least room for. */
  .topbar--tool h1 .sub { display: none; }
  /* The head is `space-between`, which is right when a title is holding the
     left. With the title gone the one control left was pushed to the far right
     of an otherwise empty bar, reading as something that had lost its label. */
  .topbar--tool { justify-content: flex-start; }
  /* `:not([hidden])`, because a head whose only other child is a control that
     has not been revealed yet is a head with nothing in it: the exporter's
     "Last failure" link left a bare 40px strip on every run that had not
     failed. A descendant `h1` rather than a child, so a head that wraps its
     title with a piece of data beside it still loses the title and keeps the
     data.
     COLLAPSED, NOT `display: none`. The h1 inside is the one place a screen
     reader learns which page this is (the masthead's copy is aria-hidden), and
     `display: none` on the head took it out of the accessibility tree on every
     phone: four tools answered "no level-one heading" to an audit on
     2026-09-05. A zero-height head with its overflow clipped is invisible to
     the eye and still there to the reader. */
  .topbar--tool:not(:has(> :not(h1):not([hidden]))),
  .topbar--tool:has(> .topbar-titles):not(:has(> :not(.topbar-titles):not([hidden])))
    { height: 0; min-height: 0; margin: 0; border: 0; overflow: hidden; }
  /* A head reduced to a hidden h1 has no height of its own to give back. */
  .topbar--tool { padding-top: 0; padding-bottom: 0; }

  .app-banner-left { gap: var(--sp-2); }
  .app-banner-tool { min-height: 46px; padding: 0 var(--sp-2); }
  .app-banner-ver { display: none; }
  /* The nav scrolls sideways on a phone; the fade is the only thing that says
     there is more of it. A mask, not an image: still no url reference here. */
  .app-banner-nav, .app-subbar-nav {
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
    mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
  }
  /* THE CHROME SCROLLS AWAY ON A PHONE. Sticky, the masthead and the band were
     100px of an 844px screen on every page for its whole length, over forms
     five screens long. They are the top of the page here and come back the
     moment it is scrolled up; a page's own head that has to stay in reach
     (a bulk run's controls) still sticks, at the top of the window. */
  .app-banner, .app-subbar { position: relative; top: auto; }
  .app-banner + .app-subbar { top: auto; }
  .app-subbar { gap: var(--sp-2); }
  .app-subbar-user { display: none; }
  /* A tab for a desk-only tool (Applications' Coordinate tool places PDF
     fields with a mouse) is no use on a phone, and it was the tab pushing
     Blue's band off the edge (2026-10-17). It stays when it is the page. */
  .app-subbar-link--desk:not(.active) { display: none; }
  .topbar { top: 0; }
  /* A 48px title is a desk size. On a phone the head that survives (the five
     that name a page inside a tool) comes down to 28 and is allowed to wrap. */
  .topbar h1, .topbar h2 { font-size: 28px; letter-spacing: -0.03em; }
  .topbar h1 > span, .topbar h2 > span { white-space: normal; }
  /* The band over a wall used to own the entire first screen of a phone: a
     120px ring, a figure at display size and two ledger lines, before a single
     tool. Nothing is dropped (the figure and the ledger are the answer); the
     ring and the figure come down a size and the band stops standing so far
     off its own edges, which is about eighty pixels back. */
  .summary-band-body {
    gap: var(--sp-3);
    padding-top: var(--sp-3); padding-bottom: var(--sp-3);
  }
  .dial-ring { width: 96px; height: 96px; }
  .dial-value { font-size: var(--t-head); }
  /* The ring and its ledger sit BESIDE each other, which they cannot do at the
     ledger's own 380px basis. Together they are the one measurement; stacked,
     they were two thirds of the band on their own.

     What a HOST puts in this band is the host's to lay out: the home page's own
     stylesheet turns its tile on its side here, because `.tool-card` is its
     component and this file styles only its own. */
  .summary-band-body > .ledger { flex: 1 1 auto; }
  .summary-band-body .ledger-row { grid-template-columns: var(--sp-2) auto auto 1fr; }
  .dob-row { flex-direction: column; }
  .result-field { flex-direction: column; gap: 2px; }
  .result-label { flex-basis: auto; }
  .run-count b { font-size: var(--t-head); }
}

/* ---- A page changes under the rail ------------------------------------------
   Moving between tools: the rail stays put, the old page drops away on the
   leaving curve and the new one rises on the arriving one. A cross-document
   view transition, so the page underneath is real and a browser without
   them simply navigates. */
@view-transition { navigation: auto; }
.app-banner { view-transition-name: rail; }
::view-transition-group(rail) { animation: none; }
::view-transition-old(root) { animation: page-leave 160ms var(--ease-leave) both; }
::view-transition-new(root) { animation: page-arrive 220ms var(--ease-arrive) both; }
@keyframes page-leave { to { opacity: 0; transform: translateY(-8px); } }
@keyframes page-arrive { from { opacity: 0; transform: translateY(10px); } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
  /* Duration, delay and count together. With the duration alone a delayed
     move sat on its FIRST frame for the whole delay (a sixth staggered child
     for 350ms, a tick for 120ms) and then popped, which is not no motion. */
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  /* The rule above lands every animation on its LAST keyframe, and .skel's last
     keyframe is the graphite lifting: left alone, every placeholder in the build
     would sit at a quarter opacity for good. Stated as a whole state rather than
     a duration, because a placeholder still has to look like one when nothing is
     allowed to move. */
  .skel {
    animation-name: none !important;
    background-size: 100% 100%;
    opacity: 1;
  }
}
