/* Self-hosted fonts (#242). Google's own css2 endpoint served these three
   families before; this is that same cut — the "latin" unicode-range block
   Google returns for a browser UA, saved locally instead of fetched from
   fonts.googleapis.com/fonts.gstatic.com on every page view. Latin only, no
   latin-ext/cyrillic/greek/vietnamese: the program has no non-Latin content.
   Weights are the ones app.css actually sets, not the full family — verified
   against every font-weight/font-family declaration below, not assumed from
   the old Google Fonts URL (which asked for a Mono 700 that nothing here
   uses). Sans and Newsreader ship one variable-weight file each, ranged to
   the weights used; font-weight 500-600 on the Newsreader face leaves its
   opsz axis on "auto", so headings still pick their optical size from
   rendered font-size the way a variable font does, just without a network
   request. Licenses (OFL) sit alongside: web/static/fonts/OFL-ibm-plex.txt
   covers Sans and Mono (one license, one Reserved Font Name, "Plex"),
   web/static/fonts/OFL-newsreader.txt covers Newsreader. */
@font-face {
	font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 400 600; font-stretch: 100%; font-display: swap;
	src: url('fonts/ibm-plex-sans-latin.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
	font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 400; font-display: swap;
	src: url('fonts/ibm-plex-mono-latin-400.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
	font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 600; font-display: swap;
	src: url('fonts/ibm-plex-mono-latin-600.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
	font-family: 'Newsreader'; font-style: normal; font-weight: 500 600; font-display: swap;
	src: url('fonts/newsreader-latin.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Program web service. The old matching canvas as the language for both
   surfaces: warm paper with a dot grid, white cards lifted off it by a warm
   three-layer shadow — no border at all, the shadow is the only edge. Steel
   blue is the primary action and the selected state only; green is done,
   amber is attention, red is veto. Two palettes, one product: see "Dark",
   and a colourblind mode that composes with either: see "Colourblind".

   THE FONT RULE: serif at 18px and up. Sans below 18. Every number in mono.

   So Newsreader carries the page title and the section headings and nothing
   else; IBM Plex Sans carries card titles, labels, table cells and prose;
   IBM Plex Mono carries every figure, tabular so columns line up. One scale
   for both surfaces — the coordinator surface is no longer a shrunken
   variant of the participant one.

   THE CARD TYPE CONTRACT: a card has at most six type roles, and every one
   of them is a rule in "Type roles" below with each class that plays it in
   the selector. A card picks from these; it never invents a seventh.

     Title   sans 600 --fs-lg                    the card's heading
     Lead    sans 600 --fs-md                    its one imperative line (.verb), a fieldset's legend
     Body    sans 400 --fs-md                    prose, descriptions, table cells, labels
     Meta    sans 400 --fs-sm  --text-2          help, status, captions, reasons
     Figure  mono 600, tabular                   a number in a data position
     Micro   sans 600 --fs-xs upper --text-3     column heads, eyebrows, state chips

   Figure is for a number that stands in a data position — a table cell, a
   timeline node, the number at the end of a label/figure row — where others
   line up under it. There is one size a card can lift a figure to, --fs-xl;
   every other figure takes the size of the line it sits on.

   A number inside a sentence is still a Figure (#234): wrap the digits
   alone in their own `.data` span rather than the sentence, so the line
   around it stays sans and only the number itself takes the mono face —
   `_stage_assignment.html`'s "Finalised now: <span class="data">10</span>
   pairs" is the pattern. Four spots read the exception the other way
   (leaving a bare figure in the sentence's own face) before #234, including
   the same check-in date rendering mono in one table and sans in another;
   the rule is "every number in mono," full stop, and a sentence around one
   is not an exemption from it.

   No italic: it would be a seventh treatment earning nothing. The heatmap's
   rails are the one exception to the scale — names at their own density, not
   type; its cells hold no text at all. */

:root {
	color-scheme: light;
	--paper: #FAF8F4; --dots: rgba(0, 0, 0, 0.05);
	--scrim: rgba(250, 248, 244, 0.72);   /* --paper at 72%: the wash over a blocked cell */
	--surface: #FFFFFF; --raised: #F5F2EC; --hover: #EEEBE4;
	--border: #D8D2C8; --border-bright: #B8B0A4;
	--text: #2C2418; --text-2: #585044; --text-3: #6F685C;   /* 5.20:1 on --paper, 4.93:1 on --raised */
	--accent: #1A5480; --accent-hover: #154468; --accent-dim: rgba(26, 84, 128, 0.10); --accent-glow: rgba(26, 84, 128, 0.30);
	--on-accent: #FFFFFF; --on-green: #FFFFFF;   /* ink on a filled accent or green chip; it swaps ends with the ground */
	--green: #3A7A52; --green-dim: rgba(58, 122, 82, 0.08); --green-border: rgba(58, 122, 82, 0.28); --green-text: #2E6844;
	--red: #B84040; --red-dim: rgba(184, 64, 64, 0.06); --red-border: rgba(184, 64, 64, 0.18); --red-text: #A03030;
	--amber: #A87A10; --amber-dim: rgba(168, 122, 16, 0.08); --amber-border: rgba(168, 122, 16, 0.18); --amber-text: #886008;
	/* The score ramp, bad to good: HEATMAP_COLORSCALE in constants.py; cells get --score-bg from the server. */
	--ramp: #B84040, #CC7A3A, #C9A227, #7FA34A, #3A7A52;
	--font-serif: 'Newsreader', Georgia, 'Iowan Old Style', 'Palatino Linotype', serif;
	--font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, -apple-system, sans-serif;
	--font-mono: 'IBM Plex Mono', Consolas, Menlo, monospace;
	/* Type scale: micro labels, meta, body, card title, section, page. */
	--fs-xs: 11px; --fs-sm: 13px; --fs-md: 15px; --fs-lg: 18px; --fs-xl: 22px; --fs-2xl: 30px;
	/* Measure: how wide a block of prose is allowed to run (#160). Sixty-five
	   to seventy-five characters is where the eye finds the start of the next
	   line without hunting for it; the widest prose on the coordinator surface
	   was 1132px holding 405 characters, about 180 to the line. A measure is
	   counted in characters, so it is written in ch and not in rem: ch is the
	   only unit that keeps the count the same when the role changes size, and
	   prose here is written in two of them. It caps prose only — a table cell,
	   a figure and a label are not prose and keep the width they need. */
	--measure: 56ch;
	/* Space: the one thing this file never tokenised until now. A 4px base
	   — --ctl, --topbar and --tap already are multiples of it — with a step
	   at every value already in use that sits cleanly on that grid, so a
	   snap never moves a number that was already right; only the values
	   that were never on a 4px grid move, to the step nearest them.
	   --gutter, --ctl-sm and --topbar stay outside it: existing layout
	   constants, not spacing to relate to anything else. */
	--sp-2: 2px; --sp-4: 4px; --sp-8: 8px; --sp-12: 12px; --sp-16: 16px; --sp-20: 20px;
	--sp-24: 24px; --sp-28: 28px; --sp-32: 32px; --sp-36: 36px; --sp-40: 40px; --sp-64: 64px;
	/* Card lift: contact, mid and ambient, all tinted with --text because the
	   ground is warm. Three layers is what makes a white card read as paper. */
	--lift: 0 2px 4px rgba(44, 36, 24, 0.08), 0 8px 16px rgba(44, 36, 24, 0.10), 0 20px 48px rgba(44, 36, 24, 0.14);
	--lift-hi: 0 3px 6px rgba(44, 36, 24, 0.10), 0 12px 24px rgba(44, 36, 24, 0.13), 0 28px 60px rgba(44, 36, 24, 0.18);
	--shadow-panel: -8px 0 30px rgba(0, 0, 0, 0.18);   /* the inspect sheet, only when it is fixed to the edge */
	--radius: 6px;        /* cards */
	--radius-ctl: 5px;    /* buttons and kbd */
	--radius-pill: 999px; /* pills and badges */
	--topbar: 44px; --panel: 380px; --stage-col: 360px; --gutter: 3px;
	--tap: 44px;     /* the least a thumb has to hit */
	/* Control height. One number decides how tall a control stands, and the
	   button, the input and the select all read it, so any two of them on a
	   line share a baseline without either being measured against the other.
	   --ctl is the full height, --ctl-sm the compact one a control takes in a
	   row, a table cell or a corner. The coordinator surface is driven by a
	   mouse, so its controls stand at --ctl; the participant surface is a
	   phone, so its own rule below raises --ctl to --tap. */
	--ctl: 36px; --ctl-sm: 30px;
}

/* --- Dark -------------------------------------------------------------- */
/* The same product at night, not a negative of it. The ground stays warm — a
   brown-black, not a neutral grey — so the steel blue still belongs to it,
   and every token below is one of the same twenty-odd names :root defines,
   never a new one.

   A shadow barely reads on a dark ground, so a card is raised by being
   lighter than the paper (--surface 1.13:1 over --paper) rather than by its
   shadow, and --lift keeps its three layers but leads with a hairline ring:
   the edge is still the shadow, drawn the way a dark ground can see it.

   --on-accent and --on-green are the ink on a filled chip. White on a light
   fill is 8.00:1 and 5.14:1; on the dark palette's lighter fills it drops to
   2.36:1 and 2.85:1, so the ink swaps ends with the ground and the warm black
   reads 7.84:1 and 6.50:1 instead. --ramp does not appear here: the score
   colours are data, not chrome, the server sends the same five per cell from
   HEATMAP_COLORSCALE, and a pair that scores 2.1 is the same pair at night.

   THREE STATES. No attribute follows the OS; data-theme="light" or "dark"
   beats it in either direction. web/pages.py reads the cookie and base.html
   renders it on <html>, so the server has already chosen before the first
   paint. The two blocks are one palette written twice — the media query for
   a dark OS with no choice made, the attribute for the explicit choice — and
   they have to stay identical; tests/test_web.py compares them to each other
   and to :root, so no token can come to exist in one state and not another. */
@media (prefers-color-scheme: dark) {
	:root:not([data-theme="light"]) {
		color-scheme: dark;
		--paper: #16130E; --dots: rgba(255, 243, 224, 0.05);
		--scrim: rgba(22, 19, 14, 0.72);
		--surface: #241F17; --raised: #2E2921; --hover: #3A342A;
		--border: #3C362C; --border-bright: #5E5646;
		--text: #F0EBE1; --text-2: #C4BCAE; --text-3: #A09889;   /* 6.48:1 on --paper, 5.05:1 on --raised */
		--accent: #78AEDC; --accent-hover: #96C3E8; --accent-dim: rgba(120, 174, 220, 0.16); --accent-glow: rgba(120, 174, 220, 0.36);
		--on-accent: #16130E; --on-green: #16130E;
		--green: #5FA87A; --green-dim: rgba(95, 168, 122, 0.14); --green-border: rgba(95, 168, 122, 0.34); --green-text: #86C79C;
		--red: #D07070; --red-dim: rgba(208, 112, 112, 0.12); --red-border: rgba(208, 112, 112, 0.30); --red-text: #E89A9A;
		--amber: #C79A3C; --amber-dim: rgba(199, 154, 60, 0.14); --amber-border: rgba(199, 154, 60, 0.30); --amber-text: #DFB760;
		--lift: inset 0 0 0 1px rgba(240, 235, 225, 0.06), 0 2px 6px rgba(0, 0, 0, 0.36), 0 16px 40px rgba(0, 0, 0, 0.44);
		--lift-hi: inset 0 0 0 1px rgba(240, 235, 225, 0.12), 0 3px 8px rgba(0, 0, 0, 0.42), 0 22px 52px rgba(0, 0, 0, 0.52);
		--shadow-panel: -8px 0 30px rgba(0, 0, 0, 0.55);
	}
}
:root[data-theme="dark"] {
	color-scheme: dark;
	--paper: #16130E; --dots: rgba(255, 243, 224, 0.05);
	--scrim: rgba(22, 19, 14, 0.72);
	--surface: #241F17; --raised: #2E2921; --hover: #3A342A;
	--border: #3C362C; --border-bright: #5E5646;
	--text: #F0EBE1; --text-2: #C4BCAE; --text-3: #A09889;   /* 6.48:1 on --paper, 5.05:1 on --raised */
	--accent: #78AEDC; --accent-hover: #96C3E8; --accent-dim: rgba(120, 174, 220, 0.16); --accent-glow: rgba(120, 174, 220, 0.36);
	--on-accent: #16130E; --on-green: #16130E;
	--green: #5FA87A; --green-dim: rgba(95, 168, 122, 0.14); --green-border: rgba(95, 168, 122, 0.34); --green-text: #86C79C;
	--red: #D07070; --red-dim: rgba(208, 112, 112, 0.12); --red-border: rgba(208, 112, 112, 0.30); --red-text: #E89A9A;
	--amber: #C79A3C; --amber-dim: rgba(199, 154, 60, 0.14); --amber-border: rgba(199, 154, 60, 0.30); --amber-text: #DFB760;
	--lift: inset 0 0 0 1px rgba(240, 235, 225, 0.06), 0 2px 6px rgba(0, 0, 0, 0.36), 0 16px 40px rgba(0, 0, 0, 0.44);
	--lift-hi: inset 0 0 0 1px rgba(240, 235, 225, 0.12), 0 3px 8px rgba(0, 0, 0, 0.42), 0 22px 52px rgba(0, 0, 0, 0.52);
	--shadow-panel: -8px 0 30px rgba(0, 0, 0, 0.55);
}

/* --- Colourblind ------------------------------------------------------- */
/* The reader's other preference, on <html data-cb> from the `cb` cookie
   (web/pages.py) and composing with the theme rather than replacing it, so
   all four combinations exist.

   WHAT IT COVERS: the score ramp, and every state on either surface that is
   said in tone and in nothing else. Everywhere else a pill or a badge says
   the word beside the tone, and a word survives any deficiency; what is left
   is the short list below, and each entry keeps the tone it had and gains a
   second channel beside it. The accent is not on the list: steel blue on warm
   paper is legible under every common deficiency, and this mode is not a
   licence to redesign. Nothing else changes, and nothing at all changes with
   the mode off.

   Every fix is a background or a shadow, both of which paint inside a box
   that already exists. No element gains width, so no row goes ragged — which
   matters most in the timeline, four cells on a repeat(4, 1fr) track where a
   glyph in one cell would move the other three.

   THE RAMP is the substantive one, and the only entry that is data rather
   than chrome. Red → green is the one ramp deuteranomaly and protanomaly
   flatten, and it is the two ends that converge, not the middle. Since #68
   the cell prints no number, so the ramp is the whole at-a-glance reading.
   Viridis carries the order in lightness instead — L* 30 → 80, rising at
   every stop — which is why it stays ordered under all three deficiencies
   and under total colour loss. --ramp here draws the legend swatch; the
   cells themselves are coloured per cell by the server, from
   HEATMAP_COLORSCALE_CB in constants.py, and those are the same five stops.
   A stylesheet cannot reach a colour computed in Python, which is why this
   preference is a cookie the route reads and not a class the page toggles.

   THE ROW TINTS. In the program page's pairs table, tr.warn and tr.behind
   are one column of rows, red for an open concern and amber for both sides
   quiet, and those two converge. They gain a texture: a hatch for the one
   that wants attention, a stipple for the one that has gone quiet. A texture
   and not a glyph or a left rule, because a td here has no left padding to
   put one in, and anything with width would ragged the column's left edge on
   the tinted rows alone; a background costs no layout at all.

   THE ASSIGNED RING. .cell.assigned is an inset ring in --text, and that
   works against the red-amber-green ramp because every stop of it sits mid
   lightness. Against a ramp that sweeps lightness it does not: the bottom
   stop is 1.65:1 against --text on the light palette and the top stop
   1.43:1 on the dark one, so the ring drops out at one end whichever
   palette is on. Two tones fix it, 2px of --text with a hairline of
   --surface inside, because those two sit at opposite ends of the ground and
   the ramp cannot be near both: at every stop, in both palettes, one of the
   pair is at least 7:1. The other cell states need nothing — blocked is a
   scrim and a diagonal, vetoed replaces the fill and draws a cross, and both
   read as shape.

   THE TWO BARS, and the participant's whole surface with them (#108). Five
   boxes and ten 3px bars are the entire account a participant gets of where
   their program has reached, and until now this mode did not touch them. The
   bars are the sponsor's side and the sponsee's, and nothing else on the page
   says which of the two has checked in — no word, no number, not even a
   shape, because on and off are the same bar in two tones. Off becomes a
   dashed rule, so the pair reads as solid against broken and the difference
   is whether there is ink rather than what colour it is; on keeps its fill.
   The dashes run along the bar, which is the only axis a 3px bar has room in.

   THE CONVERSATION BOX came off this list again (#118). Its one-side-in
   state was a border one shade darker than nothing-yet — and on the box that
   is also next, nothing at all, because next repaints the border it was
   written in. The half fill that fixed it here was doing a default-mode
   defect's work behind a preference, so it moved up into the timeline's own
   rule and every reader gets it. Nothing is left for the mode to add on top:
   a fill level is read by lightness, which is the channel that survives, and
   giving a state the default already carries a second one would be this mode
   redesigning rather than translating. */
:root[data-cb] {
	--ramp: #433E85, #2F6B8E, #20938C, #3BBB75, #9BD93C;
}
[data-cb] tr.warn td { background-image: repeating-linear-gradient(45deg, transparent 0 3px, var(--red-border) 3px 5px); }
[data-cb] tr.behind td { background-image: radial-gradient(var(--amber-border) 1px, transparent 1.1px); background-size: 8px 8px; }
[data-cb] .cell.assigned { box-shadow: inset 0 0 0 2px var(--text), inset 0 0 0 3px var(--surface); }
[data-cb] .dot:not(.on) { background: repeating-linear-gradient(90deg, var(--border) 0 2px, transparent 2px 4px); }

/* --- Base ------------------------------------------------------------- */
/* `*` does not match a pseudo-element, so ::before and ::after have to be
   named or they stay content-box and every sized one is off by its border. */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }
html { scrollbar-gutter: stable; }   /* a page that grows a scrollbar keeps its width */
body {
	font: var(--fs-md)/1.5 var(--font-sans); color: var(--text); -webkit-font-smoothing: antialiased;
	background: var(--paper) radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 18px 18px;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 0.15em; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Text for a screen reader and for no one else, clipped to a point and out
   of flow so it costs no layout wherever it is dropped. It exists for the
   timeline (#118): a box whose whole text is its number cannot say its own
   state, and a shape that says it in paint says it to nobody else. Use it
   only where there is genuinely no room for a visible word — a caption or a
   pill is better than a secret. */
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
h1, h2 { font-family: var(--font-serif); font-weight: 500; }
h1 { font-size: var(--fs-2xl); line-height: 1.15; letter-spacing: -0.015em; }
h2 { font-size: var(--fs-xl); line-height: 1.25; letter-spacing: -0.01em; margin-bottom: var(--sp-8); }
h3 { margin: var(--sp-16) 0 var(--sp-4); }   /* a heading inside a card, under its title: Lead */
h3:first-child { margin-top: 0; }   /* unless it is the title */
p { margin-bottom: var(--sp-8); }
p:last-child { margin-bottom: 0; }
b, strong { font-weight: 600; }
i { font-style: normal; }   /* no italic anywhere: it would be a seventh treatment */
fieldset { border: 0; min-width: 0; }
summary { cursor: pointer; }
details + details { margin-top: var(--sp-8); padding-top: var(--sp-8); border-top: 1px solid var(--border); }
details[open] > summary { margin-bottom: var(--sp-8); }
ul.plain { list-style: none; }
ul.plain li { padding: var(--sp-8) 0; border-top: 1px solid var(--border); }
ol.how { padding-left: var(--sp-20); display: grid; gap: var(--sp-8); margin-bottom: var(--sp-20); }
kbd {
	display: inline-grid; place-items: center; min-width: 1.2rem; height: 1.2rem; padding: 0 var(--sp-4);
	background: var(--raised); border: 1px solid var(--border); border-radius: var(--radius-ctl);
	font: 600 var(--fs-xs)/1 var(--font-mono); color: var(--text-2);
}

/* --- Type roles ------------------------------------------------------- */
/* The contract, one rule per role. Everything that plays a role is in its
   selector, so a class cannot drift into a treatment of its own: to give a
   thing a voice, add it here, not a font declaration where it lives. Only
   size, colour and layout are set anywhere else. */
/* Micro. */
.anno, th, .pill, .badge, .tb-now, .ahead, .pair .r, table.flags .cat {
	font: 600 var(--fs-xs)/1.5 var(--font-sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3);
}
/* Meta. */
.meta, .cap, .crumbs, .banner, .error, .empty-state, .adj, dl.checkin, .stage-stats, .stage-desc,
.why, .detail, .key, .heat-foot, .pair .p small, .total .l, .crit .c .j, .ins-main details summary, .ins-hint {
	font: 400 var(--fs-sm)/1.5 var(--font-sans); color: var(--text-2);
}
/* Lead. */
.verb, legend, h3, .pair .p { font: 600 var(--fs-md)/1.4 var(--font-sans); }
/* Figure. Tabular so a column of them lines up; the card sets the size. */
.data, td.num, .tl-n, .cell, .rating .seg span { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.data, td.num, .tl-n, .cell { font-weight: 600; }
/* One figure size, and the card decides whether to use it: a stage card's
   strip is 360px wide and up to three figures deep, so its .lg number takes
   --fs-xl, the panel's total sits with it, and so does a figure at the end of
   a row in a narrow column. A figure with no size takes the line's — the
   publish card's count reads inside a sentence. */
.stage-stats .lg, .total .data, .figures .data { font-size: var(--fs-xl); color: var(--text); }
/* Measure. The list of everything on either surface that carries sentences
   rather than labels — Body prose, a Meta paragraph, a participant's own
   words, a stage's description, a flag's reason. It is a list and not `p`,
   because .heat-foot is a paragraph that is really a legend row and needs
   the whole card's width, and it is not the Meta or Body role's own rule,
   because most of what plays those roles is a caption, a cell or an inline
   label that a cap would be wrong for. To give a new thing a measure, add
   it here — .pg-note, the sentence under a stage of the program, is the
   newest (#184). */
.concern p, .about, .statement, .empty-state, p.meta, p.muted,
.stage-desc, .verb, .why, .detail, table.flags caption, .flag-why > summary, .pg-note {
	max-width: var(--measure);
}
.muted { color: var(--text-2); }
/* #254: the commitment-diff hint sits inside a `.data` (mono, bold) cell but
   is words, not a figure — its own role selector resets what it would
   otherwise inherit, rather than a font declaration landing on `.muted`
   itself, which plenty of other things use unchanged. */
.commitment-diff-note { font-family: var(--font-sans); font-weight: 400; }
.crumbs { margin-bottom: var(--sp-12); }
/* The back control (#275), on every coordinator sub-page in place of a
   crumbs line: a small bordered control naming the parent, the arrow drawn
   here so the label stays the parent's name and nothing else. */
.back { display: inline-flex; align-items: center; gap: var(--sp-8); min-height: var(--ctl-sm); padding: 0 var(--sp-12) 0 var(--sp-8); margin-bottom: var(--sp-16); border: 1px solid var(--border); border-radius: var(--radius-ctl); background: var(--surface); color: var(--text-2); text-decoration: none; font: 500 var(--fs-sm) var(--font-sans); }
.back::before { content: "\2190"; color: var(--text-3); }
.back:hover { color: var(--text); border-color: var(--border-bright); text-decoration: none; }
.back + .meta { margin-left: var(--sp-8); }
/* The not-matched page's rate (#275): two figures side by side, each a
   caption, the big number and the count it is of — not two rows of a full
   width card with the numbers pushed to the far edge. */
.rate { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-24); }
.rate .f { display: grid; gap: var(--sp-2); }
.rate .big { font: 600 var(--fs-2xl)/1.1 var(--font-mono); color: var(--text); }
.rate .of { font-size: var(--fs-sm); color: var(--text-2); }
.rate + .foot { margin-top: var(--sp-20); padding-top: var(--sp-16); border-top: 1px solid var(--hover); }
.cta { margin-top: var(--sp-12); }
.title { margin-bottom: var(--sp-20); }
.title .anno, .title .meta { display: block; margin-bottom: var(--sp-4); }
/* A title with a control: the name on the left, one group in the top right,
   centred on the name. The group is a disclosure shaped like the top bar's
   Settings — a compact control that opens a card downward over the page —
   because that is what the surface already means by "a rare setting behind
   one word", and because a panel that floats leaves the name, the nav and
   the first card exactly where they were. It keeps the corner when the line
   wraps: margin-left: auto, not space-between alone. */
.title-line { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-8) var(--sp-24); flex-wrap: wrap; }
.title-line > details { position: relative; margin-left: auto; }
.title-line > details > summary { list-style: none; }
.title-line > details > summary::-webkit-details-marker { display: none; }
.title-line > details[open] > summary { margin-bottom: 0; background: var(--hover); }
.title-line > details > .menu { right: 0; top: calc(100% + 6px); }
.title-line .menu .meta { margin-top: var(--sp-8); }
/* A word inside a heading keeps the heading's size — the pair page's
   "sponsors" is separated from the two names by colour (.muted), not by
   being shrunk to 0.6em mid-sentence. Only a pill, which is not prose,
   sits at its own size in an h1. */
h1 .pill { vertical-align: middle; }
.person-name { font-family: var(--font-serif); font-size: var(--fs-lg); font-weight: 600; line-height: 1.25; margin: var(--sp-2) 0 var(--sp-2); }
.title .person-name { font-size: var(--fs-2xl); }
.about { margin-top: var(--sp-12); padding-top: var(--sp-12); border-top: 1px solid var(--border); }
/* Title. A card title sits at the bottom of the serif range, so it is sans
   wherever a card is a working panel — the whole coordinator surface: stage
   cards, the review column, the pair panel, the program page. The
   participant surface reads like a document, so its card headings stay
   serif with the rest of the page, and so does a bare h2 heading a section. */
.coord :is(.card, .heat-head) h2 { font: 600 var(--fs-lg)/1.3 var(--font-sans); }
.stage-head h2, .heat-head h2, .results-head h2, .ins-head h2 { margin: 0; }   /* these sit in a flex head */
/* A card's own internal groupers — Participants' three lists, Left over's
   three (#238) — take the Title role too, the step below the section h2,
   rather than bare h3's Lead size: level with .verb and a fieldset's legend
   is bold body text, not a heading, which is what every h3 on the page
   read as before this. Scoped to .coord so the participant pair page's
   "Raised by you" (Lead, unchanged) doesn't inherit a size decided for a
   card on the other surface. */
.coord h3 { font: 600 var(--fs-lg)/1.3 var(--font-sans); }

/* --- Top bar and page shell ------------------------------------------ */
.topbar {
	position: sticky; top: 0; z-index: 5; height: var(--topbar); display: flex; align-items: center; gap: var(--sp-16);
	padding: 0 var(--sp-20); border-bottom: 1px solid var(--border);
	background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(16px);
}
/* #266: neither the brand string (PORTAL_COORDINATOR_BRAND) nor PROGRAM_NAME
   is a length this stylesheet controls, so the row cannot assume either
   fits. min-width: 0 lets the flex item shrink below its text's own
   min-content width (a flex item's default min-width is auto, i.e. "never
   narrower than my content" — the 5px-of-slack/overflow bug was that
   default, doing exactly what it says); overflow + text-overflow then turn
   that shrink into an ellipsis instead of a clipped word. The link's full
   text is still what a screen reader gets — this is paint only. .nav is
   the side that must never give: Settings and Sign out are controls, not
   prose, so it is pinned flex-shrink: 0 and .brand absorbs all of the
   squeeze. */
.brand {
	font: 600 var(--fs-md) var(--font-sans); color: var(--text-2); white-space: nowrap;
	overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.brand:hover { text-decoration: none; color: var(--text); }
.tb-center { position: absolute; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: var(--sp-4); }
.tb-title { font: 500 var(--fs-md)/1.1 var(--font-sans); color: var(--text); white-space: nowrap; }
.track { display: flex; gap: var(--sp-4); }
.track i { width: 16px; height: 2px; border-radius: 1px; background: var(--border); }
.track i.on { background: var(--green); }
.nav { margin-left: auto; display: flex; align-items: center; gap: var(--sp-12); font-size: var(--fs-sm); color: var(--text-2); flex-shrink: 0; }
.nav a, .nav button { color: var(--text-2); white-space: nowrap; }
/* Settings: a preference, not an action, so it is the same quiet weight as
   the rest of the nav and opens downward over the page. A real <details>, so
   it works with JS off and from the keyboard, and the panel is a card — the
   surface and the lift, nothing of its own. Each choice is its own submit
   button in one form: without JS a radio group would need a Save button
   beside it, and one click is the whole interaction. The chosen one fills
   like a selected segment. It stays at every width (below, the 700px query
   drops the identity and the cohort's title but not this) because a phone at
   night is where the dark choice is most wanted, and it costs one word. */
.settings > summary { list-style: none; color: var(--text-2); }
.settings > summary::-webkit-details-marker { display: none; }
.settings > summary:hover { color: var(--text); }
.settings[open] > summary { color: var(--text); margin-bottom: 0; }
.menu {
	position: absolute; right: 1.25rem; top: calc(var(--topbar) - 4px); z-index: 10; min-width: 13rem;
	padding: var(--sp-12) var(--sp-16); background: var(--surface); border-radius: var(--radius); box-shadow: var(--lift-hi);
}
.menu .anno { display: block; margin-bottom: var(--sp-8); }
.menu form + form { margin-top: var(--sp-12); }   /* the second preference, colourblind mode */
.menu .choices { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: var(--sp-4); }
.menu .choices .btn { padding: var(--sp-4) var(--sp-8); color: var(--text); }
.menu .choices .btn.on { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }
.shell { max-width: 36rem; margin: 0 auto; padding: var(--sp-24) var(--sp-16) var(--sp-64); }
.coord .shell { max-width: 1240px; padding: var(--sp-20) var(--sp-24) var(--sp-64); }
@media (max-width: 700px) { .tb-center, .nav .meta { display: none; } }   /* Settings stays: see above */
/* Groups of cards: one gap between them, one after — except the last thing
   in a section, where the next section's own margin is already the gap and
   the two used to add up to 44px of nothing.

   grid-template-columns is minmax(0, 1fr) rather than left at auto, or a
   phone-width column sizes to its content's max-content instead of the
   track it was given (#233, #235): a grid's implicit auto column has no
   ceiling of its own, so `.stages` on the matching page — a single column
   below 900px — grew to whatever its widest stage card wanted (606px, a
   274px page-level overflow at 390px) rather than the 327px `.canvas`
   actually gave it. `.cols` and `.stack` had the same latent blowout,
   unnoticed only because nothing inside them was ever wide enough to show
   it. */
.grid, .cols, .stack, .stages { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-16); margin-bottom: var(--sp-16); align-items: start; }
main > section > :last-child { margin-bottom: 0; }
.grid { grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); }
.row { display: flex; align-items: center; gap: var(--sp-8); flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.row h2 { margin-bottom: 0; }
main > section { margin-top: var(--sp-28); }
/* Two columns only where there is room for two: the coordinator shell is
   1240px, the participant one 36rem at every width, so a viewport query on
   its own split a 545px page into two 264px columns and wrapped every line
   of prose in it three times. */
@media (min-width: 900px) {
	.coord .cols { grid-template-columns: 1fr 1fr; }
}

/* --- Cards ------------------------------------------------------------ */
.card {
	border: 0; border-radius: var(--radius); box-shadow: var(--lift);
	padding: var(--sp-28) 30px; overflow-x: auto;   /* 30px stays raw: #124 pins the pairs table at card-width minus 60px */
	/* The scroll affordance (#233). A table wider than its card already
	   scrolled — overflow-x: auto, above — but said nothing was scrollable:
	   the content just stopped at the card's edge, mid-letter, and read as
	   truncated rather than as "more this way." Pure CSS, so it needs no
	   markup on any one table and survives an HTMX fragment swap for free.
	   Two gradients (`local`) scroll with the content and sit exactly over
	   its start and end; two more (`scroll`) stay glued to the card's own
	   edges underneath them. At rest the local pair exactly covers the
	   scroll pair — nothing to see — and as the content scrolls away from
	   one edge, its cover slides off with it and that edge's shadow shows.
	   A card with nothing to scroll never uncovers either. */
	background-color: var(--surface);
	background-repeat: no-repeat;
	background-position: left, right, left, right;
	background-size: 24px 100%, 24px 100%, 10px 100%, 10px 100%;
	background-attachment: local, local, scroll, scroll;
	background-image:
		linear-gradient(to right, var(--surface) 40%, transparent),
		linear-gradient(to left, var(--surface) 40%, transparent),
		linear-gradient(to right, color-mix(in srgb, var(--text) 26%, transparent), transparent),
		linear-gradient(to left, color-mix(in srgb, var(--text) 26%, transparent), transparent);
}
/* A card alone on the page — sign in, the link confirmation — is the whole
   screen, so it takes the page title's scale: a measure wide enough for a
   line of its prose, and padding to match. Its children space themselves
   with one gap instead of each carrying its own margin, because a 44px
   button 0.6rem under a paragraph reads as glued to it. */
.card.narrow { max-width: 30rem; margin: var(--sp-32) auto; padding: var(--sp-36) var(--sp-40); display: grid; gap: var(--sp-16); }
.card.narrow > * { margin-bottom: 0; }
.card.center { text-align: center; }
/* A card whose content is measured — an intro paragraph, a handful of short
   fields — narrows to it instead of stretching the full column and leaving
   the difference empty (#238): the e-learn card was 450px of fields in a
   1070px card, half the card doing nothing. +60px is the card's own
   horizontal padding, so the card's content box comes out to exactly
   --measure rather than --measure plus whatever the padding ate from it. */
.card.measured { max-width: calc(var(--measure) + 60px); }
/* A card the reader was sent to (#122). An anchor is a promise that one card
   is the one meant, so the card it lands on says so: the resting --lift goes
   to --lift-hi and an accent ring is drawn inside the same shadow. Inside,
   because a shadow paints within the box that is already there — the card
   gains no width, and nothing beside or below it moves. The ring goes first
   in the list so it stays on top of the dark palette's own inset hairline.

   It is an edge where there was none, which is shape and not only tone, so
   colourblind mode needs no rule of its own here; the accent is also the one
   hue that section leaves alone, being legible under every common
   deficiency. And it does not fade in: a mark that animates has to be turned
   off for prefers-reduced-motion, while a mark that simply stays is still
   there when the reader looks up from the list they arrived from. */
.card:target { box-shadow: inset 0 0 0 2px var(--accent), var(--lift-hi); }
/* A card's figures down a narrow column rather than across the page (#92).
   The label reads first and the number sits at the right margin, so four of
   them make one column of numbers to run an eye down, at the size a figure
   takes in any other card. A rule between the rows, not a box around each.
   On the program page's strip they keep that shape in the column beside the
   bars, which is the width they were drawn for (#124). */
.figures { display: grid; gap: var(--sp-16); }
.figures .f { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-16); }
.figures .f + .f { padding-top: var(--sp-16); border-top: 1px solid var(--hover); }
/* Nothing here. It used to be a dashed box, which drew a container around
   the absence of one — and inside a card, a box in a box. It is a quiet
   line now, one step further back than .meta; where it is the only thing a
   card would hold, the template drops the card too. */
.empty-state { padding: var(--sp-4) 0; color: var(--text-3); }
/* A rare form behind a disclosure, in the flow of the page: deleting a
   cohort under the list. A rare *control* opens over the page instead — the
   top bar's Settings, the program page's status control, and the grid's New
   cohort tile, which is that same shape wearing a card. */
.add { margin-top: var(--sp-12); }
.add form { display: grid; gap: var(--sp-12); max-width: 24rem; }

/* --- The program page's top strip ------------------------------------- */
/* Where the cohort is up to (#124): one bar per conversation plus one for
   the pairs that have not started, every pair counted once, and the four
   figures in a column beside them.

   IT IS TEXT AND BOXES, NOT A PICTURE. No library, no canvas, no JS, no
   image: a list of three spans per row, laid out on a grid, and the bar is
   an empty element whose width is a percentage the server computed. Turn the
   stylesheet off and the list still reads "not started 3"; turn images off
   and nothing was one. The number beside every bar is the value, so the bar
   is never the only way to read it.

   ONE TONE FOR EVERY BAR. The quantity is length and nothing else — no hue
   carries meaning here, so colourblind mode has nothing to translate and
   adds no rule for this section. The fill is --text-3, the ink the labels
   are set in rather than a semantic tone: green, amber and red mean done,
   attention and out on this surface, and a histogram of where pairs have got
   to is data, not a verdict on any of them.

   The track is drawn under every bar, including the empty ones, because a
   gap in the middle of the chart IS the shape — a cohort where nobody is at
   conversation 3 is the thing a coordinator has come to see, and a chart
   that omits its zeroes cannot show one. A zero-count bar has width 0 and
   paints nothing; the track and the printed 0 are the row.

   Nothing here animates, in either motion preference: there is no transition
   and no animation to turn off, so a bar is simply the length it is when the
   page arrives. */
.strip { display: grid; gap: var(--sp-20) var(--sp-32); grid-template-columns: minmax(0, 1fr); align-items: start; }

/* Two parts side by side where there is room, and the card's own gutter is
   all that is ever between them (#159). Both were capped once — 34rem and
   17rem with space-between — which fits a card up to 908px wide and leaves
   everything past that stranded: 316px of unfilled track in the middle of
   the 1192px card this page actually draws, 27% of it doing nothing under
   the page's first heading.
   Two capped tracks in a container wider than their sum have to put the
   slack somewhere, and there is nowhere good: between them is a void, after
   them is a card trailing off to the right.

   So only the figures are measured, at the width the longest caption needs
   on one line, and the chart takes what is left. It is the honest reading of
   the two: one wants a fixed measure, the other is happy with whatever it is
   given. The track is 644px at the page's widest and shrinks from there,
   never the width of the window, because .shell caps the column at 1192.
   Under a 908px card the free column resolves to the same 34rem the cap
   used to name, so every screen the old rule fitted is laid out exactly as
   before — the change only removes the void from the ones it did not.

   The bars are untouched by it: length is still the count against the
   biggest bucket (#124), so a wider track is the same chart drawn longer,
   the differences between buckets easier to see and no bar rescaled to fill
   anything. Both tracks stay minmax(0, …) so a 900px screen shrinks the
   chart instead of pushing the figures out of the card; under 900px the two
   stack, bars first. The rule lives here and not in the shell's own 900px
   query because the base `.strip` rule is below that query in the file and
   would win. */
@media (min-width: 900px) {
	.strip { grid-template-columns: minmax(0, 1fr) minmax(0, 17rem); }
}

/* --- Cohorts ---------------------------------------------------------- */
/* The coordinator's first page: one card per cohort. A list of two or three
   is a shelf, not a spreadsheet, so each cohort gets a card that says where
   it is in its life, the figures that life is measured in, and the one thing
   to do next — and a cohort's two lives (#36) become two different buttons.

   EVERY CARD THE SAME SIZE. The grid gives equal width already. Equal height
   takes two words: align-items, because .grid packs its items to the top and
   the matching page's three columns depend on that — so the modifier is this
   page's alone and start stays the default everywhere else — and
   grid-auto-rows, because stretching alone levels a card with the row it is
   in and left the second row 1.7px shorter than the first — a percentage
   figure sets a taller line box than a plain one. A flexible row in a grid of
   indefinite height takes the height of the tallest row of the lot, so every
   row is one height and every card on the page is the same card.

   TOP ALIGNED, ACTION ON THE BOTTOM EDGE. A column flex box stacks the
   content from the top, and margin-top: auto on the button takes all the
   slack a stretched card has, so the buttons line up across the row whatever
   the cards hold above them. Nothing between them is stretched or spread:
   a card with three figures reads the same as one with two.

   grid-auto-rows: 1fr ONLY ONCE A ROW CAN HOLD MORE THAN ONE CARD (#235).
   Below 700px the shelf is one column, so every card and the New cohort
   tile is its own row — and 1fr still means "every row this tall as the
   tallest," which on a phone reached across cards that share nothing, not
   across a row. The New cohort tile has one small icon and nothing else, so
   it stretched to match whichever real cohort card was tallest: ~330px of
   empty ground around a plus sign. Above 700px, where two or three cards
   can share a row, the same rule is what makes them level — that part is
   unchanged. */
.grid.cohorts { align-items: stretch; }
@media (min-width: 700px) {
	.grid.cohorts { grid-auto-rows: 1fr; }
}
.cohort-card { display: flex; flex-direction: column; gap: var(--sp-8); }
.cohort-card h2 { margin: 0; }
.cohort-card .btn { width: 100%; margin-top: auto; }
/* The life: the four ticks the top bar draws over a cohort's name, at the
   size a card can carry, with the life and how far in named under them. */
.lifeline { display: flex; flex-direction: column; gap: var(--sp-4); }
.lifeline .track i { width: 22px; height: 3px; }
/* The two or three figures, on one line: this card's figure is the step
   under a stat card's, which is the whole page and not one of a shelf. */
.figs { display: flex; flex-wrap: wrap; gap: var(--sp-4) var(--sp-24); }
.figs .data { font-size: var(--fs-xl); }
/* NEW COHORT IS THE LAST TILE IN THE GRID (#104), not a line under it. The
   grid above already gives every item the size of a card, so a tile put in
   it matches the cards beside it for free and stays matched as they grow —
   nothing here is measured or hard-coded. It also fixes the empty page: no
   cohorts is one tile saying what to do, which is that screen's whole job.

   A GHOST CARD. Cards here are borderless — the three-layer --lift is the
   only edge — so "card shaped" is the card's radius and ground with no lift
   at rest, rising to the full lift on hover, on focus and while it is open:
   a card that has not been filled in yet. Not a dashed outline, which would
   draw a container around the absence of one (#88). The + is drawn, two bars
   of currentColor crossing, so it is neither an icon font nor an image and
   claims no seventh type role; the word under it is Meta like any caption.

   THE FORM OPENS OVER THE PAGE. Expanding the tile in place cannot work:
   grid-auto-rows: 1fr makes every row one height, so a tile grown to fit
   three fields would drag every card in its row taller with it. So the tile
   is the shape this surface keeps for a rare control — a <details> whose
   panel is absolutely positioned, the same as the top bar's Settings and the
   program page's status control — and the panel is a .menu, which is that
   floating card already, widened to the tile and hung under it. It works
   with JS off and from the keyboard: the tile is a summary, so it takes a
   tab stop of its own and opens on Enter or Space.

   The tile is the summary, so the summary is what has to fill the grid item:
   flex: 1 in a column, not height: 100%. A <details> is not one box — Chrome
   lays its content out in a ::details-content box beside the summary — and
   as a grid the two shared the row's spare height between them, which left
   the tile's ground 75px short of the card next to it. In a column flex box
   the panel is out of flow, so the summary is the only item and takes it
   all.

   AND THE EMPTY PAGE IS THE ONE SCREEN WHERE IT HAS TO BE A CARD ON ITS OWN.
   With no card in the grid to level it against, the row sized to the tile's
   own content and it came out 111px: a wide strip, not the card shape it is
   in every other state. So the tile carries a 3:2 ratio — the plainest card
   ratio there is, and a property this stylesheet already uses (the heatmap
   cell is aspect-ratio: 1). It decides the empty page and nothing else: the
   moment a card exists the row's 1fr wins, and the ratio is not measured off
   the cards — at the shelf's own column width 3:2 is 257.8px against a real
   card's 260.5.

   The width: 100% beside it is load-bearing, not decoration. An aspect ratio
   with an auto width takes the *width* from the stretched height instead, so
   the tile came out 390.7px against the cards' 386.66 and hung 4px out of
   its column. Pinning the width to the column leaves the ratio only the axis
   it is there for. */
.add-tile { position: relative; display: flex; flex-direction: column; aspect-ratio: 3 / 2; width: 100%; }
.add-tile > summary {
	flex: 1; list-style: none; display: flex; flex-direction: column; align-items: center; justify-content: center;
	gap: var(--sp-8); padding: var(--sp-28) 30px; color: var(--text-2); background: var(--surface); border-radius: var(--radius);   /* 30px matches .card, raw for the same reason */
}
.add-tile > summary::-webkit-details-marker { display: none; }
.add-tile > summary:hover, .add-tile > summary:focus-visible, .add-tile[open] > summary { box-shadow: var(--lift); }
.add-tile[open] > summary { margin-bottom: 0; }
.plus {
	width: 30px; height: 30px;
	background: linear-gradient(currentColor, currentColor) center / 2px 100% no-repeat,
	            linear-gradient(currentColor, currentColor) center / 100% 2px no-repeat;
}
.add-tile > .menu { left: 0; right: auto; top: calc(100% + 6px); width: 100%; }
.add-tile form { display: grid; gap: var(--sp-12); }

/* --- In-page nav and tables ------------------------------------------- */
/* Anything jumped to stops short of the sticky top bar: the two routes that
   303 to a section of the program page, #health, which is the four figures
   in that page's top strip now (#124) — Health stopped being a section at
   #92 and a card at #124, and the figures are what the anchor always meant
   — and #concerns, which is a section there and a card on the pair page the
   Needs-you pill links into (#122). The nav that used to sit under the page
   title went with them — a page whose own arrangement puts nothing that
   matters below the fold has nothing to jump to. */
main > section, #health, #concerns { scroll-margin-top: calc(var(--topbar) + var(--sp-16)); }
/* A concern is one object, not a paragraph with controls attached (#177).
   #160 capped prose at --measure, which took a concern's explanation from
   1132px to 504px and left the card it sits in still 1192: 628px of empty
   paper down its right-hand side, at every width the card is full-size.

   The card is not the thing that is wrong — it agrees with the pairs card
   above it, and on a surface where cards are borderless and read by their
   --lift alone, one card mid-column stopping short reads as a mistake. What
   was wrong is that a concern only ever used the left of it. So the three
   parts sit side by side where there is room: who raised it, what they said,
   and what you do about it. The middle keeps the measure; the first takes
   whatever is left, the way #159 settled it for the progress card, so the
   slack lands inside a block of text as its own ragged edge rather than
   between two things as a void. The control is the last column at its own
   width — it is the same select and button, in the same form, working the
   same without JS.

   Under 1200px there is not room for three, so it stays what it was: the
   metadata line with the control pushed to its right, and the explanation
   under both.

   The pair takes a line of its own at every width. In three columns the
   first is 365px and the line does not fit; wrapped, it breaks after the
   slash and leaves a name stranded under "Dana Whitfield /". A row's
   identity is the pair, so it gets the line rather than the leftovers of
   one, and the separator that ran before it goes with the change. */
li.concern { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-4) var(--sp-16); align-items: start; }
li.concern > .who { grid-column: 1; }
li.concern > form { grid-column: 2; grid-row: 1; justify-self: end; }
li.concern > p { grid-column: 1 / -1; margin-bottom: 0; }
li.concern .pair { display: block; }
@media (min-width: 1200px) {
	li.concern { grid-template-columns: minmax(0, 1fr) minmax(0, var(--measure)) auto; column-gap: var(--sp-24); }
	li.concern > p { grid-column: 2; grid-row: 1; }
	li.concern > form { grid-column: 3; }
}
table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
th { text-align: left; padding: 0 var(--sp-16) var(--sp-12) 0; border-bottom: 1px solid var(--border); white-space: nowrap; }
td { padding: var(--sp-12) var(--sp-16) var(--sp-12) 0; border-bottom: 1px solid var(--hover); vertical-align: middle; }
/* A toned row keeps its tone; every other row lights up under the pointer. */
tbody tr:not(.warn, .behind):hover td { background: var(--paper); }
/* A row that goes somewhere is one link, not one cell of one: the anchor in
   its first cell stretches over the whole row, so a click anywhere on the
   row follows it, a middle click opens it in a tab and the status bar names
   it — none of which a click handler gives you. One anchor is also one tab
   stop, and the focus ring goes on the stretched box so it rings the row
   the pointer would hit rather than the name inside it. */
tr.linked { position: relative; }
tr.linked td:first-child a::after { content: ""; position: absolute; inset: 0; }
tr.linked td:first-child a:focus-visible { outline: 0; }
tr.linked td:first-child a:focus-visible::after { outline: 2px solid var(--accent); outline-offset: -2px; }
/* And a row that is a link can still hold links of its own (#124): the pairs
   table's trouble cell carries the concern pill and Write, the same two the
   work list has. The stretched box is absolutely positioned, so it paints
   over anything static beside it and both would be dead to a click.
   Positioning them puts them back on top — later in the DOM and positioned,
   which is enough without a z-index — and the rest of the row still follows
   the row's own link. */
tr.linked td:not(:first-child) a { position: relative; }
/* The row is the target, not the name (#269): in both pair tables the first
   cell's anchor is a real link — the row's tab stop, resolved by keyboard and
   read by a screen reader — but it must not look like one, or the eye reads
   the name as the thing to click rather than the row. Scoped to the first
   cell because that is where each of these tables puts its one row anchor;
   a table that gave `tr.linked` to a row whose first cell held something
   other than a name would need its own rule, not a wider one. */
tr.linked td:first-child a, tr.linked td:first-child a:hover { color: inherit; text-decoration: none; }
td.num, th.num { text-align: right; }
td.num { color: var(--accent); }
/* #294: the pairs table's company and state cells are each "sponsor ·
   sponsee" — two names that must not wrap onto their own lines mid-pair, so
   the pair reads as one unit. The card already scrolls sideways for a wide
   table (#233); a cell that wrapped instead would fight that scroll rather
   than use it. */
.pairs td.company, .pairs td.state { white-space: nowrap; }
tr.warn td { background: var(--red-dim); }
tr.behind td { background: var(--amber-dim); }
td .pill { margin: 0 var(--sp-4) var(--sp-4) 0; }
.swatch { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: var(--sp-8); background: var(--score-bg, var(--border)); }
dl.checkin { margin-top: var(--sp-8); }
dl.checkin dt { margin-top: var(--sp-8); font-weight: 600; }

/* --- Tones ------------------------------------------------------------ */
/* One tone per meaning, carried in --t (ink), --t-dim (fill), --t-border:
   green = done or a bonus, amber = needs attention, red = out, accent =
   assigned or running. A pill with no tone is a neutral note. */
.banner-ok, .pill-resolved, .pill-running, .pill-acknowledged, .pill-bonus, .done > .stage-head .badge, .ins-rate .good
	{ --t: var(--green-text); --t-dim: var(--green-dim); --t-border: var(--green-border); }
.banner-warn, .pill-open, .pill-matching, .pill-penalty, .attn > .stage-head .badge, .ins-rate .bad
	{ --t: var(--amber-text); --t-dim: var(--amber-dim); --t-border: var(--amber-border); }
.banner-error, .pill-excluded, .pill-flagged, .fail > .stage-head .badge, .btn.danger
	{ --t: var(--red-text); --t-dim: var(--red-dim); --t-border: var(--red-border); }
.pill-assigned, .run > .stage-head .badge { --t: var(--accent); --t-dim: var(--accent-dim); --t-border: var(--accent-glow); }
.pill, .badge {
	display: inline-block; padding: var(--sp-2) var(--sp-8); vertical-align: middle; white-space: nowrap; border-radius: var(--radius-pill);
	color: var(--t, var(--text-2)); background: var(--t-dim, var(--raised)); border: 1px solid var(--t-border, var(--border));
}
.badge { border: 0; color: var(--t, var(--text-3)); }
/* The cohort title's status pill (#269): still a word, so still the Micro
   role's sans and case, just read at the next size up rather than a raw px —
   a title is the one place a pill sits beside a heading rather than in a
   table column or a row of figures. */
.pill.pill-lg { font-size: var(--fs-sm); padding: var(--sp-4) var(--sp-12); }
.banner {
	padding: var(--sp-8) var(--sp-12); margin-bottom: var(--sp-12);
	color: var(--t); background: var(--t-dim); border: 1px solid var(--t-border);
}

/* --- Controls --------------------------------------------------------- */
/* --ctl-h is the height in force, and every control reads it: a group of
   controls is one height because they are told one number, not because
   someone lined two paddings up. .small sets it — on a control by itself,
   and on the row or the form around a group, so the select beside a small
   button is compact too without the template saying so twice. */
.small, :is(.row, form):has(> .btn.small) { --ctl-h: var(--ctl-sm); }
.btn {
	display: inline-flex; align-items: center; justify-content: center; min-height: var(--ctl-h, var(--ctl)); padding: 0 var(--sp-16);
	font: 500 var(--fs-md) var(--font-sans); color: var(--text); background: var(--surface);
	border: 1px solid var(--border-bright); border-radius: var(--radius-ctl); cursor: pointer; white-space: nowrap;
}
.btn:hover { background: var(--hover); text-decoration: none; }
.btn-primary { color: var(--accent); border-color: var(--accent); background: none; }
.btn-primary:hover { background: var(--accent-dim); }
.btn.danger, .btn.on { color: var(--t); border-color: var(--t-border); background: var(--t-dim); }
.btn.small { padding: 0 var(--sp-8); font-size: var(--fs-sm); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.btn-link, .add summary { font: inherit; font-size: var(--fs-sm); color: var(--accent); background: none; border: 0; cursor: pointer; }
.btn-link:hover { text-decoration: underline; }
.btn-link:disabled { color: var(--text-3); cursor: not-allowed; }
/* Fields: the label reads as body, its help in .meta, an error line in red
   that also turns the label and the control's border. */
.field { display: flex; flex-direction: column; gap: var(--sp-4); }
legend { margin-bottom: var(--sp-8); }
.error { display: block; color: var(--red-text); }
.field.invalid > span, .field.invalid > legend, .rating.invalid .statement { color: var(--red-text); }
.field.invalid :is(input, textarea, select), .rating.invalid .seg span { border-color: var(--red); }
/* The e-learn links (#188): a stack of URL boxes inside a card, measured.
   A paste box run out to the full 1240px column is harder to read a link
   back in than one whose end you can see, and checking the link is the only
   thing a coordinator does here after typing it. */
.links { display: grid; gap: var(--sp-16); max-width: var(--measure); }
input:not([type="checkbox"]):not([type="radio"]), textarea, select {
	font: inherit; font-size: var(--fs-sm); color: var(--text); background: var(--surface); width: 100%;
	border: 1px solid var(--border-bright); border-radius: var(--radius-ctl); padding: var(--sp-8) var(--sp-8);
}
/* A single-line control stands at --ctl-h rather than at whatever its padding
   and font add up to, which is what left every select 5px taller than the
   button beside it. The vertical padding goes and the height decides. A
   textarea keeps its padding: it is a box of prose and its rows set its
   height. The file input is below, with the small button inside it. */
input:not([type="file"]):not([type="checkbox"]):not([type="radio"]), select { min-height: var(--ctl-h, var(--ctl)); padding-block: 0; }
/* 16px exactly, and not a scale step: under 16px iOS zooms the field on focus.
   A thumb hits the control, so --ctl is --tap for the whole surface. */
body:not(.coord) { --ctl: var(--tap); }
body:not(.coord) :is(input:not([type="checkbox"]):not([type="radio"]), textarea, select) { font-size: 16px; padding: var(--sp-8) var(--sp-12); }
body:not(.coord) :is(input:not([type="file"]):not([type="checkbox"]):not([type="radio"]), select) { padding-block: 0; }
textarea { resize: vertical; }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
.row select { width: auto; }
/* The roster's toolbar (#274): the count, Add and Export on the first line;
   the actions in one raised strip with the reason beside them; the rule in
   force for the current selection on a line under the strip. A selected
   row is tinted so the rule line and the table agree on who is picked. */
.toolbar { display: grid; gap: var(--sp-12); margin-bottom: var(--sp-16); }
.tb-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-12); }
.tb-top .sel { display: flex; align-items: center; gap: var(--sp-8); flex-wrap: wrap; }
.tb-top .right { display: flex; gap: var(--sp-8); }
.actions { display: grid; gap: var(--sp-8); padding: var(--sp-12) var(--sp-16); background: var(--raised); border-radius: var(--radius-ctl); }
.actions .grp { display: flex; flex-wrap: wrap; gap: var(--sp-8); align-items: center; }
.actions .sep { width: 1px; height: var(--sp-20); background: var(--border-bright); margin: 0 var(--sp-4); }
.actions .reason { display: flex; align-items: center; gap: var(--sp-8); margin-left: auto; }
.actions .reason input { width: 16rem; }
.btn.ghost { background: none; border-color: var(--border); color: var(--text-2); }
.btn[disabled] { opacity: 0.45; cursor: not-allowed; }
.rule { margin: 0; font-size: var(--fs-sm); color: var(--text-2); }
tr.sel td { background: var(--accent-dim); }
.add-person { margin: 0; }
.add-person > summary { list-style: none; display: inline-flex; }
.add-person > summary::-webkit-details-marker { display: none; }
.add-person[open] > summary { margin-bottom: var(--sp-12); }
@media (max-width: 700px) { .actions .reason { margin-left: 0; width: 100%; } .actions .reason input { flex: 1; width: auto; } .actions .sep { display: none; } }
/* Checkboxes and radios (#232): excluded from the rule above, which was
   written for text fields and selects. width: 100% turned a bare checkbox
   into a full-width empty box that pushed its label onto the next line, and
   the native widget's own appearance ignores our background/border anyway —
   under color-scheme: dark it draws an unchecked box as a filled mid-grey
   square, indistinguishable from checked or disabled. Drawn ourselves
   instead, natural size, so both states are legible in both themes from the
   same tokens every other control uses. (.seg's own radios override this
   again below, same as they already override the base input rule.) */
input[type="checkbox"], input[type="radio"] {
	appearance: none; width: 1.15em; height: 1.15em; flex: none; margin: 0;
	border: 1px solid var(--border-bright); border-radius: 4px; background: var(--surface);
	position: relative; cursor: pointer; vertical-align: -0.25em;
}
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]:checked, input[type="radio"]:checked { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::after {
	content: ""; position: absolute; left: 0.32em; top: 0.08em; width: 0.28em; height: 0.55em;
	border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: rotate(45deg);
}
input[type="radio"]:checked::after { content: ""; position: absolute; inset: 0.25em; border-radius: 50%; background: var(--on-accent); }
input[type="checkbox"]:focus-visible, input[type="radio"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Drop zones (the Pool card's two imports). The dashed edge is the one
   place a dashed outline is allowed: #88 removed dashes drawn around an
   absence, and this is the opposite — a target. The label is the whole
   target: the input is stretched invisibly over the box, so a click anywhere
   opens the picker and a drop on the box sets the input's files natively,
   no script. inset: -2px takes the input past the padding box and over the
   border ring as well: a click on the dashed edge itself is a click on the
   label, and Chromium does not forward that one to the control — the whole
   dashed box opens the picker or the box is lying. The box stands two
   controls tall so it reads as a target; height is padding, not a number,
   so the token rule below still holds. */
input[type="file"] { min-height: var(--ctl-h, var(--ctl)); }
.dz { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-16); cursor: pointer; text-align: center; border: 2px dashed var(--border-bright); border-radius: var(--radius-ctl); }
.dz:hover { border-color: var(--accent); background: var(--accent-dim); }
.dz input[type="file"] { position: absolute; inset: -2px; width: auto; height: auto; margin: 0; padding: 0; min-height: 0; opacity: 0; cursor: pointer; }
.dz:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
/* Filled (#270): matching.js sets this once a file is chosen, the same green
   a done step's badge already reads as. The name replaces the "click or
   drop" line in place, so it is capped to one line rather than pushing the
   box (and the card, #48) taller for a long one. */
.dz.filled { border-style: solid; border-color: var(--green-border); background: var(--green-dim); }
.dz.filled .meta { color: var(--green-text); }
.dz .meta { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Segmented choice: one cell per option with a gap between, the radio
   stretched invisibly over its cell; the chosen cell fills with the accent. */
.seg { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: var(--sp-4); max-width: 30rem; }
.seg label { position: relative; }
.seg input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.seg span {
	display: flex; align-items: center; justify-content: center; min-height: var(--ctl-h, var(--ctl)); padding: 0 var(--sp-4);
	font-size: var(--fs-md); font-weight: 600; background: var(--surface);
	border: 1px solid var(--border-bright); border-radius: var(--radius-ctl);
}
.seg label:hover span { background: var(--raised); }
.seg input:checked + span { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
.rating { padding: var(--sp-12) 0; border-top: 1px solid var(--border); }
.rating .statement { margin-bottom: var(--sp-8); }

/* --- Conversation timeline ------------------------------------------- */
/* One box per conversation, four states: empty = nothing yet, half filled =
   one side in, green = both in, ringed in the accent = the one to check in
   for next. The two bars beneath are the sponsor and sponsee sides.

   ONE SIDE IN IS A FILL BECAUSE NEXT COMPOSES WITH IT (#118). `next` is not
   a state of its own, it lands on any of the other three — and it repaints
   border-color and color, which is everything `one` used to be written in,
   later in this file and at equal specificity. So on the very box a
   participant was about to open, "the other side has already written theirs"
   was said by nothing at all: measured at 0 of 2112 computed properties
   differing from a plain `next`. The fill is a channel `next` does not take,
   `both`'s background shorthand clears it on the way to a full fill, and a
   background costs no layout, so the four cells stay on repeat(4, 1fr).

   It is the same half fill #108 gave the mode; a defect every reader had
   does not belong behind a preference, so Colourblind keeps only the two
   bars, which are the only thing here that says *which* side.

   And the state is in words as well as in paint. A box has no text but its
   number, so `.vh` in _macros.html says the state in a sentence — the cell
   reached a screen reader as "2" and two titles naming both sides
   regardless, which was worse than silence. */
.timeline { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-8); list-style: none; margin: var(--sp-16) 0 var(--sp-8); max-width: 18rem; }
.tl-cell a { display: block; }
.tl-cell a:hover { text-decoration: none; }
.tl-n {
	display: grid; place-items: center; height: 36px; font-size: var(--fs-sm);
	color: var(--text-3); background: var(--surface); border: 1px solid var(--border);
}
.tl-cell.one .tl-n {
	border-color: var(--border-bright); color: var(--text);
	background-image: linear-gradient(to top, var(--border) 50%, transparent 50%);
}
.tl-cell.both .tl-n { background: var(--green); border-color: var(--green); color: var(--on-green); }
.tl-cell.next .tl-n { border-color: var(--accent); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.tl-cell a:hover .tl-n { border-color: var(--accent); }
.tl-dots { display: flex; gap: var(--sp-2); margin-top: var(--sp-4); }
.dot { flex: 1; height: 3px; background: var(--border); }
.dot.on { background: var(--green); }
td .timeline { margin: 0; gap: var(--sp-4); width: 8rem; }
td .tl-n { height: 20px; font-size: var(--fs-xs); }

/* --- The journey (#184) ----------------------------------------------- */
/* --- The program column (#272, #273) ------------------------------------ */
/* One column of stages on both surfaces, from _macros.html's program_column.
   The track is the ::before line down the left, green through every done
   stage; the mark is the node on it. Five states, each its own shape so none
   is told by hue alone (#118): done is a green fill with a tick, part (the
   coordinator's "some of the cohort is past this") a half fill, here a ring
   in the accent, ahead a hollow ring, off a small solid dot for the two
   milestones the tool has no record of. A stage's name is Lead; the state
   word is Micro; a note is Meta. Nothing here declares a font. */
.program { list-style: none; display: grid; margin: var(--sp-12) 0 0; padding: 0; }
.pg { position: relative; display: grid; grid-template-columns: 22px minmax(0, 1fr); column-gap: var(--sp-16); }
.pg::before { content: ""; position: absolute; left: 10px; top: 0; bottom: 0; width: 2px; background: var(--border); }
.pg.is-done::before { background: var(--green); }
.pg.is-part::before { background: linear-gradient(var(--green) 0 20px, var(--border) 20px); }
.pg.is-here::before { background: linear-gradient(var(--green) 0 20px, var(--border) 20px); }
.pg:first-child::before { top: 20px; }
.pg:last-child::before { bottom: auto; height: 20px; }
.pg-mark {
	position: relative; z-index: 1; width: 22px; height: 22px; margin-top: var(--sp-8); border-radius: var(--radius-pill);
	border: 2px solid var(--border-bright); background: var(--surface); display: grid; place-items: center;
}
.is-done .pg-mark { background: var(--green); border-color: var(--green); color: var(--on-green); }
.is-done .pg-mark::after { content: "✓"; font: 600 12px/1 var(--font-sans); }
.is-part .pg-mark { border-color: var(--green); background: linear-gradient(90deg, var(--green) 50%, var(--surface) 50%); }
.is-here .pg-mark { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim); }
.is-here .pg-mark::after { content: ""; width: 8px; height: 8px; border-radius: var(--radius-pill); background: var(--accent); }
.k-milestone .pg-mark { width: 10px; height: 10px; margin: var(--sp-16) 0 0 var(--sp-4); border: 0; background: var(--border-bright); }
.k-milestone.is-done .pg-mark { background: var(--green); }
.k-milestone.is-done .pg-mark::after { content: none; }
.pg-body { display: grid; gap: var(--sp-4); padding: var(--sp-8) 0 var(--sp-20); }
.is-here .pg-body { padding-bottom: var(--sp-24); }
.k-milestone .pg-body { padding: var(--sp-8) 0 var(--sp-12); }
.pg-row { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--sp-4) var(--sp-12); }
.pg-name { font-weight: 600; }
.is-ahead .pg-name, .k-milestone .pg-name { font-weight: 500; color: var(--text-2); }
.pg-state { font: 600 var(--fs-xs)/1.4 var(--font-sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
.is-done .pg-state { color: var(--green-text); }
.is-here .pg-state { color: var(--accent); }
.pg-link { font-size: var(--fs-sm); text-decoration: none; }
.pg-link::after { content: " ↗"; }
/* The one filled control on the participant's page: the check-in (or the
   close-off) they are up to. Full width, thumb height, the only thing in
   the accent, so it cannot be mistaken for anything else on the card. */
.btn-fill {
	margin-top: var(--sp-8); width: 100%; min-height: var(--tap);
	background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600;
	box-shadow: 0 4px 10px var(--accent-dim);
}
.btn-fill::after { content: "→"; margin-left: var(--sp-8); font-weight: 500; }
.btn-fill:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); }
/* The coordinator's column: a bar and a count beside each conversation and
   the close-off, laid on a fixed grid so the bars line up down the page;
   an e-learn's name is the button that opens its dialog, dotted-underlined
   so it reads as a control and not as a link out. */
.program-coordinator .pg-row { display: grid; grid-template-columns: 12rem minmax(0, 1fr) 6.5rem; align-items: center; }
.program-coordinator .pg-body { padding: var(--sp-4) 0 var(--sp-12); }
/* Each conversation and the e-learn after it read as one unit: the
   conversation carries the bar, the e-learn tucks under it at a smaller
   size with a smaller mark and no gap, and the gap comes after the pair.
   So the bars keep one steady pitch down the column instead of a gap
   every other row. The two intro e-learns tuck under Launch the same way. */
.program-coordinator .k-conversation .pg-body, .program-coordinator .k-milestone:first-child .pg-body { padding-bottom: 0; }
.program-coordinator .k-elearn .pg-body { padding: 0 0 var(--sp-12); }
.program-coordinator .k-elearn + .k-elearn .pg-body { padding-top: 0; }
.program-coordinator .k-elearn .pg-mark { width: 12px; height: 12px; margin: var(--sp-4) 0 0 var(--sp-4); border-width: 2px; }
.program-coordinator .k-elearn.is-done .pg-mark::after { font-size: 8px; }
.program-coordinator .k-elearn .pg-row { min-height: 0; }
.program-coordinator .pg-edit { font-size: var(--fs-xs); font-weight: 500; }
.program-coordinator .k-milestone .pg-body { padding: var(--sp-4) 0 var(--sp-8); }
.program-coordinator .pg-name { font-size: var(--fs-sm); }
.program-coordinator .pg-state { grid-column: 2 / 4; justify-self: start; }
.pg-bar { display: block; height: 10px; border-radius: 2px; background: var(--raised); overflow: hidden; }
.pg-bar i { display: block; height: 100%; width: var(--w, 0); border-radius: 2px; background: var(--green); }
.pg-num { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
.pg-num .data:first-child { font-weight: 600; color: var(--text); }
.pg-edit {
	grid-column: 1 / 4; justify-self: start; margin: 0; padding: 0; background: none; border: 0; border-radius: 0;
	border-bottom: 1px dotted var(--border-bright); color: var(--text); font: 600 var(--fs-sm)/1.4 var(--font-sans); text-align: left; cursor: pointer;
}
.pg-edit:hover, .pg-edit:focus-visible { color: var(--accent); border-bottom-color: var(--accent); outline: none; }
.pg-edit.unset { color: var(--text-2); font-weight: 500; }
.pg-edit.unset::after { content: " · no link yet"; font-weight: 400; color: var(--text-3); }
@media (max-width: 600px) {
	.program-coordinator .pg-row { grid-template-columns: minmax(0, 1fr) 5.5rem; }
	.program-coordinator .pg-name { grid-column: 1 / 3; }
	.program-coordinator .pg-edit { grid-column: 1 / 3; }
	.program-coordinator .pg-state { grid-column: 1 / 3; }
}
/* The stage dialog (#273): one <dialog> on the program page, native, so the
   browser owns focus, Escape and the backdrop. It is a card. */
dialog.stage-dialog { margin: auto; border: 0; border-radius: var(--radius); box-shadow: var(--lift-hi); padding: var(--sp-24) var(--sp-28); width: min(28rem, calc(100vw - 2 * var(--sp-16))); background: var(--surface); color: var(--text); }
dialog.stage-dialog::backdrop { background: var(--scrim); }
dialog.stage-dialog h2 { margin: var(--sp-4) 0 var(--sp-12); }
dialog.stage-dialog .row { display: flex; gap: var(--sp-8); margin-top: var(--sp-16); }
/* A card that is closed until it is wanted (#272): the participant's Raise a
   concern. The summary is the card's own title line — the h2 and a quiet
   hint on the right that says it opens — and the marker is that hint, not
   a triangle. */
details.card > summary { list-style: none; display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-12); cursor: pointer; }
details.card > summary::-webkit-details-marker { display: none; }
details.card > summary h2 { margin: 0; }
details.card > summary::after { content: "▾"; font-size: var(--fs-sm); color: var(--text-3); }
details.card[open] > summary::after { content: "▴"; }
details.card[open] > summary { margin-bottom: var(--sp-12); }


/* --- Matching page ---------------------------------------------------- */
/* The stage column down the left (up to five numbered cards), the review
   column beside it, and the pair beside that again — three columns on a
   screen wide enough, never a panel over the page. The page grows with the
   work: a stage that isn't reachable yet renders an empty section, and the
   steps still ahead are the one quiet .ahead line at the foot of the
   column. The columns are here from the first visit and never move — the
   empty phase is this same left column with the Pool card alone in it, so
   ingesting a pool fills the page in rather than relaying it. All three
   start at the same y (align-items: start), so the heatmap's top edge is
   level with the Pool card's and the panel's. */
.canvas { --rail: 34px; --inspect-w: clamp(320px, 24vw, var(--panel));
	display: grid; grid-template-columns: calc(var(--stage-col) + var(--rail)) minmax(0, 1fr);
	gap: var(--sp-24); align-items: start; }
.main-col { display: grid; gap: var(--sp-24); min-width: 0; }
@media (max-width: 899px) { .canvas { grid-template-columns: minmax(0, 1fr); } }
/* From 1280px up the pair opens in a third column beside the heatmap rather
   than over it (see the Inspect panel section). The grid loses width to it,
   so the shell widens to pay most of that back: the canvas is a workspace,
   not a document, and the reading width the rest of the coordinator surface
   uses is not what a 44 x 117 matrix wants. The column itself scales with
   the screen between a legible minimum and --panel, so the narrowest screen
   that gets three columns still leaves the grid more than half the width.
   1280px is the one breakpoint the panel has: the Inspect panel section's
   max-width: 1279px is the other side of this query and moves with it. */
@media (min-width: 1280px) {
	/* The wider shell is for the workspace, not for the page: before there is
	   a grid (show.grid, #148) .canvas holds one card and the ordinary,
	   centred 1240px shell is the right width — the same one every other
	   coordinator page uses. .inspect-col is drawn from that same flag
	   (matching.html), so this and the three-column rule below key off its
	   presence rather than .canvas's — one condition, not two.

	   Growing the shell must not re-centre it: the stage column sits at the
	   ordinary shell's left edge from the first visit, and if the grid
	   arriving shifted that edge sideways, the Pool card would move under a
	   reader who did nothing to move it — the geometry #48 rules out. So
	   this isn't `margin: 0 auto` at a wider max-width — margin-left is
	   fixed at the same distance the ordinary 1240px shell's own auto margin
	   already puts there (both are a percentage of the same containing
	   block, so the two are the same number), margin-right is 0, and width
	   is left to fill outward from that fixed edge, capped at 1560px. The
	   room a grid needs is added on the right, where the extra columns
	   are. */
	.coord .shell:has(.canvas .inspect-col) {
		max-width: 1560px; margin-left: calc((100% - 1240px) / 2); margin-right: 0;
	}
	/* Only show 3 columns when inspect-col exists; otherwise 2 columns */
	.canvas:has(.inspect-col) { grid-template-columns: calc(var(--stage-col) + var(--rail)) minmax(0, 1fr) var(--inspect-w); }
}
.stages, .stage { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-12); margin: 0; }
.stage:empty { display: none; }
/* Borderless, so the rail's node and the badge carry the stage's state on
   their own; a running stage adds a glow, which is a shadow, not an edge.
   The card's overflow stays visible so its node can sit out in the rail. */
.stage-card { padding: var(--sp-24) var(--sp-28); overflow: visible; }
.stage-card.run { box-shadow: var(--lift), 0 0 12px var(--accent-dim); }
/* The top bar's stage name, beside the five-tick track (base.html). It
   shows on the matching page only, so it lives with the page's styles. */
.tb-step { display: flex; align-items: center; gap: var(--sp-8); }
.stage-head { display: flex; align-items: center; gap: var(--sp-8); margin-bottom: var(--sp-4); }
.stage-head h2 { flex: 1; }
/* white-space: nowrap keeps "N ·" one unbreakable token, and the &nbsp;
   templates put after it (admin/_stage_*.html) keeps that token glued to
   the title's first word — so a title that has to wrap at 390px wraps
   whole, "5 · Settings" together on its own line, rather than orphaning the
   number alone above it (#235). */
.stage-head .sn { color: var(--text-3); white-space: nowrap; }

/* A step that is done collapses to its head — number, name, key figure and
   badge — and opens again on a click. It is a real <details>, so it works
   with JS off and from the keyboard; the server renders the default (open
   until the step is done) and matching.js keeps the reader's own choice
   across a swap. The card's padding does not change with the state, so the
   rail's node stays at --node-y whether the card is open or shut. */
.stage-card > details > summary { list-style: none; }
.stage-card > details > summary::-webkit-details-marker { display: none; }
.stage-card > details > summary::after {
	content: ''; flex: none; width: 7px; height: 7px; margin: 0 var(--sp-2) 0 var(--sp-2);
	border-right: 1.5px solid var(--text-3); border-bottom: 1.5px solid var(--text-3);
	transform: translateY(-2px) rotate(45deg); transition: transform 0.15s ease;
}
.stage-card > details[open] > summary { margin-bottom: var(--sp-4); }
.stage-card > details[open] > summary::after { transform: translateY(1px) rotate(-135deg); }
.stage-card > details:not([open]) > summary { margin-bottom: 0; }
/* The key figure stands in for the body while the card is shut. */
.stage-card > details[open] > summary .key { display: none; }
.key .data { color: var(--text); }

/* The register rail. The five steps are a real sequence — you cannot score
   before you screen — so a 2px rule runs down the left of the stage column
   and threads a node beside every step: filled green for a step that is
   done, ringed for the one you are on, hollow grey for one that is
   reachable, hollow on the paper for one still in the .ahead line. That is
   the state the cards used to spend a numbered marker on; the number is now
   plain text at the head of the card's title.

   The rail is drawn per node, never as one element spanning the column: a
   stage out of reach renders an empty section (.stage:empty), and an empty
   section has to contribute neither a node nor a gap. So each card owns its
   node (::before) and the segment of rule from its own top down to the next
   node (::after) — the first card starts its segment at its node, the last
   one stops at its own. A node lives inside the section it describes, so a
   stage that swaps itself over SSE brings its node with it. */
.stages { padding-left: var(--rail); }
.stage-card, .ahead { --node-y: 36px; position: relative; }   /* the node's centre, down from the top */
.ahead { --node-y: 9px; }
/* Node and rule share a centre, 10px in from the column's left edge. Both
   are placed from the card's left edge, which is --rail further right, and
   border-box makes the node's border part of its 12px:

     node   left 4px - rail, width 12px  →  spans 4..16   centre 10
     rule   left 9px - rail, width  2px  →  spans 9..11   centre 10

   Vertically the node is centred on --node-y (top --node-y - 6, height 12),
   which is where the first card's rule starts and the last one's stops. */
.stage-card::before, .ahead::before {
	content: ''; position: absolute; z-index: 1; left: calc(4px - var(--rail)); top: calc(var(--node-y) - 6px);
	width: 12px; height: 12px; border-radius: 50%; background: var(--surface); border: 2px solid var(--border-bright);
}
.stage-card::after, .ahead::after {
	content: ''; position: absolute; left: calc(9px - var(--rail)); width: 2px; top: 0; bottom: -0.75rem;
	background: var(--border);
}
.stages > .stage:first-of-type .stage-card::after { top: var(--node-y); }
.stages > .stage:last-of-type > :last-child::after { bottom: auto; height: var(--node-y); }
.stage-card.now::before { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim); }
.stage-card.done::before { background: var(--green); border-color: var(--green); }
.stage-card.run::before { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim); }
.stage-card.fail::before { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px var(--red-dim); }
.ahead::before { background: var(--paper); border-color: var(--border); }
/* What to do here (the current stage only), and why a button is still
   dead; the steps the page hasn't reached yet. */
.verb { margin-bottom: var(--sp-8); }
.why { margin: var(--sp-4) 0 0; }
.ahead { margin: 0; padding-left: var(--sp-2); }
.stage-desc { margin-bottom: var(--sp-8); }
.stage-stats { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-4) var(--sp-24); margin-bottom: var(--sp-8); }
/* The card is always large enough to work in: the why line and the run's
   status line hold one line of space whether or not they have anything in
   them, so pressing a stage's button adds a spinner rather than a row and
   the card's resting height does not depend on run state. No height is
   hard-coded — the flags table still grows with the flags it has. */
.stage-card .why, .stage-card .status { min-height: 1.5em; }
.stage-card form, .stage-card .detail { margin-top: var(--sp-8); }
.stage-card .why + .detail { margin-top: var(--sp-8); }
.stage-card form.clear, .stage-card form.acknowledge-all { margin-top: var(--sp-8); }
.stage-card .btn-primary { width: 100%; }
.stage-card .banner { margin: var(--sp-8) 0 0; }
/* The Settings card's levers (#137). One row per lever: the name, the number
   on the right in mono like every other figure, and the help under both. A
   rule between rows rather than a box around each — the card already has an
   edge, and eight boxes inside one card is eight edges too many. The last
   block is the three rules no lever can move, a disclosure so the card rests
   at the height of the form and opens to the reasons. */
.stage-card fieldset.levers { margin-top: var(--sp-12); }
.stage-card fieldset.levers:first-of-type { margin-top: var(--sp-4); }
.levers > .meta { margin-bottom: var(--sp-4); }
.lever {
	display: grid; grid-template-columns: 1fr 5rem; align-items: center;
	gap: var(--sp-2) var(--sp-12); padding: var(--sp-8) 0; border-top: 1px solid var(--border);
}
.lever > input { grid-column: 2; grid-row: 1; text-align: right; padding-inline: var(--sp-8); }
.lever > .meta { grid-column: 1 / -1; }
.levers ~ button { margin-top: var(--sp-12); }
.stage-card details.hard { margin-top: var(--sp-8); padding-top: var(--sp-8); border-top: 1px solid var(--border); }
.stage-card details.hard ul.plain li:first-child { border-top: 0; padding-top: var(--sp-4); }
.stage-card details.hard .anno { display: block; }
/* The record's read-back of the same levers: the name, the number at the
   right margin in mono, one rule between rows — the form's shape without the
   form. */
.levers-read li { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-16); }
.levers-read li:first-child { border-top: 0; }
/* Organisations (#178). A row per employer in the pool, shaped like a lever
   so the card keeps one rhythm: the ask on the left as a checkbox, the name
   and the pool it brings beside it. The published record draws the same row
   without the control.

   The conflict sits under the row it belongs to and is said in words before
   it is said in tone — amber is the attention colour here as everywhere, and
   the eyebrow reads the same in any deficiency, so [data-cb] needs no rule
   for it. It is an inset ground, not an outline: the row gains no width, so
   a flag appearing never re-wraps the rows above it. */
.org {
	display: grid; grid-template-columns: auto 1fr; align-items: baseline;
	gap: var(--sp-2) var(--sp-8); padding: var(--sp-8) 0; border-top: 1px solid var(--border);
}
/* No rule of its own for the checkbox any more (#232): it used to need
   grid-column: 1 and align-self: center to recover from being stretched
   full-width by the generic input rule, but at its own natural size grid
   auto-placement already puts it in column 1, and the couple of px the
   baseline-aligned row would otherwise put it off by is not visible. */
.org > label, .org > .org-name { grid-column: 2; }
.org > .meta, .org > .optout { grid-column: 2; }
/* The record's row has no control to sit beside, so it is not a grid at
   all: the name, what the organisation asked for, and the flag if there
   is one, stacked. */
.org.read { display: block; }
.org.read > .org-name, .org.read > .meta { display: block; }
ul.orgs { margin-top: var(--sp-4); }
ul.orgs li:first-child { border-top: 0; }
.optout {
	margin-top: var(--sp-4); padding: var(--sp-8); border-radius: var(--radius-ctl);
	background: var(--amber-dim); border-left: 2px solid var(--amber-border);
}
.optout .anno { display: block; color: var(--amber-text); }
.optout ul.names { margin: var(--sp-4) 0; }
.optout ul.names li { padding: 0; border-top: 0; }
.optout .meta { display: block; max-width: var(--measure); }
.detail { display: flex; align-items: center; gap: var(--sp-8); }
/* A detail line that is a sentence rather than a marker beside a label. The
   flex row above makes every inline element its own column, which is right
   for a status dot and its words and wrong for prose carrying a figure and a
   link (#149). */
.detail.sentence { display: block; }
.spinner {
	flex: none; width: 11px; height: 11px; border-radius: 50%;
	border: 2px solid var(--border); border-top-color: var(--accent); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* The thin bar: a stage's progress (green done, accent running) and a
   criterion's score in the panel (its ramp colour). */
progress { appearance: none; display: block; width: 100%; height: 4px; border: 0; background: var(--raised); --bar: var(--border-bright); }
progress::-webkit-progress-bar { background: var(--raised); }
progress::-webkit-progress-value { background: var(--bar); }
progress::-moz-progress-bar { background: var(--bar); }
.done progress { --bar: var(--green); }
.run progress { --bar: var(--accent); }
.run progress:indeterminate, .run progress:indeterminate::-webkit-progress-bar { background: linear-gradient(90deg, var(--accent-dim), var(--accent), var(--accent-dim)); }
/* Screening flags inside the card: one row per flag with its actions. */
/* How much of the flag list stands at once. The list is the only part of a
   stage card with no bound on it, and the rail's dots space off card
   heights: seven whole reasons made this card 2007px against Finalise's
   862px, and clamping the reasons alone still left it 1411px, so the card
   was still the thing the sequence broke on. 24rem is about three clamped
   rows — enough that the list reads as a list, and a height the card keeps
   whether the cohort raised two flags or twenty. */
.flags-scroll {
	max-height: 24rem; overflow-y: auto; overscroll-behavior: contain;
	scrollbar-gutter: stable;   /* the gutter is there from the first flag, so the eighth one does not reflow the rows */
	scrollbar-color: var(--border-bright) transparent;   /* and the thumb is drawn rather than left to an overlay that fades out */
}
table.flags { margin-top: var(--sp-8); }
table.flags td { padding: var(--sp-8) 0; border-bottom: 1px solid var(--border); }
table.flags .cat { color: var(--amber-text); }
table.flags .row { margin-top: var(--sp-4); }   /* the flag's actions, or its resolution alone on the record */
/* A flag's reason, clamped to two lines with the rest one press away. Seven
   whole reasons made this card 2007px against a 862px Finalise card, and the
   rail's dots space off card heights, so the sequence stopped reading as one.
   <details> and not a script, so it opens with JS off. The clamp is on the
   span inside the summary rather than the summary itself, which leaves the
   summary a normal block that can carry the More/Less line under the text
   where the clamp cannot cut it off. That line is the summary's own Meta
   type in the action colour — a colour, not a seventh treatment. */
.flag-why { margin-top: var(--sp-4); }
.flag-why > summary { display: block; list-style: none; cursor: pointer; }
.flag-why > summary::-webkit-details-marker { display: none; }
.flag-why:not([open]) > summary > span {
	display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.flag-why > summary::after { content: 'More'; display: block; color: var(--accent); }
.flag-why[open] > summary::after { content: 'Less'; }
.flag-why > summary:hover::after, .flag-why > summary:focus-visible::after { text-decoration: underline; }
tr.excluded .flag-who { color: var(--text-3); text-decoration: line-through; }

/* --- Heatmap ---------------------------------------------------------- */
/* Sponsors down, sponsees across, one link per pair coloured by its total
   through --score-bg — red for a bad pair through amber to green for a good
   one (--ramp) — square cells with a gutter between them. A cell holds no
   number (#68): at the floor there is nowhere for a digit to go, so the
   score is in the cell's title, in the panel a click opens and in the pairs
   list under the grid, and the cell is a colour with its markers on it.
   That is what sets the floor below at 10px, the size of the legend swatch
   and the smallest square that still shows a ring, a diagonal and a cross.
   The track only reaches that floor once the columns overflow the card: a
   25 × 27 cohort sizes to ~16px and does not scroll either way, while a
   44 × 117 is 1,650px wide rather than 2,120px. Blank = not scored; dimmed
   with a diagonal = ruled out by an EOI constraint; ink ring = assigned; red
   cross = vetoed; accent ring = the pair in the panel; a bold rail label =
   screening has cleared that name; amber = a flag still to resolve. The
   grid scrolls
   inside itself with both header rails sticky — sideways always, and
   vertically once matching.js has given it a height of whole rows. */
/* One head treatment for the review column's cards: the title on the left,
   the count on the right, one baseline. */
.heat-head, .results-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-16); margin-bottom: var(--sp-8); }
/* The grid's phone behaviour, decided rather than left to shrink (#233). A
   real cohort is 44 x 117, not the 10 x 10 that still miniaturises to
   something legible: row labels go to ~6px and the rotated column labels
   overlap. Below 480px — where the grid's own 10px cell floor plus its
   sticky label rails no longer fits with room to read anything — the grid
   and its legend give way to a line pointing at Match results, which
   carries the same pairs sorted by score in a form a phone already reads
   (#124's pairs table). Above that width the grid still wins: scrolling to
   a specific cell is faster than scanning a sorted list once there is room
   for the rail labels to mean something. */
.heat-phone-note { display: none; }
@media (max-width: 480px) {
	.heat-body { display: none; }
	.heat-phone-note { display: block; }
}
/* No cap on the height here, on purpose. A cap in CSS has to be a fixed
   length, and the cell size is fluid (minmax below), so a fixed length is
   never a whole number of rows: the bottom edge slices one, and that sliver
   sat hard against the legend and read as the legend cutting the grid off
   (#52). web/static/matching.js measures the row pitch and sets a cap of
   whole rows instead, which restores the vertical scrollport the sticky
   column headers pin to (#58). Without that file the grid is simply as tall
   as its rows and the page scrolls — nothing sliced, headers not pinned. */
/* The bottom-right two edges get trailing padding matching the widest ring
   this grid ever draws (.cell:hover/:focus/.sel, 2px outline + 1px offset =
   3px past the cell's own border box, #271). Every other row or column has
   somewhere for that 3px to land — the next row's or column's gap, still
   inside the scrollport — but the last row and the last column have
   nothing after them, so their ring's far edge fell past the box's own
   content edge and overflow: auto clipped it there. A margin would not
   have helped: it sits outside the box this element clips to, so the ring
   would still be cut at the same edge. Padding sits inside that edge. */
.sgrid {
	display: grid; grid-template-columns: max-content repeat(var(--cols), minmax(10px, 30px)); gap: var(--gutter);
	overflow: auto; font: var(--fs-xs)/1.2 var(--font-sans);
	padding: 0 var(--sp-4) var(--sp-4) 0;
}
.sgrid .corner, .sgrid .ch, .sgrid .rh { position: sticky; z-index: 2; background: var(--surface); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sgrid .ch, .sgrid .rh { color: var(--text-2); font-weight: 400; }
/* The corner names both axes, one line each, and each line is its own block
   so the track's max-content is the wider line rather than the two joined —
   see the note in _grid.html. */
.sgrid .corner { top: 0; left: 0; z-index: 3; align-self: end; padding: 0 var(--sp-8) var(--sp-4) 0; }
.sgrid .corner > span { display: block; }
.sgrid .ch { top: 0; height: 84px; padding-top: var(--sp-4); writing-mode: vertical-rl; transform: rotate(180deg); }
.sgrid .rh { left: 0; max-width: 120px; padding-right: var(--sp-8); align-self: center; text-align: right; text-overflow: ellipsis; }
/* Screening has cleared a name — never flagged, or flagged and now resolved
   — and its rail label goes bold, so how much of the pool is settled reads
   off the rail as flags are worked through (#132). The bold weight has to
   be reserved before any name is actually bold: .rh sits in the max-content
   track above, and a weight change is exactly what #74 ruled out on .hl for
   the same reason — the first name to turn bold would widen the track and
   reflow the grid mid-run. So every label already claims that width at
   rest: a hidden ::after carries the same text at the cleared weight,
   height 0 and clipped, changing what the label measures without painting
   anything. Toggling which labels are visibly bold afterwards claims no new
   space, because none of them were ever without it. */
.sgrid .ch::after, .sgrid .rh::after {
	content: attr(data-name); display: block; height: 0; overflow: hidden; visibility: hidden; font-weight: 600;
}
.sgrid .cleared, .heat-foot .cleared { font-weight: 600; }
/* A participant whose screening flag is not resolved yet: their rail label
   takes the attention tone, so the pairs that are still provisional read off
   the grid before scoring. Before .hl, so hovering a cell still wins. */
.sgrid .unresolved, .heat-foot .unresolved { color: var(--amber-text); text-decoration: underline dotted var(--amber); text-underline-offset: 2px; }
/* The hovered cell's row and column headers, marked with a filled band —
   the ground and the ink trading ends — and with no metric at all. Not
   weight, size, spacing, padding or a border: .rh sits in the max-content
   track above, so anything that widens a marked name widens the track and
   slides the grid out from under the pointer (#74), and .ch is vertical in
   a fixed track, where the same extra width has nowhere to go and clips the
   name at a different letter instead. A background is the one channel with
   no layout in it, and the rails already set one because they are sticky —
   --accent is as opaque as the --surface it replaces, so the cells still
   cannot show through a rail the grid is scrolled underneath.
   Colour alone was the mark until #77 and it was a weak one, because hue is
   the only thing it changed: --text-2 and --accent are 7.9:1 and 8.0:1 on
   white, the same grey once the hue goes, so under total colour loss the
   light theme showed nothing at all (dE2000 0.3; dark 5.5). The band moves
   lightness instead — 8.0:1 against the rail beside it in light, 6.9:1 in
   dark, dE2000 53 and 51 in greyscale — so what tells you which of 117 rows
   you are on is findable in peripheral vision rather than on inspection.
   Colourblind mode redefines neither token on purpose: steel blue and its
   ink are legible under every common deficiency, and lightness, the channel
   this mark actually works in, is one no deficiency touches.
   text-decoration-color keeps an unresolved label's dotted underline (above)
   on the band, where --amber would be 1.1:1 against --accent in dark. */
.sgrid .hl { background: var(--accent); color: var(--on-accent); text-decoration-color: currentColor; }
/* #302's row/column insight mark: a small link inside the label, one more
   focusable control on the rail rather than something bolted onto the
   label's own click area (the label itself opens nothing). Absolutely
   positioned — .rh/.ch are already `position: sticky`, so they are already
   a containing block for it — which is what keeps it out of the label's
   own max-content sizing: unlike .cleared's bold weight above, a mark that
   appears or disappears as scores change can never widen or narrow the
   rail, so it needs no reservation trick of its own.
   The tone pair (--t/--t-dim/--t-border) is the one every pill and banner
   already uses; red and green stay hue-only here, unlike .hl above, because
   the mark's glyph (aria-hidden, a `.vh` sentence carries the name for a
   screen reader) is the second channel colourblind mode leans on — "!" and
   "✓" are shapes as well as colours. */
.mk {
	position: absolute; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%;
	font: 700 8px/1 var(--font-sans); text-decoration: none; writing-mode: horizontal-tb;
	color: var(--t); background: var(--t-dim); border: 1px solid var(--t-border);
}
.mk:hover, .mk:focus { outline: 2px solid var(--text); outline-offset: 1px; }
.mk-red { --t: var(--red-text); --t-dim: var(--red-dim); --t-border: var(--red-border); }
.mk-green { --t: var(--green-text); --t-dim: var(--green-dim); --t-border: var(--green-border); }
/* .rh's text is right-aligned against the rail's own edge (padding-right),
   so the mark sits at the label's left instead, in whatever space a name
   shorter than the track's own max-content width leaves open. */
.sgrid .rh .mk { left: 0; top: 50%; transform: translateY(-50%); }
/* .ch is rotated 180deg as a whole (see .sgrid .ch above), so a mark placed
   at its physical top renders at the visual bottom, beside the first row of
   cells — writing-mode is reset above so the glyph stays upright rather
   than inheriting the vertical text direction it sits beside. */
.sgrid .ch .mk { top: 2px; left: 50%; transform: translateX(-50%); }
.cell {
	position: relative; aspect-ratio: 1; min-width: 0; overflow: hidden;
	background: var(--score-bg, var(--raised));
}
.cell:hover { text-decoration: none; }
.cell.empty { box-shadow: inset 0 0 0 1px var(--border); }
/* Not scored yet. The grid is on the page from ingest, so an unscored
   44 × 117 is 5,148 cells: they go to a pale lattice, and the caption above
   counts the pairs, so the size of the job reads at a glance and the card
   does not look broken. A cell leaves the lattice as its score lands, so a
   run fills the grid in against it. */
#grid.pending .cell.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--border); opacity: 0.5; }
.cell.blocked::before { content: ''; position: absolute; inset: 0; background: var(--scrim); }
.cell.blocked::after { content: ''; position: absolute; left: -20%; right: -20%; top: 50%; height: 1px; background: var(--border-bright); transform: rotate(-45deg); }
.cell.assigned { box-shadow: inset 0 0 0 2px var(--text); }
.cell.vetoed { background: var(--red-dim); box-shadow: inset 0 0 0 1px var(--red-border); }
.cell.vetoed::before, .cell.vetoed::after { content: ''; position: absolute; left: 18%; right: 18%; top: 50%; height: 1.5px; background: var(--red); transform: rotate(45deg); }
.cell.vetoed::after { transform: rotate(-45deg); }
.cell:hover, .cell:focus { outline: 2px solid var(--text); outline-offset: 1px; z-index: 1; }
.cell.sel { outline: 2px solid var(--accent); outline-offset: 1px; z-index: 1; }
/* Every legend entry carries the same shape (#236): a small key, then a
   plain-weight word for what it means. `screening cleared` and `screening
   flag not resolved` used to BE their own key — the phrase itself set bold,
   or set amber and underlined — so they read as two unrelated things next
   to the other five: a coloured square beside a plain caption. Worse, the
   underlined one reads as a link, which it is not. `.key-sample` gives them
   the same swatch-then-caption shape as everyone else: a two-letter sample
   carrying the exact rail-label treatment (`.sgrid .cleared` /
   `.sgrid .unresolved`, above), decorative so a screen reader gets the
   caption once rather than "A A screening cleared".
   The keyboard hints are a different sentence, not a sixth legend entry, so
   flex-basis: 100% forces them onto their own line rather than trailing
   whichever entry happens to be last when the row wraps. */
.heat-foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4) var(--sp-16); margin-top: var(--sp-16); }
.heat-foot .cell, .heat-foot .ramp { display: inline-block; width: 10px; height: 10px; aspect-ratio: auto; vertical-align: -1px; margin-right: var(--sp-4); }
.heat-foot .ramp { width: 48px; background: linear-gradient(to right, var(--ramp)); }
.heat-foot .key-sample { margin-right: var(--sp-4); }
.heat-foot .keys { flex-basis: 100%; margin-left: 0; }
/* Dark mode (#236): a hollow square is an inset border or ring against a
   raised fill, and against a dark card both the fill and a --border-weight
   edge sit too close in lightness to read — the one contrast issue among 32
   coordinator screenshots, and it lands on "not scored", the state the
   whole grid is in before a run. An outer ring in --border-bright, on the
   legend's swatches only (never the grid's own cells, which are never this
   bare), gives every one of them a visible edge in both palettes regardless
   of what its own inset treatment is. */
.heat-foot .cell.empty { box-shadow: inset 0 0 0 1px var(--border), 0 0 0 1px var(--border-bright); }
.heat-foot .cell.assigned { box-shadow: inset 0 0 0 2px var(--text), 0 0 0 1px var(--border-bright); }
.heat-foot .cell.vetoed { box-shadow: inset 0 0 0 1px var(--red-border), 0 0 0 1px var(--border-bright); }

/* --- Review lists ----------------------------------------------------- */
/* Under the heatmap: the match results card worst first, then the sponsees
   left over and the excluded people. Both are .card now — the frame, the
   padding and the sideways scroll are the card's, and the pairs card's head
   shares the heatmap's (.heat-head above). */
.leftovers li { display: flex; flex-wrap: wrap; gap: var(--sp-8) var(--sp-16); align-items: center; justify-content: space-between; }
.leftovers li .reason { flex-basis: 100%; }
/* A list of bare names sets itself in columns. Unmatched is the one list
   whose rows carry neither a form nor a reason — which is what it is on the
   published record — and there it was 27 names one per row, a thousand
   pixels of column to say what four rows of three can. */
.leftovers ul.plain:not(:has(form, .reason)) { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); column-gap: var(--sp-24); }
.leftovers .row { flex-wrap: nowrap; }
.leftovers h3 { display: flex; gap: var(--sp-8); align-items: baseline; }
.exclude-form input[name="reason"] { width: auto; flex: 1 1 10rem; padding: var(--sp-4) var(--sp-8); }

/* --- Reserves --------------------------------------------------------- */
/* /admin/reserves: the people waiting between cohorts, in queue order. A row
   is laid out like a leftovers row — name and role, then the state and the
   one control on the right, then the reason on its own line — because the
   two lists hold the same kind of person and reading one should teach you
   the other. The name takes the slack so the pill and the button stay
   together on the right whatever the name's length. No colour of its own:
   waiting is a neutral pill, in a cohort is the accent tone the surface
   already uses for assigned. */
.reserves li { display: flex; flex-wrap: wrap; gap: var(--sp-8) var(--sp-16); align-items: center; }
.reserves li + li { margin-top: var(--sp-12); }
.reserves .who { flex: 1 1 12rem; }
.reserves li .pill { margin-left: auto; }
.reserves li .reason { flex-basis: 100%; order: 3; }

/* /admin/cohorts/{c}/not-matched: the report a coordinator produces for
   NAWIC (#182). Two shapes and no new ones — the rate is the .figures block
   the cohort page's strip already uses, and a person's row is laid out like
   a reserves row, because a reserve and a person who was not matched are the
   same person read at two different moments and the eye should not have to
   relearn the line.

   The name takes the slack so the category pill sits on the right of every
   row at the same place, and the reason drops to its own line under both:
   the reason is a sentence, not a column, and squeezing it beside a pill is
   what turns a readable list into a table nobody reads. No tone of its own —
   why somebody was left out is not a status to be colour-coded, and four
   categories in four hues would invite reading a severity into them that
   the record does not carry. */
.report-list li { display: flex; flex-wrap: wrap; gap: var(--sp-8) var(--sp-16); align-items: center; }
/* The list is the first thing in its card, so the rule above the first row
   would be a line under nothing. Same reason as the hard-constraints list. */
.report-list ul.plain li:first-child { border-top: 0; padding-top: 0; }
.report-list .who { flex: 1 1 12rem; }
.report-list li .pill { margin-left: auto; }
.report-list li .reason { flex-basis: 100%; order: 3; }
.report-rate .f .data { margin-right: var(--sp-8); }

/* --- Inspect panel ---------------------------------------------------- */
/* The canvas's third column: a card beside the heatmap, so the pair you are
   reading never covers the grid you picked it from. It is an ordinary card,
   with the standard lift and radius — the square corners and the shadow of
   its own it used to carry were because it was flush to the viewport edge.

   The column keeps its width whether or not a pair is open. #inspect is the
   swap target and is empty until a cell or a row fills it (Close empties it
   again, matching.js); .inspect-rest is the card that stands in its place,
   and CSS shows exactly one of the two. So opening and closing a pair never
   moves the grid.

   The column sticks under the top bar while the page scrolls, and the panel
   scrolls inside itself (.ins-main) when the justifications run long.

   Under 1280px the canvas has no room for a third column, so the panel goes
   back to what it was: a sheet fixed to the right edge, sliding in over the
   page. It has to keep its box then — square corners against the edge, its
   own shadow to lift it off the page it covers, and display: flex while
   empty so it can slide out rather than vanish. The resting card is not
   drawn at all there; a placeholder below a long heatmap is noise. */
.inspect-col { position: sticky; top: calc(var(--topbar) + 1rem); display: grid; }
.inspect {
	display: flex; flex-direction: column; padding: 0; overflow: hidden;
	max-height: calc(100vh - var(--topbar) - 2rem);
}
.inspect:empty { display: none; }
.inspect:not(:empty) ~ .inspect-rest { display: none; }
/* The resting card: the column's label, and under it the cohort's shape in
   four figures (#158). Its padding matches the panel's body so the two cards
   sit on the same inner margin, and the figure block takes the same step off
   the instruction line the definitions it replaced did. .figures itself is
   unchanged — it was drawn for a narrow column (#92, #124), which is what
   this one is, so the card adds a margin and nothing else. */
.inspect-rest { padding: var(--sp-20) var(--sp-24); }
.inspect-rest .figures { margin-top: var(--sp-12); }
@media (max-width: 1279px) {
	.inspect-col { position: static; }
	.inspect-rest { display: none; }
	.inspect {
		position: fixed; top: var(--topbar); right: calc(-1 * var(--panel) - 40px); bottom: 0;
		width: min(var(--panel), 100vw); max-height: none; z-index: 20;
		border-radius: 0; border-left: 1px solid var(--border);
		box-shadow: var(--shadow-panel);
		visibility: hidden; transition: right 0.2s ease-out, visibility 0s 0.2s;
	}
	.inspect:empty { display: flex; }
	.inspect:not(:empty) { right: 0; visibility: visible; transition-delay: 0s; }
}
.inspect-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.ins-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-16); padding: var(--sp-12) var(--sp-24); border-bottom: 1px solid var(--border); }
.ins-close {
	flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-ctl);
	color: var(--text-2); font-size: var(--fs-md); line-height: 1;
}
.ins-close:hover { text-decoration: none; background: var(--hover); color: var(--text); }
.ins-main { flex: 1; overflow: auto; padding: var(--sp-20) var(--sp-24); display: grid; gap: var(--sp-16); align-content: start; }
.ins-main .banner, .ins-main p, .rationale .banner { margin: 0; }
/* Why a pair scores what it does (the match_rationale macro). In the panel
   .ins-main spaces its parts; on the pair page it is a block inside a card,
   so it carries its own gap there. */
.rationale { display: grid; gap: var(--sp-16); }
.pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: var(--sp-8); align-items: center; }
.pair .p small { display: block; }
.pair .x { color: var(--text-3); }
.total { display: flex; align-items: baseline; gap: var(--sp-8); }
.total .data { color: var(--accent); line-height: 1; }
.crit { display: grid; gap: var(--sp-8); }
.crit .c { display: grid; grid-template-columns: 1fr 30px; gap: var(--sp-2) var(--sp-8); align-items: center; }
.crit .c .data { text-align: right; grid-row: span 2; }
.crit .c progress { --bar: var(--score-bg); }
.crit .c .j { grid-column: 1 / -1; margin-top: var(--sp-2); }
.adj { padding: var(--sp-8) var(--sp-12); background: var(--green-dim); border: 1px solid var(--green-border); color: var(--green-text); }
/* #302's disclosure: the two criteria that carry the pattern most, each with
   up to three verbatim justifications. h3 already gets its heading spacing
   from the global rule (h3, above); .insight-quote is a plain left rule
   rather than a tone box (.adj's shape) because a quote is not a verdict —
   it is the model's own sentence, unweighted by this feature's own read of
   it. */
.insight-crit { display: grid; gap: var(--sp-8); }
.insight-quote { display: grid; gap: var(--sp-2); padding-left: var(--sp-12); border-left: 2px solid var(--border-bright); }
.insight-quote cite { font-style: normal; color: var(--text-2); }
.ins-main form { display: flex; flex-wrap: wrap; gap: var(--sp-8) var(--sp-8); align-items: center; }
.ins-main details form { margin-top: var(--sp-8); }
/* Force this pair (#295), the panel's first action: the reason stays beside
   the button rather than claiming the full row and dropping it below, the
   same fix .exclude-form already needed for the identical layout problem. */
.ins-force input[name="reason"] { width: auto; flex: 1 1 10rem; padding: var(--sp-4) var(--sp-8); }
/* The panel's actions above the rationale (#136): veto (about the pair) and
   exclude (about a person, the more consequential, so behind its disclosure)
   as one quiet group, its note once underneath. */
/* The pair rating (#185): the reason field full width above its two
   buttons, because the sentence is the part worth writing and a button beside
   it would squeeze it to nothing in this column. The chosen button wears its
   tone (green for good, amber for poor) so the panel says what was said last
   time without a line of prose. It is not boxed like .ins-actions below: the
   box is what groups veto with exclude as the two things that move the
   assignment, and a rating is not one of them. */
.ins-main form.ins-rate { display: block; }   /* .ins-main form is a flex row; this one is a stack, so the reason field takes the column's width */
.ins-rate .rate-row { display: flex; flex-wrap: wrap; gap: var(--sp-8); align-items: center; margin-top: var(--sp-8); }
.ins-rate .meta { margin-top: var(--sp-8); }
.ins-actions { display: grid; gap: var(--sp-8); padding: var(--sp-12) var(--sp-12); border: 1px solid var(--border); border-radius: var(--radius-ctl); }
.ins-actions .meta { margin-top: var(--sp-2); }
.ins-hint { display: flex; gap: var(--sp-4); align-items: center; padding: var(--sp-8) var(--sp-24); border-top: 1px solid var(--border); }
.ins-hint .esc { margin-left: auto; }

/* --- Motion ----------------------------------------------------------- */
/* The matching page is one page in several states, not a sequence of
   screens, so a state change is a transition and not a re-render. The
   mechanism is the View Transitions API: every form that changes the state
   carries transition:true in its hx-swap, so htmx runs the whole swap — its
   own target and everything it appends out of band — inside one
   document.startViewTransition(). The browser then pairs the elements named
   below across the two DOM states and morphs each one: position, size and
   presence, all from the same mechanism.

   That is the four stage forms, and the three that re-run assignment from
   somewhere else on the page: the flag row's resolve, the panel's veto and
   exclude, and the leftovers' exclude and restore. All three change the
   grid, the pairs list and the leftovers at once, which is the case a morph
   is worth — excluding someone visibly moves the assignment. The leftovers
   forms swap nothing themselves (hx-swap="none transition:true"); that is
   not a no-op, because htmx's out-of-band pass runs inside swap(), which is
   inside the transition, so the transition wraps the content that arrives
   out of band.

   The error and notice banners are outside .canvas, in their own slot
   (admin/_banner.html), so a message pushes both columns or neither. The
   slot is deliberately not named below: it belongs to the root snapshot, so
   the message cross-fades in while the named elements slide down to make
   room for it.

   WHAT MUST NOT TRANSITION: a scoring run streams one SSE `cell` event per
   pair — 5,148 of them on a 44 x 117 cohort. Those go through the SSE
   extension, which calls htmx's api.swap() directly; api.swap() reads
   neither htmx.config.globalViewTransitions nor a swap spec's transition
   token, so an SSE swap cannot start a view transition. That is why
   globalViewTransitions is left at its default false and every transition
   on this page is opted into per form: the config is not consulted on the
   SSE path, but leaving it off means nothing else can drift onto it either.
   The per-cell path stays a bare attribute swap. */

/* The elements the browser pairs across a swap. A name has to be unique in
   the document, so each of these matches exactly one element. A stage still
   out of reach renders an empty section and .stage:empty hides it, which
   the browser reads as absent — so it fades in on the swap that first gives
   it content instead of snapping into the column. */
#stages { view-transition-name: stages; }
#stage-pool { view-transition-name: stage-pool; }
#stage-roster { view-transition-name: stage-roster; }
#stage-screening { view-transition-name: stage-screening; }
#stage-scoring { view-transition-name: stage-scoring; }
#stage-assignment { view-transition-name: stage-assignment; }
.card.heat { view-transition-name: heatmap; }
#pairs { view-transition-name: pairs; }
#leftovers { view-transition-name: leftovers; }
/* The inspect column is not in any swap, but it is in the canvas the swaps
   reflow. Naming it takes it out of the root snapshot, so it holds its place
   instead of being redrawn with everything else behind the transition. */
.inspect-col { view-transition-name: inspect; }
::view-transition-group(*) { animation-duration: 220ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }

/* Expanding a shut stage grows it rather than snapping. Chrome only for
   now; without ::details-content and interpolate-size the toggle is
   instant, which is what <details> does anyway. */
@supports (interpolate-size: allow-keywords) {
	.stage-card > details { interpolate-size: allow-keywords; }
	.stage-card > details::details-content {
		block-size: 0; overflow: hidden;
		transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
	}
	.stage-card > details[open]::details-content { block-size: auto; }
}

/* Two arrivals a view transition cannot cover. A full page load has no old
   state to morph from, so the canvas settles in once — on .canvas itself,
   which nothing swaps, rather than on the cards, because a stage re-renders
   on every event of a run and must not blink each time. And the flags table
   lands at the end of a screening run, on an SSE swap. */
@keyframes appear { from { opacity: 0; transform: translateY(6px); } }
.canvas { animation: appear 260ms ease-out backwards; }
table.flags { animation: appear 200ms ease-out backwards; }

@media (prefers-reduced-motion: reduce) {
	*, *::before, *::after { animation: none !important; transition: none !important; }
	::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
