/* Gervasi Lab — component styles.
 *
 * References layer-2 tokens only. No literal colour appears below; a grep test
 * enforces that, in the same shape as the §3.1(12) lock-helper test.
 *
 * Dark-default because this is a bench UI: gloved hands, a cold room, a phone
 * held at arm's length. Every interactive element clears 44 px with 8 px of
 * clear space, because 44 px abutting 44 px is one target to a gloved thumb.
 */

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

html {
  -webkit-text-size-adjust: 100%;
}

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

/* Focus is never removed, only restyled. A bench user tabbing with gloves on
 * needs to see where they are. */
:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius);
}

a {
  color: var(--accent-ink);
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}

code,
.mono {
  font-family: var(--font-mono);
  font-size: 0.95em;
  /* This product names its own machinery on screen — constraint names, column
   * names, catalog numbers, SQLSTATEs. `ck_antibody_validation_validated_
   * needs_provenance` is one 49-character word with nowhere to break, and in a
   * 234px evidence cell it laid 364px of text across the cell beside it. A
   * broken identifier is legible; one painted over its neighbour is not. */
  overflow-wrap: anywhere;
}

/* The same hazard without the mono font: a lot name, a vendor, a location
 * string copied out of a spreadsheet cell. `break-word` only breaks a word
 * that would otherwise overflow, so ordinary prose is untouched. */
body {
  overflow-wrap: break-word;
}

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

.masthead {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-2) var(--space-5);
  background: var(--surface-recessed);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: 10;
}

/* THE LOGO IS BLACK LINE ART ON AN OPAQUE WHITE FIELD, converted from the
 * OneDrive .tif, and both themes have to make that work from ONE asset.
 *
 * The previous rule was `filter: invert(1); mix-blend-mode: screen`,
 * UNCONDITIONALLY. On the dark surface that is right: invert makes the art
 * white, and screen drops the black field to nothing. On a light ground it is
 * catastrophic — screen against white paper is always white, so the mark
 * rendered invisible on every page of the light theme. It shipped that way
 * because the theme did not exist when the rule was written, and nothing
 * flagged it because a filter cannot fail.
 *
 * The fix is one blend mode per ground, and no filter in the light case:
 *
 *   dark   invert(1) + screen   black art -> white art, white field -> gone
 *   light  multiply, no filter  black art stays black, white field -> gone
 *
 * `multiply` is the exact dual of `screen` and needs no inversion, because the
 * art is already the right colour for paper. */
.masthead__logo {
  /* WAS 150px, WHICH IS A FIFTH OF THE VIEWPORT ON A PAGE SOMEBODY SCROLLS
   * THIRTEEN TIMES. The mark still carries the words "GERVASI Lab" itself, so
   * it is still the whole of the masthead's identity — it does not need to be
   * the tallest thing on a screen to do that. 28px is legible at arm's length
   * and puts the band under 48px total, which is 815px given back across the
   * 41 screens measured in `scripts/verify/out/page_height.txt`. */
  height: 28px;
  width: auto;
  display: block;
  filter: invert(1);
  mix-blend-mode: screen;
}

.masthead__title {
  font-size: var(--text-lg);
  font-weight: var(--weight-medium);
  margin: 0;
  letter-spacing: 0.01em;
}

.masthead__spacer {
  flex: 1;
}

/* THE DOOR BAR LIVES IN THE MASTHEAD NOW. It had its own band — its own padding,
 * its own recessed background, its own bottom border — for a row containing seven
 * words, sitting under a masthead that was mostly empty air. Merged, the chrome
 * above a page goes from two bands to one.
 *
 * So this drops everything that made it a band and keeps only what makes it a
 * row of doors. `min-width: 0` because it is now a flex child of the masthead and
 * a flex child will not shrink below its content otherwise: without it the seven
 * doors push the account control off the right-hand edge instead of scrolling. */
.nav {
  display: flex;
  gap: var(--space-1);
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }

.nav a {
  display: inline-flex;
  align-items: center;
  min-height: var(--target-min);
  /* WAS `0 var(--space-4)` — 16px each side, 224px of padding across seven doors
   * on a row that now also carries the mark and the account controls. 10px still
   * clears the 44px target vertically, which is the dimension a thumb misses in. */
  padding: 0 var(--space-3);
  border-radius: var(--radius);
  color: var(--ink);
  text-decoration: none;
  font-weight: var(--weight-medium);
  white-space: nowrap;
  border: 1px solid transparent;
}

.nav a:hover {
  background: var(--surface-raised);
}

.nav a[aria-current="page"] {
  background: var(--accent);
  color: var(--on-accent);
}

/* 24 top and 48 bottom on every one of 41 screens. The bottom was sized for a
 * page that ended in content; every page here ends in the tail block, which
 * brings its own margin. */
main {
  max-width: 1100px;
  margin: 0 auto;
  padding: var(--space-3) var(--space-4) var(--space-4);
}

h1 {
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  margin: 0 0 var(--space-2);
  line-height: 1.2;
}

/* 32 above every h2, and the long pages have four to seven of them. The
 * heading is already distinguished by weight and size; a third of a viewport
 * of air above it is saying so a second time. */
h2 {
  font-size: var(--text-xl);
  font-weight: var(--weight-medium);
  margin: var(--space-4) 0 var(--space-2);
}

.lede {
  color: var(--ink-secondary);
  margin: 0 0 var(--space-5);
  max-width: 68ch;
}

/* ---------------------------------------------------------------- cards */

/* Cards nest — a card inside a card inside a table cell is common here — so
 * this number is paid more than once per screen. */
.card {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-3);
}

.grid {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

.stat {
  display: block;
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  line-height: 1.1;
}

.stat__label {
  display: block;
  color: var(--ink-secondary);
  font-size: var(--text-sm);
  margin-top: var(--space-1);
}

/* The validated dilution, the gauge reading, the lot quantity. Gate 5 wants
 * `1:1000 in BSA 1% TTBS` readable at arm's length in a cold room. */
.display {
  font-size: var(--text-display);
  font-weight: var(--weight-bold);
  line-height: 1.15;
  margin: var(--space-3) 0;
}

/* ---------------------------------------------------------------- controls */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--target-min);
  padding: 0 var(--space-5);
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface-raised);
  color: var(--ink);
  font: inherit;
  font-weight: var(--weight-medium);
  cursor: pointer;
  text-decoration: none;
}

.btn--primary {
  background: var(--accent);
  color: var(--on-accent);
  border-color: var(--accent);
}

/* The six offline bench actions and every primary bench control. */
.btn--bench {
  min-height: var(--target-bench);
  font-size: var(--text-lg);
  width: 100%;
}

/* Paid once per field. The measurand form has nine, the method form has eight
 * plus twenty-seven checkboxes — 16px each is a screenful on a form nobody
 * scrolls for pleasure. */
.field {
  display: block;
  margin-bottom: var(--space-3);
}

.field label {
  display: block;
  margin-bottom: var(--space-2);
  font-weight: var(--weight-medium);
}

/* The explanatory line under a field, and the "optional" marker inside a label.
   `.why` is used 47 times across 14 templates and HAS NO BASE RULE — it renders
   as ordinary body text everywhere, so "Code" and "optional" read as one bold
   phrase and a sentence explaining a field competes with the field's own label.

   Scoped to `.field` deliberately. A global `.why` rule would change fourteen
   screens at once and I have looked at two of them; the finding is recorded in
   REMAINING 2ae rather than acted on blind. */
.field .why {
  display: block;
  margin-top: var(--space-1);
  max-width: 74ch;
  font-size: var(--text-sm);
  font-weight: var(--weight-regular);
  color: var(--ink-secondary);
}
/* Inside a label it stays on the same line — it is a qualifier of the label,
   not a sentence about the field. */
.field label .why { display: inline; margin: 0; }

/* A row header carries a code and the name behind it; without this the name is
   as bold as the code and the two compete. The only table doing this today is
   the fund grid, so the rule is written where it can be seen rather than as a
   global `.why` — that one is 47 uses across 14 templates and is REMAINING 2ae. */
th .why { display: block; font-weight: var(--weight-regular); color: var(--ink-secondary);
          font-size: var(--text-sm); }

/* `input:not([type])` FIRST, because it is 67 of the 210 inputs in this tree and
   none of them were being styled at all. An `<input name="key">` with no `type`
   attribute IS a text input — HTML says so — but `input[type="text"]` does not
   match it, because the attribute selector reads the ATTRIBUTE and there is
   none. So two thirds of a form would take the width rule below and a third
   would render as the browser's default 20-character box, on nineteen
   templates: the roster, the register, purchasing, locations, the shelf walk,
   the contacts screen and the rest.

   Found while building the protocol draft form, where every field is bare and
   the result was labels and inputs running together across the page. It is not
   a defect of that form. */
input:not([type]),
input[type="text"],
input[type="search"],
input[type="email"],
input[type="password"],
select,
textarea {
  width: 100%;
  min-height: var(--target-min);
  padding: 0 var(--space-3);
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface-recessed);
  color: var(--ink);
  font: inherit;
}

/* ---------------------------------------------------------------- tables */

.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
}

/* ROW PADDING IS THE BIGGEST SINGLE NUMBER IN THIS FILE. 12px top and bottom
 * on a 40-row table is 960px of nothing; `/alarms/windows/` is 19,260px tall on
 * 263 words of prose, which is almost entirely this rule. 12 -> 6 vertical, and
 * the horizontal is left alone because columns still need to be told apart. */
th,
td {
  text-align: left;
  padding: var(--space-2) var(--space-3);
  /* THE ONE SHARED RULE THAT REACHES INSIDE A ROW. Body line-height is 1.5,
   * which is right for a paragraph somebody reads left to right and wrong for a
   * cell they scan down a column. A `/alarms/windows/` row is 107px because its
   * tallest cell wraps a sentence into 200px — four lines at 1.5 is 78px, at
   * 1.3 it is 68px, and there are 112 rows on that page.
   *
   * 1.3 and not lower: below that the descenders of one line touch the caps of
   * the next, and a table nobody can read quickly is not denser, it is worse. */
  line-height: 1.3;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}

/* SCOPED TO `thead`, and the bug it fixes is worth naming. `th` matched EVERY
 * table header cell, including a `<th scope="row">` used as a body-level group
 * heading — so those stuck to the top of the viewport and stacked on top of each
 * other as you scrolled, one per group. An agent hit it on a real page and
 * worked around it locally; the workaround can go now.
 *
 * `scope="row"` header cells are the row's own label and belong in the row. */
thead th {
  background: var(--surface-recessed);
  font-weight: var(--weight-medium);
  position: sticky;
  top: 0;
}

th {
  background: var(--surface-recessed);
  font-weight: var(--weight-medium);
}

tbody tr:nth-child(even) {
  background: var(--surface-recessed);
}

tbody tr:last-child td {
  border-bottom: none;
}

/* ---------------------------------------------------------------- chips */

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px var(--space-2);
  border-radius: 999px;
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  border: 1px solid var(--border);
  color: var(--ink-secondary);
  white-space: nowrap;
}

/* Enriched or derived data must be VISUALLY distinguishable from lab-verified
 * data in the UI, not merely in the schema. A dotted edge and the word, never
 * a colour alone — there is no green/red in this palette and colour is never
 * the sole distinguisher of two states. */
.chip--derived {
  border-style: dotted;
  color: var(--warn-ink);
}

.chip--verified {
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* ------------------------------------------------------- panels & notices */

/* The permanent "what happens next" panel required on every low-frequency
 * screen. Median order volume is ~10 lines a month across five people, so an
 * individual touches purchasing two or three times a month. Six weeks after
 * launch nobody remembers whether a request goes through the basket. This is
 * re-learnability, not training. */
.next {
  /* THE TAIL, ON EVERY PAGE. 16px of padding inside 24px of margin, plus a
   * heading, for one paragraph — and the same shape repeats at the foot of all
   * 41 screens. The border-left still marks it; the box around the box does
   * not need to. */
  border-left: 3px solid var(--accent);
  background: var(--surface-recessed);
  border-radius: 0 var(--radius) var(--radius) 0;
  padding: var(--space-2) var(--space-3);
  margin: var(--space-3) 0 var(--space-2);
}

/* THE HEADING IS INLINE WITH ITS PARAGRAPH. "WHAT HAPPENS NEXT" in caps on its
 * own line, above one sentence, on all 41 screens — a label announcing a label.
 * The accent rail and the position at the foot already say what the block is. */
.next h3 {
  margin: 0 var(--space-2) 0 0;
  display: inline;
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  color: var(--ink-secondary);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.next p { margin: 0; display: inline; }
.next p + p { display: block; margin-top: var(--space-2); }

/* A statement the system is making about its own limits — the gated EHS
 * export label, the honest antibody count, the suppressed grant figure. */
.notice {
  border: 1px solid var(--warn);
  border-left-width: 3px;
  background: var(--surface-recessed);
  border-radius: var(--radius);
  padding: var(--space-4);
  margin: var(--space-4) 0;
}

.footnote {
  color: var(--ink-secondary);
  font-size: var(--text-sm);
  margin-top: var(--space-6);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border);
}

.empty {
  color: var(--ink-secondary);
  padding: var(--space-6);
  text-align: center;
}

@media (max-width: 640px) {
  main {
    padding: var(--space-4) var(--space-3) var(--space-7);
  }
  .masthead__title {
    font-size: var(--text-base);
  }
}

/* ========================================================================
 * COMPONENT LIBRARY — the vocabulary every screen composes from.
 * Layer-2 tokens only; no literal colour appears below.
 *
 * The aesthetic follows the planning document: dense, precise, evidence
 * forward. A number is never shown without the rule that produced it, and a
 * limit is stated rather than styled around. Decoration that does not carry
 * information is absent on purpose.
 * ==================================================================== */

/* --- the roster table ------------------------------------------------- */
/* THE OWNER'S COMPLAINT, AND IT IS NOT PADDING: "tons of overlap and makes it
 * really hard to read". `shuangshuang.guo@uconn.edu` broke across three lines,
 * `labmanager@uconn.edu` split, `maria.gracia@uconn.edu` wrapped under the name
 * — the Person column carried a name, an email, a role and a chip in a width
 * that fits none of them, because the tick columns took the space first.
 *
 * HEIGHT WAS THE SYMPTOM. Give the column enough room for the longest address
 * in the lab and the wrapping stops; the row shrinks as a consequence rather
 * than as the goal.
 *
 * The tick columns are the narrow ones: their content is a single control and a
 * one-word heading. */
.roster__table th[scope="row"] { min-width: 24ch; }
/* An email is one unbreakable token, so `nowrap` here could only ever clip it —
 * `visconti.collab@umass.edu` needed 190px in a 183px column. It wraps now, and
 * the column below is wide enough that most never have to. */
.roster__table th[scope="row"] .mono { white-space: normal; overflow-wrap: anywhere; }

/* THE PERSON COLUMN CARRIES TWO LINES — a display name and an address — and every
 * other column carries one short value, so an equal seven-way split was the wrong
 * shape. Widths total 100%; `table-layout: fixed` divides the rest evenly without
 * them. */
/* WIDTHS BY WHAT THE COLUMN HOLDS, not by its position. The version below pinned
 * nth-child(3) to (7) at 25/16/14/14/17% for a SEVEN-column roster. A later change
 * added Appointment and Cost, the percentages stayed on columns 3-7, and the two
 * columns pushed off the end — Can sign in and Timezone — were left 27px each.
 *
 * Position-based widths are a promise about a column count nobody else knows they
 * are keeping. These target the tick columns and the person column, which are the
 * only two with a real reason to differ, and let the browser divide the rest. */
.roster__table th:nth-child(1), .roster__table td:nth-child(1),
.roster__table th:nth-child(2), .roster__table td:nth-child(2) { width: 4.5rem; }
.roster__table th:nth-child(3), .roster__table td:nth-child(3) { width: 20%; }
.roster__table .rowtick__cell,
.roster__table .rowtick__head { width: 1%; white-space: nowrap; }
/* The timezone column is sized and broken at `contracts` scale further down --
 * search `THE LAST TWO ROSTER COLUMNS`. It had a `nowrap` here and a `normal`
 * 1300 lines later, and the later one won, which is how the wrapping came back
 * after being fixed. One rule now, in one place. */

/* --- sub-tabs --------------------------------------------------------- */
/* One row, under the page head, 61px including its rule. A strip of six fits at
 * 1280 with room to spare — measured in the live page, not assumed. */
.subtabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-3);
  padding-bottom: var(--space-2);
}
.subtabs a {
  color: var(--ink-secondary);
  text-decoration: none;
  font-weight: var(--weight-medium);
  padding-bottom: var(--space-2);
  margin-bottom: calc(-1 * var(--space-2) - 1px);
  border-bottom: 2px solid transparent;
}
.subtabs a:hover { color: var(--ink); }
.subtabs a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--accent); }

/* --- page header ------------------------------------------------------ */
.page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-4);
  flex-wrap: wrap;
  /* 16 + 24 of vertical furniture under a title, on every one of 41 screens.
   * Halved: the border already separates the head from the body, and the gap
   * either side of it was doing the same job twice. */
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-3);
}
.page-head h1 { margin: 0; }
.page-head__meta { color: var(--ink-secondary); font-size: var(--text-sm); }

/* --- the evidence rail: a number with the rule that produced it -------- */
/* Separate cards, not one panel divided by hairlines. The hairline version
 * painted the container in --border and let 1px gaps show through, which is
 * elegant while the cells divide evenly and wrong the moment they do not: Lab
 * Today has eight cells in five columns, and the two-column remainder rendered
 * as a large empty slab in a colour lighter than the cards — reading as a
 * broken card rather than as the absence of one. With real gaps the remainder
 * is page background and simply is not there. It also matches `.queues`, which
 * is the same idea one screen over. */
.evidence {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--space-3);
  margin: var(--space-5) 0;
}
.evidence__cell {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--space-4);
}
/* These three are <p> on some screens and <span> on others, and the browser's
 * 1em paragraph margin was collapsing against the deliberate margin-top below
 * — so the gap between a number and its label was 16px where 4px was asked
 * for, and it differed depending on which tag the template happened to use. */
.evidence__n,
.evidence__label,
.evidence__rule {
  margin: 0;
}
.evidence__n {
  display: block;
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
.evidence__label {
  display: block;
  margin-top: var(--space-1);
  font-size: var(--text-sm);
  color: var(--ink-secondary);
}
/* The rule sits WITH the number, never in a footnote nobody reads. */
.evidence__rule {
  display: block;
  margin-top: var(--space-2);
  font-size: var(--text-xs);
  color: var(--ink-secondary);
  line-height: 1.4;
}
.evidence__cell--accent { border-top: 3px solid var(--accent); }
.evidence__cell--warn { border-top: 3px solid var(--warn); }

/* --- the hero figure: one number that carries a screen ---------------- */
.hero {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-left: 4px solid var(--accent);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  margin: var(--space-5) 0;
}
.hero__n {
  font-size: var(--text-display);
  font-weight: var(--weight-bold);
  /* 1.05 crops the glyph box at 2.75rem: the largest numeral on the screen was
   * losing 3px off the bottom of its descenders and read as a rendering
   * fault. `.display` above already uses 1.15 for the same reason. */
  line-height: 1.15;
  margin: 0;
  font-variant-numeric: tabular-nums;
}
.hero__say { margin: var(--space-3) 0 0; font-size: var(--text-lg); }
.hero__why {
  margin: var(--space-3) 0 0;
  color: var(--ink-secondary);
  font-size: var(--text-sm);
  max-width: 70ch;
}

/* --- definition rows: label / value pairs ----------------------------- */
.defs { margin: 0; }
.defs > div {
  display: grid;
  grid-template-columns: minmax(140px, 22%) 1fr;
  gap: var(--space-4);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
}
.defs > div:last-child { border-bottom: none; }
.defs dt { color: var(--ink-secondary); font-size: var(--text-sm); }
.defs dd { margin: 0; }

/* --- search ----------------------------------------------------------- */
.searchbar { display: flex; gap: var(--target-gap); flex-wrap: wrap; margin-bottom: var(--space-5); }
.searchbar input[type="search"] { flex: 1 1 320px; }

/* --- tables ----------------------------------------------------------- */
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: var(--surface-raised); }
.table-note {
  padding: var(--space-3) var(--space-4);
  background: var(--surface-recessed);
  border-top: 1px solid var(--border);
  color: var(--ink-secondary);
  font-size: var(--text-sm);
}

/* AN INLINE `.table-note` IS A QUIET SECOND LINE, NOT A FOOTER STRIP — and
   until step 139 it was the strip, inside a table cell, nine times across four
   templates.

   The rule above is written for `<p class="table-note">` UNDER a table, where a
   recessed background and a top border are right. Used as `<span>` inside a
   `<td>` it stays INLINE, and vertical padding on an inline element does not
   expand its line box: the background paints straight over the row above and
   the cell beside it. That is the literal overlap the PI reported on the roster
   — "tons of overlap and makes it really hard to read" — and it was on the
   materials item page, materials search and the locations tree as well.

   NOT VISIBLE IN ANY MEASUREMENT. Row heights, column widths and the DOM all
   looked reasonable; it only showed up in a screenshot, which is why the
   instruction to go and look at it rendered was the right instruction.

   Selected by ELEMENT rather than by parent, so the 56 correct `<p>` and
   `<div>` uses keep the strip they were designed for. */
span.table-note {
  display: block;
  margin-top: var(--space-1);
  padding: 0;
  background: none;
  border-top: 0;
}

/* --- tree ------------------------------------------------------------- */
.tree { list-style: none; margin: 0; padding: 0; }
.tree ul { list-style: none; margin: 0; padding-left: var(--space-5); border-left: 1px solid var(--border); }
.tree li { padding: var(--space-2) 0; }
.tree__code { font-family: var(--font-mono); color: var(--accent-ink); }
.tree__count { color: var(--ink-secondary); font-size: var(--text-sm); }

/* --- queue cards ------------------------------------------------------ */
.queues { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--space-3); }
.queue {
  display: block;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* Narrower side padding than the vertical: the labels are queue names, and
   * the longest of them (`vendor_unresolvable`) needed 172px in a 170px card,
   * so it broke mid-word. Four pixels a side buys the whole set one line each
   * without dropping the grid from five columns to four. */
  padding: var(--space-4) var(--space-3);
  text-decoration: none;
  color: inherit;
  min-height: var(--target-min);
}
.queue:hover { border-color: var(--accent); }
.queue[aria-current="true"] { border-color: var(--accent); background: var(--surface-recessed); }
.queue__n { display: block; font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
.queue__label { display: block; color: var(--ink-secondary); font-size: var(--text-sm); margin-top: 2px; }
.queue .bar { margin: var(--space-3) 0 var(--space-2); }
.queue__mode { display: block; color: var(--ink-secondary); font-size: var(--text-xs); }

/* --- bars: proportion without a second hue ---------------------------- */
/* `display: block` is load-bearing, not tidiness. The review queue renders the
 * bar as a <span> inside an <a>, where `height: 6px` on an inline box is
 * ignored: the 6px rail laid out 88px tall, every queue card overflowed its
 * grid row by ~70px, and the ten cards that are the review screen's primary
 * navigation all rendered broken. */
.bar { display: block; height: 6px; background: var(--surface-recessed); border-radius: 999px; overflow: hidden; }
.bar__fill { height: 100%; background: var(--accent); display: block; }
.bar__fill--warn { background: var(--warn); }

/* --- section rule ----------------------------------------------------- */
.rule { border: 0; border-top: 1px solid var(--border); margin: var(--space-6) 0; }

/* --- blocked: a figure the system refuses to compute ------------------ */
/* --- a thread on a subject --------------------------------------------- */
.thread { margin-top: var(--space-4); padding-top: var(--space-3); border-top: 1px solid var(--border); }
.thread__said { margin: 0 0 var(--space-3); max-width: 74ch; }
.thread__none { margin: 0 0 var(--space-3); }
.thread__form { margin-top: var(--space-3); }
.thread__form textarea { width: 100%; max-width: 74ch; display: block; }
.thread__form button { margin-top: var(--space-2); }

/* --- hidden means hidden ----------------------------------------------- */
/* `[hidden]` works ONLY through the user-agent stylesheet's `[hidden]{display:none}`,
   and ANY author declaration beats the UA sheet regardless of specificity. `.btn`
   sets `display: inline-flex`, so `<button class="btn" hidden>` rendered at full
   size — on `/install/`, where the install button is hidden until a browser
   offers a prompt, a person on an iPhone or in Firefox (neither of which ever
   fires `beforeinstallprompt`) saw a full-size primary button that did nothing.

   That is precisely the thing the same commit's message said it was avoiding:
   "a control that looks live and does nothing teaches people the alarms are
   unreliable". Verified in real Chrome — with `hidden` set, computed display was
   still `inline-flex` and the button still measured 44px tall.

   `!important` because the whole problem is that author rules outrank the UA
   sheet: without it, any component rule that sets `display` re-breaks this. */
[hidden] { display: none !important; }

.blocked {
  border: 1px solid var(--border);
  border-left: 4px solid var(--warn);
  border-radius: var(--radius);
  background: var(--surface-recessed);
  padding: var(--space-5);
  margin: var(--space-5) 0;
}
.blocked__figure { font-size: var(--text-xl); font-weight: var(--weight-medium); margin: 0; }
.blocked__why { margin: var(--space-3) 0 0; color: var(--ink-secondary); font-size: var(--text-sm); max-width: 74ch; }

/* --- a reagent the lab still owes a recipe or a catalogue number ------- */
/* Marked, not hidden and not refused. It does not stop a run, so it must not
   look like it does; it does mean the row is incomplete, so it must not look
   like an ordinary one either. */
.reagent--owed { background: var(--surface-recessed); }
.reagent--owed .why { display: block; margin-top: var(--space-1); max-width: 60ch; }

/* --- changing a role, from the roster row ------------------------------ */
/* Deliberately not a modal and not a second page. The decision needs the row it
   is about — the name, whether they can sign in, what they hold — and a dialog
   that covers the table takes all of that away at the moment it is needed. */
.rolechange { margin-top: var(--space-3); display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; }
.rolechange select { max-width: 26ch; }
.rolechange label { font-size: var(--text-xs); color: var(--ink-secondary); }
.rolechange__holds { font-size: var(--text-xs); color: var(--ink-secondary); max-width: 40ch; }
/* Screen-reader-only, for the label on the role select. The visible label would
   repeat the column heading on every row. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- the unblock header ----------------------------------------------- */
/* `templates/unblock/header.html` has used these class names since the pattern
   landed and this stylesheet had NONE of them, so every question rendered as an
   undifferentiated run of paragraphs: on a screen with four, the answer box for
   one sat flush against the heading of the next and nobody could tell where a
   question ended. It was tolerable at one question on three screens and stopped
   being tolerable at eight screens, which is the same "it existed, nobody
   looked" failure as the headers themselves.

   Each question is a separately readable unit with a rule between them —
   cheaper than a card, and enough that the eye can find where to answer. */
.unblock__lede { margin: var(--space-3) 0 0; max-width: 74ch; }
/* 24 + 16 of separation between questions, on top of a border that already
 * separates them. Halved with the restructure that moved the reasoning behind a
 * disclosure — three questions were 1,243px on /register/. */
/* The header is a disclosure now: the count stays visible and the questions
 * fold away. It must still read as the page's warning rather than as a stray
 * control, so the summary keeps the heading's weight and size. */
.unblock__summary { cursor: pointer; font-size: var(--text-lg); }
.unblock__summary:hover { color: var(--ink); }
details.unblock[open] > .unblock__summary { margin-bottom: var(--space-2); }
.unblock__q { margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--border); }
.unblock__more > summary { cursor: pointer; color: var(--ink-secondary); font-size: var(--text-sm); }
.unblock__more > summary:hover { color: var(--ink); }
.unblock__more p { margin: var(--space-2) 0 0; }
/* The first one sits directly under the lede, so it needs no rule above it. */
.unblock__q:first-of-type { border-top: 0; padding-top: 0; }
.unblock__q > strong { display: block; max-width: 74ch; }
.unblock__why { margin: var(--space-2) 0 0; color: var(--ink-secondary); font-size: var(--text-sm); max-width: 74ch; }
.unblock__ev { margin: var(--space-2) 0 0; color: var(--ink-secondary); font-size: var(--text-xs); max-width: 74ch; }
.unblock__q form { margin-top: var(--space-3); }
.unblock__q textarea { width: 100%; max-width: 74ch; display: block; }
.unblock__q form label { display: inline-flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
.unblock__q form button { margin-top: var(--space-3); display: block; }
.unblock__done { margin-top: var(--space-4); }

/* STEP 143 · THE ANSWERS, AS CONTROLS.
   One row of buttons, each its own form because each is a complete answer and
   this project has no JavaScript to submit one form several ways. `flex-wrap`
   because "does not apply" must never be pushed off the end on a phone: it is
   the answer this change exists to make cheap. */
.unblock__answers { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-3); }
.unblock__answers form { margin-top: var(--space-3); display: flex; align-items: flex-end; gap: var(--space-2); }
/* Overrides the `display: block; margin-top` above, which would drop every
   button onto its own line and undo the row. */
.unblock__answers form button { margin-top: 0; display: inline-block; }
.unblock__answers select { max-width: 44ch; }

/* Where to go and how far along. Not a form — the controls are in the table. */
.unblock__rows { margin: var(--space-3) 0 0; max-width: 74ch; font-size: var(--text-sm); }

/* Free text, folded away on a question that has a menu and open on one that
   does not. `summary` reads as a link because that is what it behaves like. */
.unblock__prose { margin-top: var(--space-3); }
.unblock__prose > summary { cursor: pointer; color: var(--ink-secondary); font-size: var(--text-sm); }

/* --- the per-row tick ---------------------------------------------------
   A BUTTON, NOT A CHECKBOX. A checkbox that saves needs JavaScript, and a
   checkbox that does not save needs a Save button at the foot of eighteen rows —
   which is a second step and a way to lose the lot by navigating away. This is
   the checkbox: it shows the state, and one press changes it.

   Sized to the 44px touch target, because the PI does this on a phone. */
.rowtick { margin: 0; }
.rowtick__head { width: 1%; white-space: nowrap; font-size: var(--text-xs); }
.rowtick__cell { width: 1%; vertical-align: top; }
.rowtick__btn {
  display: flex; align-items: center; justify-content: center;
  width: var(--target-min); height: var(--target-min); padding: 0;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); color: var(--ink-secondary);
  font-size: var(--text-lg); line-height: 1; cursor: pointer;
}
.rowtick__btn:hover { border-color: var(--ink-secondary); }
/* Confirmed reads as confirmed WITHOUT relying on colour alone — the tick glyph
   is the signal and the fill only reinforces it (R-17). Same accent as
   `chip--verified`, because it means the same thing on the same page. */
.rowtick__btn.is-on {
  background: var(--surface-raised);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: var(--weight-bold);
}

/* --- provenance ------------------------------------------------------- */
.prov { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); color: var(--ink-secondary); }
.prov::before { content: ""; width: 8px; height: 8px; border-radius: 2px; border: 1px dotted currentColor; }
.prov--verified::before { border-style: solid; border-color: var(--accent); }

/* --- masthead identity ------------------------------------------------ */
/* THE ACCOUNT CONTROL IS A DISCLOSURE, not a row of three things. The role word,
 * the username and an underlined "Sign out" were about 200px of masthead for one
 * press a day; the chip stays and the other two moved underneath it. */
.who { position: relative; flex-shrink: 0; }
.who > summary {
  cursor: pointer;
  list-style: none;
  min-height: var(--target-min);
  display: inline-flex;
  align-items: center;
}
/* Safari draws its own triangle from a different property, and it is the one
 * browser on the iPad this gets read on. */
.who > summary::-webkit-details-marker { display: none; }
.who > summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.who__panel {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-2));
  z-index: 20;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-2);
  padding: var(--space-3);
  min-width: max-content;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* No shadow token exists in tokens.css; this is the only raised-over-content
     surface in the product, so the value lives here rather than inventing one. */
  box-shadow: 0 6px 24px rgb(0 0 0 / 28%);
}
.who__panel p { margin: 0; }
.who__role {
  font-size: var(--text-xs);
  color: var(--ink-secondary);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.who form { margin: 0; }
.who button {
  background: none;
  border: 0;
  color: var(--ink-secondary);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
  min-height: var(--target-min);
  padding: 0 var(--space-2);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* The build banner. Pre-launch is a fact about this deployment, not a mood, so
 * it sits at the top in the same type as everything else rather than as a
 * dismissible toast nobody reads twice. */
.build-banner {
  background: var(--surface-recessed);
  border-bottom: 1px solid var(--warn);
  /* One line of text needs one line of box. 8px top and bottom was sized for a
     five-line paragraph. */
  padding: var(--space-1) var(--space-5);
  font-size: var(--text-sm);
  color: var(--ink-secondary);
}
.build-banner strong { color: var(--warn-ink); font-weight: var(--weight-medium); }

/* Two bands stacked is the intended look: "pre-launch" and "no sign-in" are two
 * different facts about this build, and collapsing them into one would let the
 * louder hide the quieter. It reuses --warn rather than introducing a red: this
 * palette has no danger token, and adding a colour for a dev-only band would put
 * a hue into the system that the colour-vision work never checked. The rule
 * above it, and the words, carry the difference. */
.build-banner--dev {
  border-top: 1px solid var(--warn);
}
.build-banner--dev code {
  font-family: var(--font-mono);
  font-size: 0.95em;
  padding: 0 3px;
  border-radius: 3px;
  background: var(--surface-raised);
}

/* --- write outcomes --------------------------------------------------- */
/* A refusal is distinguished by a rule and a word, never by a hue: there is no
 * red in this palette, deliberately (R-17). */
.msgs { list-style: none; margin: 0 0 var(--space-5); padding: 0; }
.msgs__item {
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--surface-recessed);
  padding: var(--space-4);
  margin-bottom: var(--space-2);
}
.msgs__item--refused { border-left-color: var(--warn); }


/* --- the theme pin ---------------------------------------------------- */
/* Three buttons rather than a switch: a two-state toggle cannot express
 * "follow this machine", so the first click would be irreversible and somebody
 * who pinned by accident could never get back. */
/* The masthead must WRAP on a phone. Adding three buttons to it pushed the
 * header to 467px at a 375px viewport — measured by `shoot.py`, whose non-zero
 * exit is exactly for this. A control added for the bench that breaks the bench
 * layout is worse than no control. */
/* ONE BUTTON, SQUARE, CYCLING. `Dark | Light | Auto` was three labelled buttons
 * and about 150px of a row that now carries seven doors as well. The state it is
 * in reads off the glyph and, for anyone not looking at the shape, off the
 * accessible name the script keeps in step with it. */
/* 32 WIDE, STILL 44 TALL. The eighth door — People — needs 84px of a row that
 * had 66px of slack at 1024, so the doors were clipped by 34 and Oversight was
 * half a word. Abbreviating a door name is ruled out, so the space comes off the
 * chrome around the bar: 12px here, 16px from the spacer below, 16px from the
 * masthead's four gaps.
 *
 * HEIGHT IS THE DIMENSION A THUMB MISSES IN, which is the same reasoning `.nav a`
 * already runs on when it takes 10px of horizontal padding against a 44px target.
 * This button keeps the full 44 vertically and gives up 12 horizontally, where a
 * miss lands on the masthead rather than on another control — the nearest thing
 * either side is 12px of gap. */
.theme {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 32px;
  min-height: var(--target-min);
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--ink-secondary);
  cursor: pointer;
}
.theme:hover { color: var(--ink); border-color: var(--ink-secondary); }
.theme__glyph { font-size: var(--text-base); line-height: 1; }
.theme:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }


/* The masthead is a flex row that was sized for four children and now has five.
 * Wrapping rather than shrinking: the theme pin and the sign-out control both
 * have to stay at a thumb-sized target, and squeezing them to fit is how a
 * gloved tap misses. */
/* NO LONGER WRAPPING. The row used to wrap because five children could not fit;
 * it now holds six, and the one that grew is the one that can scroll. Wrapping a
 * row that contains the door bar would put the doors on their own line again,
 * which is the band this change exists to remove. So: the doors take the slack
 * and scroll sideways if a viewport is too narrow for all seven, and every other
 * child keeps its size. */
.masthead { flex-wrap: nowrap; }
/* 12 RATHER THAN 16, ACROSS FOUR GAPS. The eighth door needs 84px and the row had
 * 66 to give at 1024. This is 16 of the 44 that were found; the rest is on the
 * theme pin and the spacer. Four pixels is below the threshold at which a gap
 * reads as a gap — nothing on this row moved closer than the eye can see. */
.masthead { gap: var(--space-3); }
.masthead__logo { flex-shrink: 0; }
/* It still grows: flex-grow only ever shares out POSITIVE free space, so on a wide
 * viewport this pushes the theme pin and the account chip to the right edge, and
 * on a narrow one there is no free space to share and the doors shrink and scroll
 * instead. What it must not do is shrink to nothing and let the doors collide with
 * the controls, hence the explicit basis. */
/* THE BASIS IS NOW ZERO, and the separation it was protecting is unchanged. The
 * spacer sits BETWEEN two masthead gaps, so a last door and the theme pin are
 * 24px apart with this at nothing — wider than any two doors are from each
 * other. The 16px reservation was buying a third gap in the one place where the
 * row runs out of room, and it is the cheapest 16 of the 44 the eighth door
 * needed. */
.masthead__spacer { flex: 1 1 auto; min-width: 0; }


/* --- the logo on a light ground -------------------------------------------- */
/* Duplicated across the media query and the pin for the same reason the theme
 * tokens are: a media query cannot be beaten by a later selector of equal
 * specificity, so the explicit pin has to be able to force light on a
 * dark-preferring machine. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .masthead__logo {
    filter: none;
    mix-blend-mode: multiply;
  }
}
:root[data-theme="light"] .masthead__logo {
  filter: none;
  mix-blend-mode: multiply;
}

/* A 150px mark plus the theme pin plus sign-out does not fit a phone. The mark
 * scales rather than the wordmark coming back — it is the only thing in the
 * masthead that says which application this is. */
@media (max-width: 560px) {
  /* The desktop mark is 28px now, so the phone no longer needs its own rule to
   * stop the band eating the screen. Kept at parity rather than deleted: the
   * breakpoint is where somebody will look first if the band ever regrows. */
  .masthead__logo { height: 28px; }
}

/* --------------------------------------------------------------------------
   THE EMAIL MODAL — the message this system would send, rendered to be copied.
   Nothing here is decorative: the body is a textarea because a textarea is
   selectable and scrollable on every platform including the ones where
   clipboard script is refused, and the sheet is width-limited because an email
   body reflowed to a 27-inch monitor is not the shape it will be sent in.
   -------------------------------------------------------------------------- */
/* THE SHEET SCROLLS, NOT THE PAGE. A dialog taller than the viewport is
   clipped at the TOP by the centring, which hid the heading and the Close
   button — measured in Chrome at 800x450, not reasoned about. `max-height` plus
   an internal scroll keeps the head reachable at any window size, which matters
   because the head is where Close lives. */
.mailmodal {
  border: none; padding: 0; background: transparent;
  max-width: 46rem; width: 92vw; max-height: 90vh;
}
.mailmodal::backdrop { background: rgb(0 0 0 / 0.55); }
.mailmodal__sheet {
  background: var(--surface); color: var(--ink); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: var(--space-5);
  max-height: 90vh; overflow-y: auto;
}
/* STICKY AT ZERO, NOT AT MINUS THE PADDING. The first version used a negative
   `top` so the sheet's own padding would be covered — which parks the header
   above the scroll container and clips the title and the Close button off the
   screen. Measured in Chrome: `dialog` height 680 in a 720 viewport, fully in
   view, and the heading still cut. The padding is moved onto the header
   instead. */
.mailmodal__head {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3);
  position: sticky; top: 0; z-index: 1;
  background: var(--surface); padding-bottom: var(--space-3);
}
.mailmodal__title { margin: 0; font-size: var(--text-lg); }
.mailmodal__instruction { margin: var(--space-3) 0 0; font-weight: var(--weight-medium); }
.mailmodal__context { margin: var(--space-2) 0 0; color: var(--ink-secondary); }
.mailmodal__fields { display: grid; grid-template-columns: auto 1fr; gap: var(--space-2) var(--space-4);
                     margin: var(--space-4) 0 0; }
.mailmodal__fields dt { color: var(--ink-secondary); font-size: var(--text-sm); }
.mailmodal__fields dd { margin: 0; }
.mailmodal__gap { margin: var(--space-2) 0 0; padding: var(--space-2) var(--space-3);
                  border-left: 3px solid var(--warn); background: var(--surface-recessed); }
.mailmodal__label { margin: var(--space-5) 0 var(--space-2); font-size: var(--text-sm);
                    text-transform: uppercase; letter-spacing: .04em; color: var(--ink-secondary); }
.mailmodal__body, .mailmodal__attachment {
  width: 100%; resize: vertical; background: var(--surface-recessed); color: var(--ink);
  border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-3);
  font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.5;
}
.mailmodal__files { list-style: none; padding: 0; margin: 0; }
.mailmodal__files li { padding: var(--space-2) 0; border-bottom: 1px solid var(--border); }
.mailmodal__filenote { color: var(--ink-secondary); font-size: var(--text-sm); }
.mailmodal__foot { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
                   margin-top: var(--space-4); }
.mailmodal__said { color: var(--ink-secondary); font-size: var(--text-sm); }
.mailmodal__nothing { margin: var(--space-4) 0 0; padding-top: var(--space-3);
                      border-top: 1px solid var(--border); color: var(--ink-secondary);
                      font-size: var(--text-sm); }

/* The per-screen Ask button (§4.9). A `<details>`, so no JavaScript and a
   half-typed question costs nothing to abandon. */
.ask { margin-top: var(--space-6); border-top: 1px solid var(--border); padding-top: var(--space-4); }
.ask summary { display: inline-flex; align-items: center; min-height: var(--target-min);
               cursor: pointer; color: var(--ink-secondary); font-size: var(--text-sm); }
.ask form { display: grid; gap: var(--space-2); max-width: 42rem; margin-top: var(--space-2); }
.ask textarea { width: 100%; }


/* --------------------------------------------------------------------------
   REVIEW QUEUE, STEP 139 — clicking rather than typing.

   The PI's ruling: "all these lists that she needs to review, they need to just
   have checkboxes ... on each row for her to confirm, not type things in free
   hand into text columns."

   `.tap` is a whole answer as a button. They wrap, because a short fixed set
   should be visible without opening anything — a menu you have to open is a
   menu you have to know is there.

   `.row-note` is DELIBERATELY QUIET. It is a note and not a decision, and the
   moment it looks like a field you have to fill, it becomes a column to cross.
   -------------------------------------------------------------------------- */
.pane-head { display: flex; align-items: baseline; gap: var(--space-4); flex-wrap: wrap;
             margin-bottom: var(--space-2); }
.pane-head h2 { margin: 0; }
.pane-head__count { margin: 0; color: var(--ink-secondary); }
.pane-head__count strong { font-size: var(--text-lg); color: var(--ink); }

.row-answer--tap { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.row-answer--tap .tap { white-space: nowrap; }
.row-note { display: block; margin-top: var(--space-1); width: 100%;
            font-size: var(--text-sm); opacity: 0.72; }
.row-note:focus { opacity: 1; }


/* --------------------------------------------------------------------------
   ROSTER, STEP 139 — "tons of overlap and makes it really hard to read".

   Measured before changing anything: one row was 273px tall, and its Role cell
   was 352px wide holding EIGHT controls — the current role and a whole handover
   form, on every one of eighteen rows.

   Nothing was removed. The rare act is behind a disclosure so the common act —
   reading who somebody is — is not competing with a form nobody is using.
   -------------------------------------------------------------------------- */
.rolechange__disclose { margin-top: var(--space-2); }
/* INLINE, NOT STACKED. "Change role" and "Take off the roster" sat on two lines
 * in each of eighteen rows — thirty-six lines of control in a table somebody
 * opened to read eighteen names. Side by side they are one line, and an OPEN one
 * takes the full cell so its form is not squeezed into half of it. */
.rolechange__disclose { display: inline-block; margin-right: var(--space-3); }
/* The controls are two or three words each, and `nowrap` kept them on one line
 * so that "inline" did not become two lines apiece.
 *
 * IT ALSO MADE THIRTEEN CELLS OVERFLOW, which is the bigger fault of the two and
 * the one this table has a rule about. Measured in the browser, not read:
 * `Set an appointment` is 147px of unbreakable text in a 137px content box, so
 * every row carrying it pushed 10px past its cell. Three words is one word too
 * many for this column.
 *
 * `normal` LETS IT WRAP AT THE SPACES AND NOWHERE ELSE. `overflow-wrap: normal`
 * is stated beside it because every grid cell in this stylesheet carries
 * `overflow-wrap: anywhere` — nothing may clip — and that would break
 * `appointment` in half rather than move it to the next line. Wrapping between
 * words costs a line on the narrowest columns; clipping costs the reader the end
 * of the label. */
.rolechange__disclose > summary { white-space: normal; overflow-wrap: normal; }
/* Role, sign-in and timezone each hold a short phrase; the person column is
   the one that needs room, and it has it. */
.roster__table td { min-width: 12ch; }
.rolechange__disclose[open] { display: block; margin-right: 0; }
.rolechange__disclose > summary { cursor: pointer; color: var(--ink-secondary);
                                  font-size: var(--text-sm); }
.rolechange__disclose > summary:hover { color: var(--ink); }
.rolechange__disclose[open] > summary { margin-bottom: var(--space-2); }

.roster__whence td { border-top: 0; padding-top: 0; color: var(--ink-secondary);
                     font-size: var(--text-sm); }

/* STEP 160 · The cylinder controls, in a table cell.
   `details` inside a `td` inherits nothing useful: the summary sits on the text
   baseline of a row whose other cells are two lines tall, and the open form
   stretches the column. Constraining the width here keeps the serial and the
   last reading readable while a form is open beside them. */
.cylctl { margin: 0 0 var(--space-2); }
.cylctl > summary { cursor: pointer; white-space: nowrap; }
.cylctl[open] > summary { margin-bottom: var(--space-2); }
.cylctl form { min-width: 16rem; max-width: 22rem; display: grid; gap: var(--space-2); }
.cylctl .field { display: grid; gap: 2px; }
.cylctl input:not([type="checkbox"]), .cylctl select { width: 100%; }
.cylctl .check { display: flex; align-items: center; gap: var(--space-2); }

/* STEP 162 · A retired antibody stays in the list and stops competing for
   attention. Not `display:none`: somebody who remembers using it needs to find
   out that it went and why, and a row that is absent reads as a name they got
   wrong. */
.spec--off { opacity: .62; }
.specadd, .specedit { margin: var(--space-4) 0; }
.specadd form, .specedit form { max-width: 40rem; display: grid; gap: var(--space-3); }


/* ==========================================================================
   PHONE: THE NAV IS A SCREEN, NOT A BAR
   --------------------------------------------------------------------------
   Measured at 375px before this: 1,241px of chrome before any page began —
   `NAV.nav` 737, two build banners 262, masthead 109, footnote 133. A
   navigation taller than the viewport is not a navigation.

   The owner's shape: a compact header with a back arrow to the menu, and the
   menu as its own full-screen view. Desktop is untouched — this is a
   media-query branch, not a redesign.
   ========================================================================== */
.tomenu { display: none; }

@media (max-width: 767px) {
  /* THE DOOR BAR IS THE MENU'S JOB NOW. */
  .nav { display: none; }

  .tomenu {
    display: block;
    padding: var(--space-2) var(--space-4);
    border-bottom: 1px solid var(--border);
    color: var(--ink-secondary);
    text-decoration: none;
    font-weight: var(--weight-medium);
    min-height: var(--target-min);
    line-height: var(--target-min);
  }
  .tomenu:hover { color: var(--ink); }

  /* The mark alone identifies the application; the theme pin and sign-out stay,
     and 28px of logo does not need 8px of air on a phone. */
  .masthead { padding: var(--space-1) var(--space-3); gap: var(--space-2); }
  /* The role word and sign-out are behind the chip now, so the phone masthead is
     mark, theme pin, chip — three items that fit on one row at 375 without any of
     them giving up a target. The rules that used to shrink them here are gone
     with the things they shrank. */

  /* 262px of banner on an 800px screen is a third of the viewport before
     anything. One line each, and they still say the thing that matters. */
  .build-banner { padding: 2px var(--space-3); font-size: var(--text-xs); }

  /* A strip of six wraps to three rows at 375. It is still the fastest way
     between tabs of one door, so it stays and scrolls sideways instead. */
  .subtabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    gap: var(--space-3);
    -webkit-overflow-scrolling: touch;
  }
  .subtabs a { white-space: nowrap; }
}

/* --- the menu page ----------------------------------------------------- */
.menu__title { margin: 0 0 var(--space-3); }
.menu__door { border-top: 1px solid var(--border); padding: var(--space-2) 0; }
.menu__head {
  display: block;
  font-size: var(--text-lg);
  font-weight: var(--weight-medium);
  text-decoration: none;
  min-height: var(--target-min);
  line-height: var(--target-min);
}
.menu__tabs { list-style: none; margin: 0; padding: 0 0 var(--space-1) var(--space-3); }
.menu__tabs a {
  display: block;
  color: var(--ink-secondary);
  text-decoration: none;
  min-height: var(--target-min);
  line-height: var(--target-min);
}
.menu__tabs a:hover { color: var(--ink); }

@media (min-width: 768px) {
  /* On a desktop the bar is already there, so the menu is a list rather than a
     column of tap targets — reachable, and not pretending to be the navigation. */
  .menu { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
          gap: var(--space-4); }
  .menu__door { border-top: 0; }
}

/* ==========================================================================
   THE EDITABLE GRID
   --------------------------------------------------------------------------
   Every list becomes a grid: click a cell and type, a blank line at the bottom
   that creates a record when somebody types in it, and headers that filter and
   sort. Fifteen agents are writing markup against these four class names, so
   this file is the one place the shape is decided — fifteen inventions would be
   worse than one serial pass.

   THE RULES THIS STYLESHEET ENFORCES, because a class name cannot:

   * A cell somebody may not change RENDERS AS TEXT, never as a disabled box. A
     greyed-out input is an invitation withdrawn at the last moment; text is an
     honest answer to a question nobody asked. Give such a cell no `.grid__cell`
     input at all — there is no disabled state here on purpose.
   * `--target-min` is the floor for anything tappable. These pages are used on a
     phone at a bench with gloves on.
   * A row must not move under the cursor. Nothing here animates height, and the
     save control is FIXED rather than inserted, so committing an edit does not
     reflow the thing being edited.
   ========================================================================== */

/* `table-layout: fixed` IS THE FIX FOR THREE OF THE FIVE DEFECTS a screenshot
 * found. With `auto`, the browser sizes columns from their content — and the
 * content is inputs with intrinsic widths, so the header row and the body rows
 * were measured independently and landed at different offsets, with a dead
 * column on the left of every row and boxes narrow enough to read "The alumin"
 * and "hemocytor". Fixed layout takes the widths from the first row and every
 * cell obeys them.
 *
 * `width: 100%` on the inputs is the other half: a fixed-width box inside a
 * sized cell is what left them narrow. */
/* SCOPED TO `table`, BECAUSE `.grid` WAS ALREADY TAKEN — and that collision is
 * the whole of why the header and body columns did not line up.
 *
 * `.grid` has been a CARD LAYOUT in this file since long before tonight —
 * `display: grid`, auto-fill 260px columns — and eight templates use it that
 * way. I published `.grid` as the editable-table class without checking what the
 * name already meant, fifteen apps wrote `class="grid"` onto a `<table>`, and
 * every one of them got `display: grid`. A table told to be a CSS grid stops
 * being a table: `thead` and `tbody` become blocks, their rows size
 * independently, and the header lands 361px left of the body with a dead column
 * beside it.
 *
 * `display: table` is restored explicitly rather than relied on, because the
 * card rule above still matches and still wins on source order otherwise. */
table.grid {
  display: table;
  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  /* 13px. A spreadsheet is read by scanning a column, not by reading a
   * sentence, and 15px on 618 rows is 1,200px of type size alone. */
  font-size: var(--text-xs);
}
table.grid > thead { display: table-header-group; }
table.grid > tbody { display: table-row-group; }

/* EVERY control in a grid fills its cell, whatever an app called it. Targeted by
   element rather than by class because fifteen apps wrote this markup and only
   the four container names were fixed in advance. */
table.grid input:not([type="checkbox"]):not([type="radio"]),
table.grid select,
table.grid textarea {
  width: 100%;
  box-sizing: border-box;
  min-width: 0;
}

/* AND A TOGGLE KEEPS THE SIZE ITS OWN PAGE GAVE IT. `materials/search.html`
 * declares `.rowedit__flip { position: absolute; width: 1px; opacity: 0 }` — an
 * invisible checkbox that flips a row into edit mode — and then, four lines
 * above, `.grid td input { width: 100%; min-height: var(--target-min) }`, which
 * is (0,1,2) against the toggle's (0,1,0) and wins. Absolutely positioned, that
 * 100% resolves against the viewport, so the page carries SIXTY invisible
 * 375x44 hit areas, each one over content, each one eating the tap. Measured on
 * /inventory/ at 375px.
 *
 * The page-local block that causes it is already marked "MINIMAL AND TEMPORARY"
 * and should come out. Until it does, this outranks it centrally rather than
 * leaving a phone with sixty dead spots. */
/* `td` AND `th`. The filter row lives in `thead` and its cells are `th`, so the
 * Show-hidden checkbox on the roster missed this rule entirely and was drawn
 * 155x44 — a tick box the width of its column, stretching the filter row to 71px
 * and making the whole header look broken. A tap-target minimum is for something
 * you press; a checkbox already is one. */
table.grid td input[type="checkbox"],
table.grid td input[type="radio"],
table.grid th input[type="checkbox"],
table.grid th input[type="radio"],
.roster__table td input[type="checkbox"],
.roster__table th input[type="checkbox"] {
  width: auto;
  min-height: 0;
  height: auto;
}

/* THE ROW DIET, AND IT IS THE SHAPE THAT CHANGED, NOT JUST THE PADDING.
 *
 * A cell was a form field: a 44px input with its own border, its own 8px corner
 * radius and its own background, drawn inside a cell that already had a border.
 * That is 44px of chrome to display a value that costs 16px to read, and on the
 * 618-row catalogue it is what took /inventory/ from 6,667px to 17,887.
 *
 * A spreadsheet cell is not a field. It is a VALUE, which happens to be typable
 * over — and the fact that it is typable shows up when you touch it, not before.
 * So the input gives up its border, its radius, its background and its height,
 * and what draws the grid is the cell's own hairline on all four sides. That is
 * also what makes it read AS a grid rather than as a stack of boxes.
 *
 * `--target-min` still governs everything TAPPED — the save bar, the filter row,
 * the buttons. It never governed a text cell dragged through with a keyboard,
 * and a 44px floor on 618 of them is a page nobody scrolls. */
table.grid td,
table.grid th {
  padding: 1px 4px;
  border: 1px solid var(--border);
  vertical-align: middle;
}

/* `table.grid td input` AND NOT `table.grid input`, DELIBERATELY, for two
 * reasons that happen to want the same selector.
 *
 * One: six app templates carry a local `<style>` block saying
 * `.grid td input { min-height: var(--target-min) }` — the fifteen-inventions
 * problem this stylesheet exists to prevent, arrived by a different door. That
 * selector is (0,1,2) and so was mine, so the page-local copy won on source
 * order and the diet did nothing on the six pages that most needed it. Naming
 * the `td` makes this (0,1,3) and settles it centrally. Those local blocks are
 * now dead weight and should come out of the templates.
 *
 * Two: it scopes the diet to DATA cells. The filter row lives in `<th>` and
 * keeps its 44px targets, which is what we want — it is tapped, and it costs
 * that once rather than 618 times. */
/* NOT CHECKBOXES AND NOT RADIOS. `width: 100%` on a checkbox is not a wide
 * checkbox, it is a wide HIT AREA — and these grids use an `opacity: 0`,
 * `position: absolute` checkbox as a row toggle (`.rowedit__flip`), so absolute
 * positioning resolved that 100% against the viewport and put sixty invisible
 * 375px targets across the page, each one swallowing taps meant for something
 * else. A checkbox already knows what size it is. */
table.grid td input:not([type="checkbox"]):not([type="radio"]),
table.grid td select,
table.grid td textarea,
table.grid td .grid__cell,
table.grid td.grid__cell {
  width: 100%;
  box-sizing: border-box;
  min-width: 0;
  min-height: 0;
  height: auto;
  margin: 0;
  padding: 0 2px;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
  outline: 0;
  color: inherit;
  font: inherit;
  line-height: 1.2;
}

/* THE RING IS INSIDE THE CELL. `outline-offset: -2px` draws it within the box
 * the cell already occupies, and an outline never takes part in layout — so
 * focusing a cell cannot make it grow, cannot shift the row, and cannot move the
 * thing being edited out from under the cursor. That is the same rule the fixed
 * save bar exists for, applied one level down. */
table.grid td input:focus,
table.grid td select:focus,
table.grid td textarea:focus,
table.grid td .grid__cell:focus {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  background: var(--surface);
}

/* NUMBERS RIGHT, TEXT LEFT, NOTHING CENTRED — a column of figures is compared by
 * its last digit. `[type=number]` and the inputmode hints catch what the browser
 * already knows; `.grid__num` is for a column an app knows is numeric and the
 * markup does not say so. */
table.grid input[type="number"],
table.grid input[inputmode="numeric"],
table.grid input[inputmode="decimal"],
table.grid .grid__num,
table.grid td.grid__num,
table.grid th.grid__num { text-align: right; }

/* AFFORDANCES STAY ON THE VALUE'S LINE. The catalogue's first column carries an
 * "Open" link and the legacy item-number chip under the input, and stacked they
 * made that one column 84px while every other cell in the row needed 18 — one
 * column setting the height of all 618 rows.
 *
 * Reserved inline space rather than absolute positioning: the chip's width is
 * the item number's width and nobody knows it in advance, so anchoring it over
 * the input would eventually cover a value. This just makes the input narrower
 * in the cells that have company, and anything too long still wraps and grows
 * its own row instead of being hidden. */
/* THE `nowrap` HERE CONTRADICTED THE PARAGRAPH ABOVE IT, which promised "anything
 * too long still wraps and grows its own row instead of being hidden". It did not:
 * a pasted shopping link put the catalogue cell at 398px inside a 157px column and
 * the product code the buyer needs was the half off-screen. The reserved inline
 * width below is what actually keeps the affordances on the value's line in the
 * ordinary case; wrapping is the right answer in the rare long one. */
table.grid td:has(> .grid__open),
table.grid td:has(> .chip) { white-space: normal; overflow-wrap: anywhere; }
table.grid td:has(> .grid__open) > input:not([type="checkbox"]),
table.grid td:has(> .chip) > input:not([type="checkbox"]) { width: calc(100% - 7.5em); }
table.grid td > .grid__open,
table.grid td > .chip {
  display: inline-block;
  vertical-align: middle;
  margin: 0 0 0 4px;
  padding: 0 4px;
  font-size: 10px;
  line-height: 14px;
  white-space: nowrap;
}

/* CHANGED BUT NOT SAVED, which is the state a person most needs to see. Set by
   the page when an input's value differs from the value it was rendered with, so
   "what have I actually changed" is answerable by looking rather than by
   remembering.

   An inset ring now rather than a border, because the border belongs to the cell
   and is no longer the input's to colour — and because a ring on all four sides
   cannot be confused with `.cell--empty`, which is a left edge. The focus ring
   above outranks this on specificity, not on order, which is what we want:
   while you are in a cell, where you are matters more than what you did to it. */
table.grid [data-dirty="yes"],
.grid__cell[data-dirty="yes"] {
  outline: 2px solid var(--warn);
  outline-offset: -2px;
  background: color-mix(in srgb, var(--warn) 8%, transparent);
}

/* AN EMPTY CELL THAT WANTS A VALUE, and the whole of what used to be a queue.
   454 questions were a page somebody worked through in one sitting, which is a
   sitting nobody ever has; they are now the cell where the answer belongs, in a
   list that is already editable, seen by whoever opened that list for their own
   reasons and is standing in front of the thing.

   THE HIGHLIGHT IS THE MESSAGE. No icon, no tooltip, no text — those need
   reading, and this needs noticing. Gold and not red on purpose: nothing here is
   wrong, it is unfilled, and a red cell would make 454 ordinary gaps look like
   454 faults in a system the lab is being asked to trust on its first day.

   A LEFT BORDER rather than all four, so it cannot be read as `[data-dirty]`
   above — that one means "you changed this and have not saved it", and the two
   states have to stay tellable apart when they land on the same cell, which is
   exactly what happens the moment somebody fills a gap in.

   THE LONG SELECTOR LIST IS NOT DEFENSIVENESS, it is two things measured on the
   page. The catalogue grid's cells are a bare `<input>` inside a bare `<td>`, so
   (1) `table.grid input` is one specificity step above a bare class and silently
   won — the class rendered nothing at all on an input — and (2) that input's
   background is opaque and full-bleed, so a tint set on the `<td>` behind it was
   invisible. Hence: both carriers spelled out, scoped to the grid to win, and
   the tint put on the input itself. The hover and focus repeats exist for the
   same reason. */
.cell--empty {
  border-left: 3px solid var(--warn);
  background: color-mix(in srgb, var(--warn) 8%, transparent);
}
table.grid td.cell--empty,
table.grid input.cell--empty,
table.grid select.cell--empty,
table.grid textarea.cell--empty,
table.grid input.cell--empty:hover,
table.grid input.cell--empty:focus,
table.grid td.cell--empty > input,
table.grid td.cell--empty > select,
table.grid td.cell--empty > textarea,
table.grid td.cell--empty > input:hover,
table.grid td.cell--empty > input:focus,
table.grid td.cell--empty > .grid__cell,
.grid__cell.cell--empty,
.grid__cell.cell--empty:hover,
.grid__cell.cell--empty:focus {
  border-left: 3px solid var(--warn);
  background: color-mix(in srgb, var(--warn) 8%, transparent);
}

/* --- the filter row ---------------------------------------------------- */
/* Inside `<thead>`, under the headings, so it sticks with them — a filter that
   scrolls away is one somebody retypes.

   THE HEADING ROW IS PINNED TO A KNOWN HEIGHT so the filter row underneath knows
   where to stop. `top` was `--target-min`, which was right only while the heading
   row happened to be 44px tall; on a diet row that left a 20px window of scrolled
   content showing between the two sticky rows. Declared rather than measured,
   because CSS cannot ask. */
table.grid > thead > tr:first-child > th { height: 24px; }
.grid__filter th { padding: 1px 4px; top: 24px; }
/* The filter row IS tapped — one control per column, pressed with a thumb — so
   it keeps the target floor the data rows give up. It costs 44px once, not per
   row, which is the whole difference. */
.grid__filter input,
.grid__filter select {
  width: 100%;
  min-height: var(--target-min);
  font: inherit;
  font-size: var(--text-xs);
}

/* --- the new-record line ------------------------------------------------ */
/* THE LAST ROW IS ALWAYS BLANK, and it is a row rather than a button because
   adding a record and editing one should not be two different motions. It reads
   as an invitation rather than as data: dashed above, and no zebra stripe. */
.grid__new,
.grid__new:nth-child(even) { background: transparent; }
.grid__new td { border-top: 1px dashed var(--border); }
.grid__new input::placeholder,
.grid__new .grid__cell::placeholder { color: var(--ink-secondary); opacity: .8; }

/* --- the save control --------------------------------------------------- */
/* FIXED TO THE VIEWPORT, not appended to the table. On a 578-row catalogue a
   button at the end of the list is a button nobody reaches, and one that appears
   when the first edit is made would push the row being edited out from under the
   cursor. It is here from the start and it does not move.
   
   It sits above the safe-area inset so a phone's home indicator cannot cover it. */
/* A BAR ACROSS THE FOOT, NOT A PILL OVER THE ROWS. Centred and floating, it sat
 * on top of the table with rows visible either side of it — which reads as a
 * control that has come loose rather than one that is waiting. Overlapping
 * content is worse than scrolling to it.
 *
 * Full width, its own ground, and `main` carries bottom padding to match so the
 * last row can always be scrolled clear of it. */
.grid__save {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  width: 100%;
  border-radius: 0;
  border: 0;
  border-top: 1px solid var(--border);
  min-height: var(--target-bench);
  padding: 0 var(--space-5);
  padding-bottom: env(safe-area-inset-bottom, 0px);
  box-shadow: 0 -2px 12px rgb(0 0 0 / 35%);
}

/* THE LAST ROW MUST CLEAR THE BAR. Without this the final record sits under it
   permanently and no amount of scrolling reaches it — the failure the fixed
   position was supposed to prevent, one row further down. */
body:has(.grid__save) main { padding-bottom: calc(var(--target-bench) + var(--space-5)); }

/* Nothing to save is not a reason to hide the control — a control that comes and
   goes is one somebody hunts for. It goes quiet instead, and says so. */
.grid__save[disabled] { opacity: .55; box-shadow: none; }

@media (max-width: 767px) {
  /* A grid is the one thing on these pages that may scroll sideways: the columns
     are the record and dropping one on a phone hides data rather than tidying
     it. The page still must not, which `shoot.py` checks. */
  table.grid { min-width: max-content; }
  /* Already full width; nothing to narrow on a phone. */
}

/* ==========================================================================
   THE PHONE CARD
   --------------------------------------------------------------------------
   SIDEWAYS SCROLLING WAS THE WRONG ANSWER AND THE AUDIT PROVED IT. The rule
   above kept every column rather than dropping one, which is right — but at
   375px the inventory grid is 1,900px wide in a 349px window, so an item's name
   and its shelf can never be on screen at the same time. Finding a tube in a
   freezer is the most common thing anybody does with this on a phone, and it was
   the one job the phone could not do. Keeping all seven columns and making them
   unreachable together is not better than dropping one; it is the same loss with
   more scrolling.

   So below 700px a row stops being a row and becomes a card: the name, then its
   fields under it, each one full width with its own label. Nothing is dropped —
   every column is still there, stacked instead of beside.

   THE LABEL CONTRACT IS `data-label` ON THE `<td>`. CSS cannot reach into
   `<thead>` and copy the column heading down, and the heading is the only place
   the field's name exists on most of these grids, so the markup has to carry it:

       <td data-label="Location">…</td>

   Until a page does, this falls back to the off-screen `.grid__hidden-label`
   those templates already put in each cell for screen readers, and unhides it.
   That covers the first four columns of the catalogue and none of the last
   three — Lots, Storage and Location have no label of any kind in the markup,
   and Location is the whole reason somebody has the phone out. Those three need
   `data-label` before this is finished.
   ========================================================================== */
@media (max-width: 700px) {
  table.grid,
  table.grid > tbody,
  table.grid > tbody > tr,
  table.grid > tbody > tr > td { display: block; }
  /* Beats the `min-width: max-content` above, which is what made it scroll. */
  table.grid { min-width: 0; width: 100%; font-size: var(--text-sm); }
  table.grid > thead { display: none; }

  table.grid > tbody > tr,
  table.grid > tbody > tr:nth-child(even) {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    margin: 0 0 var(--space-3);
    padding: var(--space-2) var(--space-3);
  }

  table.grid > tbody > tr > td {
    width: auto;
    /* AND `max-width`, BECAUSE THE COLUMN WIDTHS DO NOT KNOW THE TABLE IS GONE.
     * Every one of these grids declares its columns page-locally — `.c-name`
     * at 22%, `.grid__n` at 4.5rem — sized against a table 1,900px wide. Stacked
     * into a 349px card those percentages resolve against the wrong box and the
     * name cell came out 384px inside a 349px card. `width: auto` alone does not
     * catch it because a page-local `td.c-name` outranks the element selector;
     * this caps whatever they ask for at the card. */
    max-width: 100%;
    border: 0;
    border-top: 1px solid var(--border);
    padding: var(--space-2) 0;
    white-space: normal;
  }
  table.grid > tbody > tr > td > * { max-width: 100%; }
  /* The name is the card's heading, so it carries no label and no rule above. */
  table.grid > tbody > tr > td:first-child {
    border-top: 0;
    padding-top: 0;
    font-size: var(--text-base);
    font-weight: var(--weight-medium);
  }

  table.grid > tbody > tr > td[data-label]::before {
    content: attr(data-label);
    display: block;
    margin-bottom: 2px;
    font-size: var(--text-xs);
    font-weight: var(--weight-regular);
    color: var(--ink-secondary);
    text-transform: uppercase;
    letter-spacing: .06em;
  }
  table.grid > tbody > tr > td:first-child[data-label]::before { content: none; }
  table.grid > tbody > tr > td:not([data-label]) .grid__hidden-label {
    position: static;
    width: auto;
    height: auto;
    clip-path: none;
    display: block;
    margin-bottom: 2px;
    font-size: var(--text-xs);
    color: var(--ink-secondary);
    text-transform: uppercase;
    letter-spacing: .06em;
    white-space: normal;
  }
  table.grid > tbody > tr > td:first-child .grid__hidden-label { display: none; }

  /* THE DIET IS OFF INSIDE A CARD, and that is not a contradiction. A 19px cell
     is right for a desktop grid dragged through with a keyboard; on a phone every
     one of these is a separate tap, so `--target-min` governs again — which is
     the rule the stylesheet has stated all along, applied to the case it was
     written for. It also fixes what the audit measured on /locations/: a select
     rendering 18px tall and clipping its own value to "MINUS8". */
  table.grid td input:not([type="checkbox"]):not([type="radio"]),
  table.grid td select,
  table.grid td textarea,
  table.grid td .grid__cell {
    width: 100%;
    min-height: var(--target-min);
    padding: var(--space-2);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface-recessed);
    line-height: 1.3;
  }
  table.grid td input:focus,
  table.grid td select:focus,
  table.grid td textarea:focus,
  table.grid td .grid__cell:focus { outline-offset: 0; }

  /* The inline reservation for the affordances is a desktop idea — there is a
     whole line for them here. `Open` was 34x14px and half off the right edge;
     it becomes a real target on its own line. */
  table.grid td:has(> .grid__open),
  table.grid td:has(> .chip) { white-space: normal; }
  table.grid td:has(> .grid__open) > input:not([type="checkbox"]),
  table.grid td:has(> .chip) > input:not([type="checkbox"]) { width: 100%; }
  table.grid td > .grid__open {
    display: inline-flex;
    align-items: center;
    min-height: var(--target-min);
    margin: var(--space-2) var(--space-2) 0 0;
    padding: 0 var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    font-size: var(--text-sm);
  }
  table.grid td > .chip { font-size: var(--text-xs); line-height: 22px; padding: 0 var(--space-2); }

  /* The add-a-row line is a card too, and still reads as an invitation. */
  .grid__new,
  .grid__new:nth-child(even) { border-style: dashed; }
  .grid__new td { border-top-style: dashed; }
}

/* ─────────────────────────────────────────────────────────────────────────
 * NOTHING IN A CELL IS EVER CLIPPED. Ruled 2026-08-09, and it corrects an
 * instruction I gave: "target 24-26px row height, like Google Sheets". A
 * spreadsheet can clip because one click puts the whole value in a formula
 * bar. This has no formula bar, so a clipped cell is simply a fact the lab
 * cannot read. Measured before this rule: 126 clipped cells across seven
 * screens — `walk.postdoc@example.invali`, `shuangshuang.guo@uconn.ed`, and
 * two roster columns 11px wide whose headers read "R".
 *
 * DENSITY COMES FROM PADDING, NOT FROM TRUNCATION. A row is as tall as its
 * tallest cell needs; most rows stay one line, and the ones carrying a long
 * email or a pasted product name take two. That is the correct trade.
 * ───────────────────────────────────────────────────────────────────────── */
table.grid th,
table.grid td,
.roster__table th,
.roster__table td,
.hits th,
.hits td {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  /* `anywhere` and not `break-word`: an email or a catalogue number is one long
   * token with no break opportunity, and `break-word` leaves it overflowing. */
  overflow-wrap: anywhere;
  vertical-align: top;
}

/* A control still fills its cell, and still grows to show what is in it. */
table.grid textarea,
.roster__table textarea { min-height: var(--target-min); }

/* THE TICK COLUMNS. `width: 1%` asks for the narrowest column the content
 * allows — but with `table-layout: fixed` the content no longer votes, so both
 * collapsed to 11px and their headers ("Real & here", "Role right") rendered as
 * a single letter. A confirm column needs a thumb, so it gets one. */
.roster__table .rowtick__head,
.rowtick__head { width: 4.5rem; white-space: normal; }

/* THE LAST TWO ROSTER COLUMNS, and why this is not a `nowrap` any more.
 *
 * The PI, reading the deployed page: "the last two columns are completely messed
 * up in format. They need to be wider." They had been widened once already, to
 * 13ch and 17ch counted from the end, and that was still too tight:
 *
 *   `America/New_York` is sixteen characters in a seventeen-character column.
 *   Cell padding takes it under, so it wrapped -- and because every grid cell
 *   carries `overflow-wrap: anywhere` (nothing may clip), it broke MID-WORD
 *   into `America/New_Yor` + `k`. Unreadable, exactly as she said.
 *
 *   `Can sign in` is eleven characters in a thirteen-character column, so the
 *   HEADER wrapped to two lines for a column whose cells hold `pi` or a dash.
 *
 * Two rules had been fighting over this for a while: an early `nowrap` added
 * because the timezone split across lines, and a later `normal` added because
 * `America/Argentina/Buenos_Aires` does not fit on one. The later won, so the
 * common case regressed to serve a zone nobody on this roster uses.
 *
 * NEITHER EXTREME IS RIGHT. `nowrap` pushes a long zone off the edge; `anywhere`
 * chops a short one in half. `overflow-wrap: normal` breaks at the slashes and
 * nowhere else, so `America/New_York` sits on one line in a column now wide
 * enough for it, and `America/Argentina/Buenos_Aires` wraps into readable parts.
 * This is the one place either column's width or wrapping is set. */
.roster__table td:last-child { white-space: normal; }
.roster__table th:nth-last-child(1), .roster__table td:nth-last-child(1),
.roster__table th:nth-last-child(2), .roster__table td:nth-last-child(2),
.roster__table:has(.rowdel__cell) th:nth-last-child(3),
.roster__table:has(.rowdel__cell) td:nth-last-child(3) {
  overflow-wrap: normal;
  word-break: normal;
}

/* A pasted shopping link is one token with no break opportunity, so it sat 398px
 * wide in a 157px cell and the code the buyer needs was the half off-screen.
 * `anywhere` breaks it; `.mono` keeps it readable as a code. */
.hits td a, .hits td .mono, .hits td span,
table.grid td a, table.grid td .mono { overflow-wrap: anywhere; min-width: 0; }

/* `.rowedit` wraps the input in a catalogue cell and had no width of its own, so it
 * sized to the input's intrinsic width and pushed the cell to 398px inside a 157px
 * column — the product code the buyer needs was the half off-screen. */
.hits td > .rowedit { display: block; min-width: 0; max-width: 100%; }
.hits td > .rowedit > input { width: 100%; box-sizing: border-box; min-width: 0; }

/* `white-space` inherits, and the catalogue cell was picking up `nowrap` from an
 * ancestor, so its link and chip could not fall to a second line. Stated on the
 * cell itself, where inheritance cannot reach past it. */
table.hits tbody td { white-space: normal; }

/* ─────────────────────────────────────────────────────────────────────────
 * A GRID GETS THE SCREEN; PROSE KEEPS ITS MEASURE. Ruled 2026-08-09.
 * `main { max-width: 1100px }` is right for a page of sentences and wrong for
 * 618 rows: on a 2560px monitor the catalogue used 45% of the width and the
 * columns that clipped were clipping against empty space.
 *
 * The cap moves onto the TEXT rather than the page. A table page runs to the
 * window; a paragraph still stops at a readable line length wherever it sits.
 * ───────────────────────────────────────────────────────────────────────── */
main:has(table) {
  max-width: none;
  /* Not edge-to-edge: a value hard against the bezel is harder to read than one
   * with a margin, and the sticky save bar needs the same gutter. */
  padding-inline: clamp(var(--space-4), 3vw, 4rem);
}

/* Prose inside a table page keeps its own measure, since `main` no longer sets one. */
main:has(table) > p,
main:has(table) .lede,
main:has(table) .table-note,
main:has(table) .notice p { max-width: 74ch; }

/* The table itself fills whatever it is given. `min-width` stays: below that the
 * wrapper scrolls rather than the columns crushing. */
main:has(table) table.grid,
main:has(table) .hits,
main:has(table) .roster__table { width: 100%; }

/* THE LAST TWO ROSTER COLUMNS, SIZED TO WHAT THEY HOLD.
 * `America/New_York` is sixteen characters. At 1280px an even split gave every
 * column 136px, the timezone wrapped onto a second line on every row, and 169
 * cells were taller than one line — which is what "the last two columns are
 * completely messed up" looks like.
 *
 * Counted from the END, not the start. Counting from the start is what broke this
 * table the first time: widths pinned to nth-child(3)-(7) for a seven-column
 * roster, then two columns were added and the percentages landed on the wrong
 * ones. Columns get appended to the right of this table, so the last two keep
 * their identity when a tenth arrives. */
@media (min-width: 701px) {
  /* 21ch holds `America/New_York` plus padding on one line; 16ch holds the
   * `Can sign in` header on one line. Measured against the strings actually on
   * this roster, not guessed -- 17ch and 13ch were the guess, and both wrapped. */
  .roster__table th:nth-last-child(1), .roster__table td:nth-last-child(1) { width: 21ch; }
  .roster__table th:nth-last-child(2), .roster__table td:nth-last-child(2) { width: 16ch; }

  /* AND ONE COLUMN FURTHER ALONG FOR THE PI, who is drawn a Remove column that
   * nobody else is. Counting from the end is what makes these rules survive a
   * column being appended -- and it does NOT survive a column that is there for
   * one reader and absent for another, which is what Remove is. `:has()` asks
   * the table which shape it is rather than guessing: with the Remove cell
   * present, Timezone and Can sign in are one place further from the end, and
   * Remove itself is narrow because it holds one word. */
  .roster__table:has(.rowdel__cell) th:nth-last-child(1),
  .roster__table:has(.rowdel__cell) td:nth-last-child(1) { width: 9ch; }
  .roster__table:has(.rowdel__cell) th:nth-last-child(2),
  .roster__table:has(.rowdel__cell) td:nth-last-child(2) { width: 21ch; }
  .roster__table:has(.rowdel__cell) th:nth-last-child(3),
  .roster__table:has(.rowdel__cell) td:nth-last-child(3) { width: 16ch; }

  /* And below this the table scrolls inside `.table-wrap` rather than squeezing
   * every column past the point of reading. The phone card layout takes over at
   * 700px and is untouched by this. */
  .roster__table { min-width: 78rem; }
}

/* --- THE DELETE BUTTON ON EVERY ROW ------------------------------------
 * `templates/_row_delete.html`. The PI: "Every row in every table must have a
 * delete button." It is a `details` so the destructive control is one step in
 * rather than under the thumb, and this repository has no inline JS to do it
 * another way.
 *
 * SIZED TO SIT IN A TABLE CELL. The summary is the whole affordance when
 * closed, so it carries the tap target; the form only exists once opened, and
 * it is allowed to be wider than its cell because it appears above the rest of
 * the row rather than inside the column's width. */
.rowdel { display: inline-block; }
.rowdel__ask {
  cursor: pointer; color: var(--danger, #d05); font-size: var(--text-sm);
  white-space: nowrap; list-style: none; padding: 2px 4px; border-radius: 4px;
}
.rowdel__ask::-webkit-details-marker { display: none; }
.rowdel__ask:hover { text-decoration: underline; }
.rowdel__ask:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.rowdel[open] > .rowdel__ask { font-weight: 600; }
/* OPENS LEFTWARD FROM THE RIGHT EDGE OF ITS CELL, and that is the whole fix.
 * The PI: "the pop up delete confirmation dialogue extends to the right of the
 * edge of the screen, forcing the user to scroll to the right to confirm."
 * Anchored at `left` (the default) it grew away from the cell, and the cell it
 * grows from is the LAST column on every one of these tables — so it grew off
 * the page every time. `right: 0` pins its right edge to the cell's and it
 * expands back across the table, where there is always room.
 *
 * AND IT WRAPS. `white-space: nowrap` made it one long line whose width was the
 * length of the person's name, so a long name reproduced the same overflow from
 * a different direction. `max-width` is the smaller of a comfortable box and the
 * viewport itself, so it can never be wider than the window however it is
 * reached. */
.rowdel__form {
  position: absolute; right: 0; z-index: 5; margin-top: 4px;
  padding: var(--space-2, .5rem);
  max-width: min(24rem, calc(100vw - 2rem));
  background: var(--surface); border: 1px solid var(--danger, #d05);
  border-radius: 6px; box-shadow: 0 4px 14px rgb(0 0 0 / 28%);
  display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;
  white-space: normal;
}
/* The phone card layout turns cells into blocks, so there is no column to open
 * back across — it becomes an ordinary block under the button. */
@media (max-width: 700px) {
  .rowdel__form { position: static; max-width: none; margin-top: .35rem; }
}
.rowdel__warn { font-size: var(--text-sm); }
.rowdel__go {
  background: var(--danger, #d05); color: #fff; border: 0; border-radius: 4px;
  padding: 4px 10px; cursor: pointer; font: inherit; font-size: var(--text-sm);
}
/* The cell holding one of these has to be the positioning context, or the open
 * panel lands relative to the page and appears at the top of the table. */
td:has(> .rowdel), th:has(> .rowdel) { position: relative; }

/* THE REFUSAL, BESIDE THE ROW IT IS ABOUT. Wraps rather than stretching the
 * column: it is a sentence, and the cell it sits in is as wide as the word
 * "Delete". */
.rowdel__why {
  margin: 0 0 .35rem; padding: .3rem .45rem; max-width: 34ch;
  font-size: var(--text-sm); line-height: 1.35; white-space: normal;
  color: var(--ink); background: color-mix(in srgb, var(--danger, #d05) 12%, transparent);
  border-left: 3px solid var(--danger, #d05); border-radius: 3px;
}
