/*
 * The dashboard's stylesheet.
 *
 * A real .css file rather than a template literal in chrome.ts, and that is a
 * correctness change rather than tidying. As a literal, a single backtick
 * anywhere in here ended the string and the file stopped compiling — which
 * happened FIVE times, twice from inside a comment explaining something else,
 * and at least once the server died mid-run while a browser check went on
 * reporting ALL CLEAN against the corpse. A .css file cannot have that failure.
 *
 * It is served from /static/app-<hash>.css on the laptop and /ops-assets/app.css
 * at the edge, so the Content-Security-Policy can be `style-src 'self'` with no
 * inline <style> block to allow.
 *
 * ── The system this implements ───────────────────────────────────────────────
 * Derived from the public site's own tokens (src/app/globals.css) and its
 * declared roles (docs/brand-tokens.md), so the tool and the site read as one
 * product. What is NOT borrowed is anything the site has no equivalent for:
 * the demand/supply pair, the amber tier, and the dense tabular rung. Those are
 * declared here and documented as this tool's own.
 *
 * Contrast is verified against these tokens by contrast.test.ts, which reads
 * this file and checks itself against a known-good and a known-bad pair first.
 * The two map radii and the map's aspect ratio are asserted by map.test.ts
 * against the geometry helper — they are single-line by requirement, not style.
 */

/* ── Typefaces ───────────────────────────────────────────────────────────────
   The same three the public site self-hosts, copied from what next/font already
   downloads, so the tool renders in the same metal instead of falling back to
   whatever sans the machine supplies — which it did for its whole life, while
   this file named "IBM Plex Sans" and shipped no font at all.

   The url() is RELATIVE on purpose. It resolves against the stylesheet's own
   address, which differs per host and so needs no branch:
     laptop  /static/app-<hash>.css  ->  /static/fonts/...
     edge    /ops-assets/app.css     ->  /ops-assets/fonts/...
   Cloudflare Pages copies public/ verbatim, so the edge path is a real file.
   `font-src 'self'` already permits both; the policy does not widen.

   Latin only — this is an internal tool in English. Jost and Plex Sans are
   variable, so one file spans every weight used; Plex Mono is not.
   `font-display: swap` because text that is readable late beats text that is
   invisible early on a tool somebody reloads all day. */
@font-face {
  font-family: "Jost Ops";
  src: url(fonts/jost-latin-var.woff2) format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Plex Sans Ops";
  src: url(fonts/plex-sans-latin-var.woff2) format("woff2");
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Plex Mono Ops";
  src: url(fonts/plex-mono-latin-400.woff2) format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Plex Mono Ops";
  src: url(fonts/plex-mono-latin-500.woff2) format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

:root {
  /* ── Colour ───────────────────────────────────────────────────────────────
     Values from the public site's globals.css. The roles below are its roles,
     not new ones: three text colours and no fourth, forest split into text and
     mark, and anything failing AA declared a MARK in the token's own comment. */
  --ink: #101010; --paper: #fcfcfa;
  --n700: #3d3d3d; --n600: #545454; --n500: #6e6e6e;
  /* MARKS, never text. n400 measures 3.11:1 on paper and n300 lower still —
     both are hairlines, quiet bars and disabled rules. contrast.test.ts asserts
     n300 FAILS AA rather than omitting it, so nobody reaches for it as a text
     colour later. */
  --n400: #909090; --n300: #b5b5b5;
  --n200: #d6d6d6; --n100: #e9e9e9; --n50: #f5f5f4;

  /* forest800 is the only green for text on paper (links). forest700 is a MARK
     — rules, borders, focus rings, diagram strokes. forest900 is forest text on
     a forest tint only. Green is an accent and never the field: a page reading
     as green overclaims sustainability, which the site's own rules forbid. */
  --forest: #17621f; --forest700: #17621f; --forest800: #114a17;
  --forest900: #0c3210; --forest50: #f1f9f2;

  /* Two different claims, and they are not interchangeable.
       --amber  the numbers on this page are wrong
       --alert  this was refused
     Amber is the tool's own; alert is the site's oxide, used here so a refusal
     looks the same to a founder in both places. Neither is ever the only
     channel — every use carries a mark or a word as well (WCAG 1.4.1). */
  --amber: #8a5a00; --amber50: #fbf5e9;
  --alert: #8a3a1e; --alert50: #fdf4f0;

  /*
   * The two sides of the marketplace, and the only reason this tool has a red
   * and a blue.
   *
   * Demand is what senders ask for; supply is what stewards declare. Every
   * figure that draws both uses these and nothing else uses them at all — a
   * third role borrowing one is how forest-700 and forest-800 came to mean the
   * same thing on the public site, twice, in two greens.
   *
   * They are marks first (arcs, lattice cells), so the bar is 3:1 rather than
   * AA's 4.5 — but both clear AA, because each one also sets the text of its
   * own legend. Measured in contrast.test.ts, not judged here.
   */
  --demand: #a4243b; --demand50: #fbeef0;
  --supply: #1d4e89; --supply50: #eef2f8;

  /* ── Type ─────────────────────────────────────────────────────────────────
     Eight rungs, one role each. The tool previously ran 32/17/15/14/12/11 and
     jumped straight from the page title to a lead — so a section heading was a
     12px eyebrow and a record page had no heading level between them at all.
     24 and 19 are what the detail pages needed and the ramp did not have.

     14 is kept rather than dropped, which is a deliberate departure from the
     public scale. There it sat 1px from body copy doing five different jobs and
     was deleted for it; here it has exactly two, both "secondary and dense" —
     tabular cells and supporting prose — and an operations table set at 15px
     buys nothing and costs rows on screen. */
  --t-title: 32px;    /* h1 — the page */
  --t-section: 24px;  /* h2.title — a section of a record */
  --t-sub: 19px;      /* h3 — a heading inside one */
  --t-lead: 17px;     /* the page's opening statement, and the north star */
  --t-body: 15px;     /* running text */
  --t-dense: 14px;    /* table cells, supporting notes, form controls */
  --t-fine: 12px;     /* eyebrow, caption, tile sub-label */
  --t-label: 11px;    /* mono label role: th, pill, badge, the read stamp */

  --font-display: "Jost Ops", ui-sans-serif, system-ui, sans-serif;
  --font-sans: "Plex Sans Ops", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "Plex Mono Ops", ui-monospace, SFMono-Regular, Menlo, monospace;

  /*
   * One measure for every prose role.
   *
   * RE-MEASURED for the real typeface, and the interesting result is that it
   * barely moved. Loading IBM Plex Sans instead of the machine's system sans
   * was expected to change the ch-to-character ratio enough to matter; 56ch was
   * the estimate and the browser reported prose at 73-83 characters, well over
   * the 60-72 band, across 78 page/width combinations. Measured, one ch unit is
   * ~1.39 real characters here, so the band wants ~48.
   *
   * The lesson is the estimate, not the number: this was calculated from font
   * metrics and was wrong by a fifth. scripts/dev/dashboard-look.mjs reports
   * characters per line per block, and it is the only thing that settles this.
   * If it shows a block outside the band, THIS value is what moves — never the
   * individual block, which is how a stylesheet ends up with six widths.
   */
  --measure: 48ch;

  /* ── Space and shell ──────────────────────────────────────────────────────
     Two widths, because two kinds of page.

     --shell-max is for data: tables, grids, maps, anything compared across
     columns. It is CENTRED. The old rule was `margin: 0; max-width: 68rem`,
     which at 1920px left a 1088px column in a 1680px track and 592px of
     unexplained white to the right of every page — the complaint that started
     this work, and one that got worse the bigger the screen.

     There is no second, narrower width. One was declared here for "documents
     read start to finish" and applied by nothing for its whole life; measured,
     neither candidate is a document — Definitions and the printable board are
     wide reference tables that already fill the track. Their real defect was
     prose inside a `td` at 119 and 139 characters a line, which a narrower
     shell would barely have touched. See the cap on `td` further down. */
  --shell-max: 90rem;
  --gutter: 24px;
  --rail-w: 15rem;

  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px;
  --s6: 32px; --s7: 48px; --s8: 64px;

  --radius: 3px;
  --radius-sm: 2px;

  /* ── Motion ───────────────────────────────────────────────────────────────
     Fast enough to explain a relationship, short enough never to be waited on.
     Every use is behind the reduced-motion guard at the foot of this file. */
  --motion-fast: 150ms;
  --motion: 200ms;
  --ease: cubic-bezier(0.2, 0, 0.2, 1);
}

* { box-sizing: border-box; }
body {
  margin: 0; background: var(--paper); color: var(--ink);
  font: var(--t-body)/1.6 var(--font-sans);
  -webkit-font-smoothing: antialiased;
}
a { color: var(--forest800); }
/* One visible focus treatment everywhere. A keyboard user on a page of tables
   and filter links is otherwise navigating blind. */
:focus-visible { outline: 2px solid var(--forest); outline-offset: 2px; border-radius: var(--radius-sm); }
.skip { position: absolute; left: -9999px; top: 0; }
.skip:focus {
  position: static; display: inline-block; margin: var(--s2) var(--gutter);
  padding: var(--s2) 14px; background: var(--ink); color: var(--paper); text-decoration: none;
}
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.shell { max-width: var(--shell-max); margin: 0 auto; padding: 0 var(--gutter); }

/* ── The 12-column grid ──────────────────────────────────────────────────────
   Plain CSS grid, no framework. Below the desktop breakpoint every span
   collapses to the full width, because a 5-column aside on a phone is a
   3-word-wide column — which is the failure the site's own `rail-aside` was
   measured and rejected for.

   Two pairs, and only the two that are actually rendered: 8+4 (a record and its
   metadata) and 7+5 (an analysis and its explanation). 6+6 and 3+3+3+3 were
   declared here and used by nothing, which is the debt the dead-export gate
   catches in TypeScript and CSS has no gate for. Add a span back in the commit
   that renders it. */
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--s5); }
.grid > * { min-width: 0; }
.c-4, .c-5, .c-6, .c-7, .c-8 { grid-column: span 12; }
@media (min-width: 60rem) {
  .c-4 { grid-column: span 4; }
  .c-5 { grid-column: span 5; }
  .c-6 { grid-column: span 6; }
  .c-7 { grid-column: span 7; }
  .c-8 { grid-column: span 8; }
}

/* ── Filling the width: put another block in the row ─────────────────────────
   The one rule this tool needed and did not have.

   Measured over all thirty-four routes at 1470, before any of this: the median
   widest block on a page was 69% of the track it was given, eleven routes had
   nothing at all reaching half of it, and 92% of blocks were under half. It got
   worse the wider the screen — 59% and fourteen routes at 1728 — which is what a
   centred 90rem shell full of --measure-capped blocks produces.

   The wrong fix is to stretch what is there, and it is wrong by arithmetic
   rather than by taste. Prose cannot fill a 1182px column and stay readable: at
   15px that is about 110 characters a line, against a 60-72 band the browser
   gate FAILS on. So width is filled by putting another block beside the first,
   and never by widening one.

   Content-sized, wrapping, and nothing is stretched. A short table keeps
   `width: auto` (see .scroll below — 92fa457 exists because a full-width short
   table put 720px between a row and its own number), a panel keeps its measure,
   and whatever does not fit wraps to the next line on its own. There is no
   breakpoint and no column count to keep in sync with a shell width that has
   already changed twice.

   `lay-` prefixed on purpose. Every figure class in this file is unprefixed —
   map, lattice, flow, spread, lanes — so a layout wrapper sharing one of those
   names silently restyles the figure, which cost 20px of horizontal scroll on a
   phone once already. */
.lay-abreast { display: flex; flex-wrap: wrap; align-items: start; gap: var(--s5) var(--s6); }
/* Items SHARE the row rather than each keeping its own width, and this reverses
   what the comment above used to promise. It was measured: two sections on
   /turnaround came to 512 + 675 + a 32px gap = 1219 against a 1182 track, and
   `flex-wrap` decides to wrap BEFORE it shrinks — so they stacked, 37px short of
   fitting, and the class filled nothing it was applied to. Content-sized and
   wrapping is a rule that works on a diagram and not on real tables.
   28rem, which is two to a row on a 1182px track rather than three. Three was
   tried and LOOKED WRONG rather than measuring wrong: at ~380px a column each,
   /quality's tables were clipped by their own scroll box and a reader had to
   scroll sideways inside a column to read a sentence. Fill is not worth that,
   and only the screenshot showed it — the numbers went up. */
.lay-abreast > * { flex: 1 1 28rem; min-width: 0; max-width: 100%; }
/* A table in a column is NOT stretched to fill it, and this was tried and
   measured. `.lay-abreast .scroll > table { width: 100% }` looked right and put
   TEN prose cells over the measure across /scoreboard, /board, /economics,
   /money and /quality — up to 109 characters a line at 1728.

   The cause is the same one that stopped /runs spanning: `td { max-width }` is
   not honoured once a table is stretched, because the spare width is
   distributed across the columns and the cap is ignored. Stretching a table
   with a prose column can only ever lengthen its lines.

   Fill and measure are a pair and measure wins. So the rule moved to the
   CONTENT: `table()` marks a table `packed` when no cell holds a sentence, and
   only those fill their column. A table of numbers and references has nothing
   to wrap and cannot be hurt by the width; a table with a sentence in it is
   left content-sized. */
.lay-abreast .scroll.packed > table { width: 100%; }
/* A block holding a SPANNING table takes the whole row rather than a column.
   Without this, laying it abreast quietly un-spans it: a flex item sizes to its
   content, so the table's `width: 100%` becomes 100% of the item rather than of
   the track. Measured when it was missing — /movement fell 54% to 39% and /pnl
   67% to 52% the moment their sections were laid abreast. Same column count
   decides both, so the two rules cannot disagree. */
.lay-abreast > *:has(.scroll.wide) { flex: 1 0 100%; }
/* A section used as a column carries no band spacing of its own. Both
   selectors, because `section:first-of-type` is declared further down this file
   at the same specificity and would otherwise win on order. */
.lay-abreast > section,
.lay-abreast > section:first-of-type,
.grid > section,
.grid > section:first-of-type { padding: 0; margin: 0; }

/* ── The exception spine on /today ───────────────────────────────────────────
   Every exception answers the same five questions in the same order, in
   /ready's own `dl.proof`: those two pages are the only ones that sequence
   rather than measure, and a reader crossing between them should not have to
   learn a second shape for one question.

   Two abreast, because one column of prose capped at the measure leaves half a
   90rem shell empty — which is what the fill measurement caught on this page.
   The rule below must stay AFTER `.grid > section { padding: 0 }`: the two
   selectors have identical specificity, so order is the only thing deciding
   whether the card has a top rule at all. */
.exceptions > section {
  border-top: 2px solid var(--n200); padding-top: var(--s3);
}
.exceptions h2 {
  font-size: var(--t-sub); font-weight: 500; line-height: 1.3;
  margin: 0 0 var(--s1); max-width: var(--measure);
}
.exceptions > section > p.s { margin: 0 0 var(--s3); max-width: var(--measure); }
.exceptions dl.proof { margin: 0; }

/* ── The situation row ───────────────────────────────────────────────────────
   Three questions somebody opens an operations tool to ask, side by side: what
   needs me, what is blocked, what changed. They were three stacked panels, so
   reading all three meant scrolling past the fourth answer.

   Each column is a list of short lines rather than prose, which is what makes
   equal columns right here where they would be wrong for paragraphs: at 1470
   a four-column track is 378px, and these lines are five to nine words. */
.situation { margin-bottom: var(--s7); }
.situation-n { margin: 0 0 var(--s2); color: var(--n600); font-size: var(--t-dense); }
.situation-n b { font-family: var(--font-display); font-size: var(--t-lead);
  font-weight: 400; color: var(--ink); }
ul.situation-list { list-style: none; margin: 0 0 var(--s3); padding: 0; }
/* Stacked, not two columns, and that was decided by looking at it. Side by
   side in a 378px card the title wrapped to three lines beside a detail that
   wrapped to three of its own, and the two texts interleaved into something
   that read as one garbled sentence. The title says what happened and the line
   under it says which row: that is a caption, not a column. */
ul.situation-list li { padding: 6px 0; border-top: 1px solid var(--n100);
  font-size: var(--t-dense); line-height: 1.4; }
ul.situation-list li:first-child { border-top: 0; }
ul.situation-list li b { display: block; font-weight: 600; color: var(--ink); }
ul.situation-list li span { display: block; color: var(--n600); }
/* A delta is a number and its name, which is one line and not a caption. */
ul.situation-list.deltas li b,
ul.situation-list.deltas li span { display: inline; }
ul.situation-list.deltas li b { margin-right: var(--s2); font-family: var(--font-mono); }
/* A card's action is the point of the card, and 18px is not a target under a
   thumb — this tool exists partly so the founder can read it on a phone. WCAG
   2.5.8 exempts a link sitting inside a sentence, and the references in the
   detail lines genuinely are; the line at the foot of a card is the card's own
   action and is not. Padded on the stacked layout only.

   `a:only-child` is the tool's own idiom rather than a guess: nine sections
   end in a note holding nothing but one link, and that is what "the action of
   this section" looks like here. */
@media (max-width: 60rem) {
  p.note > a:only-child { display: inline-block; padding-block: 13px; }
}
/* A batch that has stopped is not another item on a list: it says the figures
   on this page were computed from rows nobody has reconsidered. */
p.note-stale { color: var(--amber); font-weight: 500; }

/* ── The frame ───────────────────────────────────────────────────────────────
   A rail down the left and one content column beside it.

   Twenty-six pages is past what a horizontal nav can carry: the two-tier one
   this replaces showed a section's pages only once you had opened that section,
   so a destination you did not already know about was one you could not find.
   Down the left every destination is visible at once.

   Grid rather than a fixed-position aside, so the rail scrolls with a long page
   instead of trapping its own overflow, and so the content column is sized by
   the browser rather than by a margin that has to be kept in sync with a width.
*/
.frame { display: grid; grid-template-columns: 1fr; }
.col { min-width: 0; }
.col .shell { margin: 0 auto; }

header.rail {
  border-bottom: 1px solid var(--n100);
  background: var(--n50);
  padding: var(--s4) var(--gutter) 14px;
}
.railtop { display: flex; align-items: center; gap: var(--s4); flex-wrap: wrap; }
/* The lockup: the site's own mark, drawn in currentColor, beside the wordmark.
   Not the PNGs — every one has a baked opaque white ground and reads as a white
   rectangle on anything but white. */
.brand { display: inline-flex; align-items: center; gap: var(--s2);
  color: var(--ink); text-decoration: none; }
.brand svg { display: block; width: 30px; height: 21px; flex: none; }
.brand .name { font-family: var(--font-display); font-size: var(--t-lead);
  font-weight: 400; letter-spacing: .22em; line-height: 1; }
.brand .sub { font-family: var(--font-mono); font-size: 8px; font-weight: 500;
  letter-spacing: .24em; text-transform: uppercase; color: var(--n500);
  line-height: 1; margin-top: 3px; }
.brand .lock { display: flex; flex-direction: column; }
.brand:hover .name { color: var(--forest800); }

/* Search. A real form, so it works with the script off; app.js binds "/" to
   focus it, which is a convenience over a working control and not the way in. */
.find { display: flex; gap: 6px; flex: 1 1 16rem; min-width: 0; }
.find input {
  flex: 1 1 auto; min-width: 0; font: inherit; font-size: var(--t-dense);
  padding: 6px 10px; border: 1px solid var(--n200); border-radius: var(--radius);
  background: var(--paper); color: var(--ink);
}
.find input:focus-visible { outline: 2px solid var(--forest); outline-offset: 1px; }
.find button {
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .1em; text-transform: uppercase; padding: 6px var(--s3);
  border: 1px solid var(--n200); border-radius: var(--radius);
  background: var(--paper); color: var(--n700); cursor: pointer;
}
.find button:hover { border-color: var(--forest); color: var(--forest800); }

/* ── A group in the rail ─────────────────────────────────────────────────────
   A heading and a list, not a <details>, and that reverses 26 August.

   Collapsing was remembered in localStorage and restored on every later page,
   so a founder who closed one section met six bare headings for weeks — a
   stuck control indistinguishable from a tool that had lost its own pages.
   Underneath it was the reason the control was reached for at all: with
   everything open the rail was 1120px in an 830px viewport. Flat is only
   honest if it FITS, which is what the desktop query below is spending its
   pixels on. */
.sect { border-top: 1px solid var(--n100); padding-top: var(--s2); margin-top: var(--s2); }
.sect:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.railgroup {
  display: flex; align-items: center; gap: 6px; margin: 0 0 3px;
  font-family: var(--font-mono); font-size: var(--t-label);
  font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
  color: var(--n500);
}
/* `.rollup` is gone with the section headings that carried it. It existed so a
   CLOSED section could not hide the one number whose job is to say something
   needs you; flat, it rendered WORK 3 directly above Today 3 — the same fact
   twice, and two numbers that add up to six if you are reading quickly. The
   phone menu still closes and still carries a total, on its own summary. */
.sect ul { list-style: none; margin: 0; padding: 0; }
.sect li { margin: 0; }
.sect li a {
  display: flex; align-items: center; gap: 6px;
  font-size: var(--t-dense); line-height: 1.45; text-decoration: none; color: var(--n600);
  padding: 2px 6px 2px 10px; border-left: 2px solid transparent; border-radius: 0 var(--radius) var(--radius) 0;
}
/* A box, and never a weight change. A font-weight change alters glyph widths,
   so every sibling shifts sideways as you move between pages — the reason the
   public site marks its active nav with a rule too. The transparent border
   stops the row moving 2px when one appears.

   It gained a tint when the rail went flat: among twenty-eight visible rows a
   2px rule is findable only if you already know where to look, and "where am
   I" is the one question a rail has to answer without being asked. */
.sect li a.on {
  color: var(--ink); border-left-color: var(--forest); background: var(--forest50);
}
.sect li a:hover { color: var(--forest800); }

.badge {
  display: inline-block; min-width: 16px; margin-left: 5px; padding: 0 var(--s1);
  border-radius: var(--s2); background: var(--amber); color: var(--paper);
  font-family: var(--font-mono); font-size: 10px;
  line-height: 16px; text-align: center; vertical-align: 1px;
}
/* A different kind of alarm gets a different mark, not a larger number. A
   failed tab read invalidates every status on every page; a count cannot say
   that, and adding 1 to one made the two indistinguishable. Square rather than
   round so the two are still told apart without colour. */
.badge.alarm { border-radius: var(--radius-sm); background: var(--ink); }
.asof { font-family: var(--font-mono); font-size: var(--t-label);
  color: var(--n500); margin: var(--s3) 0 0; line-height: 1.6; }
/* System status: is the batch still running, and how old is what you are
   reading. "Nothing to report" and "nothing has run for nine days" look
   identical on a page of figures, so the second says so here. */
.asof .state { display: block; margin-top: 6px; }
.asof .state.stale { color: var(--amber); font-weight: 500; }

/* The phone menu. A <details> so it collapses with no script at all; the
   desktop query below overrides the UA rule rather than toggling the attribute,
   so there is no width measured in JavaScript and nothing jumps after load. */
summary.railmenu {
  display: flex; align-items: center; gap: 6px; cursor: pointer;
  list-style: none; margin-top: 14px; padding: var(--s2) 0;
  border-top: 1px solid var(--n100);
  font-family: var(--font-mono); font-size: var(--t-label);
  font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
  color: var(--n700);
}
summary.railmenu::-webkit-details-marker { display: none; }
summary.railmenu::after { content: "\2261"; margin-left: auto; color: var(--n500); font-size: var(--t-dense); }
details.railnav[open] > summary.railmenu::after { content: "\00D7"; }
summary.railmenu:focus-visible { outline: 2px solid var(--forest); outline-offset: 2px; }

/* Desktop: the rail becomes a column and stops scrolling with the page.
   Below this it is a band above the content, which is the honest phone layout —
   a 15rem column on a 390px screen leaves nothing for the content. */
@media (min-width: 60rem) {
  .frame { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
  header.rail {
    border-bottom: 0; border-right: 1px solid var(--n100);
    min-height: 100vh; position: sticky; top: 0; align-self: start;
    max-height: 100vh; overflow-y: auto;
  }
  .railtop { display: block; }
  .brand { margin-bottom: var(--s2); }
  .find { margin-top: var(--s2); }
  /* ── The rail has to fit, and this is where that is paid for ───────────────
     Twenty-eight destinations, six groups, a lockup, a search box and a read
     stamp, in 830px of MacBook Air. Measured before: 1120px, so the last five
     destinations were below the fold and collapsing a section was the only way
     to reach them.

     Rows are tightened only above this breakpoint. Below it the rail is a
     touched menu on a phone and a row that is comfortable to hit matters more
     than one that fits — a phone scrolls a menu without anybody minding. */
  header.rail { padding-top: var(--s2); padding-bottom: var(--s3); }
  .brand { margin-bottom: 6px; }
  .find { margin-top: 6px; }
  .sect { padding-top: 4px; margin-top: 4px; }
  .railgroup { margin-bottom: 0; }
  .sect li a { padding-top: 0; padding-bottom: 0; line-height: 1.4; }
  /* Centred, not flush left. This is the line that closes the 592px. */
  .col .shell { margin: 0 auto; max-width: var(--shell-max); }
  /* No menu to open: every destination is already visible.
     `::details-content` is the one that matters and it took measuring to find.
     Chrome hides a closed <details> by putting `content-visibility: hidden` on
     that pseudo-element, not `display: none` on the children — so overriding
     `display` left the nav with a correct 898px box and **nothing painted**,
     which is invisible to every DOM assertion and obvious in a screenshot.
     Both rules are kept: `display` for engines that still hide the children. */
  summary.railmenu { display: none; }
  details.railnav::details-content { content-visibility: visible; }
  details.railnav > nav { display: block; content-visibility: visible; }
}

/* ── Page header ─────────────────────────────────────────────────────────────
   Where you are, what this is, and what you can do about it — in that order,
   once, at the top of every page. The tool previously had no location
   indicator at all: /shipment and a /person profile marked their PARENT in the
   rail and left the reader to infer they were one level down. */
/* The header is a <section>, so it collected `section`'s own 64px
   padding-bottom on top of its 24px margin AND the lead's 28px — about 116px
   of dead space between a record's context line and the first thing it says.
   Found by looking; invisible to every assertion in the suite. The margin is
   the one that sets this gap, so the other two are zeroed here. */
.pagehead { padding-top: var(--s7); padding-bottom: 0; margin-bottom: var(--s5); }
.pagehead p.lead { margin-bottom: 0; }
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--s2);
  margin: 0 0 var(--s3); padding: 0; list-style: none;
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .1em; text-transform: uppercase; color: var(--n500); }
.crumbs li { display: flex; align-items: center; gap: 0 var(--s2); }
.crumbs li + li::before { content: "/"; color: var(--n300); }
.crumbs a { color: var(--n600); text-decoration: none; border-bottom: 1px solid transparent; }
.crumbs a:hover { color: var(--forest800); border-bottom-color: var(--forest700); }
.crumbs [aria-current="page"] { color: var(--ink); }
.pagehead .headrow { display: flex; flex-wrap: wrap; align-items: flex-start;
  justify-content: space-between; gap: var(--s3) var(--s5); }
.pagehead .actions { margin: 0; }
/* The identity strip: the reference, copyable, beside the record's status.
   Every major record shows one, because an id you cannot copy is an id you
   retype wrong into a spreadsheet. */
.idline { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s3);
  margin: 0 0 var(--s3); font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .08em; color: var(--n500); }
.idline .ref { color: var(--ink); text-transform: none; letter-spacing: .04em; }
button.copy { font: inherit; font-size: var(--t-label); letter-spacing: .08em;
  text-transform: uppercase; padding: 3px var(--s2); cursor: pointer;
  border: 1px solid var(--n200); border-radius: var(--radius-sm);
  background: var(--paper); color: var(--n600); }
button.copy:hover { border-color: var(--forest700); color: var(--forest800); }
button.copy[data-copied="yes"] { border-color: var(--forest700); color: var(--forest800);
  background: var(--forest50); }
/* No script, no copy button: it would be a control that does nothing. The
   reference itself is selectable text either way. */
html:not([data-js="on"]) button.copy { display: none; }

/* ── The command palette ─────────────────────────────────────────────────────
   Cmd/Ctrl-K, or "/". A <dialog> the server renders on every page with every
   destination already inside it, so the script shows it and hides rows and
   never builds one. Closed, a <dialog> is display:none and out of the
   accessibility tree, which is why nothing here needs a data-js guard: with
   the script off it simply never opens and the rail's own form still works. */
/* Every rule is on `[open]`, and that is load-bearing rather than tidy.
   The UA hides a closed dialog with `dialog:not([open]) { display: none }`,
   which a bare `dialog.palette { display: flex }` outranks on specificity — so
   the closed palette rendered in normal flow over the page, swallowing every
   click on it. Nothing looked wrong in the markup and the `open` attribute was
   correctly absent, which is why only a browser found it. */
dialog.palette[open] {
  width: min(40rem, calc(100vw - 2 * var(--gutter)));
  max-height: min(34rem, calc(100vh - 4rem));
  padding: var(--s4);
  border: 1px solid var(--n300); border-radius: var(--radius);
  background: var(--paper); color: var(--ink);
  /* Above centre. A palette pinned to the middle of a tall screen puts the
     field under the reader's hands and the answers below the fold. */
  margin: 12vh auto auto;
  overflow: hidden;
  display: flex; flex-direction: column; gap: var(--s3);
}
dialog.palette[open]::backdrop { background: rgba(16, 16, 16, .38); }
.palette-find { display: flex; gap: 6px; }
.palette-find input {
  flex: 1 1 auto; min-width: 0; font: inherit; font-size: var(--t-body);
  padding: 9px 12px; border: 1px solid var(--n200); border-radius: var(--radius);
  background: var(--paper); color: var(--ink);
}
.palette-find button {
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .1em; text-transform: uppercase; padding: 6px var(--s3);
  border: 1px solid var(--n200); border-radius: var(--radius);
  background: var(--paper); color: var(--n700); cursor: pointer;
}
.palette-find button:hover { border-color: var(--forest); color: var(--forest800); }
/* What Enter will do, in words. The palette has two actions leading to two
   different places, so a reader who cannot tell which is armed navigates by
   accident. */
.palette-hint, .palette-none {
  margin: 0; font-size: var(--t-fine); color: var(--n600); max-width: var(--measure);
}
.palette-none { color: var(--alert); }
.palette-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1 1 auto; }
.palette-list li[hidden] { display: none; }
.palette-list li + li { border-top: 1px solid var(--n100); }
.palette-list a {
  display: block; padding: 7px var(--s2); text-decoration: none;
  border-left: 2px solid transparent; color: var(--n600);
  font-size: var(--t-dense); line-height: 1.4;
}
.palette-list b { display: block; font-weight: 500; color: var(--ink); }
/* A saved view is a page with a question already asked of it. "Waiting on you"
   and "All shipments" lead to the same route and answer different things, so
   the list must not draw them identically. */
.palette-list .tag {
  margin-left: var(--s2); font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .1em; text-transform: uppercase; font-weight: 400;
  color: var(--forest800); vertical-align: 1px;
}
.palette-list a:hover { background: var(--n50); }
/* The armed row, marked the same way the rail marks the page you are on: a
   forest rule and a tinted ground, never a weight change, because a font-weight
   swap shifts every glyph sideways as the highlight moves. */
.palette-list li[data-on="yes"] a {
  border-left-color: var(--forest); background: var(--forest50); color: var(--ink);
}
.palette-close {
  align-self: flex-start;
  font-family: var(--font-mono); font-size: var(--t-label);
  /* A real tap target: this is the only way off the palette on a phone, where
     there is no Escape key. `drive-ops-login.mjs` holds every visible control
     to 44px and would count this one the moment the dialog is open. */
  letter-spacing: .1em; text-transform: uppercase; padding: 13px var(--s4);
  border: 1px solid var(--n200); border-radius: var(--radius-sm);
  background: var(--paper); color: var(--n600); cursor: pointer;
}
.palette-close:hover { border-color: var(--forest700); color: var(--forest800); }

/* ── Buttons ─────────────────────────────────────────────────────────────────
   Three roles. Every variant carries a 1px border, transparent where it is not
   drawn, so a control's box does not change size with its variant — the site
   measured a filled/outlined pair at 54.5 and 56.5px tall side by side. */
.btn { display: inline-flex; align-items: center; gap: 6px;
  font: inherit; font-size: var(--t-dense); line-height: 1.2;
  padding: 9px 14px; cursor: pointer; text-decoration: none;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: var(--paper); color: var(--ink); }
.btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.btn--primary:hover { background: var(--forest800); border-color: var(--forest800); }
/* A disabled control says why. A dead button with no explanation is the thing
   an operator retries three times before asking. */
.btn[aria-disabled="true"] { color: var(--n500); border-color: var(--n200);
  background: var(--n50); cursor: not-allowed; }

/* ── Chips ───────────────────────────────────────────────────────────────────
   A status, in one word, in a shape you can scan down a column. Colour is never
   the only channel: each tone also differs in border and ground, and the word
   itself is the meaning. */
.chip { display: inline-block; padding: 2px var(--s2); border-radius: 10px;
  border: 1px solid var(--n200); background: var(--paper); color: var(--n700);
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; }
.chip--live { border-color: var(--forest700); color: var(--forest900); background: var(--forest50); }
.chip--wait { border-color: var(--amber); color: var(--amber); background: var(--amber50); }
.chip--stop { border-color: var(--alert); color: var(--alert); background: var(--alert50); }
.chip--mute { color: var(--n500); }

/* ── Panels ──────────────────────────────────────────────────────────────────
   A bordered group. Sizes to its own content, never to the shell — a panel's
   interior is its own coordinate system and is not on the page grid. */
.panel { border: 1px solid var(--n200); border-radius: var(--radius);
  background: var(--paper); }
.panel__head { display: flex; flex-wrap: wrap; align-items: baseline;
  justify-content: space-between; gap: var(--s2) var(--s3);
  padding: var(--s3) var(--s4); border-bottom: 1px solid var(--n100);
  background: var(--n50); }
.panel__head h2, .panel__head h3 { margin: 0; }
.panel__body { padding: var(--s4); }
.panel__body > :last-child { margin-bottom: 0; }

/* A record's metadata, as pairs. Two columns where there is room; stacked where
   `max-content 1fr` would leave the answers nine characters wide. */
/*
 * One label column, fixed, so every definition list on the page lines up.
 *
 * `max-content` sizes each list to its OWN longest label, so two lists stacked
 * in one column start their values at different x — measured at 130px under
 * "The pairing" and 175px under "The money", 600px apart vertically on the same
 * page. Nothing about either list looks wrong alone; together they read as a
 * page that was assembled rather than composed.
 *
 * 11rem clears the longest label in use ("STEWARD IS PAID", "DECLARED STOPS")
 * at the 11px tracked mono these are set in.
 */
dl.meta { display: grid; grid-template-columns: 11rem minmax(0, 1fr);
  gap: 6px var(--s4); margin: 0 0 var(--s4); }
/* Inside a panel the column is narrow and the labels are short, so a fixed
   11rem spends a third of it on empty gutter. The alignment argument above is
   about lists stacked in ONE column; two lists in two different panels are
   never read against each other. */
.panel dl.meta { grid-template-columns: max-content minmax(0, 1fr); }
dl.meta dt { font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .06em; text-transform: uppercase; color: var(--n500); line-height: 1.9; }
dl.meta dd { margin: 0; font-size: var(--t-dense); color: var(--n700); }
dl.meta dd.strong { color: var(--ink); }
@media (max-width: 640px) {
  dl.meta { grid-template-columns: 1fr; gap: 2px; }
  dl.meta dd { margin: 0 0 var(--s2); }
}

/* ── The record layout ───────────────────────────────────────────────────────
   A record's substance on the left, everything that points ELSEWHERE on the
   right. The split is by role and not by size: the aside is what you use to
   leave this record, so putting it beside the content rather than under it is
   what makes a chain of records feel like one investigation.

   Below the desktop breakpoint both spans collapse to full width and the aside
   follows the record, which is the right reading order on a phone — you read
   the thing before you decide where to go next. */
.record { align-items: start; }
.record .aside { position: sticky; top: var(--s5); }
@media (max-width: 59.99rem) { .record .aside { position: static; } }
.aside .panel + .panel { margin-top: var(--s4); }
/* A list of ways out. Each row is one related record, named by what it is. */
ul.related { list-style: none; margin: 0; padding: 0; }
ul.related li { display: flex; flex-wrap: wrap; align-items: baseline;
  gap: 0 var(--s2); padding: var(--s2) 0; border-top: 1px solid var(--n100); }
ul.related li:first-child { border-top: 0; padding-top: 0; }
ul.related .what { font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .08em; text-transform: uppercase; color: var(--n500);
  flex: 1 0 100%; }

/* ── Loading ─────────────────────────────────────────────────────────────────
   A skeleton that is the shape of what is coming, so the page does not jump
   when it lands. Static rather than shimmering: this tool renders server-side
   and a skeleton is only ever seen for the length of one navigation, where an
   animation is noise. */


/* A figure. Rhythm lives here rather than at the call site, the same rule the
   rest of this stylesheet follows: fifteen figures each spacing themselves is
   fifteen chances to pick a different number. */
.fig { position: relative; margin: 0 0 20px; }
.fig svg { display: block; width: 100%; height: auto; }
.fig .overlay { position: absolute; inset: 0; pointer-events: none; }
/* The map is the one figure with a natural size worth holding. Left to fill
   the shell it draws its cities too far apart to read as a cluster.

   The aspect ratio is the frame's own, and it has to be: the city labels are
   positioned as a percentage of this box, while the dots beneath them are
   placed by the viewBox. Two transforms for one point is the 13px displacement
   the public map shipped, and jsdom cannot see it — so map.test.ts asserts this
   number against staticViewBox rather than trusting it. */
.fig.map { max-width: 46rem; margin-left: auto; margin-right: auto;
  aspect-ratio: 209.57 / 175.73; }
.fig.map svg { height: 100%; }
/* On a record page the map supports the content rather than dominating it: in a
   4-column aside it takes the column's width and no more. */
.c-4 .fig.map, .c-5 .fig.map { max-width: 100%; }
/* A city dot at a constant 4 screen pixels.

   These two numbers are user units, not pixels, and that distinction is the
   whole defect: CSS px on an SVG geometry property resolves in user units, so
   r:4px is identical to the r attribute and scales with the viewBox just the
   same. The first version of this map drew 28px dots and clipped the first
   letter of every city name. Both values come from dotRadiusUnits in
   render-map.ts, and map.test.ts asserts them against it — including the exact
   single-line shape of these two declarations. */
.fig.map circle.city { r: 2.451; }
@media (min-width: 40rem) { .fig.map circle.city { r: 1.139; } }
/* A city name. 11px mono, the tool's label role, and the same size at every
   width — which is the whole reason it is HTML rather than SVG text. */
.maplabel { position: absolute; font-family: var(--font-mono);
  font-size: var(--t-label); letter-spacing: .04em; color: var(--n700); white-space: nowrap;
  transform: translateY(-50%); }
.maplabel.left { transform: translate(-100%, -50%); padding-right: var(--s2); }
.maplabel.right { padding-left: var(--s2); }
.maplabel.off { color: var(--n500); }
/* The two ends of a lattice's horizon. Room is made for them below the grid
   rather than over it, so a label never sits on a cell it is describing. */
.fig.lattice { padding-bottom: 18px; }

/* ── the pairing matrix ──────────────────────────────────────────────────────
   A real grid of elements, not an SVG. `marks.ts` states the rule: continuous
   position is a drawing, categorical position is a grid. The label column and
   the cells are items in the same flex row, so every column edge lines up by
   construction rather than by arithmetic against a viewBox. */
.matrix { margin: 0 0 var(--s5); }
.matrix__row { display: flex; align-items: center; gap: var(--s3); }
.matrix__row + .matrix__row { margin-top: 3px; }
/* These two are below the 44px tap floor and knowingly so: at 44px a ten-row
   matrix is 440px of squares and stops being a shape you take in at a glance,
   which is the only thing it is for. Measured against the rest of the tool this
   is not a regression — /matches carries 40 sub-44px targets and /shipments 11,
   because a dense table's links are all this size. The mitigation is real
   rather than notional: every destination reachable from a matrix label is also
   a full-height link in the "Request by request" table directly beneath it. */
.matrix__label { flex: none; width: 5.5rem; font-family: var(--font-mono);
  font-size: var(--t-label); text-align: right; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.matrix__cells { display: flex; flex: 1 1 auto; gap: 2px; min-width: 0; }
.matrix__head { flex: 1 1 0; min-width: 0; font-family: var(--font-mono);
  font-size: var(--t-label); text-align: center; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; padding-bottom: var(--s1); }

/* The cascade ramp. Four greys, dark meaning the pair got further, plus forest
   for one that got all the way. `cascadeClass` in marks.ts picks the step, so
   the encoding is declared once and this is only its rendering. */
/* A fixed row height rather than a square cell. Square cells at six columns
   across a desktop shell are 88px each, so ten requests become an 880px wall
   and the shape stops being something you take in at a glance — which is the
   only thing this figure is for. */
.cell { flex: 1 1 0; min-width: 0; height: 20px; border-radius: 1px;
  background: var(--n50); }
.cell--d1 { background: color-mix(in srgb, var(--n300) 32%, var(--paper)); }
.cell--d2 { background: color-mix(in srgb, var(--n300) 55%, var(--paper)); }
.cell--d3 { background: color-mix(in srgb, var(--n300) 78%, var(--paper)); }
.cell--d4 { background: var(--n300); }
.cell--ok { background: var(--forest); }

/* ── the supply calendar ─────────────────────────────────────────────────────
   Seven fixed columns, so the weekday strip and the days share one set of
   column edges without anything being computed. */
/* One grid: a date gutter, then seven day columns. The weekday strip and every
   week row are items in it, so a label lines up with its own row without
   anything being computed. */
/* Capped, because the days are squares in a seven-column grid: uncapped they
   render 62px at 1280 and 78px at 1920, so the same figure is a different size
   on two desktops and the larger one spends a lot of ink on a value that is
   nearly binary. The cap holds a day at about 61px from 480px up. */
.cal { display: grid; grid-template-columns: 3.2rem repeat(7, 1fr);
  gap: 3px; align-items: center; margin: 0 0 var(--s5); max-width: 30rem; }
.cal__week, .cal__day { font-family: var(--font-mono); font-size: var(--t-label);
  color: var(--n500); }
.cal__week { text-align: right; padding-right: var(--s2); }
.cal__day { text-align: center; padding-bottom: var(--s1); }
.day { aspect-ratio: 1; border-radius: 2px; background: var(--n50); }
.day--blank { background: none; }
.day--s1 { background: color-mix(in srgb, var(--forest) 25%, var(--paper)); }
.day--s2 { background: color-mix(in srgb, var(--forest) 50%, var(--paper)); }
.day--s3 { background: color-mix(in srgb, var(--forest) 75%, var(--paper)); }
.day--s4 { background: var(--forest); }
/* A steward nobody has screened is never shaded — they are not supply. The
   outline is what one phone call would turn into a filled square. */
.day--pending { box-shadow: inset 0 0 0 1.5px var(--supply); }
.day--today { outline: 2px solid var(--ink); outline-offset: -1px; }

/* ── the queue lanes ─────────────────────────────────────────────────────────
   The overlay is a grid of equal rows matching the figure's own equal lanes,
   so a lane name sits on its lane without a per-index rule. `.axis` is
   absolutely positioned and therefore out of that flow. */
/* NOT `.lanes` — `figure({className: "lanes"})` renders `<div class="fig lanes">`,
   so a wrapper of that name is applied to the figure as well. It was: the fig
   became a two-column grid whose first track was `max-content`, an <svg> with a
   viewBox and no width attribute reports an intrinsic 300px, and the document
   came out 20px wider than a 390px phone. A horizontal scroll is invisible in a
   screenshot and immediate under a thumb.

   Every figure class in this file is unprefixed — map, lattice, flow, spread,
   lanes — so any layout class sharing one of those names silently restyles the
   figure. Prefix layout wrappers. */
.lanes-row { display: grid; grid-template-columns: max-content minmax(0, 1fr);
  gap: var(--s3); margin-bottom: var(--s5); }
/* A grid item's automatic minimum is its content, and that <svg> intrinsic
   width is what it would be measured as. `.grid` carries the same line. */
.lanes-row > * { min-width: 0; }
/* Equal rows matching the figure's own equal lanes, aligned to the baseline
   each lane's rule sits on. Outside the drawing, so a tall pile of dots can
   never sit on top of a name — which is exactly what the first render did. */
.lanes-row__names { display: grid; grid-auto-rows: 1fr; }
.lanes-row__names span { align-self: end; padding-bottom: 2px;
  font-size: var(--t-fine); color: var(--n600); white-space: nowrap; }
.lanes-row__names b { font-family: var(--font-mono); color: var(--ink);
  margin-left: var(--s2); }
.lanes-row__fig .fig { margin-bottom: 0; }
.fig.lanes { padding-bottom: 18px; }
/* A dot's radius is in USER UNITS even when written in CSS — `r: 4px` on an SVG
   geometry property resolves exactly like `r="4"` and scales with the viewBox.
   That is why the map converts its radius per width band rather than setting a
   pixel value, and the same applies here: at 390 the figure is about 236px wide
   against a 500-unit box, so an unbanded radius renders a 2px dot on the device
   this tool is most often carried on. Two bands, both landing near 7px. */
.fig.lanes circle { r: 3.4; }
@media (min-width: 40rem) { .fig.lanes circle { r: 1.8; } }

/* ── the attrition funnel ────────────────────────────────────────────────────
   Two columns matching the figure: the trunk occupies 160 of its 196 units and
   the leak wedges reach into the remaining 36. */
.fig.flow .overlay { display: grid; grid-template-columns: 320fr 180fr;
  grid-auto-rows: 1fr; column-gap: var(--s3); }
/* The trunk is left-aligned, so its label is too. Centring it would drift with
   the taper and stop naming the band it sits on. */
.flow__stage { align-self: center; justify-self: start; padding-left: var(--s3);
  font-size: var(--t-fine); color: var(--n600); }
.flow__stage b { font-family: var(--font-mono); font-size: var(--t-dense);
  color: var(--ink); margin-left: var(--s2); }
.flow__leak { align-self: center; font-size: var(--t-label);
  color: var(--demand); line-height: 1.25; }
.flow__leak b { font-family: var(--font-mono); }
@media (max-width: 52rem) {
  /* The leak labels have nowhere to go on a phone, and a wrapped four-word
     label overlapping the trunk says less than none. The reading is in the
     note and the table beneath. */
  .fig.flow .overlay { grid-template-columns: 1fr; }
  .flow__leak { display: none; }
}

/* ── the price spread ────────────────────────────────────────────────────────
   Same equal-rows overlay as the lanes. */
.fig.spread { padding-bottom: 18px; }
.fig.spread .overlay { display: grid; grid-auto-rows: 1fr; }
.spread__name { align-self: start; padding-top: 2px; font-size: var(--t-label);
  color: var(--n500); }

/* A legend whose swatches are the marks themselves, so a reader matches the
   key to the figure by shape and not by description. */
.legend--ramp b.cell, .legend--ramp b.day { display: inline-block; flex: none;
  width: 11px; height: 11px; vertical-align: -1px; aspect-ratio: auto; }

.axis { position: absolute; bottom: 0; font-family: var(--font-mono);
  font-size: var(--t-label); color: var(--n500); }
.axis.start { left: 0; }
.axis.end { right: 0; }
.fig + p.note, .fig + .scroll { margin-top: -8px; }
/* The legend beneath a figure. Text, so the marks it names must meet AA — both
   do, which is why the two market tokens were chosen above the mark bar. */
.legend { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 0 0 20px;
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .08em; text-transform: uppercase; }
.legend b { font-weight: 500; }
.legend .demand { color: var(--demand); }
.legend .supply { color: var(--supply); }

/* ── the pairing board ──────────────────────────────────────────────────────
   A card is a panel, so it sizes to its own text rather than to the shell —
   the rule docs/dashboard.md already states. The three columns are equal
   because the two people and the journey carry equal weight; making the route
   narrower reads as the route being the caption to the names. */
.card { border: 1px solid var(--n200); border-radius: var(--radius); margin: 0 0 var(--s4); }
.card > header { display: flex; justify-content: space-between; gap: var(--s3);
  padding: var(--s3) var(--s4); border-bottom: 1px solid var(--n100); background: var(--n50); }
.parties { display: grid; gap: 1px; background: var(--n100); }
.party { background: var(--paper); padding: 14px var(--s4); }
.party .who { margin: var(--s1) 0 2px; font-size: var(--t-body); }
.party p.note { margin: 2px 0; }
.card > footer { padding: var(--s3) var(--s4); border-top: 1px solid var(--n100); }
.marks { display: flex; flex-wrap: wrap; gap: 6px 18px;
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .06em; text-transform: uppercase; color: var(--n500); }
.mark b { margin-left: 5px; font-weight: 600; }
.mark.yes b { color: var(--forest); }
.mark.no b { color: var(--n300); }
.mark i { font-style: normal; text-transform: none; letter-spacing: 0; }
p.waiting { margin-top: 10px; color: var(--amber); }
/* The one form in the tool. Stacked on a phone, inline where there is room. */
.accept { margin-top: var(--s3); padding-top: var(--s3); border-top: 1px dashed var(--n200);
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s3); }
.accept label { font-family: var(--font-mono);
  font-size: var(--t-label); letter-spacing: .08em; text-transform: uppercase; color: var(--n500); }
.accept input { flex: 1 1 18rem; min-width: 0; padding: var(--s2) 10px; font: inherit;
  font-size: var(--t-dense); border: 1px solid var(--n300); border-radius: var(--radius-sm);
  background: var(--paper); color: var(--ink); }
.accept button { padding: 9px 14px; font: inherit; font-size: var(--t-dense); cursor: pointer;
  border: 1px solid var(--forest); border-radius: var(--radius-sm);
  background: var(--forest); color: var(--paper); }
.accept p.note { flex: 1 0 100%; margin: 0; }
ul.reasons { margin: var(--s2) 0 0; padding-left: 18px; font-size: var(--t-dense); color: var(--n600);
  max-width: var(--measure); }
/* The chain on /ready. A number in its own gutter, held open whether or not the
   row has one, so every line of prose starts where every other line does — the
   same text-column rule the public site settled on 23 August. */
ol.stages { list-style: none; margin: 0; padding: 0; }
ol.stages li.stage { display: grid; grid-template-columns: 34px 1fr;
  gap: 0 var(--s3); padding: 20px 0; border-top: 1px solid var(--n100); }
ol.stages li.stage:last-child { border-bottom: 1px solid var(--n100); }
ol.stages .n { font-family: var(--font-mono);
  font-size: var(--t-dense); color: var(--n500); line-height: 1.6; }
/* The measure is held by the COLUMN, not by each element inside it.
   Per-element, the 19px heading and the 15px prose beneath it resolve the
   measure to different widths and end at different right edges — the ragged
   pair the 23 August audit names. One cap on the column gives the stage one
   right edge, and the heading is a title rather than prose so it loses nothing. */
ol.stages .body { min-width: 0; max-width: var(--measure); }
ol.stages h3 { font-size: var(--t-sub); font-weight: 400; margin: 0 0 6px;
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
ol.stages p { margin: 0 0 6px; }
/* The column above already holds the measure, so a note inside one must not
   cap tighter and introduce a third right edge. */
ol.stages p.note { max-width: none; }
/* The evidence field on an acknowledgement.
   Stacked label above input, because the prompt is a question rather than a
   caption and reads badly beside a box. */
label.ack-evidence { display: block; margin: 0 0 var(--s2); max-width: var(--measure); }
label.ack-evidence span { display: block; font-size: var(--t-fine); color: var(--n600);
  margin-bottom: 3px; }
label.ack-evidence input { width: 100%; box-sizing: border-box; padding: 6px var(--s2);
  font: inherit; font-size: var(--t-dense); color: var(--ink); background: var(--paper);
  border: 1px solid var(--n200); border-radius: var(--radius-sm); }
label.ack-evidence input:focus-visible { outline: 2px solid var(--forest);
  outline-offset: 1px; }
dl.proof { display: grid; grid-template-columns: max-content 1fr;
  gap: var(--s1) var(--s3); margin: 10px 0 var(--s2); }
dl.proof dt { font-size: var(--t-label); letter-spacing: .06em; text-transform: uppercase;
  color: var(--n500); line-height: 1.9; }
dl.proof dd { margin: 0; font-size: var(--t-dense); color: var(--n700); }
/* At a phone width max-content plus 1fr leaves the answers about nine
   characters wide, so the two stack. */
@media (max-width: 640px) {
  dl.proof { grid-template-columns: 1fr; gap: 2px; }
  dl.proof dd { margin: 0 0 var(--s2); }
}
/* A stage nobody can start is drawn back rather than hidden. The useful fact
   about it is which stage to work on instead, and that is in the copy. */
ol.stages li.blocked .body { opacity: .62; }
ol.stages li.waiting .n { color: var(--amber); }
@media (min-width: 40rem) {
  .parties { grid-template-columns: 1fr 1fr 1fr; }
}
section { padding-bottom: var(--s8); }
section:first-of-type { padding-top: var(--s7); }
/* The page header owns the top padding where it is used, so a page does not get
   both. */
.pagehead + section, section.pagehead:first-of-type + section { padding-top: 0; }
h1 { font-family: var(--font-display); font-size: var(--t-title); font-weight: 300;
  letter-spacing: -.015em; margin: 0 0 var(--s2); }
/* A real section heading, which this tool did not have: h2 was a 12px eyebrow,
   so a record page's parts were marked more quietly than its table captions. */
h2.title { font-family: var(--font-display); font-size: var(--t-section);
  font-weight: 300; letter-spacing: -.01em; margin: 0 0 var(--s3); }
h3 { font-size: var(--t-sub); font-weight: 400; margin: 0 0 var(--s2); }
h2.eyebrow { font-family: var(--font-mono); font-size: var(--t-fine); font-weight: 500;
  letter-spacing: .15em; text-transform: uppercase; color: var(--ink); margin: 0 0 20px; }
/* A sub-section inside a section. Same role, one step quieter. */
h2.eyebrow.sub { color: var(--n500); margin-top: 36px; }
/* One width for the bar column, whatever the table. It was 22, 25, 30, 40 and
   45 per cent across five tables doing the identical job. */
/* ── Prose in a table cell obeys the measure like prose anywhere else ────────
   Measured at 1470 before this existed: definition cells at 119 characters a
   line, scoreboard cells at 139. Nearly double the 60-72 band, on three pages,
   for the life of the tool — and invisible, because the browser gate inspected
   `p`, `li > p`, `dd` and `.empty` and never a `td`. A checker with a
   hand-written list of what to look at is a checker that will one day look at
   the wrong set and say nothing; the gate reads `td` now.

   `--measure` is in `ch`, so this self-calibrates: at a cell's own 14px it
   resolves smaller than the same token does at 15px body copy, which is the
   whole reason the token is not in rem. A cap, not a width — a short cell is
   unaffected, and the bar column below is narrower than this at every shell
   width the tool has. */
td { max-width: var(--measure); }
th.bar-col, td.bar-col { width: 32%; }
/* A table of three short columns should not stretch its label column across
   the container. The funnel put ~500px of nothing between each stage and its
   own count, over nine rows: connected content reading as unrelated, which is
   item 3 of the review. Same rule as the warn panel, applied to a table. */
table.compact th:first-child, table.compact td:first-child { width: 42%; }
p.lead { font-size: var(--t-lead); color: var(--n600); max-width: var(--measure); margin: 0 0 28px; }

/* The north star: the one figure that closes the loop.
   Sized at the lead's 17px rather than given a rung of its own — the type scale
   is eight sizes and adding a ninth for one element is how a scale stops being
   one. It earns prominence from the forest rule and the ink weight instead,
   which is the same move the custody line makes on the public site. */
p.northstar { font-size: var(--t-lead); color: var(--n700); max-width: var(--measure);
  margin: 0 0 28px; padding-left: 14px; border-left: 2px solid var(--forest); }
p.northstar b { color: var(--ink); display: block; margin-bottom: var(--s1); }

/* ── A page's own conclusion ─────────────────────────────────────────────────
   The same visual language as the north star, because it does the same job one
   page down: the thing the reader came for, with the evidence beneath it.

   The withheld state is quieter AND says what is missing. A blank where an
   answer should be cannot be told apart from a page that has not finished
   loading, and this tool has already learned that a quiet day and a failed read
   produce identical blanks. Grey and a dashed rule, so the two states are told
   apart without relying on colour alone. */
p.conclusion { font-size: var(--t-lead); color: var(--ink); max-width: var(--measure);
  margin: 0 0 28px; padding-left: 14px; border-left: 2px solid var(--forest); }
p.conclusion--withheld { color: var(--n600); border-left-style: dashed;
  border-left-color: var(--n300); }
/*
 * Supporting prose is 14px, not 12px.
 *
 * It was the smallest and palest type in the tool and it carries running
 * sentences — several paragraphs of them on the Scoreboard. 12px is now the
 * eyebrow's size and nothing else's, so each of the two has one role.
 *
 * The rhythm is encoded in the sibling selectors below rather than typed at
 * each call site. Thirty-four inline margins carried three different values —
 * 16, 20 and 24 — for the same two relationships, which is the "collection of
 * arbitrary values" the review names.
 */
p.note { font-size: var(--t-dense); color: var(--n500); max-width: var(--measure); margin: 0 0 var(--s3); }
p.note:last-child { margin-bottom: 0; }
/* A note that explains the figure above it. */
.scroll + p.note, .tiles + p.note, table + p.note { margin-top: 20px; }
/* A note that introduces what follows. */
p.note-intro { margin-bottom: 20px; }
/* The hairline between tiles is an OUTLINE on each tile, not a 1px gap over a
   coloured container. The container trick paints the empty cells too, so four
   tiles in a three-column row rendered a grey rectangle where the fourth should
   have been — visible at 390px, invisible at 1280 where the row is full.
   An outline sits outside the box without affecting layout, so adjacent tiles'
   outlines meet in the gap and the outermost ones draw the frame. */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; }
/* min-width:0 is load-bearing, not defensive. A grid item's automatic minimum is
   its content, so one unbreakable string — the corridor key under the Longest
   tile — widens its track, widens the grid, and pushes the whole document
   sideways. Found at 390px by measuring scrollWidth against clientWidth: 9px
   over, invisible in a screenshot and obvious to a thumb. */
.tile { background: var(--paper); padding: 18px 20px; min-width: 0; outline: 1px solid var(--n200); }
.tile .v, .tile .s { overflow-wrap: anywhere; }
.tile .k { font-family: var(--font-mono); font-size: var(--t-label); letter-spacing: .12em;
  text-transform: uppercase; color: var(--n500); }
.tile .v { font-family: var(--font-display); font-size: var(--t-title); font-weight: 300;
  letter-spacing: -.02em; margin-top: 6px; font-variant-numeric: tabular-nums; }
.tile .s { font-size: var(--t-fine); color: var(--n500); }

/* ── a tile's own history ───────────────────────────────────────────────────
   The sparkline is small on purpose: it is read as a shape beside a number,
   not as a chart, and a tile that becomes a chart stops being a tile. 26px is
   the drawing's own 24-unit box plus a hairline of air; the width is the
   tile's, so a narrow column shortens the series' span rather than its height.

   `.fig` carries a 20px bottom margin for a figure that stands on its own.
   Inside a tile it is followed by the delta line, so that margin is removed
   here rather than made conditional in `figure()` — the wrapper knows what it
   contains and the mark does not. */
.tile .t { margin-top: 10px; }
.tile .t .fig { margin: 0; height: 26px; }
.tile .t .fig svg { height: 26px; }
.tile .t p.s { margin: 6px 0 0; }

/* The change, and the interval it is against, on one line. The interval is
   never optional: a bare "+5" invites the reader to supply their own, and the
   one they supply is whatever the page said last time they looked. */
.tile .t .d { font-family: var(--font-mono); color: var(--ink); }
.tile .t .dl { color: var(--n500); }
/* A tile that is also a link to the rows behind its number. The whole tile is
   the target, so there is never a question of what is clickable. */
a.tile { display: block; text-decoration: none; color: inherit; }
a.tile:hover { outline-color: var(--forest700); }
a.tile:hover .k { color: var(--forest800); }
/* The trend inside a tile. A mark, not a chart: no axis, no label, and the
   figure above it is the value. */
/* A table on a 390px screen scrolls inside its own box. The alternative — which
   is what shipped — is the whole document scrolling sideways, which this project
   has already shipped once on the public site and calls invisible in a
   screenshot and obvious to a thumb. */
.scroll { overflow-x: auto; }
/*
 * A table stops stretching once its columns have what they need.
 *
 * `width: 100%` puts every spare pixel into the widest column, which is almost
 * always the label. That was invisible at a 1088px shell and became a defect at
 * 1440: on the Scoreboard the assumption text ended at x=660 and its own
 * READING sat at x=1380 — **720px of nothing between a row and its own
 * number**, which is connected content reading as unrelated. Widening the shell
 * caused it, so it is fixed here rather than left for the page rewrites.
 *
 * Automatic layout sizes to content and wraps only past max-width, so a
 * genuinely wide table (Records) still fills the shell while a label-plus-three-
 * numbers table stays legible. The slack lands at the table's right edge, where
 * it reads as margin, instead of in the middle of every row.
 */
.scroll > table { width: auto; min-width: 32rem; max-width: 100%; }
/* A table with five or more columns spans instead. The `width: auto` rule above
   exists because a full-width THREE-column table put 720px between a row and its
   own number (92fa457); at five columns that gap is a column boundary rather
   than a void, and the reader is already tracking across. The count is read off
   the header in `table()` — never chosen at a call site, because a count
   supplied at 58 call sites is a count that is wrong at one of them. */
.scroll.wide > table { width: 100%; }
/* A reference is atomic. `S-0002` broken across two lines in a narrow column
   reads as two things, and it is the one string in a table a person retypes
   into a spreadsheet. The route beside it may wrap; this may not. */
td a[href^="/trip?"], td a[href^="/shipment?"], td a[href^="/proposal?"],
td a[href^="/run?"] { white-space: nowrap; }
/* Per CELL, which is what three render modules have been asking for since they
   were written: `render-record.ts` and `render-shipments.ts` emit
   `<td class="nowrap">` for a date range and a window, and only the TABLE form
   below existed — so those cells wrapped anyway wherever their table did not
   carry the class. A dead selector that looks live is worse than a missing one. */
td.nowrap, th.nowrap { white-space: nowrap; }
table.nowrap td, table.nowrap th { white-space: nowrap; }
table { width: 100%; border-collapse: collapse; font-size: var(--t-dense); }
caption { text-align: left; font-size: var(--t-fine); color: var(--n500); padding-bottom: 10px; }
th { text-align: left; font-family: var(--font-mono); font-size: var(--t-label); font-weight: 500;
  letter-spacing: .12em; text-transform: uppercase; color: var(--n500); padding: 0 var(--s3) var(--s2) 0; border-bottom: 1px solid var(--n200); }
/* A sortable header has to look sortable before it is clicked. Every header on
   Records is a link and none of them looked like one. */
th a { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--n300); }
th a:hover, th a:focus-visible { color: var(--ink); border-bottom-color: var(--forest); }
th a.sorted { color: var(--ink); border-bottom: 1px solid var(--forest); }
td { padding: 10px var(--s3) 10px 0; border-bottom: 1px solid var(--n100); vertical-align: middle; font-variant-numeric: tabular-nums; }
td.num, th.num { text-align: right; }

/* A value the tool declines to give, inside a numeric column.
   Deliberately a third thing: a blank cannot be told apart from a page that has
   not finished loading, and the em dash already means "this figure did not
   move". --n500 rather than --n400, which contrast.test.ts asserts FAILS AA as
   a text colour and keeps around as a mark colour only. */
.muted { color: var(--n500); }

/* ── the corridor wall ──────────────────────────────────────────────────────
   56 directed corridors as identical small multiples. A grid of real elements
   rather than an SVG, per marks.ts: categorical position is a grid, so every
   label sits with the cell it names by construction.

   auto-fill with a minimum, so the wall reflows from one column on a phone to
   four or five at 1728 without a breakpoint anywhere. */
.wall { display: grid; gap: 1px; background: var(--n200);
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  outline: 1px solid var(--n200); margin: var(--s3) 0; }

.wcell { display: grid; grid-template-columns: 1fr auto; gap: 2px var(--s2);
  align-items: baseline; padding: 10px 12px; background: var(--paper);
  text-decoration: none; color: var(--ink); min-width: 0; }
.wcell:hover, .wcell:focus-visible { background: var(--n100); }

.wname { font-size: var(--t-fine); overflow-wrap: anywhere; }
.warrow { color: var(--n500); }
.wnum { font-family: var(--font-mono); font-size: var(--t-label);
  color: var(--n600); white-space: nowrap; }
.wsep { color: var(--n400); }

/* Both hairlines share one scale across the whole wall, so a cell compares to
   every other cell rather than to itself. */
.wbars { grid-column: 1 / -1; display: grid; gap: 2px; }
.wbar { height: 2px; background: var(--n300); min-width: 0; }
.wbar.wdemand { background: var(--demand); }
.wbar.wsupply { background: var(--supply); }

/* An empty corridor is quiet, not invisible: the point of the wall is that you
   can see how many there are. */
.w-empty { background: var(--n50); }
.w-empty .wname, .w-empty .wnum { color: var(--n500); }
.w-both { box-shadow: inset 3px 0 0 var(--forest); }
.w-demand-only { box-shadow: inset 3px 0 0 var(--demand); }
.w-supply-only { box-shadow: inset 3px 0 0 var(--supply); }

.wlegend { list-style: none; margin: var(--s3) 0 0; padding: 0;
  font-size: var(--t-fine); color: var(--n600); }
.wlegend li { display: flex; align-items: center; gap: var(--s2);
  padding: 3px 0; }
.wlegend b { margin-left: auto; font-family: var(--font-mono);
  font-weight: 400; color: var(--ink); }
.wkey { width: 14px; height: 14px; outline: 1px solid var(--n200);
  flex: 0 0 auto; }
td .s { font-size: var(--t-fine); color: var(--n500); }
/* A row you can act on looks like one. Without this a reader has to hover every
   cell to discover which tables are navigable and which are read-only. */
tbody tr:hover { background: var(--n50); }
.bar { display: inline-block; height: var(--s2); background: var(--forest); vertical-align: middle; }
.bar.quiet { background: var(--n300); }
/*
 * Three tiers, because the overview stacks three banners and all three looked
 * identical — only the left rule's colour differed. Importance has to track
 * contrast, which is item 1 of the review, and the count was never the problem.
 *
 *   .warn.amber   the numbers themselves are wrong   (loudest)
 *   .warn         something needs a person today
 *   .warn.quiet   a standing condition               (quietest, and permanent)
 *
 * The quiet tier is for the dark-gate banner, which will be there for weeks. It
 * is quieter, not silent: CLAUDE.md is emphatic that a dark gate must announce
 * itself, and the wording is unchanged.
 *
 * The panel sizes to the text it holds, never to the container. At full width
 * its 15px prose ran to 152 characters a line — the widest measure in the tool
 * was its most important sentence.
 */
.warn { border-left: 2px solid var(--forest); background: var(--forest50);
  padding: var(--s4) 20px; margin-bottom: 28px; max-width: calc(var(--measure) + 40px); }
.warn.amber { border-left-color: var(--amber); background: var(--amber50); }
.warn.quiet { border-left-color: var(--n300); background: var(--n50); }
/* A refusal, which is a fourth claim again: not "these numbers are wrong" but
   "this did not happen". Carries a mark as well as a colour. */
.warn.stop { border-left-color: var(--alert); background: var(--alert50); }
.warn.stop strong::before { content: "! "; font-family: var(--font-mono); color: var(--alert); }
.warn strong { display: block; margin-bottom: var(--s1); }
.warn p { margin: 10px 0 0; }
/*
 * Not a warning tier. A fourth thing entirely.
 *
 * The .warn class had come to carry five different jobs at once: an infrastructure
 * alarm, a founder to-do, a business fact, a UI filter notice, and a standing
 * privacy disclosure. The last two are not conditions — nothing is wrong, and
 * there is nothing to act on. A records filter the reader has just set was
 * rendered in the SAME class as "the numbers on this page are wrong", which
 * teaches a reader to discount both.
 *
 * So this says what is currently on screen and why, and nothing louder. The
 * wording of every message that moved here is unchanged: the fix is the class,
 * never the sentence.
 */
.note-state { border-left: 2px solid var(--n200); padding: 2px 0 2px 14px;
  margin-bottom: var(--s5); max-width: var(--measure); font-size: var(--t-dense);
  color: var(--n600); }
.note-state strong { display: block; margin-bottom: var(--s1); color: var(--ink); }
.note-state p { margin: var(--s2) 0 0; }
/* Nothing here — and why, and what would put something here. An empty state
   that only says "none" leaves the reader unable to tell a working page with no
   data from a broken one. */
.empty { color: var(--n500); padding: 18px 0; max-width: var(--measure); }
.empty.quiet { font-size: var(--t-fine); padding: 6px 0; }
.empty strong { display: block; color: var(--ink); margin-bottom: var(--s1); }
.pill { display: inline-block; padding: 2px var(--s2); border: 1px solid var(--n200); border-radius: 10px;
  font-family: var(--font-mono); font-size: var(--t-label); letter-spacing: .06em; text-transform: uppercase; }
.pill.pass { border-color: var(--forest); color: var(--forest800); background: var(--forest50); }
.pill.fail { border-color: var(--amber); color: var(--amber); background: var(--amber50); }
.pill.mute { color: var(--n500); }
.pill.stop { border-color: var(--alert); color: var(--alert); background: var(--alert50); }
.filters { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4); align-items: baseline;
  padding: 14px 0 18px; border-top: 1px solid var(--n100); border-bottom: 1px solid var(--n100); margin-bottom: var(--s5); font-size: 13px; }
.filters form { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s3); align-items: baseline; }
.filters label { font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .12em; text-transform: uppercase; color: var(--n500); }
.filters input, .filters select { padding: 6px var(--s2); border: 1px solid var(--n300); background: var(--paper); font: inherit; font-size: 13px; border-radius: var(--radius-sm); }
.filters button { padding: 6px 14px; border: 1px solid var(--ink); background: var(--ink); color: var(--paper); font: inherit; font-size: 13px; cursor: pointer; border-radius: var(--radius-sm); }
.filters a.clear { color: var(--n500); font-size: var(--t-fine); }
.actions { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: baseline; margin-bottom: 20px; font-size: 13px; }

/* ── Pagination ──────────────────────────────────────────────────────────────
   Bounded, and honest about why. The store is a set of spreadsheets read whole
   into memory, so this is a rendering bound rather than a query — it keeps the
   page fast and the DOM small, and it does not make the read cheaper.
   docs/leaving-sheets.md holds the trigger for when that stops being enough. */


/* ── Sign in ─────────────────────────────────────────────────────────────────
   The front door, and for most of the tool's life it was a 22rem stack with the
   rail's 12px mono class standing in for a logo. It is the first thing anybody
   shown this product sees.

   Two columns where there is room: the mark and what this is on the left, the
   form on the right. One column below that, because a phone has no room for a
   statement beside a form. */
.signin-page { display: grid; gap: var(--s7); align-items: center;
  /* align-CONTENT, not align-items. `align-items: center` centres each item
     inside its own row; with a single content-height row the row itself is
     still placed at the start, which put the whole composition in the upper
     third of a 900px viewport with a void beneath it. Only looking found it. */
  align-content: center;
  /* The full viewport, so "centred" means centred. At 84vh the block was
     centred inside a box that stopped 16vh short of the fold, which put 245px
     of space above it and 389px below — an asymmetry that reads as the page
     having been left unfinished rather than composed. */
  min-height: 100vh; padding: var(--s7) 0; }
@media (min-width: 60rem) {
  /* Capped and centred rather than two halves of the full shell. At 1440 a
     1fr/1fr split put a 384px form alone in the middle of a 664px column, ~400px
     from the text it belongs with — two objects on a page rather than one
     composition. The form column is sized to the form. */
  .signin-page { grid-template-columns: minmax(0, 1fr) 24rem; gap: var(--s8);
    max-width: 58rem; margin: 0 auto; }
}
.signin-intro .brand { margin-bottom: var(--s5); }
.signin-intro .brand svg { width: 52px; height: 36px; }
.signin-intro .brand .name { font-size: 28px; }
.signin-intro .brand .sub { font-size: 10px; }
.signin-intro h1 { margin-bottom: var(--s3); }
.signin-intro p { max-width: var(--measure); color: var(--n600); margin: 0 0 var(--s3); }
.signin-intro .where { font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .1em; text-transform: uppercase; color: var(--n500);
  padding-top: var(--s4); border-top: 1px solid var(--n100); }
.login { max-width: 24rem; }
.login label { display: block; font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .12em; text-transform: uppercase; color: var(--n500); margin: var(--s4) 0 6px; }
.login input { width: 100%; padding: 10px var(--s3); border: 1px solid var(--n300);
  background: var(--paper); font: inherit; border-radius: var(--radius-sm); color: var(--ink); }
.login input:focus-visible { outline: 2px solid var(--forest); outline-offset: 1px; }
.login button { margin-top: 20px; width: 100%; justify-content: center;
  padding: var(--s3) var(--s5); font-weight: 500; }
/* A refused sign-in is a refusal, so it takes the refusal colour and a mark —
   it was forest green, the colour this tool uses for success everywhere else. */
.err { color: var(--alert); font-size: var(--t-dense); margin-top: 14px;
  padding-left: 14px; border-left: 2px solid var(--alert); }
.err::before { content: "! "; font-family: var(--font-mono); }
.signin { margin-top: var(--s3); }

@media (max-width: 640px) {
  .shell { padding: 0 var(--s4); }
  .skip:focus { margin: var(--s2) var(--s4); }
  h1 { font-size: 26px; }
  h2.title { font-size: 20px; }
  section { padding-bottom: 44px; }
  section:first-of-type { padding-top: var(--s6); }
  .pagehead { padding-top: var(--s6); }
  .asof { margin-left: 0; width: 100%; }
  .tiles { grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); }
  .tile { padding: 14px 14px; }
  .tile .v { font-size: 26px; }
  p.lead { font-size: 16px; }
  .grid { gap: var(--s4); }
}
.foot { border-top: 1px solid var(--n100); padding-top: 20px; padding-bottom: 40px;
  font-size: var(--t-fine); color: var(--n500); }
.foot a { color: var(--n600); }
@media print {
  /* The rail and the controls go; the footer stays, because on paper it is the
     only thing left saying where these numbers came from. */
  header.rail, .filters, .actions, .skip, button.copy { display: none; }
  .frame { display: block; }
  body { background: #fff; }
  section { padding-bottom: 28px; break-inside: avoid; }
  .grid { display: block; }
}

/* ── Shipments ───────────────────────────────────────────────────────────────
   Saved views as a row of tabs. Each carries its own count, because the reason
   to switch view is usually that one of them is not zero. */
nav.views { display: flex; flex-wrap: wrap; gap: var(--s1) 18px; }
nav.views a {
  font-size: var(--t-dense); text-decoration: none; color: var(--n600);
  padding-bottom: 3px; border-bottom: 2px solid transparent;
}
nav.views a.on { color: var(--ink); border-bottom-color: var(--forest); }
nav.views .count {
  font-family: var(--font-mono); font-size: var(--t-label);
  color: var(--n500);
}

/* The timeline. Real events only: a row here is something that happened, never
   a stage that is merely due, because this is the artefact Roam sells as a
   record of the past. */
ol.timeline { list-style: none; margin: 0 0 28px; padding: 0; }
ol.timeline li {
  display: grid; gap: 2px var(--s4); padding: var(--s2) 0 var(--s2) 14px;
  border-left: 2px solid var(--n200);
}
ol.timeline li:last-child { border-left-color: var(--forest); }
ol.timeline .when {
  font-family: var(--font-mono); font-size: var(--t-label);
  color: var(--n500);
}
ol.timeline .what { font-size: var(--t-body); color: var(--ink); }
ol.timeline .src { font-size: var(--t-fine); color: var(--n500); }
@media (min-width: 60rem) {
  ol.timeline li { grid-template-columns: 12rem minmax(0, 1fr) 10rem; align-items: baseline; }
  ol.timeline .src { text-align: right; }
}

/* Progressive disclosure. Open where the panel has something to say, closed
   where it does not — a page of eight collapsed headings hides the one that
   matters as effectively as a page with nothing on it. */
details.disc { border-top: 1px solid var(--n100); margin: 0; }
details.disc summary {
  cursor: pointer; list-style: none; padding: var(--s3) 0;
  font-family: var(--font-mono); font-size: var(--t-fine);
  letter-spacing: .08em; text-transform: uppercase; color: var(--n700);
  display: flex; align-items: center;
}
details.disc summary::-webkit-details-marker { display: none; }
details.disc summary::after { content: "\002B"; margin-left: auto; color: var(--n500); }
details.disc[open] summary::after { content: "\2212"; }
details.disc summary:focus-visible { outline: 2px solid var(--forest); outline-offset: 2px; }
.discbody { padding-bottom: var(--s3); }

/* The synthetic-data banner. Inverted rather than tinted: the amber tier means
   "the numbers on this page are wrong", and this is a different claim — the
   numbers are fine, they are simply not about anything. Full bleed and above
   the frame, so it cannot be scrolled past or mistaken for a page's own note. */
.synthetic {
  margin: 0; padding: var(--s2) var(--gutter); background: var(--ink); color: var(--paper);
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: .06em; line-height: 1.6;
}
@media print { .synthetic { display: none; } }

/* ── The snapshot's age, on every page of the edge dashboard ─────────────────
   Not decoration. "There is nothing to report" and "nothing has run for nine
   days" look identical on a page of figures, so the second is the state the
   founder is actually in, and this line is where he would find that out.

   The stale variant is the alert colour rather than a second grey, because at
   that point the page is reporting a problem with itself. */
.snapshot-age {
  margin: 0 auto; padding: 14px var(--gutter) 28px;
  max-width: var(--shell-max); font-family: var(--font-mono);
  font-size: var(--t-fine); letter-spacing: .04em; color: var(--n500);
}
.snapshot-age--stale { color: var(--alert); font-weight: 500; }
@media (max-width: 640px) { .snapshot-age { padding: var(--s3) var(--s4) var(--s5); } }

/* ── Motion ──────────────────────────────────────────────────────────────────
   Functional only: it says where something came from or that a control
   responded. Nothing moves on load, nothing loops, and nothing is waited on.
   Declared inside the guard rather than paired with a reset, so a reader who
   asks for less motion gets none of this rather than a shorter version. */
@media (prefers-reduced-motion: no-preference) {
  a, .btn, button.copy, .chip, .tile, th a, .sect li a, nav.views a {
    transition: color var(--motion-fast) var(--ease),
                background-color var(--motion-fast) var(--ease),
                border-color var(--motion-fast) var(--ease),
                outline-color var(--motion-fast) var(--ease);
  }
  details.disc[open] > .discbody, details.railnav[open] > nav {
    animation: reveal var(--motion) var(--ease);
  }
  @keyframes reveal {
    from { opacity: 0; transform: translateY(-2px); }
    to { opacity: 1; transform: none; }
  }
}

/* ── Tap targets, below the desktop breakpoint ───────────────────────────────
   Measured on an iPhone 13 viewport before this existed: the rail's navigation
   links rendered **24.3px** tall, and thirty-eight interactive elements were
   under 44px. The dashboard was built for a pointer, and the founder asked for
   it on a phone.

   44px is the same floor scripts/dev/touch-probe.mjs already holds the two
   public forms to, so this is the site's own standard applied to its own tool
   rather than a new number. It is scoped below 60rem because a desktop rail
   with 44px rows would be a rail you have to scroll, and a pointer does not
   need the room.

   Padding rather than height: a fixed height clips a label that wraps, and
   several of these wrap at 390px. */
@media (max-width: 59.99rem) {
  .sect li a { padding-top: var(--s3); padding-bottom: var(--s3); }
  /* A group heading is a heading now, not a control — nothing to hit. */
  .railgroup { padding: 10px 0 4px; }
  summary.railmenu { padding: 13px 0; }
  nav.views a { padding-top: var(--s3); padding-bottom: var(--s3); }
  .find button, .find input { min-height: 44px; }
  /* `refresh` and `sign out` measured 13px: they read as prose because they sit
     in a sentence, but they are the two standalone controls in the rail and
     signing out on a phone should not need aiming. */
  .asof a { display: inline-block; padding: 14px 0; }
  .brand { padding: 13px 0; }
  .btn { min-height: 44px; }
  button.copy { min-height: 44px; padding-left: var(--s3); padding-right: var(--s3); }
  .crumbs a { display: inline-block; padding: 10px 0; }
}

/* ── What is deliberately left under 44px ────────────────────────────────────
   Three links measure 17–18px and stay that way: "See which rows, and why",
   "Open the list", "Open the rows behind this". Each sits **inside a sentence**,
   and WCAG 2.5.8 exempts exactly that case — a link in a block of text cannot be
   padded to 44px without wrecking the line spacing of the paragraph around it,
   and the sentence itself is the target you aim at.

   "Skip to content" measures 24px and is `position: absolute` off-screen until
   focused, so it is not a target anybody taps at all.

   Recorded so a future measurement pass does not "fix" them and make the prose
   worse. */

/* ── messages people have written in ────────────────────────────────────────
   /messages is the one page in this tool whose subject is prose somebody
   wrote, not a measurement. Rendered as blocks rather than table rows,
   because a body in a `td` is the exact defect the page-width pass found on
   seven pages: prose in cells at up to 166 characters a line.

   Laid out with the same wrapping-flex grammar as .lay-abreast — a 28rem
   basis so two messages share the track instead of one column of prose
   leaving half the page empty. Prefixed `lay-` per the rule a `.lanes`
   collision cost 20px of horizontal scroll to learn: a layout wrapper must
   never share a figure class's name. */
.lay-messages { display: flex; flex-wrap: wrap; align-items: start;
  gap: var(--s5) var(--s6); margin: var(--s3) 0; }
.lay-messages > * { flex: 1 1 28rem; min-width: 0; max-width: 100%; }

.msg { border: 1px solid var(--n200); padding: var(--s4) var(--s5); background: var(--paper); }
.msg h3 { margin: 0 0 var(--s3); font-size: var(--t-dense); color: var(--ink); }
.msg dl.meta { grid-template-columns: max-content minmax(0, 1fr); margin-bottom: var(--s4); }

/* The message itself. `pre-wrap` because the line breaks are the writer's own
   and collapsing them reformats what somebody wrote — the one field where
   that is never acceptable. The forest rule is the same mark the notification
   email uses, so the two read as the same object. */
.msg blockquote { margin: 0; padding-left: var(--s4);
  border-left: 2px solid var(--forest); white-space: pre-wrap;
  overflow-wrap: anywhere; font-size: var(--t-dense); color: var(--ink);
  max-width: var(--measure); }
.msg p.note { margin: var(--s4) 0 0; }

/* ── Stacked forms on /outreach (plan A4, 24 Sep 2026) ────────────────────────
   The add form had no rule of its own, so its labels and inputs flowed inline
   and wrapped mid-label; the "your own email" box then rendered a few words
   wide. A label over its field, the field the width of the form, and the
   email box tall enough to read a paragraph. The form is capped at the
   measure because it holds prose. */
.add-prospect,
.custom-email form {
  display: grid; gap: var(--s1); max-width: var(--measure); margin-top: var(--s3);
}
.add-prospect label,
.custom-email label { margin-top: var(--s2); }
.add-prospect input,
.add-prospect textarea,
.custom-email input,
.custom-email textarea {
  width: 100%; box-sizing: border-box; padding: var(--s2); font: inherit;
  border: 1px solid var(--n300); background: var(--paper); color: var(--ink);
}
.custom-email textarea { min-height: 16rem; line-height: 1.5; }
.add-prospect button,
.custom-email button { justify-self: start; margin-top: var(--s3); min-height: 44px; }
.custom-email { margin: var(--s3) 0; }
.custom-email summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
