/* ExfilSense blueprint design system.
 *
 * Extracted from web/design/blueprint-reference.html. Two deliberate departures
 * from that file: fonts and Bootstrap are served locally rather than from a CDN
 * (the project runs in environments without internet, and every generated report
 * already has zero external requests), and the inline styles are classes here.
 *
 * Load order: vendor/bootstrap/bootstrap.min.css, then this file.
 */

/* ---------------------------------------------------------------- fonts --- */
/* Variable font: one file covers 400 through 900, which is what the display
 * weights need (400/500/700/900). */
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('../vendor/fonts/archivo-latin-wght-normal.woff2') format('woff2-variations');
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../vendor/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../vendor/fonts/ibm-plex-mono-latin-500-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../vendor/fonts/ibm-plex-mono-latin-600-normal.woff2') format('woff2');
}

/* --------------------------------------------------------------- tokens --- */
:root {
  --paper: #0b1620;
  --ink:   #dff0f5;
  --dim:   #6d8a97;
  --line:  #17313f;
  --grid:  #102734;
  --cyan:  #4fd6e8;
  --amber: #f2b544;
  --rose:  #f2726f;
  --mint:  #6fe3a8;

  /* The paper at nine tenths: what a pinned fixture has to be painted with, so
   * the page's own text cannot scroll through it while the grid behind still
   * reads. The header carried this literal already; naming it is what keeps the
   * bar and the trail below it the same colour by construction, not by care. */
  --paper-veil: rgba(11, 22, 32, .9);

  /* ------------------------------------------------------------ type scale --
   * Nine steps, each with a job. The sizes are not new - they are the ones the
   * sheet already used most - but naming them is what stops a tenth appearing:
   * an audit across eight pages found nineteen distinct rendered sizes, five of
   * which sat within half a pixel of a neighbour (10.5, 11.48, 12.04, 12.25,
   * 13.5) and accounted for fourteen uses between them. Nobody can see half a
   * pixel; what it does is stop the page looking settled.
   *
   * --fs-prose is deliberately half a pixel above --fs-base rather than equal to
   * it: it is the only step set in a proportional face, and Archivo at 14px reads
   * smaller than IBM Plex Mono at 14px. Matching the numbers would mismatch the
   * appearance.
   *
   * --fs-lead is the one step added after the audit, and it is a rank the scale did
   * not have: everything between body and a section heading was a nine-point gap
   * with nothing in it. 17px was free - the sheet's only remaining literal, at
   * .markdown h3 - but that is a heading rank inside prose, and handing it to a row
   * of links would repeat the mistake --fs-prose exists to avoid: one number doing
   * two jobs because the number happened to be spare.
   *
   * It arrived for the nav and now carries the lede and the table body too, which is
   * why it is not named for any one of them. Those three are what a reader is on the
   * page for; --fs-small is what names them. Holding a section page to those two
   * steps plus the title is what stops it reading as four faint gradations of the
   * same thing. */
  --fs-label: 10px;    /* uppercase, tracked: .spec, .tag, dl.meta dt          */
  --fs-micro: 11.5px;  /* the contents rail's own heading                        */
  --fs-small: 12.5px;  /* what names things: column headings, badges, plate       */
                       /* labels, code, buttons, legends                         */
  --fs-data: 13px;     /* form fields, crumbs, markdown h4 - a column of figures  */
  --fs-base: 14px;     /* body, monospaced                                      */
  --fs-prose: 14.5px;  /* body, proportional (thesis, generated prose)          */
  --fs-lead: 17.6px;   /* what a page is read for: nav and table data            */
  --fs-opening: 19.2px;/* a page's opening statement, and nothing else           */
  --fs-h2: 22px;       /* section heading inside a document or a plate          */
  --fs-title: 32px;    /* the title of an interior page - section or document    */

  /* -------------------------------------------------------- spacing ladder --
   * Multiples of 7, because 28px is the grid drawn on the page background and a
   * block edge that lands on a quarter of it looks placed rather than nudged.
   * Before this the gaps between top-level blocks measured 0, 8, 10, 16, 18, 22
   * and 24 - seven intervals with no relation to each other or to the grid. */
  --sp-1: 4px;    /* hairline: inside a component            */
  --sp-2: 7px;    /* between a label and what it labels      */
  --sp-3: 14px;   /* between related blocks                  */
  --sp-4: 21px;   /* between blocks                          */
  --sp-5: 28px;   /* between sections - one grid square      */
  --sp-6: 42px;   /* between a section and the next subject  */

  /* The interface has one theme. Declaring it keeps scrollbars, <select>,
   * autofill and the built-in PDF viewer from rendering light on a dark page. */
  color-scheme: dark;
}

/* ----------------------------------------------------------------- base --- */
body {
  background:
    linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 28px 28px,
    linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 28px 28px,
    var(--paper);
  color: var(--ink);
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: var(--fs-base);
}
a { color: var(--cyan); text-decoration: none; }
/* Bootstrap sets `code` to .875em, which makes its size a function of wherever it
 * lands: 10.5px inside a legend, 12.25px inside a lede. Two of the sheet's stray
 * sizes were that rule, not a decision. One step, everywhere. */
code { font-size: var(--fs-small); }

/* One spacing system, not two. The templates lean on Bootstrap's spacers, whose
 * steps (8, 16, 24px) are a different ladder from the one the drawn grid implies,
 * so the intervals between top-level blocks came out 8, 16, 21 and 24 on the same
 * page. Rewriting ~85 utility classes across ten templates would be a large edit
 * for a small idea; remapping the four steps actually in use onto the rungs they
 * were reaching for is the same result in five lines. The class names stay, so the
 * markup keeps reading the way it did - `mb-0` still means "no gap here".
 *
 * The !important matches Bootstrap's own: without it these lose to the utilities
 * they are overriding, whatever the source order. */
.mt-2 { margin-top: var(--sp-2) !important; }
.mt-3 { margin-top: var(--sp-3) !important; }
.mt-4 { margin-top: var(--sp-4) !important; }
.mb-3 { margin-bottom: var(--sp-3) !important; }
.mb-4 { margin-bottom: var(--sp-4) !important; }
.p-4  { padding: var(--sp-4) !important; }
.py-4 { padding-block: var(--sp-5) !important; }
a:hover { text-decoration: underline; }

/* Focus has to survive on a dark ground; the Bootstrap default is too faint. */
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }

.display-face { font-family: Archivo, sans-serif; }
.dim { color: var(--dim); }
/* The measure is named because two rules need it: the wrap that enforces it, and
 * the offset below that divides what is left over. A second literal 1200 would be
 * a constant that has to be changed in two places to stay true.
 *
 * It grows on a large display rather than staying at 1200px. Fixed, it used 46.9%
 * of a 2560px screen and left 680px empty on each side - a 27-inch monitor showed
 * exactly what a 15-inch one did, with the difference spent on nothing.
 *
 * All three stops are one tenth larger than they were - 1200/62%/1720 became
 * 1320/68.2%/1892 - and scaling them together is what keeps the curve the shape it
 * was rather than merely wider. The crossover is the proof: the floor gave way to
 * the percentage at 1200/0.62 = 1935px before, and at 1320/0.682 = 1935px now. Same
 * window, same behaviour, ten percent more of it.
 *
 * The ceiling still stops an ultrawide from stretching a table across a metre of
 * glass; it just does so 172px later. */
:root { --measure: clamp(1320px, 68.2%, 1892px); }
.wrap { max-width: var(--measure); margin: 0 auto; padding: 0 24px; }

/* Half-left, and wider for it. Centring at the measure leaves a quarter of the
 * window empty on each side; this takes a quarter of that leftover as the inset
 * and gives the rest to the sheet, so the left edge lands halfway to the window
 * and the right edge moves out by the same amount rather than the content
 * staying 1200px wide and merely sliding across.
 *
 * Both insets are the same share, so the block is still symmetrical — it is the
 * measure that has been dropped, not the balance. Expressed as a fraction of the
 * window, so it closes on its own as the window narrows, and max() holds it at
 * zero below the measure itself, where there is nothing left over to divide. */
@media (min-width: 992px) {
  .page-left > .wrap {
    max-width: none;
    margin-inline: max(0px, calc((100% - var(--measure)) / 4));
  }
}

/* A document is read as one wide sheet rather than a column: the contents rail
 * against the left edge, and the notices, the heading and the breadcrumbs
 * spanning the whole of it. Anything left at the centred measure would read as a
 * second, narrower column that happens to share the page — which is the state
 * this rule exists to remove. Templates opt in with `page-full` on <body>.
 *
 * The child combinator is the whole point: the page's own wrap is a direct child
 * of <body>, while the header's sits inside <header>, so the bar is left exactly
 * where it is. It has to be — the bar carries the site
 * mark and the navigation, and a mark that moves sideways when you open a
 * document reads as the page having jumped rather than the content having
 * widened. The frame is the one fixed thing on the site.
 *
 * The inset is a share of the window, not a constant: the same 28px is a wide
 * margin on a 1024 screen and a hairline on a 2560 one. The bounds are what make
 * the percentage usable rather than merely proportional — under 1rem the plate
 * borders sit on the glass, over 2.5rem the sheet drifts back towards the centred
 * measure it just left. */
@media (min-width: 992px) {
  .page-full > .wrap { max-width: none; padding-inline: clamp(1rem, 1.5%, 2.5rem); }
}

/* The home headline is set as one unbroken line, and that is what fixes the
 * scale rather than taste. The rate was derived at weight 900, where the string
 * measured 16.84 times its own font size and 4.8vw was the largest rate that still
 * left the line whole at 360px — the narrowest width this site is checked at. At
 * 575, and with the wider word gaps below already counted in, the same string
 * measures 14.25, so 4.8vw is no longer the largest rate but a conservative one:
 * the line clears 360px with 86px to spare. The rate was left
 * where it was on purpose. The weight was asked to come down; the display size was
 * not asked to come up behind it, and a headline that grew every time it got
 * lighter would be answering a question nobody asked. The 46px ceiling is where the
 * growth stops, because past 1200px the column stops growing too.
 *
 * The floor is honest about its cost: holding one line on a 360px phone means a
 * headline of about 17px. A wrap would read better there; a broken promise about
 * the line would not. */
/* The word gap is set from the gap that is actually rendered, not from the space
 * glyph: letter-spacing lands on the space like on any other character, so at
 * -.045em the real gap is .1563em rather than the face's own .2em. 15% of the real
 * gap is .0234em, and stating it in em is what keeps the ratio true at every size
 * the clamp resolves to — a fixed px gap would be 15% at 46px and 43% at 16px. */
h1 {
  font-family: Archivo, sans-serif; font-weight: 575;
  font-size: clamp(16px, 4.8vw, 46px); line-height: .98;
  letter-spacing: -.045em; word-spacing: .0234em;
  margin: var(--sp-5) 0 var(--sp-3);
}
h1 em { font-style: normal; color: var(--cyan); }
/* One size for this rank. It was 24 here, 22 in a document and 20 in the thesis -
 * the same heading three sizes, depending only on which page you were reading. */
h2 {
  font-family: Archivo, sans-serif; font-weight: 700;
  font-size: var(--fs-h2); letter-spacing: -.03em; margin: 0 0 var(--sp-2);
}

/* --------------------------------------------------------------- header --- */
/* The reference mock-up fixed the header at 60px and let the nav sit on one
 * line. That holds down to about 583px and then MODEL REPORTS wraps inside a
 * box too short for two lines, and the labels collide. The fix is to stop
 * fixing the height: the bar keeps its one-line look wherever the nav fits, and
 * below that the nav drops to its own full-width row. No burger, no hidden
 * items, no JavaScript - and every section stays one tap away.
 *
 * 84px is three squares of the 28px grid drawn on the page background, so the
 * bar's bottom rule lands on a grid line rather than 4px short of one. The
 * mock-up's 60px was a round number rather than a measurement, and at 2560 it
 * read as a strip laid over the page instead of the top of it.
 *
 * --header-h is what scroll-margin-top reads, so in-page anchors clear the
 * sticky bar at either height. */
:root { --header-h: 84px; }

header {
  border-bottom: 1px solid var(--line);
  background: var(--paper-veil);
  backdrop-filter: blur(8px);
  position: sticky; top: 0; z-index: 40;
}
/* The mark and the nav are one centred group. Nothing is pushed to either end of
 * the bar any more — the build stamp that used to hold the right edge moved to the
 * footer and went with it when the footer was dropped, and with it gone an
 * edge-aligned pair would read as two objects that happen to share a row rather
 * than one masthead. */
header .wrap {
  display: flex; align-items: center; justify-content: center; gap: 28px;
  min-height: var(--header-h); flex-wrap: wrap;
}
/* The mark takes the h2 step rather than a size of its own: 20px was the sheet's
 * last stray heading-rank literal, and at the taller bar it was undersized
 * anyway. The 900 is the logotype and stays where it is — the mark grows by
 * size, not by weight, and the nav below carries no weight at all. */
.mark {
  font-family: Archivo, sans-serif; font-weight: 900;
  letter-spacing: -.04em; font-size: var(--fs-h2);
  color: var(--ink); text-decoration: none;
}
.mark:hover { text-decoration: none; }
.mark span { color: var(--cyan); }

/* No flex-grow: the nav sizes to its labels so that centring the row centres the
 * masthead. A growing nav would absorb the slack and leave the mark at the left
 * edge with nothing to balance it. */
.site-nav { display: flex; gap: 24px; }
/* The nav has its own step rather than borrowing the body one. It is the only text
 * in the bar besides the mark, so reading it as an aside to something was reading
 * it as an aside to nothing, and at body size it read as a caption to the mark
 * instead of the other half of the masthead. Weight stays normal: the tracking and
 * the caps already separate these four words from prose, and bold on top of both
 * would make a heading out of a row of links. */
header a.n { color: var(--dim); font-size: var(--fs-lead); letter-spacing: .05em; white-space: nowrap; }
header a.n:hover, header a.n[aria-current="page"] { color: var(--ink); text-decoration: none; }
header a.n[aria-current="page"] { border-bottom: 1px solid var(--cyan); }

/* Two labels per item, one shown at a time: DOCUMENTATION on a desktop,
 * DOCS where the full word would push the row over. */
.nav-short { display: none; }

/* Below the desktop the four full labels still fit, but only on a tighter gap. */
@media (max-width: 991.98px) {
  .site-nav { gap: 20px; }
}

@media (max-width: 767.98px) {
  /* Two rows, so unlike the desktop bar this height is content plus padding, not a
   * floor the content sits inside — which means the token has to equal what the box
   * measures. It did not: 96px was declared against a box measuring 110.25, so the
   * sticky crumbs parked 14px above the bar's bottom edge and the content ran under
   * it. Bringing the three spacing gaps from 10px onto the ladder's 7px puts the
   * taller type's content at 107.25, and 112px - four grid squares - is the first
   * step clear of it. Net cost against the bar that was really on screen: 1.75px. */
  :root { --header-h: 112px; }
  header .wrap {
    padding-top: var(--sp-2); padding-bottom: var(--sp-2); gap: var(--sp-2) 18px;
  }
  /* The nav has its own full-width row here, so centring the bar does not centre
   * it — it needs its own. `safe` is load-bearing: plain `center` on a scroller
   * pushes the overflow off both ends and the first item becomes unreachable,
   * whereas `safe center` drops back to start alignment the moment the row is
   * wider than the box. Browsers that do not know the keyword discard the
   * declaration and get that same start alignment. */
  .site-nav {
    order: 3; width: 100%; gap: 18px; overflow-x: auto; padding-bottom: 2px;
    justify-content: safe center;
  }
  .site-nav::-webkit-scrollbar { height: 0; }
  .nav-full { display: none; }
  .nav-short { display: inline; }
}

/* ---------------------------------------------------------- skip / crumbs --- */
/* Keyboard users reach the content without tabbing the whole header. */
.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 60;
  background: var(--paper); border: 1px solid var(--cyan);
  color: var(--cyan); padding: 8px 14px; font-size: var(--fs-small);
}
.skip-link:focus { left: 8px; top: 8px; }

/* The trail is pinned under the bar, centred, and set one step below the nav.
 *
 * 56px is two squares of the 28px grid, so the strip's rule lands on a drawn line
 * the way the bar's does. Pinned, the two stack to exactly five squares: the strip
 * tops out at the bar's 84 and paints over the bar's own hairline, so its rule
 * falls at 140 rather than the 141 the two boxes measure in flow. It is also
 * the bar's own proportion: 84px of bar around a 22px mark is 3.8 to 1, and 56
 * around 14 is 4.0, so the strip carries the same air rather than looking pinched
 * under something roomier.
 *
 * The type is --fs-base, one step under the nav's --fs-lead, and that order is the
 * point: the bar names the site's sections, the trail names where you got to
 * inside one. Matching them would make the trail a second navigation instead of a
 * subordinate line.
 *
 * Pinning it is cheap in a way that pinning a masthead is not: the strip costs
 * 56px of viewport, and what it buys is the answer to "where am I" being true
 * halfway down a long document rather than only at the top of it. It costs that
 * only on the desktop — below 768px, and on any screen under 460px tall, the strip
 * is unpinned further down this file and stops reserving anything at all.
 *
 * The ground and the rule are structural. A pinned strip over a transparent
 * background has the page's own text sliding through its letters — so it is
 * painted with the paper it sits on, in the same veil as the bar above.
 *
 * Its height is named because two other rules have to clear it: the sticky
 * contents rail on document pages, and the anchor offset that stops a heading
 * from landing behind the chrome. Three copies of one number are three chances
 * for it to stop being the same number. */
:root { --crumbs-h: 56px; }

.crumbs {
  position: sticky; top: var(--header-h); z-index: 35;
  display: flex; align-items: center; min-height: var(--crumbs-h);
  background: var(--paper-veil); backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--line);
  margin-bottom: var(--sp-4);
}
.crumbs ol {
  list-style: none; display: flex; flex-wrap: wrap; gap: 8px;
  justify-content: center; width: 100%;
  margin: 0; padding: var(--sp-1) 0;
  font-size: var(--fs-base); letter-spacing: .12em; text-transform: uppercase;
  color: var(--dim);
}
.crumbs li + li::before { content: "/"; margin-right: 8px; color: var(--line); }
.crumbs li[aria-current="page"] { color: var(--ink); }

/* --------------------------------------------------------------- prose --- */
/* The lede is a paragraph doing a block's job: it ends a page's opening and the
 * next thing after it is a plate. Left with the 1rem a paragraph inherits, it was
 * the one interval on the site still off the ladder - 16px where everything around
 * it measured 21. Prose paragraphs keep their inherited margin, because between two
 * paragraphs 16px is a text interval and not a block one. */
/* The opening statement has a step of its own, one nobody else uses. It was set at
 * the reading step, which put it level with a table cell, and before that at body
 * size — the page's first sentence at the size of the smallest thing on it, which
 * on a section page read as a caption that had drifted up from somewhere.
 *
 * 19.2px is not a step the scale had: it is 16 × 1.2. It clears its neighbours by
 * 3.2px below and 2.8px above, so it is a rank rather than the half-pixel twin the
 * audit spent a pass removing.
 *
 * 76ch is measured, not chosen. The longest lede on the site is /models at 152
 * characters, and 76 is the exact width at which it stops being three lines: at 75
 * it wraps, at 76 it does not. Stating the measure in characters is what makes that
 * survive a change of type size — 62 characters was a reading measure and so is
 * this, just one sized to the sentence that has to fit in two lines.
 *
 * The padding is a tenth of the block it pads: two lines at line-height 1.5 are 3em
 * tall, and .3em is 10% of that. In em rather than px so the ratio holds at any
 * size, and as padding rather than margin because it belongs to the paragraph — on
 * a bare paragraph the two look identical, but only one of them still would if this
 * block ever took a ground. */
.lede {
  max-width: 76ch;
  font-size: var(--fs-opening);
  padding-block: .3em;
  margin-bottom: var(--sp-4);
}

/* The opening of a page: the section number, the title, and the sentence that ends
 * it. `.hero` is the home page's, `.opening` is every section's, and they are one
 * rule because they are one object — a masthead that centres on the home page and
 * hugs the left on the next section would read as the page having slipped rather
 * than as two different layouts.
 *
 * Alignment sits on the wrapper so the lines centre as one object rather than three
 * separately. The lede needs the auto margins as well as the inherited text-align,
 * because a capped measure would otherwise be a left-hugging box with centred text
 * inside it — which reads as a mistake rather than as a decision.
 *
 * Only the opening is centred. Everything below it is a plate, and a plate's
 * contents stay left: a table centred inside its panel would break the one column
 * of figures the eye reads down. */
.hero, .opening { text-align: center; }
.hero .lede, .opening .lede { margin-inline: auto; }

/* Centring is a masthead's setting, not a paragraph's. It holds while the lede is
 * the two lines it is measured for; on a phone the same sentence runs to six, and a
 * six-line block centred is ragged on both edges — measured at 375px, the lines ran
 * from 35px to 334px inside a 347px column, a 299px spread with no straight edge to
 * return to. The heading above it keeps the centre, because one or two lines is what
 * centring is for.
 *
 * Below the rule it overrides and carrying the same specificity, so position in the
 * file is the mechanism - the third time in this sheet that has been the fix. */
@media (max-width: 767.98px) {
  .hero .lede, .opening .lede { text-align: left; margin-inline: 0; }
}
.plain-list { list-style: none; padding: 0; }
.plain-list li { padding: 6px 0; border-bottom: 1px dotted #142935; }

/* A step down from the display size the home page uses, and two steps below what
 * this was: these headings now run as one line rather than two, and at the old
 * scale a single line read as the loudest thing on a page whose subject is the
 * material below it. All three numbers come down together so the curve keeps its
 * shape instead of flattening into a plateau at the middle widths.
 *
 * The floor is what the one-line promise costs, and it does not buy the promise
 * outright. "The set, by what you are doing" measures 14.54 times its font size and
 * clears the narrowest column easily — but it is not the longest heading wearing
 * this class, which is what the note here used to claim. "Five versions, one frozen
 * threshold each" on /models measures 16.67 at the present weight and word gap,
 * wants 333px at the 20px floor, and so holds down to 362px and breaks below it.
 *
 * That is the last 2px of a promise rather than the whole of it. At 900 the heading
 * wrapped below 417px, so the two rounds of lightening bought 55px and with them
 * every 375 and 412 phone. The last 2px are the word gap's doing and worth naming:
 * at 575 alone the string wants 331px and would clear 360 by a hair, and widening
 * the gaps by 15% put it back over. That is the trade as made — the gap was asked
 * for, 360px was not, and no phone in the checked range sits between them. Closing
 * it properly still means dropping the floor to about 16px, which would set section
 * headings smaller than the body they head, and that stays open rather than taken.
 * Every other heading sharing the class is shorter than the /models one, so all of
 * them are whole wherever it is. */
.section-title {
  font-size: clamp(20px, 3.4vw, var(--fs-title));
  margin: var(--sp-4) 0 var(--sp-2);
}

/* Label/value pairs: the Scope and review-date block above a document, and the
 * key/value panels beside a model report. */
dl.meta { display: grid; grid-template-columns: minmax(0, 22ch) 1fr; gap: 6px 18px; margin: 0; }
dl.meta dt {
  font-size: var(--fs-label); letter-spacing: .16em; text-transform: uppercase;
  color: var(--dim); font-weight: 500; padding-top: 2px;
}
dl.meta dd { margin: 0; }
@media (max-width: 575.98px) {
  dl.meta { grid-template-columns: 1fr; gap: 2px; }
  dl.meta dd { margin-bottom: 10px; }
}

/* ------------------------------------------------------- document layout --- */
/* Contents rail first, text taking everything left over. The markup keeps the
 * article before the aside — reading order and the tab order follow the text,
 * not the navigation — and `order` moves the rail into the first column
 * visually. On one column that same `order` puts it above the text, which is
 * where a collapsed rail belongs. */
/* Both track sizes and the gutter are shares of the grid's own width, so the
 * layout breathes with the window instead of holding one measurement through
 * every screen it lands on. The rail is bounded at both ends because its width is
 * really a property of its labels: below 16rem the chapter titles shred into
 * three lines, and past 22rem it is empty space wearing a border. */
.doc-layout {
  display: grid;
  grid-template-columns: clamp(16rem, 18%, 22rem) minmax(0, 1fr);
  gap: clamp(1.5rem, 2.8%, 3.5rem);
}
.doc-body { min-width: 0; }
/* Scaled down by the same factor across all three stops rather than capped at the
 * top: a ceiling pulled on its own leaves the rate and the floor where they were,
 * so the middle widths keep the old size and the curve flattens into a plateau
 * before the drop. Multiplying the whole clamp keeps its shape, which is what
 * makes the reduction read as one size change instead of three.
 *
 * The factor is the one the rest of the scale took (~0.77), so this stays just
 * under .section-title exactly as it did before — 32 to its 34, where it was 42 to
 * its 44. A document title is the page's own name, not the section's. */
/* Same step as .section-title. They never share a page, so the 34-versus-32 they
 * carried before was two pixels nobody could see and one more number to keep in
 * step - the same flaw as 12 versus 12.5, one tier up. */
.doc-title { font-size: clamp(20px, 3.4vw, var(--fs-title)); margin: var(--sp-3) 0 var(--sp-4); }

/* The rail is read from the corner of the eye, while attention is on the text,
 * so it is sized to be legible from there: entries at the body size against
 * --ink rather than under it against --dim. `.spec` sets its own 10px for the
 * labels it marks elsewhere, and the heading of the rail is part of the rail. */
.doc-toc { min-width: 0; order: -1; }
/* Clears the bar and the pinned trail beneath it, not just the bar. */
.toc-shell { position: sticky; top: calc(var(--header-h) + var(--crumbs-h) + 18px); }
.toc-shell summary { cursor: pointer; padding: .55em 0; font-size: var(--fs-micro); letter-spacing: .18em; }
.toc-shell ol { list-style: none; margin: .7em 0 0; padding: 0; border-left: 1px solid var(--line); }
/* Sized in em against the body text rather than in px: an entry is meant to read
 * at the same weight as the prose it points into, and the indent is a step off
 * the rail's border, so both are quantities of type. `1em` is the body's own
 * size — the rail used to be set below it, which is why it read as fine print. */
.toc-shell li { padding: .36em 0 .36em 1.05em; font-size: var(--fs-base); line-height: 1.45; }
.toc-shell li.toc-l3 { padding-left: 2.1em; font-size: var(--fs-data); }
.toc-shell a { color: var(--ink); }
.toc-shell li.toc-l3 a { color: var(--dim); }
.toc-shell a:hover { color: var(--cyan); }

/* The rail needs no trick of its own to reach the left edge of the window: on
 * these pages the frame already starts there (`.page-full` above), so the rail
 * is simply the first column of a grid that begins at the edge. */
@media (min-width: 992px) {
  /* The prose keeps a measure of its own, because the wrap no longer bounds it:
   * a full-width page must not stretch a line of text across the window. The cap
   * is in `ch` because a measure is a count of characters and nothing else — tie
   * it to px and it stops being a measure the moment the type changes size. */
  .doc-body { max-width: min(100%, 112ch); }
}

@media (max-width: 991.98px) {
  /* The contents collapse above the text: on a phone a sticky rail would eat a
   * third of the viewport for navigation nobody asked for. */
  .doc-layout { grid-template-columns: minmax(0, 1fr); }
  .toc-shell { position: static; border: 1px solid var(--line); padding: .7em 1em; }
}

/* ------------------------------------------------------- rendered markdown --- */
/* No measure cap here: the text fills the column the rail leaves, and the cap
 * that keeps the line in a readable band lives on .doc-body — one limit, at the
 * level that knows how wide the column actually got. */
.markdown { max-width: none; }
.markdown h2 {
  font-family: Archivo, sans-serif; font-weight: 700; font-size: var(--fs-h2);
  letter-spacing: -.03em; margin: var(--sp-5) 0 var(--sp-3); padding-top: var(--sp-2);
  border-top: 1px solid var(--line);
}
.markdown h3 { font-family: Archivo, sans-serif; font-weight: 700; font-size: 17px; margin: var(--sp-4) 0 var(--sp-2); }
.markdown h4 {
  font-size: var(--fs-data); letter-spacing: .1em; text-transform: uppercase;
  color: var(--dim); margin: var(--sp-3) 0 var(--sp-2);
}
.markdown p, .markdown li { line-height: 1.7; }
.markdown ul, .markdown ol { padding-left: 22px; }
.markdown li { margin: var(--sp-1) 0; }
.markdown hr { border: 0; border-top: 1px solid var(--line); margin: var(--sp-5) 0; }
.markdown blockquote {
  border-left: 2px solid var(--amber); margin: var(--sp-4) 0; padding: 10px 16px;
  background: rgba(242, 181, 68, .05);
}
.markdown blockquote > :last-child { margin-bottom: 0; }
.markdown code {
  font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: var(--fs-small);
  background: rgba(23, 49, 63, .55); padding: 1px 5px; color: var(--ink);
}
/* A wide code block or table scrolls inside itself; the page never does. */
.markdown pre {
  border: 1px solid var(--line); background: rgba(9, 20, 29, .9);
  padding: 14px 16px; overflow-x: auto; margin: var(--sp-3) 0;
}
.markdown pre code { background: none; padding: 0; }
/* Tables keep table layout; the wrapper the renderer puts around them is what
 * scrolls. Giving the table itself display:block would take that layout away. */
.markdown table { margin: var(--sp-3) 0; min-width: 34ch; }
.markdown .table-scroll { margin: var(--sp-3) 0; }
.markdown img { max-width: 100%; height: auto; }

/* codehilite tokens, dark ground. Only the categories this documentation
 * actually produces - bash, python, yaml, json. */
.codehilite .k, .codehilite .kn, .codehilite .kd { color: var(--cyan); }
.codehilite .s, .codehilite .s1, .codehilite .s2, .codehilite .sb { color: var(--mint); }
.codehilite .c, .codehilite .c1, .codehilite .cm { color: var(--dim); font-style: italic; }
.codehilite .m, .codehilite .mi, .codehilite .mf { color: var(--amber); }
.codehilite .nf, .codehilite .nb { color: var(--ink); }
.codehilite .err { color: var(--rose); }

/* ---------------------------------------------------------------- thesis --- */
/* Word output is long-form prose with numbered headings; it reads better with
 * a proportional face than the monospace the rest of the interface uses. */
.thesis-body p, .thesis-body li { font-family: Archivo, sans-serif; font-size: var(--fs-prose); line-height: 1.72; }
.thesis-body h1 { font-size: clamp(26px, 4vw, 38px); margin: var(--sp-2) 0 var(--sp-4); }
.thesis-body h2 { font-size: var(--fs-h2); }
.thesis-body table { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
.thesis-body img { border: 1px solid var(--line); }

.chapter-nav {
  display: flex; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap;
  margin: var(--sp-6) 0 0; padding-top: var(--sp-4); border-top: 1px solid var(--line);
}
.chapter-nav a { text-decoration: none; }

/* ------------------------------------------------------------- doc search --- */
.field {
  width: 100%; background: rgba(9, 20, 29, .9); color: var(--ink);
  border: 1px solid var(--line); border-radius: 0; padding: 9px 12px;
  font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: var(--fs-data);
}
.field:focus { outline: 2px solid var(--cyan); outline-offset: 1px; }
.search-results { list-style: none; margin: 0; padding: 0; }
.search-results li { padding: 10px 0; border-top: 1px dotted #142935; }
.search-results p { margin: var(--sp-1) 0 0; font-size: var(--fs-small); line-height: 1.6; }
.search-results mark { background: rgba(79, 214, 232, .18); color: var(--ink); padding: 0 2px; }

/* ---------------------------------------------------------------- plate --- */
/* The signature component: a bordered panel whose label is notched into the
 * top border, the way a callout sits on an engineering drawing.
 *
 * The label has come up two steps from --fs-label, 10 -> 11.5 -> 12.5, and both
 * moves landed on steps the scale already carried. The second was asked for as
 * +10%, which is 12.65 — but 12.65 sits .15px from --fs-small, and a size that
 * close to a neighbour is the exact thing the type audit went through the sheet to
 * remove. --fs-small gives +8.70% instead: the whole of the increase available
 * here without putting a half-pixel twin back into the scale.
 *
 * `top` moves with every one of these, because line-height is a ratio rather than a
 * length and the label's box tracks the type: 15 -> 17.25 -> 18.75px. Left alone at
 * the -9px this started from, the notch slides down off the line it is cut into —
 * at 12.5px its centre would sit 0.375px below the border instead of 1.5px above.
 * -10.875px is -(18.75/2 + 1.5): the standoff the label was drawn with at 10px,
 * restated against the box it has now. */
.plate {
  border: 1px solid var(--line);
  background: rgba(9, 20, 29, .78);
  position: relative;
  padding: var(--sp-5);
  margin-bottom: var(--sp-4);
}
.plate::before {
  content: attr(data-tag);
  position: absolute; top: -10.875px; left: 18px;
  background: var(--paper); padding: 0 8px;
  font-size: var(--fs-small); letter-spacing: .22em;
  color: var(--cyan); text-transform: uppercase;
}
.plate.warn { border-color: #4a3b16; }
.plate.warn::before { color: var(--amber); }

/* ------------------------------------------------------------- labelling --- */
.spec {
  font-size: var(--fs-label); letter-spacing: .22em;
  text-transform: uppercase; color: var(--dim);
}

/* The standfirst above the home headline. It keeps `.spec` for the treatment and
 * overrides two things: it is centred on the page, and it is set above the label
 * size, because it is read as a line in its own right rather than as a caption on
 * something else. In em, so it stays in proportion to the body text.
 *
 * The text-indent cancels the letter-space trailing the last character. That
 * space is measured into the line, so centring puts the glyphs half of it left of
 * true centre — an amount the eye registers as "not quite centred" without being
 * able to name it. Indenting by the full letter-spacing shifts them back by
 * exactly half. */
.hero-kicker { font-size: var(--fs-small); text-align: center; text-indent: .22em; }
.sepnum {
  font-family: Archivo, sans-serif; font-weight: 900;
  font-size: var(--fs-small); color: var(--cyan); letter-spacing: .2em;
}

/* Status vocabulary. Four states, not three: a simulation is not "implemented",
 * and colouring it mint would put the badge at odds with the manifest's own
 * measurement:false.
 *
 * The badge borders carry the same two encodings as the Fig. 1 outlines, so
 * the drawing and the table read as one language:
 *   colour  - what kind of thing it is;
 *   solid   - it was executed;   dashed - it never ran.
 * Simulation and specified are both amber because both are hedged claims; the
 * stroke separates them, because sim_v2 actually ran and late fusion did not. */
/* The badge sits on the naming step, the same one the column headings take. It was
 * --fs-label, 23% under the row it annotates, which is what made a status column
 * read as a footnote to its own table rather than a reading of it. */
.tag {
  font-size: var(--fs-small); letter-spacing: .14em; text-transform: uppercase;
  border: 1px solid var(--line); padding: 3px 9px;
  display: inline-block; white-space: nowrap;
}
.tag.ok  { color: var(--mint);  border-color: #1d4c37; }  /* implemented     */
.tag.sim { color: var(--amber); border-color: #4a3b16; }  /* simulation, ran */
.tag.sp  { color: var(--amber); border-color: #4a3b16;    /* specified       */
           border-style: dashed; }
.tag.no  { color: var(--rose);  border-color: #4a2321;    /* not implemented */
           border-style: dashed; }

/* --------------------------------------------------------------- tables --- */
/* A table is the substance of most plates on this site, so its data sits on the
 * body step rather than one below it. Four sizes used to stack up inside a single
 * plate — badge 10, column heading 11.5, footnote 12.5, data 13 — four ranks inside
 * a 3px band. A band that narrow is not read as four ranks; it is read as one thing
 * that will not settle, which is exactly how it looked.
 *
 * There are two ranks here in truth: what the table states, and what names it. They
 * are now --fs-lead and --fs-small, and the status badges share the naming step with
 * the column headings because they are the same kind of object — a tracked
 * upper-case label, not a statement.
 *
 * The data went to the reading step rather than the body one for a second reason,
 * which is width. `width: 100%` spreads whatever slack the table has evenly across
 * its columns, and a nine-column table of short figures had 406.6px of it in
 * 1124.7 — 36% air, which is why the numbers drifted apart instead of holding as a
 * block. Column sizing cannot fix that: shrinking the numeric columns to their
 * content was measured, and it moves the slack rather than removing it, opening a
 * 328px canyon after the model name where there had been 178. Larger type is the
 * one lever that closes the gap by putting content in it. */
table { width: 100%; font-size: var(--fs-lead); }
/* Column headings carry more weight than the 10px/500 they had. They are read
 * once, at a glance, to decide which column to follow down — and at the label size
 * against --dim they were quieter than the data they name. A named step below the
 * data step, not an em fraction of it: every size in this sheet is on the scale, and
 * a heading that resized itself with its table would be the one value in the file
 * that could not be looked up. */
th {
  font-size: var(--fs-small); letter-spacing: .16em; text-transform: uppercase;
  color: var(--dim); font-weight: 700; text-align: left;
  padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line);
}
td { padding: 9px 12px 9px 0; border-bottom: 1px dotted #142935; }

/* A table filled its panel, then briefly did not, and now does again. The middle
 * state was `width: auto` on the desk, and it was measured rather than guessed:
 * shrink-to-fit put the nine-column table at 916 and the four-column one at 680
 * inside a 1152 panel, dropping the widest column gap from 52 to 38 and from 154 to
 * 59. It bought that by leaving 178px and 414px of empty panel to the right of each
 * table, and the two no longer ended on the same line.
 *
 * Both readings are defensible and only one of them is the author's, so the ragged
 * right edge is what lost. What remains true is the diagnosis: `width: 100%` spends
 * its surplus on the gaps between columns, so the fewer columns a table has, the
 * further apart they drift. The lever against that is content, not layout — the type
 * above is a step larger than it was for exactly this reason, and column sizing was
 * tried and rejected on measurement (shrinking the numeric columns moved the slack
 * into the model name and opened a 328px canyon where there had been 178). */
/* Metrics are read by comparison down a column, so the digits must line up. */
td.n { text-align: right; font-variant-numeric: tabular-nums; }

/* A leading number column is as wide as its digits and no wider. At `width: 100%`
 * the table shares spare width among its columns, and a right-aligned column
 * spends its share on the left — which surfaces as an indent in front of every
 * row, pushing the whole table away from its own left edge. The 1% is the
 * shrink-to-fit idiom: a width too small to honour, so the column falls back to
 * its minimum content width and the flexible column keeps the remainder.
 *
 * Only above the reflow break. Below it a row is a card and the cells are grids,
 * where a 1% width would collapse the label column instead of a table column. */
@media (min-width: 768px) {
  th:first-child.text-end, td:first-child.n { width: 1%; white-space: nowrap; }
}

/* A metrics table is 8 columns wide; it scrolls inside its own box rather than
 * letting the page scroll sideways. */
.table-scroll { overflow-x: auto; }

/* Below the tablet break a wide table stops being a table. Squeezing eight
 * columns into 360px produces a grid nobody can read; one card per row, with
 * each cell labelled by the header it came from, stays readable and keeps the
 * reading order. The labels come from data-label, which the template fills
 * from the same header text - so a renamed column renames its label too.
 *
 * Only tables marked .reflow do this. A two-column key/value table is already
 * narrow and would only get taller. */
/* ------------------------------------------------------------------ phones --- */
/* The listed device resolutions are physical; CSS sees them divided by the pixel
 * ratio, so 1440x3200 on a flagship is a 360-412px viewport and the whole range -
 * WVGA through 4K Xperia - lands between 320 and 432 CSS pixels. 320 is the floor
 * that had never been checked, and it is where the numbers went wrong:
 *
 *   - 48px of wrap padding was 15.7% of a 305px viewport, leaving 257px of content;
 *   - the nav row overran its box by 8px, so the last section sat off-screen;
 *   - a reflow card gave its label column 86px and the value 71px - the label was
 *     wider than the data it names.
 *
 * All three are width being spent on furniture at the width where there is least of
 * it. Nothing above 576px is touched.
 *
 * The card-column part of this lives after the reflow rules further down, because it
 * overrides one of them and carries no extra specificity to win on. */
@media (max-width: 575.98px) {
  .wrap { padding: 0 var(--sp-3); }
  .site-nav { gap: 12px; }
}

@media (max-width: 767.98px) {
  table.reflow thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
  table.reflow tr {
    display: block; border: 1px solid var(--line);
    padding: 12px 14px; margin-bottom: 12px;
  }
  table.reflow td {
    display: grid; grid-template-columns: minmax(0, 11ch) 1fr;
    gap: 12px; align-items: baseline;
    padding: 5px 0; border-bottom: none; text-align: left;
  }
  /* Same labels as the column headings above, so they are set the same way: on a
   * phone this ::before is the header, and one label at two sizes reads as two
   * different things. It follows `th` step for step — when the headings moved up to
   * --fs-small this had to move with them, or the same word would be 11.5px on a
   * phone and 12.5px on the desk it was copied from. */
  table.reflow td::before {
    content: attr(data-label);
    font-size: var(--fs-small); letter-spacing: .16em; text-transform: uppercase;
    color: var(--dim); font-weight: 700;
  }
  /* Numbers keep their alignment inside the card so a column of cards still
   * compares down the page. */
  table.reflow td.n { text-align: left; }
}

/* The phone half of the block above, placed here because it overrides the card grid
 * declared just now and has the same specificity - order is the whole mechanism.
 *
 * At 320px the label column took 86px and left the value 71px: the label was wider
 * than the data it names. The labels are a fixed, short vocabulary - "Thr.", "Rows",
 * "Metric", "Size" - so they can hold the tighter column, and the value cannot: it
 * may be a path, a checksum, or an unrounded figure. */
@media (max-width: 575.98px) {
  table.reflow td { grid-template-columns: minmax(0, 8ch) 1fr; gap: 10px; }
}

/* -------------------------------------------------------------- content --- */
.callout {
  border-left: 2px solid var(--amber);
  padding: 12px 16px;
  background: rgba(242, 181, 68, .05);
  font-size: var(--fs-base);
}
.figure {
  border: 1px solid var(--line); height: 200px;
  display: grid; place-items: center;
  color: #2c4655; font-size: var(--fs-label); letter-spacing: .2em;
  background: repeating-linear-gradient(45deg, transparent, transparent 10px,
              rgba(23, 49, 63, .35) 10px, rgba(23, 49, 63, .35) 11px);
}

/* The schematic scales by viewBox and carries no width of its own. Below the
 * tablet break the labels inside it would fall under 8px, so instead of
 * shrinking further it keeps a legible minimum and scrolls inside its box -
 * the same rule the wide tables follow. */
.schematic { width: 100%; height: auto; display: block; }
.figure-scroll { overflow-x: auto; }
@media (max-width: 767.98px) {
  .schematic { min-width: 660px; }
}
.figure-legend { font-size: var(--fs-small); }

/* Images from an artifact never widen the page. */
.plate img { max-width: 100%; height: auto; }
/* Generated prose reads as prose: proportional face, generous leading. */
.ai p {
  font-family: Archivo, sans-serif;
  font-size: var(--fs-prose); line-height: 1.68; color: #c2d8e0;
}
.ai p b { color: var(--ink); }

/* Anchors have to clear everything pinned above the text: the bar, whose height is
 * not one number any more, and the breadcrumb strip pinned under it. Landing a
 * heading behind the chrome is the same defect as not scrolling to it at all. */
section, [id] { scroll-margin-top: calc(var(--header-h) + var(--crumbs-h) + 10px); }

/* On a phone the trail is not pinned, and the offset above stops reserving room
 * for it. Two reasons, and the first is a correctness one: a long trail wraps to
 * two rows at this width, so `--crumbs-h` is no longer its height and an anchor
 * lands behind a strip that is taller than the number says. The second is cost —
 * 56px of permanent chrome over a 112px bar is a fifth of the screen for a line
 * the reader can reach by scrolling up.
 *
 * Both declarations sit here, after the rules they override, because they carry no
 * extra specificity: the same selector later in the file is the whole mechanism.
 * They also sit together because they are one decision — unpinning the strip while
 * still reserving its height is exactly how a heading ends up hidden. */
@media (max-width: 767.98px) {
  .crumbs {
    position: static; background: none; backdrop-filter: none;
    border-bottom: 0; min-height: 0; margin-bottom: 14px;
  }
  section, [id] { scroll-margin-top: calc(var(--header-h) + 10px); }
}

/* A phone held sideways: wide enough for the strip to pin, and 375px tall, where the
 * bar and the strip together took a third of the screen and left 250px for the text.
 * Width was never the constraint here - height was, and no width query can see it.
 *
 * The offset and the contents rail come down with the strip, because they were
 * reserving room for something that is no longer pinned. */
@media (max-height: 460px) {
  .crumbs {
    position: static; background: none; backdrop-filter: none;
    border-bottom: 0; min-height: 0; margin-bottom: var(--sp-3);
  }
  section, [id] { scroll-margin-top: calc(var(--header-h) + 10px); }
  .toc-shell { top: calc(var(--header-h) + 18px); }
}
section { padding-top: var(--sp-3); }

/* No page scrolls sideways. Wide content scrolls inside its own box, and the
 * layout is built so nothing overflows in the first place - this is only a
 * backstop.
 *
 * `clip`, not `hidden`, and not on <html>. `overflow-x: hidden` forces
 * `overflow-y` to compute to `auto` on the same element, which turns <html>
 * into a scroll container: window.scrollTo stops moving the viewport and the
 * sticky header has an ancestor that scrolls independently of the page.
 * `clip` cuts the overflow without creating a scroll container at all. */
body { overflow-x: clip; }

/* -------------------------------------------------------------- buttons --- */
.btn-b, .btn-g {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: var(--fs-small); padding: var(--sp-2) 16px;
  border-radius: 0; background: transparent;
}
.btn-b { border: 1px solid var(--cyan); color: var(--cyan); }
.btn-b:hover { background: var(--cyan); color: #06131b; }
.btn-g { border: 1px solid var(--line); color: var(--dim); }
.btn-g:hover { color: var(--ink); border-color: var(--ink); }

.modal-content {
  background: #0a1620; border: 1px solid var(--line); border-radius: 0;
  color: var(--ink); font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
/* A figure at natural size, or a run's JSON: both can exceed the dialog, and
 * both scroll inside it rather than stretching the page behind. */
.modal-scroll { max-height: 72vh; overflow: auto; margin: 0; }
.modal-scroll img { display: block; max-width: none; }
.modal-scroll pre, pre.modal-scroll {
  font-size: var(--fs-small); line-height: 1.55; white-space: pre; color: var(--ink);
}

/* ------------------------------------------------------------ shap figures --- */
/* The gutter between figures and the gap above the grid were 16 and 12 - close
 * enough to look like one interval and different enough to unsettle it. Both are
 * the same rung now, so a row of plots sits at the same distance from its heading
 * as the plots sit from each other. auto-fill keeps the column count following the
 * plate's width rather than the window's. */
.shap-grid {
  display: grid; gap: var(--sp-3); margin-top: var(--sp-3);
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.shap-figure { margin: 0; }
.shap-open {
  display: block; width: 100%; padding: 0; background: none;
  border: 1px solid var(--line); cursor: zoom-in;
}
.shap-open:hover { border-color: var(--cyan); }
.shap-open img { display: block; width: 100%; height: auto; }
.shap-figure figcaption { margin-top: var(--sp-2); word-break: break-all; }

/* Artifact paths and sample identities are long and have no spaces. */
.break-all { word-break: break-all; }

/* Present to a screen reader, absent from the page. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* ---------------------------------------------------------------- heatmap --- */
/* Magnitude on one hue, light to dark, with the value printed in every cell:
 * nothing on this grid is readable only by shade. The tint is capped at 0.35
 * alpha because that is the strongest step whose composite still leaves the
 * label above 4.5:1 against --ink; a fuller mint would look better and be
 * unreadable.
 *
 * The excluded cells are a *state*, not a small magnitude, so they are off the
 * ramp entirely: hatched, outlined amber, and labelled with the count that
 * excluded them. Colour never carries that distinction alone. */
/* The grid fills its plate instead of stopping where its content happens to end.
 * Sized by content it measured 750px inside a 1094px panel and left 344px empty on
 * the right, which reads as the figure having been cropped rather than sized.
 *
 * The spare width goes to the data cells, not to the row-label column: that one is
 * shrunk to its longest label so the nine cells stay equal to each other, which is
 * the whole point of a matrix. `min-width` on the cells is still the floor, so on a
 * narrow screen the grid overflows into its own scroller exactly as before. */
table.heatmap { width: 100%; border-collapse: separate; border-spacing: 2px; }
table.heatmap thead th:first-child, table.heatmap tbody th { width: 1%; }
table.heatmap th {
  border-bottom: none; padding: 6px 10px; white-space: nowrap;
}
table.heatmap thead th { text-align: right; }
/* No font-size here any more: it said --fs-small back when `th` was --fs-micro, and
 * now that the headings sit on that step it would be the same value written twice. */
table.heatmap tbody th { color: var(--ink); letter-spacing: 0; }
table.heatmap td.cell {
  border: 1px solid var(--line); padding: 8px 10px; min-width: 62px;
  text-align: right; font-variant-numeric: tabular-nums; font-size: var(--fs-small);
  background: rgba(111, 227, 168, var(--fill, 0));
}
table.heatmap td.cell-excluded {
  color: var(--amber); border-color: #4a3b16;
  background:
    repeating-linear-gradient(45deg, transparent, transparent 5px,
      rgba(242, 181, 68, .16) 5px, rgba(242, 181, 68, .16) 6px);
}
table.heatmap td.cell-absent { color: var(--dim); }
table.heatmap td.cell:hover, table.heatmap td.cell:focus-visible { border-color: var(--cyan); }

.scale-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: var(--sp-3); }
.scale-ramp {
  display: inline-block; width: 140px; height: 12px; border: 1px solid var(--line);
  background: linear-gradient(90deg,
    rgba(111, 227, 168, .07), rgba(111, 227, 168, .35));
}
.legend-excluded { display: inline-flex; align-items: center; gap: 6px; }
.swatch-excluded {
  display: inline-block; width: 14px; height: 12px; border: 1px solid #4a3b16;
  background: repeating-linear-gradient(45deg, transparent, transparent 3px,
    rgba(242, 181, 68, .35) 3px, rgba(242, 181, 68, .35) 4px);
}

/* ---------------------------------------------------------------- figures --- */
figure { margin: 0; }
figure img { max-width: 100%; height: auto; border: 1px solid var(--line); display: block; }

/* --------------------------------------------------------------- iframe --- */
/* Reports carry their own stylesheet and do not inherit this palette. Without
 * an explicit ground they open as a white rectangle inside a dark page. */
.report-frame {
  width: 100%; border: 1px solid var(--line); background: var(--paper);
  display: block; min-height: 70vh;
}

/* ---------------------------------------------------------------- touch --- */
/* A finger needs about 44px whatever the screen measures, and a 24px nav link is a
 * coin toss. Gated on the pointer rather than the viewport, so a narrow desktop
 * window keeps the mouse-sized target it was designed with: the cost is a taller
 * bar, and it is only paid where it buys something.
 *
 * Last in the file on purpose. These are overrides of component rules declared
 * above and carry no extra specificity, so position is the whole mechanism - the
 * button kept its 35px until this block moved below the `padding` shorthand that
 * was quietly winning. */
@media (pointer: coarse) {
  header a.n { display: inline-block; padding-block: 11px; }
  .btn-b, .btn-g { padding-block: 12px; }
  .crumbs a { display: inline-block; padding-block: 6px; }
  .toc-shell li a { display: inline-block; padding-block: 4px; }
}

/* ---------------------------------------------------------------- print --- */
/* These pages get printed — the material backs a thesis. Printing the screen
 * palette would put a black rectangle on the paper. */
@media print {
  body { background: #fff; color: #000; }
  header, .btn-b, .btn-g { display: none; }
  /* Paper does not scroll, so the trail has nothing to hold its place against;
   * left pinned it would carry a dark ground and an offset meant to clear a bar
   * that is not printed. */
  .crumbs { position: static; background: none; backdrop-filter: none; }
  .plate { border-color: #999; background: none; break-inside: avoid; }
  .plate::before { color: #000; background: #fff; }
  a { color: #000; text-decoration: underline; }
  .dim, .spec, th { color: #444; }
  /* The rail's own link colours are specific enough to beat the rule above, and
   * both of them are picked for a dark ground — on paper they would print from
   * faint to invisible. */
  .toc-shell a, .toc-shell li.toc-l3 a { color: #000; }
}

@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
