/* johnbrodish.com — page styles.
 * Binding spec: the design language. Tokens live in tokens.css; every
 * colour, family, size, line height and rule in this file names a token.
 *
 * Four things this file is now responsible for holding, all from the spec:
 *
 * 1. THE TYPE ROLE SPLIT (§2.4) MUST NOT LEAK. --apparatus (Schibsted Grotesk)
 *    sets the wordmark, headings, kickers, captions, chips, table headers and
 *    keys. --prose (Literata) sets the lede, body, notes and table cells.
 *    `body` is --prose, so every heading and every piece of apparatus declares
 *    --apparatus explicitly — silence means prose. The single shared
 *    `h1,h2,h3,h4,h5,h6` rule below is what keeps headings out of the serif.
 *
 * 2. THE PEN IS NOT A RULE COLOUR (§2.5). --pen marks up the work: annotation
 *    markers, and — at wash strength, as --pen-soft — the paired annotation
 *    note and the `not measured` chip. Nothing else. It is not a state
 *    colour: the focus ring is ink, because a red ring reads as an error
 *    rather than as a cursor position. See :focus-visible below. Every
 *    rule in this file is the single --rule token declared in tokens.css
 *    (see tokens.css and the design language §2.6). There is no accent colour
 *    either: a blanket accent→pen rename would make every hairline an
 *    annotation, so borders take the rule token and chrome takes an ink
 *    token.
 *
 * 3. THE RIGHT-EDGE SET IS DECLARED, NOT EMERGENT. See the block below.
 *
 * Body copy runs at full --ink and is never greyed (§2.4). Zero border-radius
 * and zero box-shadow anywhere (§2.6).
 *
 * 4. BASE-4, AND ITS DECLARED EXCEPTIONS.
 *
 * The 1px hairline and the 2px focus ring are drawing devices, not
 * measurements — but they are not the only exceptions. Counted honestly they
 * are two of the SIXTEEN authored below, and a seventeenth arrives from the
 * UA stylesheet without being authored anywhere. The values are all
 * defensible; a false CLAIM OF COMPLETENESS is the worse defect, because a
 * false "only" is what stops the next author looking. This list is written
 * to be exhaustive, and is to be re-counted rather than trusted.
 *
 * Scope: §2.6 puts every size, line height, padding and gap on the 4px grid,
 * stepped at 40rem/60rem, never fluid. Everything below is counted on
 * computed values at 375/768/1440, not read off the source.
 *
 * (a) DRAWING DEVICES, not measurements — a rule's weight is how thick the
 *     pen is, not how far apart two things sit:
 *       1px   the single --rule token; .link's text-decoration-thickness
 *       2px   :focus-visible's outline
 *       2px   .progress-bar's height
 *       2px   .annot-mark's ground-coloured outline — the "ground on at least
 *             one side" constraint tokens.css states for the pen
 *
 * (b) COORDINATES, not sizes — parking an element the reader cannot see:
 *       -9999px  .skip-link's left, until it takes focus
 *       1px / -1px  .sr-only, and the .claims-table thead clone below 40rem.
 *             The standard visually-hidden box: a 1px square pulled back by a
 *             -1px margin so it occupies no line.
 *
 * (c) MOTION OFFSETS — distances travelled, not distances measured, and each
 *     is inside a prefers-reduced-motion: no-preference guard or an @supports
 *     fallback, so none of them is ever a static layout value:
 *       1px     .gate-btn:active's translateY
 *       -13px 2px  the .theme-toggle cutout's translate, in the @supports
 *             fallback for browsers that cannot animate the `d` property
 *     No page reveal, so no reveal offset — see the note under the hero.
 *
 * (d) EM-RELATIVE TYPOGRAPHIC VALUES — sized to the type they sit in rather
 *     than to the layout grid, which is why they are not on it. These are the
 *     ones an "only two exceptions" claim silently drops:
 *       0.35em  .case-nav a.link / .entry-cta a.link gap        →  5.6px
 *             AUTHORED BUT NOT RENDERED: both links are single-child (no
 *             arrow glyph — the underline alone carries the affordance), so
 *             this gap has nothing to sit between on any page. Kept because
 *             the inline-flex box it belongs to is what makes padding-block
 *             reach the 44px tap target at all. The same situation, and the
 *             same note, at .back-link in the fun site's styles.css.
 *       0.35em  .facts .sep padding-inline                      →  4.2px  (15×
 *               on the homepage, the largest single population here)
 *       1.1em   .resume-points padding-left     → 17.6px screen / 13.2px print
 *       0.2em   .resume-points li padding-left  →  3.2px screen /  2.4px print
 *             A bullet's hanging indent is sized to its own type; this is
 *             the only bulleted list on the site, so the pair has one home.
 *
 * (e) NOT AUTHORED HERE, but it shows up in any computed sweep and is worth
 *     one line so nobody re-finds it as a defect:
 *       13.3333px  the UA stylesheet's font-size on .theme-toggle, a <button>
 *             with no text in it. fun's toggle carries the identical UA
 *             default; fixing it in one file and not the other would be worse.
 *
 * 5. NO font-variant-numeric: tabular-nums, ANYWHERE.
 *
 * Schibsted Grotesk's tabular figure set re-widths the COMMA and the FULL STOP
 * to the full figure advance.
 *
 * The table below is on ONE measurement basis, stated so it can be re-run
 * (mixing bases — a per-glyph range in one row, a differently tracked
 * reading for the middot — is what makes the period come out narrower than
 * the comma in one place and wider in another):
 *
 *   ADVANCE WIDTH, difference method, both faces awaited via
 *   document.fonts.load(): w(('H'+glyph) × 400) − w('H' × 400), divided by 400,
 *   with font-kerning:none and liga/clig/dlig/calt off. The glyph never sits
 *   next to a copy of itself, so no ellipsis substitution can fire — a naive
 *   '.'×N repeat is the trap there.
 *
 *                    proportional → tabular
 *       12px/400   comma  3.182 →  7.617 (+139.4%)   period  3.363 →  7.617 (+126.5%)
 *       12px/500   comma  3.271 →  7.617 (+132.9%)   period  3.442 →  7.617 (+121.3%)
 *       48px/700   comma 14.039 → 30.469 (+117.0%)   period 14.608 → 30.469 (+108.6%)
 *
 * The period's proportional advance equals the middot's exactly at every size —
 * they share a width class in this face — which is why the two are easy to
 * confuse. The middot is untouched by tabular (3.363 /
 * 3.442 / 14.608, identical both ways) and Literata is untouched at 12px/400
 * (comma 3.936, period 3.996, middot 3.996, all identical both ways), which is
 * why the defect is easy to miss: the separator that appears in nearly every
 * apparatus row looks right. With the 0.08em tracking that rides every
 * uppercase micro-label on top, a comma renders as a space, then a comma.
 * With tabular figures on a type role, the site's own labels render, among
 * others, as:
 *       SENIOR PRODUCT DESIGNER · PORTLAND , OREGON   (first line of copy)
 *       39 , 000 , 000                                 (48px, the stat line)
 *       johnbrodish@gmail . com                        (the 404's only exit)
 *       fun . johnbrodish . com                        (every footer)
 *       Lift-summary redesign ,  Sept 2025             (every SHIPPED row)
 *
 * That is a finding rather than a blanket: tabular figures align COLUMNS of
 * numbers, and neither site has one. The three candidates each do nothing:
 *   .gen-mark  — four single digits stacked on the left axis. Range-measured
 *       ink left edge is 256px for 1/2/3/4 in BOTH modes; tabular only pads
 *       the advance to the right of a glyph whose line ends there anyway.
 *   .annot-mark / .annot-n — one digit centred in a fixed 24px box. Ink
 *       centre offset from the box centre: 0.000px tabular, -0.008px
 *       proportional. Below one device pixel, in both rooms.
 *   .claims-table — no numerals in the source column at all.
 * If a real column of figures is ever added, scope tabular-nums TO THAT
 * COLUMN. Do not put it back on a type role: every role here carries prose
 * punctuation, and this is what that costs.
 *
 * Two families sit OUTSIDE the rule rather than in exception to it, and are
 * listed only so the distinction is on the record: letter-spacing (0.08em on
 * every uppercase micro-label, -0.02em on the display numerals) and
 * text-underline-offset (0.3em resting, 0.42em hover, 0.24em active). Both are
 * type-fitting ratios. A 4px grid has never governed either, and putting them
 * on one would be the grid overreaching into typography. */

* { margin: 0; padding: 0; box-sizing: border-box; }

html { background: var(--ground); overflow-x: clip; }
body { overflow-x: clip; }

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

body {
  font-family: var(--prose);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
  background: var(--ground);
  -webkit-font-smoothing: antialiased;
}

/* Ink-on-paper inversion, not the pen: a selection is the reader's mark, and
   §2.5 reserves the pen for the designer's. 17.49:1 in both polarities. */
::selection { background: var(--ink); color: var(--ground); }

/* ---------- right-edge set ----------
 * §2.6 asks for a single left axis. The right side needs the same discipline,
 * or right edges accumulate one per component: a title at 920 over a note at
 * 880, a section h2 at 717 over its own paragraphs at 720, a meta grid landing
 * on a different edge on every page because it sized to `fit-content`.
 *
 * FOUR EDGES ARE DECLARED. Nothing else may set a max-width.
 *   --page                 the content column — structure, exhibit grids,
 *                          entry rows, nav rows, and display/title headings
 *   --page-exhibit         72rem — the annotated proof only, on the page and
 *                          in the lightbox. It is the one block allowed past
 *                          --page, and the reasoning is at `.annot` and in
 *                          tokens.css. An artifact is examined, not read, so
 *                          the reading column is the wrong cap for it —
 *                          inside --page the signature exhibit runs 895px at
 *                          a 959 viewport and 568px at 1600. Not wider than
 *                          72rem: a larger ceiling reads as jarringly wide.
 *                          The running margin marker (`.when`) does not share
 *                          this edge — see tokens.css and `.when` in this
 *                          file for why.
 *   --measure-apparatus    40rem — apparatus fine print: fact rows, captions,
 *                          colophon, meta grid, section and entry headings.
 *                          ONE NAMED EXCEPTION: .case-meta drops this cap at
 *                          ≥60rem so its cells can run in one row — full
 *                          reasoning at .case-meta below. Every other
 *                          apparatus element this token governs is
 *                          unaffected.
 *   --measure              34rem — all prose: lede, dek, body, notes.
 *                          "notes" includes the annotation notes, which
 *                          uncapped run the full artifact column (895px,
 *                          ~110 CPL, stacked at 959).
 *
 * No `ch` caps. Each is another edge that moves with the type scale and the
 * breakpoint, and `text-wrap: balance` already does the job they would be
 * hired for. A caption inside a bordered exhibit takes the exhibit's own
 * padding as an offset — that is a nested container, not a fourth edge. */

/* ---------- motion system ----------
 * Layer 0 — token crossfade (universal, no JS). The eight polarity colours are
 * registered in tokens.css via @property; transitioning them once here means
 * every consumer (html, body, rules, exhibit grounds, links, ::selection)
 * interpolates in lockstep instead of hard-cutting mid-fade. Works in every
 * browser with @property, so Firefox gets the fix too. Gated on no-preference
 * so a motion-sensitive visitor gets the instant swap, without relying on the
 * reduced-motion clamp further down to catch it.
 *
 * This list must name EVERY registered token or the ones left out hard-cut
 * while the rest fade. The --lb-* tokens are deliberately absent, because the
 * lightbox is fixed in both polarities and must not transition. */
@media (prefers-reduced-motion: no-preference) {
  :root {
    transition:
      --ground var(--dur-theme) var(--ease-cross),
      --ink var(--dur-theme) var(--ease-cross),
      --ink-2 var(--dur-theme) var(--ease-cross),
      --ink-3 var(--dur-theme) var(--ease-cross),
      --pen var(--dur-theme) var(--ease-cross),
      --pen-soft var(--dur-theme) var(--ease-cross),
      --pen-ink var(--dur-theme) var(--ease-cross),
      --hairline var(--dur-theme) var(--ease-cross),
      --exhibit-ground var(--dur-theme) var(--ease-cross);
  }
}

/* Layer 1 — View Transitions (enhancement). Where document.startViewTransition
 * exists, the theme flip composites as one snapshot swap instead of a live
 * repaint of every element. mix-blend-mode: normal is required — the default
 * root crossfade blends with plus-lighter, which flashes bright on a
 * dark-to-light flip. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--dur-page);
  animation-timing-function: var(--ease-cross);
  mix-blend-mode: normal;
}
/* The same root pseudo-elements serve two different events. A cross-document
   navigation (Layer 2, below) is the default and runs at --dur-page; only the
   theme flip, which site.js marks with .vt-theme on the root for its
   duration, takes the longer --dur-theme. Running both at --dur-theme gives
   every page change a 320ms ghost of the previous layout — a visible glitch
   before the page changes. */
:root.vt-theme::view-transition-old(root),
:root.vt-theme::view-transition-new(root) {
  animation-duration: var(--dur-theme);
}

/* Layers 0 and 1 must not both run at once, or the browser does the work
 * twice. site.js adds this class for the duration of a JS-driven view
 * transition (the theme toggle); it disables Layer 0's crossfade without
 * touching the --dur-theme value itself, because the view-transition pseudo
 * tree above inherits custom properties from :root — zeroing --dur-theme
 * instead of the transition would silently zero the crossfade it drives too. */
:root.vt-theme { transition: none; }

/* The title shared elements below carry view-transition-name unconditionally,
 * so a same-document theme toggle would otherwise lift them out of the root
 * snapshot and crossfade them under their own case-N group at --dur-page /
 * --ease-out (the expo curve meant for arrivals) instead of riding the root
 * at --dur-theme / --ease-cross with everything else — the wrong easing, and
 * a property that doesn't ease in lockstep with the rest, on exactly the
 * element that's most visible when it happens. Only the
 * theme toggle sets .vt-theme, so this has no effect on cross-document
 * navigation, where the morph is the point. */
:root.vt-theme #e1-title,
:root.vt-theme #e2-title,
:root.vt-theme #e3-title { view-transition-name: none; }

/* Layer 2 — cross-document navigation. Both documents in a navigation share
 * this stylesheet, so every page on the site opts in together. */
@view-transition { navigation: auto; }

/* Shared element: the homepage entry title and its case page's <h1> are
 * character-for-character identical, so the morph is honest — the same
 * object growing from entry-title scale into page-title scale. Named via the
 * ids already on each element; each id exists on exactly one element per
 * document, so the name stays unique within any single page. Animated on
 * --dur-page/--ease-out: a positional move should arrive decisively, unlike
 * the crossfades above. */
#e1-title { view-transition-name: case-1; }
#e2-title { view-transition-name: case-2; }
#e3-title { view-transition-name: case-3; }
::view-transition-group(case-1),
::view-transition-group(case-2),
::view-transition-group(case-3),
::view-transition-old(case-1),
::view-transition-old(case-2),
::view-transition-old(case-3),
::view-transition-new(case-1),
::view-transition-new(case-2),
::view-transition-new(case-3) {
  animation-duration: var(--dur-page);
  animation-timing-function: var(--ease-out);
}

/* No view transitions for a motion-sensitive visitor — neither the
 * JS-driven theme flip nor a browser-initiated cross-document navigation.
 * !important because the named-group rule above is otherwise more specific
 * than a bare wildcard selector. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

.wrap {
  max-width: var(--page);
  margin-inline: auto;
  padding-inline: var(--pad);
}

/* ---------- accessibility ---------- */

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: var(--z-sticky);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  /* 44px tap target: only visible on focus, so the extra padding-block
     carries no layout cost — nothing else sits below it. */
  padding: var(--space-sm) var(--space-md);
  /* Ink, not the pen: chrome, not annotation. Ground on ink is 17.49:1. */
  background: var(--ink);
  color: var(--ground);
}
.skip-link:focus { left: 0; }

/* Visually hidden, still announced. Carries the gate's structural h1. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
  font: inherit;
}

/* THE FOCUS RING IS INK, NOT PEN. A red ring around a field is the web's
   error convention, and on the one page that carries an actual input it read
   as a rejected password rather than as the cursor's position. The pen marks
   up the work; it does not report state. Ink is also what the focus states
   already built on top of this ring use — the gate field's border, the
   lightbox badge, the annotation frame — so the ring now matches them
   instead of contradicting them.
   Ink on ground is 17.49:1 in both polarities, against WCAG 1.4.11's 3:1,
   and it needs no per-polarity value because --ink flips with the ground.
   Two constraints from tokens.css still hold: the ring must have --ground on
   at least one side, which is why this is an outline with an offset and never
   a border sitting flush against a rule; and border-radius is zero everywhere
   (§2.6), so the ring carries no rounding. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

/* Reading progress — fixed hairline at the very top of the viewport, filled
   by site.js as the visitor scrolls. Yields to the lightbox modal (z-modal)
   when one is open. Track is --hairline (the same colour --rule now draws
   everywhere), fill is ink: 10.8:1 between them, and the pen stays out of a
   decorative indicator. */
.progress-bar {
  position: fixed;
  top: 0; left: 0;
  width: 100%;
  height: 2px;
  background: var(--hairline);
  z-index: var(--z-sticky);
}
.progress-bar-fill {
  height: 100%;
  width: 0%;
  background: var(--ink);
}

/* ---------- shared type roles ---------- */

/* Every heading is apparatus. This rule is load-bearing: `body` is --prose, so
   without it the whole heading set silently falls into the serif — the exact
   leak §2.4 warns about. Size and measure are set per role below; family,
   weight, tracking and rag are set once, here. */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--apparatus);
  font-weight: 500;
  letter-spacing: -0.02em;
  text-wrap: balance;
  min-width: 0;
  overflow-wrap: anywhere;
}

.eyebrow {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

/* Small type — footers, colophon, gate note. Not an evidence apparatus:
   evidence rides the exhibit captions and the labeled fact rows.
   --ink-3 is authored against --ground only, and everything in this class
   sits on --ground. 4.60:1 light / 4.61:1 dark. */

/* Labeled fact rows — metadata, not footnotes: the label does the work.
   Apparatus by role (keys and values), and --ink-2 rather than --ink-3: these
   carry the evidence, so they are not allowed to be the faintest thing here. */
.facts {
  font-family: var(--apparatus);
  font-weight: 400;
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
  max-width: var(--measure-apparatus);
}
.facts b { font-weight: 500; color: var(--ink); }
.facts + .facts { margin-top: var(--space-xs); }

/* 44px tap target — the bare-inline fix also used at .contact-links .link
   and .footer-nav-col .link. A plain inline link is 19.50px tall at every
   width, under both WCAG 2.5.5 (44px) and 2.5.8 (24px). The gate's mailto is
   the ONLY escape route for a visitor who does not have the password, which
   makes it at least as high-value as the homepage's contact pair — and
   .contact-links's fix is scoped to that container, so nothing outside it
   inherits it.

   Scoped to `.facts .link`, and that selector is exact: five .link elements
   sit inside a .facts paragraph across the site — the gate's mailto and its
   fun.johnbrodish.com link, the 404's mailto, the homepage contact line's
   resume link, and the resume's closing link back to the site root — and all
   five take the tap target, which is what this rule is for. Nothing else
   matches, so this changes no other line box. inline-block also makes each
   link unbreakable; the widest ("johnbrodish.com/resume", 189.45px) still
   clears the 200px content box the 240 floor gives, so it introduces no
   overflow above OR below the declared floor. At 320 the homepage link wraps
   to its own line and lands at right=299.8 against a content edge of 300.0 —
   inside, and the tightest instance on the site.

   .wordmark takes the same treatment — see .wordmark below, and the
   identical rule in the fun site's styles.css. */
.facts .link {
  display: inline-block;
}

/* Standalone links — apparatus, full ink, the underline carries the
   affordance. Not set in an accent: §2.5 has no colour for a link, and a
   coloured link is the first thing an accent gets spent on. */
.link {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
  white-space: nowrap;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.3em;
  text-decoration-color: var(--ink-3);
  transition: text-decoration-color var(--dur-micro) var(--ease-out), text-underline-offset var(--dur-micro) var(--ease-out);
}
.link:hover { text-decoration-color: var(--ink); text-underline-offset: 0.42em; }
.link:active { text-underline-offset: 0.24em; }

/* Flex would centre a carried icon against the text's full line height
   rather than its font baseline; neither link carries one (a text-plus-arrow
   shape reads as a generic CTA cliché — the underline alone carries the
   affordance), so `gap` and `align-items` are inert but harmless with a
   single child, and inline-flex still earns its keep for the tap-target
   trick below.
   44px tap target: these are the highest-value links on the site (case-study
   entries, case-nav) and a bare inline box is 24px tall at 375 — inline-flex
   lets padding-block grow the hit area directly. The matching negative
   margin-block cancels it back out of the surrounding flow, same trick as
   the masthead nav below: 12px padding on a 24px line reaches 48px with zero
   visual shift. */
.case-nav a.link, .entry-cta a.link {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
}
/* 24/38 rather than the shared rule's 16px, so the CTA is easier to spot —
   case-nav (page-turning, not a call to action) deliberately stays at 16px. */
.entry-cta a.link { font-size: 1.5rem; line-height: 2.375rem; }

/* Orphan control on running text — headings already carry text-wrap: balance */
.lede, .dek, .prose p, .case-body p, .spine-claim { text-wrap: pretty; }

/* ---------- masthead ---------- */

.masthead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-lg);
  padding-block: var(--space-lg);
}

.brand { white-space: nowrap; }
/* Wordmark — apparatus, like every other piece of chrome.

   44px TAP TARGET, the same on both sites: this rule is byte-identical here
   and in the fun site's styles.css, so the two mastheads stay one treatment. A
   plain inline wordmark is 98.95 × 20.00 at every declared width — the width
   clears 44, the height does not.

   The fix is the bare-inline pattern this file uses elsewhere
   (.contact-links .link, .facts .link, .case-nav a.link): a plain inline box
   sizes to font metrics, not the box model, so `display: inline-block` is
   what makes padding-block do anything at all. inline-block brings the box to
   the 24px line-height; 12px of block padding takes it to 48px; the matching
   negative margin returns the margin box to 24px so the masthead's height,
   the brand's baseline and the wordmark's painted position do not move. NOT
   a larger wordmark — the visual is unchanged and only the hit area grows
   (§2.6: the type scale is not a tap-target lever).

   No inline padding: the wordmark is 98.95px wide, already well past 44, and
   inline padding here would push the hit area toward the masthead-rail on a
   narrow viewport for no gain. */
.wordmark {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  display: inline-block;
}
.masthead-rail {
  display: flex;
  /* center, not baseline: the toggle is an icon now, so the rail centers the
     nav text against it and hands the masthead its baseline via the nav */
  align-items: center;
  gap: var(--space-xl);
  /* BELOW THE 320px FLOOR. `header.masthead` wraps; without this line the
     rail does not, so the toggle is pushed out of the viewport — and
     `overflow-x: clip` above then removes the horizontal scroll that would
     have been the way back to it. At 240 the toggle box sits at 251.5→295.5,
     entirely off-screen and not hit-testable. The theme toggle is the only
     control in the masthead; masking an unreachable control is worse than
     the overflow it hides.

     Wrapping is the fix rather than unmasking, because unmasking alone leaves
     the control off-screen and merely restores a scrollbar to chase it with.
     This line is inert at and above the declared floor: flex-wrap does
     nothing until the line overflows, and the rail fits at 320, so the
     toggle box, the rail's edges and the masthead's height are unchanged at
     every declared width. At 240 the toggle drops to its own row, on-screen
     and hit-testable.

     the fun site's styles.css carries the identical line for the identical
     reason. The masthead is one component across two sites; fixing it on one
     would split the treatment. */
  flex-wrap: wrap;
}
/* the gate has no nav, so its lone icon toggle centers against the brand */
.masthead:not(:has(.masthead-nav)) { align-items: center; }

.masthead-nav { display: flex; gap: var(--space-lg); }
.masthead-nav .link {
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration-color: transparent;
  /* 44px tap target: the 12px text box alone is well under the minimum.
     Padding grows the hit area on every side; the matching negative
     margin cancels it back out of the nav row's flow, so the masthead's
     height and the links' visible positions don't move. The inline padding
     is smaller than the block padding — space-xs, not space-md — so two
     adjacent hit areas encroach on the row's gap without touching. */
  padding-block: var(--space-md);
  padding-inline: var(--space-xs);
  margin-block: calc(-1 * var(--space-md));
  margin-inline: calc(-1 * var(--space-xs));
}
.masthead-nav .link:hover { text-decoration-color: var(--ink); }
.masthead-nav .link[aria-current] { text-decoration-color: var(--ink); }

/* Theme toggle — the "classic" sun/moon from toggles.dev (theme-toggles,
   MIT), translated from its Tailwind classes to token-pure rules driven by
   [data-theme] instead of a .dark class. Dark shows the moon: the disc
   scales up while the clip path morphs a bite out of it and the rays
   retreat. Safari has no CSS `d` support, so @supports slides the whole
   cutout instead — same crescent, straight path.
   No accent flash on the pressed state: the pen is not available for a
   chrome control, and the hover step to full ink is affordance enough. */
.theme-toggle {
  color: var(--ink-2);
  background: none;
  border: none;
  cursor: pointer;
  /* 44px tap target: the 20px icon plus 2xs padding would be a 28px box.
     space-sm padding reaches 44px; the negative margin cancels the
     growth back out of the masthead-rail's flow so the icon doesn't shift. */
  padding: var(--space-sm);
  margin: calc(-1 * var(--space-xs));
  transition: color var(--dur-micro) var(--ease-out);
}
.theme-toggle:hover { color: var(--ink); }
/* 20px: base-4 but between spacing tokens (16/24) — recorded exception */
.theme-toggle svg { display: block; width: 20px; height: 20px; }
.theme-toggle circle {
  transform-origin: center;
  transition: transform var(--dur-long) var(--ease-out);
}
.theme-toggle g path {
  transform-origin: center;
  transition: transform var(--dur-long) var(--ease-out),
              opacity var(--dur-long) var(--ease-out);
  transition-delay: calc(var(--dur-long) * 0.15);
}
.theme-toggle defs path {
  transition: d var(--dur-long) var(--ease-out),
              translate var(--dur-long) var(--ease-out);
}
:root[data-theme="dark"] .theme-toggle circle { transform: scale(1.7); }
:root[data-theme="dark"] .theme-toggle g path {
  transform: scale(0.5) rotate(45deg);
  opacity: 0;
  transition-delay: 0s;
}
:root[data-theme="dark"] .theme-toggle defs path {
  d: path("M0 2h13a1 1 0 0 0 10 10v14H0Z");
  transition-delay: calc(var(--dur-long) * 0.15);
}
@supports not (d: path("M0 0")) {
  :root[data-theme="dark"] .theme-toggle defs path { translate: -13px 2px; }
}
@media (prefers-reduced-motion: reduce) {
  .theme-toggle circle,
  .theme-toggle g path,
  .theme-toggle defs path { transition: none; }
}

/* Mobile: the nav stays (case pages need a way around that isn't the footer).
   The rail wraps to its own row — nav left, toggle right. :has() scopes the
   full-width row to pages that have a nav, so the gate's lone toggle keeps its
   place beside the brand. Without :has() the rail simply wraps right-aligned. */
@media (max-width: 40rem) {
  .masthead { flex-wrap: wrap; }
  .masthead:has(.masthead-nav) .masthead-rail {
    flex: 1 1 100%;
    justify-content: space-between;
  }
}

/* ---------- hero ---------- */

.hero {
  /* bottom > top: the hero sits into the page rather than floating */
  padding-block: var(--hero-pad-top) var(--hero-pad-bottom);
}

.hero .eyebrow { display: block; margin-bottom: var(--space-sm); }

/* Display scale, one per site. No max-width: display and title headings run to
   the content edge and let text-wrap: balance set the rag. The two-tone
   headline device and its accent-coloured full stop are gone — both were named
   as top-tier tells, and the weight-split remnant (.h1-tail) went with them. */
.hero h1 {
  font-size: var(--text-display);
  line-height: var(--lh-display);
}

.hero .lede {
  margin-top: var(--space-lg);
  max-width: var(--measure);
  font-size: var(--text-lede);
  line-height: var(--lh-lede);
}

/* NO PAGE REVEAL, on purpose — neither a below-the-fold scroll reveal nor a
   hero load reveal, and this is why no rule sits here.

   A scroll reveal (an `animation-timeline: view()` block that fades and
   rises every top-level article as it enters the viewport) is the single
   most template-y move of the last five years, applied uniformly to every
   section on a site whose entire argument is that it is a printed object.
   Paper doesn't fade in.

   A load reveal — a one-shot staggered entrance on the hero's eyebrow, h1
   and lede — is the same species. It declares `opacity: 0` and depends on an
   animation to hand the text back, and "paper doesn't fade in" reaches a
   page's first paint exactly as it reaches its fifth section. There is no
   --stagger token in tokens.css for the same reason: it would describe a
   sequence that does not happen, and it is not a scale rung.

   THE FAIL-OPEN IS THE POINT, and it is stronger than a guard. There is no
   `opacity` declaration on those three elements from any rule — not one
   inside a prefers-reduced-motion guard, not one waiting on an animation to
   undo it. An element that is never `opacity: 0` cannot stay at `opacity: 0`,
   because nothing sets it to 0 in the first place: the hero paints at
   opacity 1 with scripting disabled, under `prefers-reduced-motion: reduce`,
   under `no-preference`, and with animations suppressed entirely. */

/* ---------- sections ---------- */

/* A section head, drawn with the same --rule every boundary on the page
   uses (the design language §2.6). There is one kind of rule, so a section
   boundary and an evidence-unit boundary do not need telling apart by
   weight.
   Full-bleed: the sitewide rule is a full-width divider between major
   content sections, hairline for division within one (the annotation
   marker's notes, a table row, a group head) — this is the former, so it
   uses the same viewport-sized `::before` `.entry` uses, escaping `.wrap`'s
   own max-width/padding the way a border on the box itself can't. */
.section { position: relative; }
.section::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  border-top: var(--rule);
}

/* No padding-top of its own — About and Contact, the only two consumers,
   carry their own symmetric padding-block directly below, and a second top
   offset here would double it against the divider. */
.section-head h2 {
  font-size: var(--text-h2);
  line-height: var(--lh-h2);
  max-width: var(--measure-apparatus);
}

/* The recurring problem — beat 2 of the argument spine (§2.1). No heading:
   the kicker above it names the beat and the two rules bracket it.
   Body scale, not lede scale: the lede IS the thesis and has to stay the
   loudest prose on the page; a second 24px block directly beneath it reads
   as a continuation of the same voice and flattens the two beats into one
   (§5's "indistinguishable levels," in prose rather than in headings).
   Padding is symmetric, both --space-xl: with a single --rule token there is
   no rule-weight difference between the two ends to key an asymmetry to.
   The 64px a full section carries still belongs to blocks with a section's
   worth of content in them, which this is not. */
.section--spine { padding-block: var(--space-xl); }
.section--spine .eyebrow { display: block; margin-bottom: var(--space-sm); }
.spine-claim { max-width: var(--measure); }

/* The hero does not close the page's opening — the problem beat does — so
   the hero hands off on the section rhythm instead of its own full closing
   padding. This is the screen test (§1, tier 1) paid for structurally: the
   thesis is 428px of type at 1440, and the opening has to reach the first
   evidence unit inside 900. */
.hero:has(+ .section--spine) { padding-bottom: var(--space-xl); }

/* Symmetric top and bottom, same reasoning as .section--spine above:
   nothing keys the two ends to different rule weights, so each end matches
   the other instead of the heading supplying one of them. Contact takes the
   larger step — its own closing weight before the footer. */
.section--about { padding-block: var(--space-2xl); }
.section--contact { padding-block: var(--space-3xl); }

/* ---------- evidence units ---------- */

/* An evidence-unit boundary. Every unit opens on it, so the rule means one
   thing on this page: below this line is a claim and its support. Not
   `.entry + .entry`, which would leave the first unit opening on the same
   rule as About and Contact; the units are top-level beats of the argument,
   not items inside a "Selected work" list, and they are marked as such.
   Drawn with the single --rule token (tokens.css, the design language §2.6),
   not a heavier rank of its own: the honest cost of a conventional rule is
   not routed around with a second weight.
   Full-bleed: `.entry` is also `.wrap`, so a border-top on the element
   itself would only span the centred content column. The rule
   needs to reach the viewport edge, so it moves to a `::before` sized off
   the viewport rather than the box — the same escape `.wrap`'s own
   max-width/padding-inline can't provide. Safe against a scrollbar-induced
   horizontal scroll because `overflow-x: clip` is already set on html/body
   (see the --annot-room reasoning below). */
.entry {
  position: relative;
  padding-block: var(--space-2xl);
}
.entry::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  border-top: var(--rule);
}

/* Entry head — stacked, single column: eyebrow above title above dek */
.entry .eyebrow { display: block; margin-bottom: var(--space-sm); }

/* h2, not h3: with the catalog section head gone, a unit title is a top-level
   head and a peer of About and Contact. Same size as .section-head h2 for the
   same reason — they sit at the same level of the page outline. */
.entry h2 {
  font-size: var(--text-h2);
  line-height: var(--lh-h2);
  max-width: var(--measure-apparatus);
}

.entry .dek {
  margin-top: var(--space-md);
  max-width: var(--measure);
}

/* Single column: evidence, exhibits, fact rows, and the link flow in order */
.entry .strip { margin-top: var(--space-lg); }
.entry .facts { margin-top: var(--space-lg); }
.entry-cta { margin-top: var(--space-lg); }

/* Evidence: generations strip (entry 02). No border-top: the four images
   already read as a new evidence beat on their own, and a rule above them
   would imply a boundary the content already draws. */
.strip {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: var(--space-lg) var(--space-md);
}
.strip .g {
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
}
.strip .g b {
  display: block;
  font-weight: 500;
  color: var(--ink);
  margin-bottom: var(--space-2xs);
}
@media (min-width: 60rem) {
  .strip { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-md); }
}

/* Entry-02 filmstrip: the artifact for each generation sits above its label,
   so the four beats read as a sequence. Full size via the lightbox.
   Not .exhibit.filled's bordered-card treatment: four bordered cards in a
   grid read as componentized UI (the generic "everything in a card"
   pattern), where a single full-width case-study exhibit reads as a mounted
   artifact. Different shape of problem, different treatment on purpose. The
   hairline sits on the image alone; the caption sits free below it,
   unboxed. */
.strip-figs .g { display: flex; flex-direction: column; }
.strip-img {
  display: block;
  width: 100%;
  height: 12rem;
  object-fit: cover;
  object-position: top;
  border: var(--rule);
  background: var(--exhibit-ground);
}
.strip-figs .g-txt { display: block; margin-top: var(--space-sm); }

/* Evidence: the market-failure ratio (entry 03) — one sentence at display
   scale, not stat tiles. The numbers are [doc]; the contrast IS the point.
   A group head, same job as .strip, drawn with the same --rule (§2.6).

   Three constraints hold this line, and they compound.

   1. THE EDGE. --page, not --measure. --measure is the PROSE edge — "all
      prose: lede, dek, body, notes" — and this line is apparatus: Schibsted
      Grotesk, --text-fine, with display numerals in it. Capped at 544px the
      sentence breaks into three lines and the SECOND one opens with the
      first stat's 12px label followed immediately by the second stat's 48px
      numeral: "~700 certified headache / specialists·39,000,000 people in
      the US living with / migraine". A 12px word and a 48px numeral on one
      line is not a hierarchy, it is a collision. On the content column the
      sentence IS one: one line from 959 up, two at 768, and no mixed-size
      line that does not begin with its own numeral at any width from 768 up.

   2. THE LEADING. The line height belongs on the thing it was sized for.
      `line-height: var(--lh-title)` on the PARENT would give the numerals
      their own line height — but the parent is the 12px text, so every label
      would compute at 52px of leading and float in mid-air with 20px of
      half-leading above and below it. The parent takes --lh-fine (the pair
      for --text-fine) and `b` takes --lh-title, so a line carrying a numeral
      is 52px and a line of label alone is 20px. Same baseline, no float: the
      element is 69px tall at 1440 (1px border-top + 16px padding-top + the
      52px line box) rather than 199px. No `@media (max-width: 40rem)`
      override for this: "--lh-title reads as a gap above a caption line with
      no adjacent numeral" is the same defect seen from the mobile end, and
      patching it at one breakpoint is not fixing it.

   3. THE WRAP. Below 768 two stats written as one run of inline words can
      still wrong-side the join: "specialists · 39,000,000" as one line, the
      second stat's numeral landing mid-line after the first stat's tail
      instead of opening its own line. Each stat as one atomic flex item
      holds that invariant but, at widths where both fit on one row, a
      stat's own label can still wrap onto a second line under it while its
      neighbour doesn't — "certified headache specialists · 39,000,000" on
      row one, "people in the US living with migraine" alone on row two,
      reading as detached from its own numeral rather than as the second
      stat's own second line. The root cause is the two stats negotiating
      width with each other, so the negotiation is removed on the vertical
      axis — numeral above its own label inside each stat (`.stat` stacks
      them, column direction). A stat's label wraps under its own numeral,
      never beside the other stat's, because the two no longer share a text
      line at all.
      Not two equal-width grid columns for the horizontal axis: stretching
      each stat across half the row puts visible daylight between a short
      numeral and its own label's left edge, and pushes the second stat's
      numeral out to the far right instead of sitting a fixed distance from
      the first. Flex instead, un-wrapped, `gap: var(--space-lg)`: each stat
      sized to its own content, side by side, a constant 32px apart
      regardless of how much room either one actually uses. */
.stat-line {
  display: flex;
  gap: var(--space-lg);
  border-top: var(--rule);
  padding-top: var(--space-md);
  max-width: var(--page);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
}
.stat { display: flex; flex-direction: column; gap: var(--space-2xs); }
.stat-line b {
  font-weight: 500;
  font-size: var(--text-title);
  line-height: var(--lh-title);
  color: var(--ink);
  letter-spacing: -0.02em;
}
.facts .sep { padding-inline: 0.35em; }
.entry .stat-line { margin-top: var(--space-lg); }

/* Exhibits — the artifact gets a 1px hairline and nothing else (§2.6): no
   device frame, no browser chrome, no shadow. */
.exhibits {
  margin-top: var(--space-xl);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: var(--space-md);
}
.exhibit {
  border: var(--rule);
  background: var(--exhibit-ground);
  display: grid;
  align-content: end;
  padding: var(--space-md);
  min-height: 11rem;
}
/* Captions are apparatus. --ink-2, not --ink-3: this text sits on
   --exhibit-ground, where --ink-3 is 4.21:1 and fails. tokens.css declares
   that as a rule, and this is the rule's main consumer. --ink-2 is 7.37:1. */
.exhibit figcaption {
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
  /* Caption measure: a full-width exhibit otherwise runs captions 150+
     characters per line at 12px. The declared apparatus edge, offset by the
     exhibit's own padding. */
  max-width: var(--measure-apparatus);
}
.exhibit figcaption b {
  display: block;
  font-weight: 500;
  color: var(--ink);
}

/* A filled exhibit: image on top, credit-line caption below.

   NO MOUNT. A standard-rectangle capture fills its container edge to edge,
   matching the homepage's unmounted images (`.annot-plate`, `.strip-img` —
   both zero padding); a mount is only earned by a non-standard shape that
   needs centring within a wider box, which is what `.exhibit.phone` and
   `.exhibit.doc` do. `padding: 0` means three quarters of the perimeter has
   no mount, and in dark a light-UI capture meets the near-black page with no
   step between. That trade-off is deliberate on a standard rectangle, where
   the mount argument below does not apply.

   WHAT A MOUNT BUYS, AND WHY IT IS NEVER POLARITY-CONDITIONAL — the
   reasoning `.phone` and `.doc` run on. A mount that exists only in dark is
   a layout that changes when the reader flips the toggle — an artifact that
   resizes on a colour change, plus two different geometries to verify for
   every exhibit on the site. The mount's JOB is polarity-independent
   (tokens.css argues exactly this at --exhibit-ground), so the geometry is
   too, and only the value of the separation differs:

     artifact (white) → mount   1.12:1 light · 13.26:1 dark
     mount → page               1.09:1 light ·  1.35:1 dark
     artifact → page, unmounted 1.03:1 light · 17.96:1 dark

   In dark the single 17.96:1 / ΔL* 92.4 cliff becomes two steps, ΔL* 80.3
   then 12.1, which is what a mount is for. In light the mount costs almost
   nothing and buys the same reading — the artifact sits ON something. */
/* A FILLED EXHIBIT SIZES TO ITS OWN CONTENT. `.exhibits` is a grid, so its
   items stretch to the tallest cell in the row by default. On a `.phone` or
   a `.doc` that surplus is usable — the artifact is mounted, so it can float
   in the middle of it and the caption can fall to the bottom. A `.filled`
   exhibit has nowhere to put it: it is full-bleed (a standard rectangle
   fills its container edge to edge) and top-anchored by `object-position:
   top`, so centring it would open ground bands above AND below a full-width
   capture, and dropping the caption to the bottom would detach it from the
   picture it credits. Stretched, the surplus just pools under the shorter
   caption — 169px in a before/after pair, 51px in a three-up.
   `align-self: start` lets the item opt out of the stretch, so the ground
   ends up OUTSIDE the box instead of inside it. A shorter artifact should
   end where it actually ends, and this ends its box there too. Nothing is
   masked, resized or aligned by script.
   The two artifacts in a row still share a top edge, which is what a
   before/after is read from; only the bottom edges differ, and they differ
   because the captures do. `.exhibit`'s `min-height` still sets the floor. */
.exhibit.filled { align-content: start; padding: 0; align-self: start; }
.exhibit.filled img {
  display: block;
  width: 100%;
  height: auto;
  max-height: 30rem;
  object-fit: cover;
  object-position: top;   /* tall page captures show their top */
}
/* Opt-out for captures whose evidence runs to the bottom edge: the steppers
   under a projection, the totals row under a table, the third panel of a
   glossary. On these the 30rem cap crops away precisely what the caption points
   at, so they run at full height. Use only where the caption or alt text names
   something that lives below the cap — the cap is still right for long page
   captures, which are meant to show their top.

   THE CAP IS AN EDITORIAL CHOICE, SO EVERY IMAGE IT CROPS OWES A PER-IMAGE
   REASON, not a blanket one. What is inside a hidden band is a question
   about the evidence, not about pixels. At a 926px exhibit column
   (viewport-invariant above 1440, since --page pins the column) four
   `.exhibit.filled img` hit it; the homepage and the Juva page crop nothing.
   masked = renderedWidth × (naturalHeight / naturalWidth) − renderedHeight.
   A list that claims completeness and is not complete is what stops the
   next author looking, so re-count this one rather than trust it.

     image                          masked     of natural   ruling
     orita-brand-portal.jpg         1,025.9px    68.1%      KEPT
       The hidden band carries the co-brand lockups, colour tokens and type
       specimens — real content — but the visible band carries every element
       of the claim the exhibit is there to evidence. Named in its caption.
     gen2-slide-templates.png         100.5px    17.3%      KEPT
       Hidden band is the Google Slides window's bottom chrome: the last
       thumbnail row's edge, "Click to add speaker notes", the view-mode
       buttons. Zero evidence. Everything the alt names — cover slide, octopus
       mascot, brand-guidelines note, template slides in the sidebar — is in
       the visible band.
     gen1-automation-deck-variables    40.8px     7.8%      KEPT
       Hidden band is the blank foot of the slide: the last ink in the
       capture is the month-label row under the savings card, and it sits
       21px above where the cap falls. Zero evidence. Everything the alt
       names — the headline's merge tags, both audit cards, the savings row
       and its month tags — is in the visible band.
     whiteboard-working-designs.png    18.9px     3.8%      KEPT
       Hidden band is the whiteboard's floating tool palette and zoom control.
       Zero evidence. The alt's stat cards, tooltip and formatting notes, and
       approval checkmarks are all visible.
     site-wide total                1,186.1px */
.exhibit.filled.uncropped img { max-height: none; object-fit: contain; }
/* No thumbnail modifier (a `max-width: 20rem` plus `justify-self: start` on
   a filled exhibit) for a document that renders too large: shrinking is the
   right instinct aimed at the wrong property. */
/* The defect is a document being treated as a fill, not a fill that is
   large. `.exhibit.doc` below carries the size AND the mount, centring and
   edge that make it read as paper. */
/* Even padding on all four sides — the image fills the container edge to
   edge (.exhibit.filled above is padding: 0), but the caption underneath
   needs its own inset so its text doesn't hug the same left/bottom border
   the image is deliberately flush against. */
.exhibit.filled figcaption { padding: var(--space-sm); }

/* ARTIFACTS THAT SIT *ON* THE EXHIBIT GROUND, rather than filling it edge to
   edge the way `.exhibit.filled` does. Two subjects, one idea: a phone at
   device proportions, and a document at page proportions. Mount, centre,
   air, and the ground visible all the way round the object, so it reads as
   a thing resting on a surface.

   `grid-template-rows: 1fr auto`, NOT `align-content: start`. Every cell in
   an `.exhibits` row is stretched to the tallest of them — that is the grid
   working, and the fix is never to trim the artifact (no script syncs row
   heights, on purpose). But packing content to the TOP of a stretched cell
   dumps the whole surplus in one lump under the shortest caption — 216px,
   194px and 118px on one page, up to 39 per cent of a cell — reading as a
   hole rather than as air. With the artifact in a `1fr` row and the caption
   in an `auto` row beneath it, the surplus splits above and below the object
   instead: every caption in a row bottom-aligns on one line, and each artifact
   floats centred in its own space. Nothing is masked and nothing is resized —
   only where the existing surplus sits changed. Rows whose cells already match
   are untouched, because there is no surplus in them to move. */
.exhibit.phone,
.exhibit.doc {
  justify-items: center;
  padding: var(--space-lg) var(--space-md) var(--space-md);
  grid-template-rows: 1fr auto;
}
/* TWO PATHS, BECAUSE THE LIGHTBOX RESTRUCTURES THIS FIGURE.
   With JS off the figure really does hold two children — artifact, then
   caption — and they are the two grid rows above: the artifact centres in the
   `1fr`, the caption sits in the `auto` beneath it.
   With JS on, site.js has already folded BOTH into one `.lb-open` button so
   the trigger's hit area covers the caption too. The figure then has a
   single child, `grid-template-rows` has nothing to split — the second row
   computes to 0px — and centring that child floats the
   caption up with the artifact, which is the opposite of the goal. So the
   distribution moves inside the button, which is already a column flex: an
   auto block margin on the image wrap absorbs the free space evenly above and
   below it, leaving the caption last and therefore flush to the bottom.
   The margin goes on the WRAP and not on the button, deliberately: the zoom
   badge is pinned to `.lb-img-wrap`'s own corner, so the wrap has to keep
   shrink-wrapping the image or the badge drifts off it. */
.exhibit.phone > img,
.exhibit.phone > video,
.exhibit.phone > .stand-pair,
.exhibit.doc > img { align-self: center; }
.exhibit.phone .lb-img-wrap,
.exhibit.doc .lb-img-wrap { margin-block: auto; }
.exhibit.phone img { display: block; width: 15rem; max-width: 100%; height: auto; }
/* A phone exhibit carrying a silent looping capture. Sized exactly like the
   stills beside it so a video and a screenshot can share a section without
   breaking rhythm. Reduced-motion users get the poster frame and real controls
   instead of an autoplaying loop — handled in site.js, since CSS cannot pause
   playback. */
.exhibit.phone video { display: block; width: 15rem; max-width: 100%; height: auto; }
/* A phone exhibit carrying two photographs side by side.
   The flex child is the <img> with JS off and the .lb-open button with JS on,
   so the sizing rides `> *` rather than `img` — otherwise the lightbox wrapper
   collapses the pair to two full-width images on the live site. */
.exhibit.phone .stand-pair { display: flex; gap: var(--space-sm); width: 100%; justify-content: center; }
.exhibit.phone .stand-pair > * { flex: 1 1 0; min-width: 0; }
.exhibit.phone .stand-pair img { width: 100%; }
.exhibit.phone figcaption,
.exhibit.doc figcaption { padding: var(--space-sm) 0 0; justify-self: stretch; }

/* A DOCUMENT SITTING IN SPACE — a page capture should read as an actual
   document resting on the ground, the way the vertical phone screens do. A
   20rem image jammed flush to the left edge of a full-width cell, its white
   stock running straight into --exhibit-ground (1.06:1 away from it), reads
   as a cropped card, not a sheet of paper.
   The hairline is the load-bearing part. --exhibit-ground is a near-white in
   the light polarity, so paper on it has effectively no edge of its own; the
   obvious fix is a drop shadow and §2.6 forbids one outright. `--rule` is the
   only legal edge in this system and it is already the edge every artifact on
   the page carries, so the sheet gets the same line rather than a new device.
   In the dark polarity the paper separates on its own and the line simply
   reads as the page's own border.
   20rem, not the phone's 15rem: a page is a wider object than a handset, and
   at 20rem this 1950x2689 capture renders 320x441 — close to the 240x454 the
   phones next to it render, so the two objects read as the same size class.
   Legibility is not the point and never was: this is 0.16 scale and the
   caption says "click to read it in full". */
.exhibit.doc img {
  display: block;
  width: 20rem;
  max-width: 100%;
  height: auto;
  max-height: none;
  border: var(--rule);
}
/* A DOCUMENT MOUNTED BY HEIGHT. The plain `.doc` is a sheet — a fixed 20rem
   wide, tall. A dashboard capture (1598x1427) as a `.filled` exhibit
   stretches to the 926px column and stands 827px tall, taller than the
   viewport on most laptops. Governing the HEIGHT instead — 37.5rem, 600px on
   the 4px grid — renders it 672x600, centred on the ground with a hairline,
   exactly the way the sheet and the phones sit.
   `width: auto` is what makes the height govern; `max-width: 100%` keeps it
   inside the cell on narrow screens, where the aspect ratio then shrinks the
   height below the cap on its own. Centring is the `.doc` machinery above:
   the lightbox wrap is `align-self: center` in a column flex, so it
   shrink-wraps to the image's width, whatever that width is. */
.exhibit.doc.wide img {
  width: auto;
  max-height: 37.5rem;
}

/* ---------- about / contact ---------- */

.prose { margin-top: var(--space-lg); max-width: var(--measure); }
.prose p + p { margin-top: var(--space-md); }
.prose + .facts { margin-top: var(--space-lg); }
/* The "View resume" line under About is its own beat, not a continuation of
   the "Also —" row, so it takes the same lg gap the row takes from the prose
   rather than the xs that stacks fact rows elsewhere. */
.section--about .facts + .facts { margin-top: var(--space-lg); }

.contact-links {
  margin-top: var(--space-lg);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md) var(--space-xl);
  list-style: none;
}
.contact-links + .facts { margin-top: var(--space-lg); }

/* 44px tap target: these are the highest-value links on the site — email
   and LinkedIn — and a bare inline link is 20px tall at 375. Bare inline
   .link elements need display: inline-block before padding-block does
   anything (plain inline boxes size to font metrics, not the box model).
   inline-block alone brings the box to the line-height's 24px; the same
   12px-padding/negative-margin cancellation used above then reaches 48px
   with the text sitting exactly where it did before. */
.contact-links .link {
  display: inline-block;
}

/* ---------- footer ---------- */

/* Above the page's closing apparatus, drawn with the same single --rule
   token as every other boundary on the site (§2.6).
   Full-bleed, same `.section` treatment and the same reason: the page's
   last major-section divider, so it earns the same full-width `::before`
   rather than a border on the (padded, capped) box itself. Padding-block is
   asymmetric on purpose. */
.footer {
  position: relative;
  padding-block: var(--space-xl) var(--space-2xl);
}
.footer::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  border-top: var(--rule);
}

.footer-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-sm) var(--space-xl);
}

.footer-meta {
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-3);
}

/* Footer nav — case-study links plus fun.johnbrodish.com, persistent
   site-wide navigation on every johnbrodish.com page. Two columns, each an
   eyebrow label over its links; wraps to one column on narrow viewports via
   flex-wrap. Does not apply to fun.johnbrodish.com (a separate site). */
.footer-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xl) var(--space-2xl);
  margin-bottom: var(--space-2xl);
}
/* Below 40rem the two columns stack (flex-wrap), so the row-gap becomes the
   space between them — tightened to --space-lg there, along with the gap to
   the bottom row, which the base rule's --space-2xl leaves oversized once
   the block is already single-column. */
@media (max-width: 39.99rem) {
  .footer-nav {
    gap: var(--space-lg);
    margin-bottom: var(--space-lg);
  }
}
.footer-nav-col .eyebrow { display: block; margin-bottom: var(--space-md); }
.footer-nav-col ul {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}
/* 44px tap target: a bare inline link is 20px tall at 375, and reaches 48px
   with the same bare-inline padding/margin-block trick. */
.footer-nav-col .link {
  display: inline-block;
}

/* ---------- the 44px tap target, for every link that needs one ----------
 * THE TARGET IS AN OVERLAY, NOT PADDING, AND THE REASON IS THE FOCUS RING.
 * All six selectors below used to buy their pointer target with
 * `padding-block: var(--space-sm)` plus a cancelling negative margin: the box
 * grew by 24px, the margin box did not, so layout never moved. It works for a
 * pointer and fails for a keyboard. :focus-visible draws its ring around the
 * link's own box, so the ring wrapped 12px of invisible padding on every side
 * — on the gate, where two such links sit on consecutive lines, the rings
 * measured 52px tall around 16px text and overlapped the neighbouring line.
 *
 * An absolutely positioned ::after is out of flow: it takes no space inside an
 * inline-block and is removed from flex layout in the two inline-flex cases,
 * so it enlarges the hit area without touching the box the ring measures.
 * Pointer and touch events land on it and resolve to the link, because it is
 * the link's own pseudo-element. Same 24px of extra height as the padding it
 * replaces, so every target measured in the notes above is unchanged.
 *
 * `inset-inline: 0` on purpose: the target grows vertically only. Horizontal
 * growth would push these toward their neighbours — the wordmark toward the
 * masthead rail, the contact pair toward each other — and none of them needs
 * it, since all are already well past 44px wide.
 *
 * The links keep their `display` values. Those carry measurements recorded in
 * the notes above (unbreakable widths at 320px, the masthead's height and the
 * wordmark's painted position) and are not what the ring was catching on. */
.facts .link,
.case-nav a.link,
.entry-cta a.link,
.wordmark,
.contact-links .link,
.footer-nav-col .link { position: relative; }

.facts .link::after,
.case-nav a.link::after,
.entry-cta a.link::after,
.wordmark::after,
.contact-links .link::after,
.footer-nav-col .link::after {
  content: "";
  position: absolute;
  inset-block: calc(-1 * var(--space-sm));
  inset-inline: 0;
}


/* ---------- reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 150ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 120ms !important;
  }
}

/* ---------- case-study pages (the template) ----------
 * Anatomy: .case-hero (eyebrow · h1 · lede · note · meta grid)
 * then .case-body (h2 sections · prose at measure · exhibits · facts · fine)
 * then .case-nav (All work · Next study). */

.case-hero { padding-block: var(--hero-pad-top) var(--space-2xl); }
.case-hero .eyebrow { display: block; margin-bottom: var(--space-sm); }
.case-hero h1 {
  font-size: var(--text-title);   /* one step below the homepage display */
  line-height: var(--lh-title);
}
.case-hero .lede {
  margin-top: var(--space-lg);
  max-width: var(--measure);
  font-size: var(--text-lede);
  line-height: var(--lh-lede);
}

/* Front matter: Role / Timeline / Shipped under a drawn rule — a document
   cover, not the homepage's poster hero. Labels stack above values.
   A group head, drawn with the single --rule token (§2.6). */
.case-meta {
  margin-top: var(--space-xl);
  border-top: var(--rule);
  padding-top: var(--space-md);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-md) var(--space-xl);
  max-width: var(--measure-apparatus);
}
@media (min-width: 40rem) {
  /* ONE ROW FROM HERE, AS MANY EQUAL COLUMNS AS THERE ARE CELLS. The column
     count follows the markup rather than a `repeat(4, …)`: `grid-auto-flow:
     column` with equal auto-columns lays N cells in N tracks, so a cover with
     three cells and one with four both get a single row and neither leaves an
     orphan. Never `width: fit-content`, which would size to the longest
     Shipped item and land on a DIFFERENT right edge on each case page.

     THE APPARATUS CAP IS DROPPED HERE, AND THAT IS A NAMED EXCEPTION TO THE
     RIGHT-EDGE DOCTRINE, not an oversight — see the "right-edge set" comment
     near the top of this file, which lists --measure-apparatus as governing
     "the meta grid" by name. The cap is what would starve the row at this
     size: held to the 40rem apparatus edge, four cells leave each column about
     114px. Off the cap the row tracks the parent instead, measuring 114px at
     640, 144px at 760 and 202px at 1280. The narrow end wraps Role to four
     lines and Shipped to six, which the 12px apparatus type carries. A row
     needs the wider column, not a smaller grid, so the cap comes off where
     the row starts.
     `max-width: none` rather than `var(--page)`: `.case-meta` already sits
     inside `.case-hero` inside `.wrap`, which caps at --page AND pays --pad
     on top of it, so an explicit `var(--page)` here would be inert — the
     parent is already narrower. Scoped to this one component;
     --measure-apparatus is untouched everywhere else it governs (fact rows,
     captions, colophon, section and entry headings).

     row-gap stays declared for the single-column stack below 40rem, which
     keeps 16. */
  .case-meta {
    grid-template-columns: none;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: var(--space-lg) var(--space-xl);
    max-width: none;
  }
}
@media (min-width: 60rem) {
  /* THE RAIL STOPS SPANNING THE PAGE HERE. Equal tracks across the whole
     --page column strand the short cells: Timeline is one date and Site is one
     domain, and each was sitting alone in a 226px track with 160-odd px of
     nothing to its right. Two changes together fix it, and neither works
     alone. `auto` tracks let a column take the width its content needs, so a
     date gets a date's worth of room; and the apparatus cap comes back, which
     is what stops the row from stretching to the full column. Without the cap
     the tracks simply shrink to fill --page and the short cells end up flush
     against the right edge instead of beside their neighbours.

     --measure-apparatus is the edge the right-edge set at the top of this file
     already names for the meta grid, so this returns the component to its
     declared edge rather than inventing one. The 40-60rem band keeps
     `max-width: none` and equal tracks: it has no slack to give, and held to
     the cap there the four columns would be about 130px each.

     `auto` rather than `max-content` so a track still shrinks instead of
     overflowing when a cell runs long — Juva's Team cell is one long sentence
     and would otherwise demand a single 700px line. */
  .case-meta {
    grid-auto-columns: auto;
    justify-content: start;
    max-width: var(--measure-apparatus);
  }
}
.meta-label {
  display: block;
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--space-2xs);
}
.meta-value {
  display: block;
  font-family: var(--apparatus);
  font-weight: 400;
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink);
}
/* No `.meta-value .sep` rule: `.sep` never appears inside a `.meta-value` —
   the cover blocks run a `<ul class="meta-value list">` instead — so it
   would match nothing (§4.8, dead selectors). The middot run lives in
   `.facts` and `.stat-line`. */
/* Shipped as a stacked list rather than a middot run, with real bullets and
   the same 1.1em/0.2em hanging indent .resume-points uses — a cell can hold
   six items now, and at the narrow end each of those wraps, so the marker is
   what tells a wrapped line from a new one. Markers take --ink-3: they
   separate the items without competing with them. */
.meta-value.list { margin: 0; padding-left: 1.1em; }
.meta-value.list li { padding-left: 0.2em; }
.meta-value.list li::marker { color: var(--ink-3); }
/* A link INSIDE a Shipped item is part of that line, not a thing on top of it.
   `.link` carries prose sizing — 16/24 at weight 500 — which in a 12/20 cell
   sets the domain a third larger than the words on either side of it and
   breaks the list's rhythm, so the item stops reading as one of the bullets.
   Size, leading and weight come from the cell instead; the underline, the
   nowrap and the outbound glyph all stay, which is what still marks it as a
   link. Scoped to the list: the Site cell's link is the only thing in its
   cell, with no line to sit inside, so it keeps the prose size. */
.meta-value.list .link {
  font-size: inherit;
  line-height: inherit;
  font-weight: inherit;
}

/* An outbound link in the cover rail says so. Only the rail: elsewhere on the
   site an external link sits in prose, where the icon would interrupt the
   line. Drawn as a mask rather than an <img> or inline markup so it takes
   currentColor and so it flips with the polarity for free, and so the glyph
   lives in one place instead of being pasted into three files. Same glyph as
   the prototype badge in site.js — an arrow leaving a box means the same
   thing in both places, and they must not drift.
   inline-block keeps the underline off it. */
.case-meta a[target="_blank"]::after {
  content: "";
  display: inline-block;
  width: 0.85em;
  height: 0.85em;
  margin-left: 0.3em;
  vertical-align: -0.06em;
  background-color: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 5h5v5'/%3E%3Cpath d='M19 5l-7.5 7.5'/%3E%3Cpath d='M18 14.5V18a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 5 18V8a1.5 1.5 0 0 1 1.5-1.5H10'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 5h5v5'/%3E%3Cpath d='M19 5l-7.5 7.5'/%3E%3Cpath d='M18 14.5V18a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 5 18V8a1.5 1.5 0 0 1 1.5-1.5H10'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  mask-size: contain;
}

/* The boundary between the cover and the body of the document, the single
   --rule token every boundary on the site draws (§2.6).
   Full-bleed: the page's cover→body boundary is a major section divider by
   the same test as `.section` and `.footer` — case-nav and case-meta stay
   hairline as groups within a section, not between them. `.case-body`
   isn't its own `.wrap` (the parent `.case` article is), so this still
   needs its own positioning context for the viewport-sized `::before`. */
/* flow-root: without a border or padding-top, the first child's top margin
   collapses straight through this box and lands above the divider (between
   .case-hero and the rule) instead of below it (between the rule and the
   content) — the .when marker then reads as flush against the divider
   because its whole 64px margin was spent one level up. flow-root contains
   it without adding any visible box of its own. */
.case-body {
  position: relative;
  padding-bottom: var(--space-3xl); /* 3xl: air between the last paragraph and the case-nav rule */
  display: flow-root;
}
.case-body::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  border-top: var(--rule);
}
.case-body h2 {
  font-size: var(--text-h2);
  line-height: var(--lh-h2);
  max-width: var(--measure-apparatus);
  margin-top: var(--space-2xl);
}
.case-body p { max-width: var(--measure); margin-top: var(--space-md); }
.case-body .exhibits { margin-top: var(--space-xl); }
.case-body .facts { margin-top: var(--space-lg); }
.case-body .facts + p { margin-top: var(--space-lg); }

/* A group, not a top-level boundary — the footer's rule sits directly below
   it. With a single --rule token the two are visually identical and the
   grouping is carried by spacing alone (§2.6). */
.case-nav {
  border-top: var(--rule);
  padding-block: var(--space-2xl); /* 2xl both sides: even air so the links read as the page's closing action */
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-md) var(--space-xl);
}
/* EVEN PADDING IS NOT EVEN SPACE WHEN ONE COLUMN CARRIES AN EYEBROW. The
   box is symmetric — 64 top, 64 bottom — but the two links are
   bottom-aligned, so on a nav that carries the Next-case eyebrow the reader
   sees 64 + the eyebrow's line + the column's gap above the link text, and
   only 64 below it: 84 against 64. What the eye is measuring is
   rule-to-link, not rule-to-box.
   The compensation is that eyebrow row, written as the thing it compensates
   for rather than rounded to a token that happens to land on the same number
   — 64 + 16 + 4 = 84, still base-4. Scoped with :has() so the same class on a
   nav with no eyebrow (the resume's Return line) keeps the true 64/64 and
   does not sit bottom-heavy. A browser without :has() gets that symmetric
   pair. */
.case-nav:has(.case-nav-next) {
  padding-bottom: calc(var(--space-2xl) + var(--lh-eyebrow) + var(--space-2xs));
}

/* Next-case-study group — eyebrow stacked over the link, both left-aligned
   to each other. */
.case-nav-next {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2xs);
}

/* ---------- case-study stubs (404) ---------- */

.stub { padding-block: var(--space-3xl); }
.stub .eyebrow { display: block; margin-bottom: var(--space-lg); }
.stub h1 {
  font-size: var(--text-title);
  line-height: var(--lh-title);
}
.stub .lede {
  margin-top: var(--space-xl);
  max-width: var(--measure);
  font-size: var(--text-lede);
  line-height: var(--lh-lede);
}
.stub .facts { margin-top: var(--space-xl); }
.stub .entry-cta { margin-top: var(--space-2xl); }

/* ---------- /resume/ (the document of record) ----------
 * Same tokens, same type role split, same single --rule (see tokens.css and
 * the design language §2.6), same single left axis. What differs is the
 * REGISTER, and the register is the whole design decision here, so it is
 * written down.
 *
 * THE CASE PAGES ARE EVIDENCE SURFACES. They argue, and the apparatus — the
 * annotated proof, the claims ledger, the graded provenance — IS the argument's
 * machinery. THE RESUME IS THE INDEX. It is read once, at speed, usually
 * printed, and it is the one surface the reader takes away.
 *
 * WHAT THAT RULES OUT, and this is the load-bearing half. No claims ledger and
 * no annotated proof on this page. the design language §2.2 caps the annotated
 * proof at 3–5 exhibits per site precisely because "filler annotations on a
 * site about honest provenance is the worst possible place to pad," and §2.3's
 * ledger grades claims a reader can then go and check. A resume makes no claim
 * a reader can check on the page — that is what a resume IS — so a ledger here
 * would grade nothing and would be exactly the filler both rules forbid.
 * Porting the apparatus wholesale would spend the site's distinctiveness
 * budget on a page that cannot use it.
 *
 * WHAT CARRIES THE DISCIPLINE INSTEAD, three things, none of them new devices:
 *   1. THE DATE LEADS EVERY ENTRY. `.resume-when` is the kicker-above-heading
 *      shape `.when` and `.gen-mark` already use on the case pages. The site's
 *      thesis is producible dates (§2.7); on this page the dates are the
 *      structure rather than an annotation on it.
 *   2. THE COPY IS CORRECTED RATHER THAN SOFTENED. A claim the record does
 *      not support comes out or is restated to match the live case pages.
 *      That is the ledger's posture applied to prose, which is the only form
 *      it can take at this length.
 *   3. NOTHING POINTS AT THE GRADED VERSION. No closing line saying the case
 *      studies carry the sources: it would sit in a list whose other rows
 *      are Education, Skills and Certifications, so it reads as a credential
 *      when it is a pointer; the site's URL is already on the page four
 *      times; and announcing the discipline is weaker than the corrections
 *      in item 2, which demonstrate it. A resume earns the click. The case
 *      studies do the grading once someone arrives.
 *
 * WHY THIS PAGE DOES NOT USE `.when`, AND THE RUNNING MARGIN MARKER WITH IT.
 * §2.7's marker is scoped to `.when` and would have attached to this page for
 * free — "any page that gains `.when` spans gets the marker with no further
 * work." It is deliberately not taken. At 90rem the marker moves the date OUT
 * of the reading column and into the margin, which is right for a case page
 * whose sections are dated beats you read through. On a resume the date is
 * part of the entry's IDENTITY: a reader scans employer-title-dates as one
 * object, and a layout that separates them above 1440 and rejoins them below
 * it would be a different document at two widths. So the kicker gets its own
 * class and stays put. The marker is unchanged and still appears on the Juva
 * case page only.
 *
 * NO NEW RIGHT EDGE. Prose sits on --measure, apparatus on
 * --measure-apparatus, the page on --page. NO NEW 12px ROLE either: the
 * section labels and the date kickers are the existing .eyebrow treatment
 * reused, so the design language §5's 12px crowding count is unchanged.
 * ONE NEW RUNG, --text-h3 at 24/28: see tokens.css for why it is earned
 * here and nowhere else. */

/* 3xl: the page closes on a `.case-nav`, and this is the air above that
   nav's rule. The case studies give the same boundary
   `--space-3xl` from `.case-body`; matching it is what makes the block read
   as the same closing apparatus rather than a link tacked on. Print zeroes
   it below, and hides the nav. */
.resume { padding-bottom: var(--space-3xl); }

.resume-hero { padding-block: var(--hero-pad-top) var(--space-xl); }
.resume-hero .eyebrow { display: block; margin-bottom: var(--space-sm); }
.resume-hero h1 {
  font-size: var(--text-title);   /* the same step every interior page's h1 takes */
  line-height: var(--lh-title);
}

/* Seat and city. Apparatus, not prose — it is a label under a name, the same
   job .meta-value does on a case cover. --ink-2, 8.05:1 light / 8.07:1 dark. */
.resume-standfirst {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink-2);
  margin-top: var(--space-sm);
  max-width: var(--measure-apparatus);
}

/* Four items rather than the homepage's two, so the row gets the tighter of
   the two declared gaps. .contact-links already carries the 44px tap-target
   fix and the flex-wrap; nothing else is redeclared. */
.resume-contact { gap: var(--space-sm) var(--space-lg); }

/* The boundary between the cover and the body of the document, the
   identical job .case-body's rule does. Exactly one per page. Both this
   rule and the one below it draw the single --rule token (§2.6). */
.resume-hero + .resume-section {
  border-top: var(--rule);
  padding-top: var(--space-lg);
}
/* A section head inside the document, not a top-level boundary. */
.resume-section + .resume-section {
  border-top: var(--rule);
  padding-top: var(--space-md);
  margin-top: var(--space-xl);
}

/* The section label carries the .eyebrow treatment on a heading element. The
   precedent is .claims-ledger-head / .annot-notes-head: the class carries the
   styling, the element carries the outline level. Overrides the shared
   h1–h6 rule's 700 weight and -0.02em tracking, and wins on specificity. */
.resume-section-head {
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  max-width: var(--measure-apparatus);
}

.resume-summary {
  max-width: var(--measure);
  margin-top: var(--space-md);
  text-wrap: pretty;
}

.resume-entry { margin-top: var(--space-xl); }
.resume-section-head + .resume-entry { margin-top: var(--space-lg); }

/* The date kicker. Same treatment as .when and .meta-label; its own class so
   the 90rem margin-marker rule cannot reach it (see the header above).
   It is a <p>, so it is also a direct child of .resume-entry — every rule
   below that targets the entry's prose paragraph excludes it by name rather
   than by position, because "the first p" is a fact about today's markup. */
.resume-when {
  display: block;
  max-width: var(--measure-apparatus);
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.resume-entry h3 {
  font-size: var(--text-h3);
  line-height: var(--lh-h3);
  margin-top: var(--space-2xs);
  max-width: var(--measure-apparatus);
}
/* Employer at 700 full ink, seat at 400 --ink-2 — two axes, both of which
   survive greyscale printing. */
.resume-entry h3 span {
  font-weight: 400;
  color: var(--ink-2);
}

.resume-entry > p:not(.resume-when) {
  max-width: var(--measure);
  margin-top: var(--space-sm);
  text-wrap: pretty;
}

.resume-points {
  max-width: var(--measure);
  margin-top: var(--space-sm);
  padding-left: 1.1em;
}
.resume-points li { padding-left: 0.2em; text-wrap: pretty; }
.resume-points li + li { margin-top: var(--space-xs); }
.resume-points li::marker { color: var(--ink-3); }
.resume-points b { font-weight: 600; }

.print-only { display: none; }
.resume-section .facts + .facts { margin-top: var(--space-xs); }
.resume-section-head + .facts { margin-top: var(--space-md); }

/* ---------- print ----------
 * The resume is the only page written to be printed, and this block is scoped
 * to it through `body.doc`: printing any other page on the site is
 * unaffected.
 *
 * PAPER IS PAPER. The polarity toggle is a screen affordance. A visitor who
 * has chosen dark and then hits Cmd-P should not get a black page, so the
 * light polarity is re-declared here rather than filtered — which also means
 * every contrast ratio on the printed page is the light one already measured
 * in tokens.css.
 *
 * THE COLOUR AND THE LAYOUT OVERRIDES SIT IN DIFFERENT PLACES, DELIBERATELY.
 *
 *   TYPE AND MEASURE go on `body.doc`. Custom properties inherit, so a value
 *   set on <body> wins for that whole subtree over anything :root declares.
 *   On :root they would silently re-scale the type on every other page of the
 *   site the moment anyone printed one.
 *
 *   COLOUR goes on `:root`, and it has to. The page canvas — including the
 *   @page margin band, which no element's box covers — is painted from the
 *   ROOT element, so a light body inside a dark root prints a light column
 *   with a dark border: with the colour tokens on body.doc the sheet prints
 *   #FCFCFB inside a 0.75in × 0.4in band of #121212. Forcing light at the
 *   root fixes it, and forcing light at the root for EVERY page is the right
 *   behaviour anyway — this is the print stylesheet; paper is paper. Three
 *   selectors, because dark arrives from two (0,1,1) places, the explicit
 *   [data-theme="dark"] and the prefers-color-scheme fallback, and this
 *   block's source position is what wins the tie.
 *
 * Only `@page` is unscopeable, and its values are close enough to Chrome's
 * own default that no other page changes visibly.
 *
 * THE INCH VALUES IN @page ARE NOT ON THE 4px GRID and cannot be: a physical
 * page margin is measured against the sheet, not against the layout grid.
 * Everything inside the text block is still base-4.
 *
 * THE TYPE LADDER DROPS ONE FULL STEP, in 4px multiples throughout:
 *   h1 24/28 · entry title 16/20 · body, labels and dates 12/16
 * Three ranks, and below 16 the separation is weight, case, colour and the
 * rules — not size — because 8px is the next 4px step down and 6pt is not a
 * size a person reads a phone number at.
 *
 * THE MEASURE, AND THE ARITHMETIC THAT SET IT. Screen holds 68 characters per
 * line (544px at 16px). Holding that ratio at 12px means a 408px column, and
 * three candidates were printed and page-counted rather than argued:
 *     408px (68 CPL)  — never tried past arithmetic: a 3.5in white gutter down
 *                       a letter sheet, and more lines than 512 already gives.
 *     512px (85 CPL)  — 3 pages. Page 3 carries the education block and the
 *                       closing line and nothing else.
 *     560px (93 CPL)  — 2 pages. This is what ships.
 * A resume is scanned, not read through, and its longest unit is a three-line
 * bullet — the measure penalty lands almost entirely on the one summary
 * paragraph. Two pages is a hard constraint; 93 CPL is the
 * price of it, stated rather than hidden. 560px is base-4, and the 1.42in of
 * right margin it leaves is a document margin rather than an accident. */
@media print {

  :root,
  :root[data-theme="dark"],
  :root:not([data-theme]) {
    --ground: #FCFCFB;
    --ink: #15171A;
    --ink-2: #4A4F55;
    --ink-3: #6E747B;
    --pen: #C0341E;
    --pen-soft: #F7E7E3;
    --pen-ink: #75423B;
    --hairline: #C4C9CF;
    --exhibit-ground: #F2F2F0;
    color-scheme: light;
  }

  body.doc {
    --text-title: 1.5rem;     --lh-title: 1.75rem;    /* 24 / 28 */
    --text-h3: 1rem;          --lh-h3: 1.25rem;       /* 16 / 20 */
    --text-body: 0.75rem;     --lh-body: 1rem;        /* 12 / 16 */
    --text-lede: 0.75rem;     --lh-lede: 1rem;        /* 12 / 16 */
    --text-eyebrow: 0.75rem;  --lh-eyebrow: 1rem;     /* 12 / 16 */
    --text-fine: 0.75rem;     --lh-fine: 1rem;        /* 12 / 16 */

    --measure: 35rem;              /* 560px — see the header above */
    --measure-apparatus: 38rem;    /* 608px */
    --page: none;
    --pad: 0;
    --hero-pad-top: 0;
    --hero-pad-bottom: 0;
  }

  @page { size: letter; margin: 0.4in 0.75in; }

  /* The page and the printed sheet are one document at two lengths. The page
     carries the whole record; the sheet is capped at one side of letter, so
     entries and bullets marked `print-hide` come off it and the single
     `print-only` line stands in for the ones it names. Screen is the default
     state: `print-only` is hidden outside this block. */
  .doc .print-hide { display: none !important; }
  .doc .print-only { display: block; }

  /* Site chrome is screen furniture. The wordmark goes with it, which is why
     the page's <h1> is a name and not the word "Resume". */
  .doc .progress-bar,
  .doc .skip-link,
  .doc .masthead,
  /* The Return-to-main-page nav is screen chrome by the same test as the
     masthead: a link is not a thing a sheet of paper can offer. */
  .doc .case-nav,
  .doc .footer { display: none; }

  .doc .resume { padding-bottom: 0; }
  .doc .resume-hero { padding-block: 0 var(--space-md); }
  .doc .resume-contact { margin-top: var(--space-sm); gap: var(--space-2xs) var(--space-md); }
  .doc .resume-hero .eyebrow { margin-bottom: var(--space-xs); }
  .doc .resume-standfirst { margin-top: var(--space-2xs); }

  .doc .resume-hero + .resume-section { padding-top: var(--space-sm); }
  .doc .resume-section + .resume-section { margin-top: var(--space-md); padding-top: var(--space-xs); }
  .doc .resume-entry { margin-top: var(--space-sm); }
  .doc .resume-section-head + .resume-entry { margin-top: var(--space-xs); }
  .doc .resume-entry > p:not(.resume-when),
  .doc .resume-points { margin-top: var(--space-2xs); }
  .doc .resume-points li + li { margin-top: var(--space-2xs); }
  .doc .resume-summary { margin-top: var(--space-xs); }

  /* A tap target is a touchscreen idea. On paper the padding only adds
     leading no reader asked for. */
  .doc .contact-links .link,
  .doc .facts .link { padding-block: 0; margin-block: 0; }

  /* PAGE BREAKS. A resume that breaks mid-role is a defect, so the rules are
     stated per shape rather than blanket:
     - An entry that is a heading plus one paragraph is indivisible; it carries
       .resume-entry--whole in the markup. A class, not :has(), because the
       print engine is the one place a novel selector is least worth betting
       on and the markup can just say what it means.
     - An entry with a bullet list MAY break internally — Orita and Juva are
       long enough that forbidding it would strand most of a page — but never
       between its head and its first line, and never inside a bullet.
     - The name block stays with the summary; the closing three lines stay
       together. */
  .doc p, .doc li { orphans: 3; widows: 3; }
  .doc .resume-hero,
  .doc .resume-entry--whole,
  .doc .resume-points li { break-inside: avoid; }
  /* Third bullet, and its own rule: the closing block is not an entry. Held
     apart from the list above so a future author reads it as the shape it is
     rather than as a fourth entry selector — same behaviour folded in, wrong
     reading. */
  .doc .resume-section:last-of-type { break-inside: avoid; }
  .doc .resume-hero,
  .doc .resume-section-head,
  .doc .resume-when,
  .doc .resume-entry h3 { break-after: avoid; }
  .doc .resume-entry h3 + p,
  .doc .resume-entry h3 + ul > li:first-child { break-inside: avoid; }
}

/* ---------- gate (gate.html) ----------
 * The public cover page shown to unauthenticated visitors. It is a door, not a
 * page: no display headline, no lede, no explanatory fine print — just the
 * wordmark, the form, and two fact rows, centered in the viewport. The h1 is
 * .sr-only so structure survives without anything shouting on screen.
 *   1. The input and button are apparatus, like every other control.
 *   2. The error line is full --ink under the site's single --rule. An
 *      inline validation message attached to one input is an attachment
 *      line, not a structural boundary, and a heavier rule here would be
 *      emphasis-by-weight, a job a rule does not have (§2.6): this line's
 *      legibility comes from full --ink text, never from the rule's weight.
 *      The palette has no error colour and the pen is not one: §2.5 gives
 *      the pen to annotation, and a failed password is a state, not the
 *      designer's mark. */

.gate { padding-block: var(--hero-pad-top) var(--hero-pad-bottom); }

/* The gate centers in the viewport. This is the ONE place viewport units are
   allowed — recorded exception: the base-4 rule bans vh-based *padding*, and
   centering a door needs viewport height by definition. svh so mobile browser
   chrome can't push the field off-screen; vh first as fallback. */
.gate-page {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}
/* Masthead lifts out of the flow so "centered" means centered in the VIEWPORT,
   not in the leftover space beneath it. Keeps .wrap's max-width and gutters. */
.gate-page .masthead {
  position: absolute;
  top: 0;
  inset-inline: 0;
}
.gate-page .gate {
  flex: 1;
  /* Vertically centred, horizontally on the axis. Without the explicit width
     the .wrap's own `margin-inline: auto` acts as a cross-axis auto margin in
     this column flex container, which suppresses the stretch and shrinks the
     door to its widest child — its left edge landing near 390 against a
     masthead sitting at 176. §2.6 allows one left axis and nothing centred;
     the vh exception below is for the vertical only. */
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* symmetric, base-4, and a floor for short viewports */
  padding-block: var(--space-2xl);
}
/* A LEADING margin inside a centered flex column is part of the centered block,
   so it pushes visible content down by half its value. Whichever element comes
   first carries that job: the claim line when it is present, the form when it
   is not. Everything after it keeps its own spacing, because an internal gap
   is not a leading margin. */
.gate-page .gate-claim,
.gate-page .gate-claim:first-child + .gate-form { margin-top: 0; }
.gate-page .gate-claim ~ .gate-form { margin-top: var(--space-xl); }
.gate-page .gate-form:first-child { margin-top: 0; }

.gate-form { margin-top: var(--space-xl); }

/* The door says what the work is. One sentence at lede scale — the standfirst
   register, not display: this is still a door and §2.6 keeps display for the
   homepage. Capped at --measure like any prose, so it breaks where a sentence
   should rather than running the width of the field row. Full --ink: it is the
   only claim on the page and §2.4 does not grey running prose. */
.gate-claim {
  max-width: var(--measure);
  font-size: var(--text-lede);
  line-height: var(--lh-lede);
  color: var(--ink);
}

.gate-label {
  display: block;
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--space-xs);
}

.gate-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
}

.gate-input {
  flex: 1 1 auto;
  min-width: 0;
  /* 20rem: recorded exception to the declared right-edge set (top of this
     file) — that rule governs text-column measure, and this is a form control's
     intrinsic width, not a content edge. Left at a password-field width so
     it doesn't stretch to the apparatus measure on wide viewports. */
  max-width: 20rem;
  height: 3rem;                 /* 48 — base-4, border included via box-sizing */
  padding-inline: var(--space-md);
  font-family: var(--apparatus);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
  background: var(--exhibit-ground);
  border: var(--rule);
  border-radius: 0;
  -webkit-appearance: none;
  appearance: none;
  transition: border-color var(--dur-micro) var(--ease-out);
}
.gate-input:hover { border-color: var(--ink-2); }
/* Field state only. The accessible focus indicator is the :focus-visible
   outline above — a border sitting flush against the field can't satisfy the
   ground-on-one-side constraint, which is exactly why the pen isn't here. */
.gate-input:focus { border-color: var(--ink); }

.gate-btn {
  height: 3rem;                 /* matches the input so the row sits on one baseline */
  padding-inline: var(--space-lg);
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ground);
  background: var(--ink);
  border: var(--rule);
  border-radius: 0;
  cursor: pointer;
  white-space: nowrap;
  transition: background-color var(--dur-micro) var(--ease-out),
              border-color var(--dur-micro) var(--ease-out),
              transform var(--dur-micro) var(--ease-out);
}
/* Hover lifts the fill one step off ink, in both polarities: ground on --ink-2
   is 8.05:1 light / 8.07:1 dark. */
.gate-btn:hover {
  background: var(--ink-2);
  border-color: var(--ink-2);
}
.gate-btn:active { transform: translateY(1px); }

.gate-error {
  margin-top: var(--space-md);
  font-family: var(--apparatus);
  font-weight: 400;
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink);
  max-width: var(--measure-apparatus);
  border-left: var(--rule);
  padding-left: var(--space-sm);
}
.gate-error[hidden] { display: none; }

.gate .facts {
  margin-top: var(--space-xl);
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
}
/* Consecutive facts lines get their own breathing room, not the tight stack
   of a single block — but still less than the gap above the first line. */
.gate .facts + .facts { margin-top: var(--space-md); }
.gate .facts .link { white-space: normal; }

/* ---------- exhibit lightbox ----------
 * Progressive enhancement: JS wraps each exhibit image in .lb-open and appends
 * one <dialog class="lb"> per page. With JS off the figures are untouched.
 * Native <dialog> carries Esc-to-close, the focus trap, and focus return.
 *
 * Chromeless by design: the dialog is the full viewport, there is no panel, no
 * border, and no framed ground. The image is centered in the screen; the
 * caption sits directly on the dark overlay; the close control parks in the
 * overlay's top-right corner, not on the image.
 *
 * ONLY --lb-* tokens are legal in here. The room is dark in both page
 * polarities, so --ink/--ink-2/--ink-3 would be wrong half the time, and
 * --pen is 3.19:1 against the composited scrim. */

/* THE ENLARGE AFFORDANCE HAS TO EXIST WITHOUT A CURSOR.
   `cursor: zoom-in` alone is no hover state, no mark, and therefore NOTHING
   AT ALL on touch, where there is no cursor to change. Every artifact on the
   site opens, and on a phone none of them would say so.
   Two parts, and the badge is the load-bearing one:
     · the badge is drawn by site.js into the button, beside the artifact, so
       it exists in every input mode and needs no hover to appear. §1's
       colophon veto asks for one sentence: a control that only announces
       itself to a mouse is not an ornament question, it is a missing control.
     · the hover/focus step is the artifact's own hairline going from
       --hairline to full ink, plus the badge from --ink-2 to --ink. No new
       vocabulary: it is the same rule the artifact already carries, at a
       darker colour, not a new weight.
   Ink and ground, never the pen — §2.5 gives the pen three jobs and a zoom
   control is not one. Zero radius, zero shadow (§2.6). */
.lb-open {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  /* Written for a <button>, which underlines nothing. The prototype
     exhibit uses an <a> in the same slot and the caption sits inside it,
     so without this the browser underlines the whole caption. */
  text-decoration: none;
  text-align: inherit;
  cursor: zoom-in;
}
/* Phone exhibits keep the image at its own fixed width, centred, while the
   button still stretches to the card's full width underneath it — so the
   hit area reaches the caption and the mount either
   side of the image, not just a column as narrow as the phone shot. Column
   flex, default (stretch) cross-axis alignment, so figcaption still spans
   the full width the way `justify-self: stretch` would give it as a grid
   item — `.lb-img-wrap` is the one child that opts out, shrinking
   to the image's own width and centring itself with `align-self`. */
.exhibit.phone .lb-open,
.exhibit.doc .lb-open { display: flex; flex-direction: column; }
.exhibit.phone .lb-img-wrap,
.exhibit.doc .lb-img-wrap { align-self: center; }
/* The image's own positioning context, separate from the button's — the
   button now spans the whole card (image + caption) so the badge below has
   a smaller box to pin itself to (see the badge comment). display: block
   matches how the image already behaves everywhere but .phone. */
.lb-img-wrap { position: relative; display: block; line-height: 0; }
.lb-open img { display: block; }

/* THE BADGE AND THE ANNOTATION MARKER ARE NOT THE SAME SPECIES. The one
   place the two co-occur is the annotated exhibit, where this 24px filled
   square sits in the same image box as four 24px filled `.annot-mark` discs
   and could be mistaken for a fifth marker.

   In both polarities they differ on four axes:
     fill      badge --ink-2  #4A4F55 light / #A4AFBC dark   — neutral
               marker --pen   #C0341E light / #F14126 dark   — the only
               chromatic value on the page (§2.5: the pen means one thing)
     keyline   marker outline: 2px solid var(--keyline-quiet); badge
               outline-style computes to `none`. Every marker carries a keyline
               in the artifact's own paper; the badge carries none (see the
               keyline note at `.annot-mark`).
     content   badge a 16px magnifier-plus glyph; marker a 12px/700 numeral
     placement badge pinned to the IMAGE's bottom-right at every width (the
               button's own hit area is the full card, image and caption —
               the badge stays keyed to `.lb-img-wrap` so it reads as marking
               the artifact, not the card); markers on content-derived
               --mx/--my that move at 32rem

   §2.5's own collision test — at least one marker diameter of clear space
   between a marker's edge and any coloured status glyph, checked at EVERY
   declared breakpoint — passes by 8×. Nearest marker edge to badge edge, on
   the lift-summary exhibit: 193.8px at 320 (8.1 diameters), 241.3 at 375,
   560.3 at 768, 726.3 at 960, 802.4 at 1440, 940.8 at 1600, 968.5 at 1920.
   The badge is not a coloured status glyph in the first place; it is --ink-2,
   the apparatus value.

   Not differentiated further, deliberately. The badge is a site-wide
   affordance — seven instances on the homepage alone, one per exhibit — so a
   reader meets it repeatedly outside any annotated context. Restyling it only
   where a marker happens to co-occur would split one affordance in two to fix
   an adjacency that the system's own clearance rule already clears by 8×. If
   this is ever revisited, the axis to move is placement, not the pen. */
.lb-badge {
  position: absolute;
  right: 0;
  bottom: 0;
  display: grid;
  place-items: center;
  width: var(--icon);
  height: var(--icon);
  background: var(--ink-2);   /* --ground on --ink-2 is 8.05:1 / 8.07:1 */
  color: var(--ground);
  pointer-events: none;       /* the artifact is the target, not the badge */
  transition: background-color var(--dur-micro) var(--ease-out);
}
.lb-badge svg { width: var(--space-md); height: var(--space-md); }

/* The prototype badge says what it is. The lightbox badge stays a bare
   24px square because enlarging an image needs no words; this one leaves
   the site, so the label carries the promise the icon alone cannot. The
   square sizing is overridden rather than duplicated, so the shared
   ground, hover step and transition still apply. */
.lb-badge--proto {
  width: auto;
  height: auto;
  grid-auto-flow: column;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-xs) var(--space-sm);
}
.lb-badge-label {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
}
.lb-open:hover .lb-badge,
.lb-open:focus-visible .lb-badge { background: var(--ink); }
.lb-open:hover .annot-frame,
.lb-open:focus-visible .annot-frame { border-color: var(--ink); }
.exhibit:has(.lb-open:hover),
.exhibit:has(.lb-open:focus-visible) { border-color: var(--ink); }
/* Inside the lightbox the cloned frame is not a trigger — it is the artifact
   already enlarged — so no badge travels with it and the hover step does not
   apply to the room's own --lb-ink-3 keyline.

   NO `.lb-annot .lb-badge { display: none }` RULE, on purpose. It would
   match zero elements in every state, dead BY CONSTRUCTION rather than by
   state: site.js appends the badge to `.lb-open` as a SIBLING of
   `.annot-frame` (the §2.2 contract says the frame contains the image and
   the markers and nothing else), and openLightbox clones `.annot-frame`
   alone. No code path can put a badge inside `.lb-annot`; the cloned
   stage's markup is `<span class="annot-frame">` with the img and the
   markers and nothing else.

   Not made reachable either, because making it reachable means cloning the
   badge into the lightbox — which the paragraph above argues against,
   correctly. A defensive rule against a clone the code deliberately does
   not make reads to the next author as evidence that the badge DOES travel.
   Do not add the rule. */

.lb {
  position: fixed;
  inset: 0;
  width: 100%;
  max-width: 100%;
  height: 100%;
  max-height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: var(--lb-ground);
  color: var(--lb-ink);
  overflow: hidden;
  cursor: zoom-out;
  z-index: var(--z-modal);
}
/* Image and caption are centered as one group, not one per row — on a tall
   phone screen a rowed layout strands the caption at the bottom edge, away
   from the thing it describes. The image is the flexible item: it shrinks to
   whatever the caption leaves rather than pushing it off-screen. */
.lb[open] {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
  /* Top stays 2xl at every width — it is what keeps the image clear of the
     close control. Sides and bottom step with the page, mobile-first. */
  padding: var(--space-2xl) var(--space-md) var(--space-md);
}
@media (min-width: 40rem) {
  .lb[open] { padding: var(--space-2xl) var(--space-xl) var(--space-xl); }
}
.lb::backdrop { background: var(--lb-ground); }

/* The page's --ink flips with the page polarity, so in light mode it would put
   a near-black ring in this near-black room. The ring switches to --lb-ink,
   which is authored against the composited scrim at 17.38:1 worst case —
   the same "maximum contrast, no state colour" rule the page ring follows. */
.lb :focus-visible,
.lb-close:focus-visible { outline-color: var(--lb-ink); }

.lb-img {
  display: block;
  flex: 0 1 auto;
  min-height: 0;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  cursor: default;
}

.lb-cap {
  flex: 0 0 auto;
  max-width: var(--measure);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--lb-ink-2);
  text-align: center;
}
.lb-cap b { display: block; font-weight: 500; color: var(--lb-ink); }
.lb-cap[hidden] { display: none; }

/* Anchored to the overlay's corner — the conventional place — so it stays put
   whatever size the image is. top/right sit at --space-sm so the icon's
   centered position on screen doesn't move; the box grows outward around it. */
.lb-close {
  position: absolute;
  top: var(--space-sm);
  right: var(--space-sm);
  z-index: var(--z-raised);
  display: grid;
  place-items: center;
  /* 48px: base-4, above --space-xl — recorded exception */
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--lb-ink-2);
  cursor: pointer;
}
.lb-close:hover { color: var(--lb-ink); }
.lb-close svg {
  width: var(--icon);
  height: var(--icon);
}

.lb-lock { overflow: hidden; }

/* A full-bleed overlay can only fade — anything that moves or scales it would
   peel the page back into view at the edges. The image is what rises. */
@keyframes lb-fade { from { opacity: 0; } }
@keyframes lb-rise { from { opacity: 0; transform: translateY(var(--space-xs)); } }

@media (prefers-reduced-motion: no-preference) {
  .lb[open] { animation: lb-fade var(--dur-short) var(--ease-out); }
  .lb[open]::backdrop { animation: lb-fade var(--dur-short) var(--ease-out); }
  .lb[open] .lb-img,
  .lb[open] .lb-cap { animation: lb-rise var(--dur-short) var(--ease-out); }
}

/* ---------- the annotated proof (§2.2, the signature pattern) ----------
 * The artifact is the object; the page is the annotation around it. Numbered
 * markers sit ON the image, keyed to a notes column that gives the REASON for
 * each change, not a description. Hover or focus either half highlights the
 * pair and quiets the rest. The annotation travels into the lightbox, because
 * the marks are part of the evidence.
 *
 * Reserved for 3–5 hero exhibits per site (§2.2). An exhibit with one thing to
 * say will grow filler annotations, and filler annotations on a site about
 * honest provenance is the worst possible place to pad.
 *
 * FIVE THINGS IN HERE ARE FIXES FOR DEFECTS THAT SHIPPED. Do not undo them:
 *
 * 1. .annot-frame IS THE POSITIONING CONTEXT AND MUST COMPUTE TO
 *    `display: block`. The prototype's equivalent was a <span> that inherited
 *    `display: inline`; an inline box has no usable padding box, so all four
 *    absolutely-positioned markers resolved to left: 0 and stacked in a column
 *    with marker 1 completely hidden under marker 2. It looked plausible, and
 *    a whole breakpoint override block was tuned for coordinates that were
 *    never used. site.js re-asserts this at runtime and warns in the console
 *    if it is ever overridden (§4, mistake 4 / mistake 7).
 *
 * 2. THE FRAME WRAPS THE IMAGE AND NOTHING ELSE. Markers are positioned
 *    against the image, never the figure — percentage offsets resolve against
 *    the frame's padding box, so the frame carries the hairline but zero
 *    padding and holds no caption. Put a caption inside it and a reflow at
 *    narrow widths drags every marker off its target.
 *
 * 3. NO PER-PAIR SELECTORS. The earlier attempt enumerated
 *    `[data-active="1"] .co-1, [data-active="2"] .co-2, …` in four places and
 *    one of those copies stopped after marker 2 — markers 3 and 4 silently
 *    lost their active ring. Here `data-active` on the container drives the
 *    QUIETING (one rule), and site.js puts `data-paired` on the two elements
 *    that match (one rule each). Nothing enumerates the count, so a fifth
 *    marker needs no CSS at all.
 *
 * 4. NOTE TEXT IS NEVER FADED. Quieting the rest is done by draining the pen
 *    wash out of the unpaired chips, not by dropping opacity on prose: §2.4
 *    forbids greying body copy, and text at opacity .4 measures about 2.4:1.
 *    Only the markers dim, and they are aria-hidden decoration.
 *
 * 5. THE LIGHTBOX USES --lb-* AND NOTHING ELSE. The previous attempt reused
 *    --pen-soft for the active note on the near-black scrim: --lb-ink on that
 *    light tint is 1.17:1. The dark-room equivalents are below.
 *
 * THE PEN / PRODUCT-ALERT COLLISION RULE (§2.5, "known collision"):
 * where the artifact carries its own warning colour, a 24px pen marker beside
 * a ~14px amber warning triangle is the same visual species — the eye reads
 * two alerts, not an annotation pointing at one. THE MARKER MOVES, THE PEN
 * NEVER CHANGES COLOUR (recolouring it would cost the pen its single meaning,
 * §2.5). In order of preference, a future author:
 *   a. places the marker in the artifact's own quiet margin on the same
 *      horizontal band as its target — the reader still joins them by row;
 *   b. keeps at least one marker diameter of clear space between the marker's
 *      edge and any coloured status glyph. THE MARKER IS A FIXED 24 CSS px AND
 *      --mx/--my ARE PERCENTAGES OF THE RENDERED BOX, so one diameter is a
 *      DIFFERENT PERCENTAGE AT EVERY RENDER WIDTH — it is 24 ÷ rendered width:
 *          333px (375 viewport, stacked)     → 7.2%
 *          894px (960, stacked)              → 2.7%
 *          1150px (1440 and 1600, breakout)  → 2.1%
 *          830px (the lightbox floor)        → 2.9%
 *      Do not carry one number between breakpoints. (Do not compute it against
 *      the capture's source width either: a 1600px source rendered at 1150
 *      still gives 2.1%, not 1.5%.)
 *      The breakout row is a function of the `.annot` layout at 90rem (see
 *      `.annot`, below); a room whose ceiling is already reached below
 *      1440 renders identically at 1440 and 1600. 333px (375, stacked) is
 *      the binding case — the largest percentage in the table, meaning the
 *      tightest clearance — so it is the render the coordinates are tuned
 *      against.
 *   c. re-crops the capture so the alert region is not the thing being
 *      annotated;
 *   d. annotates a different exhibit.
 * That varying percentage IS the reason to check at each breakpoint override
 * rather than once: the markers are positioned in percentages, the artifact's
 * own glyphs scale with the render, and the marker does not. */

.annot {
  margin-top: var(--space-xl);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-lg);
  align-items: start;
}
/* TWO COLUMNS FROM 60rem: the image beside a fixed 400px notes column. The
 * two-column block keeps the SAME OUTER FOOTPRINT a single-column image
 * would have — the 960px plateau from 1024–1439px, the 1152px breakout from
 * 1440px up — and that space is split between the image and the rail rather
 * than given whole to the image. At 1024px: 928px `.wrap` content + 32px
 * clawed back = 960px total, image 528px / rail 400px / gap 32px. At
 * 1440px+ the 1152px breakout splits to image 720px / rail 400px / gap
 * 32px. The split is a deliberate trade: the reader joins a marker to its
 * note without scrolling, and the image pays for it in width. */
/* THE ROOM, AND WHERE THE TWO STEPS COME FROM — arithmetic rather than
 * taste. Let A(w) be the artifact width. At any breakpoint B where a rail
 * arrives, A(B) = room(B) − rail while A(B−1) = room(B−1), so A can only be
 * non-decreasing across B if the room grows by at least the rail's cost at
 * B. Inside --page it cannot: the room is capped at 1024. Hence the fourth
 * edge (--page-exhibit, tokens.css) — the room, not the rail, is what has
 * to be sized.
 *
 * 1. ≥60rem, −1rem inline. --pad steps 2rem → 3rem at 60rem, which is a
 *    READING gutter: the page opens up as the measure widens. An artifact is
 *    examined, not read, so it keeps the 2rem gutter it had at 59.9rem and
 *    the 16px-per-side difference is given back. Without this the block
 *    drops 895 → 864 at the same pixel.
 *
 * 2. ≥90rem, the block breaks out to --page-exhibit. 90rem is NOT a round
 *    number picked to look tidy. Step 1 plateaus the block at 960px (--page
 *    1024, less 2×3rem pad, plus 2×1rem back). A 360px rail-and-gap needs
 *    room ≥ 960 + 360 = 1320, and room = 100vw − 2×--pad = 100vw − 96, so
 *    a rail cannot arrive without loss below 1416px. 90rem (1440) is the
 *    first stepped breakpoint at or above that floor, and it is also where
 *    every common designer's monitor sits.
 *
 * `100vw - 2 * var(--pad)` is not a fluid clamp on a SIZE — it is the same
 * width `.wrap` already computes for the content column (min(viewport, cap)
 * less its gutters), written out because this block needs a different cap
 * from its parent. Every type size, gap and pad here is still stepped. The
 * 96px of gutter is also what makes it scrollbar-safe: a classic scrollbar
 * takes at most ~17px, so 100vw − 96 can never exceed the client width and
 * `overflow-x: clip` on <html> never has anything to clip.
 *
 * --page-exhibit is --page + 2×--space-2xl = 72rem (1152px): a small fixed
 * offset past the reading column, the conventional breakout (derivation and
 * citations at --page-exhibit, tokens.css). A wider ceiling reads as
 * jarringly wide. Room = min(--page-exhibit, 100vw − 2×--pad) reaches 1152
 * for every viewport ≥1248px, so the token is the binding term almost
 * immediately and there is no further growth above 1440 — one jump, then
 * flat. */
/* Rejected, recorded so it is not re-tried:
 *   · Notes beneath the artifact at ALL widths. Biggest artifact of any
 *     option (1184px at 1440) and it keeps the single left axis — but at
 *     1184 the capture renders 1056px tall, so the notes start below the
 *     fold and the reader is back to joining a screenshot to a list further
 *     down the page. That is precisely the failure §2.2 exists to prevent;
 *     the pattern would be deleted in the act of enlarging it.
 *   · A later rail (100rem) so no breakout is needed. It works
 *     arithmetically and gives no rail at all on a 1440 monitor, which is
 *     the machine most readers of this page sit at.
 *   · Left-anchored breakout, to keep the single left axis (§2.6) intact.
 *     Only reaches 1184px of room at 1440 — 824 for the artifact. Centring
 *     is what buys the width, and it is the same centre line `.wrap` is
 *     already on.
 *
 * Below 60rem the notes stack under the artifact and read as a numbered
 * list — the same join, just vertical. */
@media (min-width: 60rem) {
  .annot {
    margin-inline: calc(-1 * var(--space-md));
    grid-template-columns: minmax(0, 1fr) 400px;
  }
}
@media (min-width: 90rem) {
  .annot {
    --annot-room: min(var(--page-exhibit), calc(100vw - 2 * var(--pad)));
    margin-inline: calc((100% - var(--annot-room)) / 2);
  }
}

.annot-plate { margin: 0; min-width: 0; }
/* THE BADGE'S ANCHOR. The zoom badge (site.js's zoomBadge(), appended as
   .annot-frame's sibling inside .lb-open per the §2.2 contract — "never
   inside it") is `position: absolute; bottom: 0; right: 0`. Every other
   exhibit gets that anchored by `.lb-img-wrap` (`position: relative`), but
   the annot case has no equivalent — its `.lb-open` wraps only the frame
   and the badge, no `.lb-img-wrap` layer — so without this the badge
   escapes to the nearest actually-positioned ancestor, `.wrap.entry` two
   levels up, and renders pinned to the bottom-right of the whole entry
   section instead of the image. `.lb-open`'s own box
   already matches the frame's box for this component (no caption inside
   it, unlike the generic case), so this is the correct, minimal anchor. */
.annot .lb-open { position: relative; }

/* The positioning context. `display: block` is fix 1 above — assert it, never
   inherit it. Zero padding is fix 2: percentage offsets on the markers resolve
   against this box, so it must be exactly the image's box. line-height: 0
   keeps the inline descender gap out of that box.
   The artifact hairline §2.6 asks for, and nothing else: no device
   frame, no browser chrome, no shadow. */
.annot-frame {
  display: block;
  position: relative;
  padding: 0;
  line-height: 0;
  border: var(--rule);
  background: var(--exhibit-ground);
}
.annot-frame > img {
  display: block;
  width: 100%;
  height: auto;
}

/* Marker positions live in custom properties, one rule per marker, scoped by
   the exhibit's data-exhibit name — so a per-breakpoint override is a two-line
   addition and never an edit inside the markup. The component ships the
   mechanism and a centred default; the coordinates belong to the artifact:

     [data-exhibit="lift-summary"] .annot-mark-1 { --mx: 5%;  --my: 9%; }
     [data-exhibit="lift-summary"] .annot-mark-2 { --mx: 36%; --my: 9%; }
     @media (max-width: 60rem) {
       [data-exhibit="lift-summary"] .annot-mark-1 { --mx: 5.5%; --my: 9.5%; }
     }

   SCOPED BY ATTRIBUTE, NOT BY id, AND THIS IS NOT A STYLE PREFERENCE. The
   lightbox clones the frame into a dialog on <body>, outside the page
   container — so an id-scoped rule stops matching the moment the reader opens
   the artifact, every marker falls back to the 50%/50% default, and all of
   them stack in the centre. That is bug 1's exact symptom relocated into the
   lightbox, and the shared-position guard in site.js exists to catch it.
   site.js mirrors data-exhibit onto .lb-annot-stage, so one attribute name
   scopes both rooms and the media queries stay live in both.

   --mx/--my are the marker's CENTRE (the translate below), so a marker reads
   as pointing at a cell rather than starting at one.
   A disc, not a square — the one shape exception to §2.6's zero-border-radius
   rule on either site, scoped to this marker and its note numeral (.annot-n)
   only; §2.6 still holds everywhere else. The keyline is an outline rather
   than a box-shadow
   for the §2.6 zero-box-shadow reason, and outline draws outside the border
   box without touching layout — it follows the circular edge too, so the
   32px marker stays 32px round. 2px is a drawing device, like the focus ring
   and the hairline, not a measurement.

   THE KEYLINE IS KEYED TO THE ARTIFACT, NOT TO THE ROOM. --keyline-quiet is
   fixed in both polarities; the derivation and the four-room measurement
   are at its declaration in tokens.css. Do not reach for --ground or
   --lb-ground here: the outline sits on the marker, the marker sits on the
   artifact, and the artifact is light in every room.
   IT IS ALSO THE ONLY VALUE THE OUTLINE EVER TAKES — there is no sharper
   ink keyline for the paired state. Full note below the quieting rule. */
.annot-mark {
  position: absolute;
  left: var(--mx, 50%);
  top: var(--my, 50%);
  transform: translate(-50%, -50%);
  width: var(--icon);
  height: var(--icon);
  border-radius: 50%;
  /* Disc and numeral are keyed to the artifact, not the room, for the same
     reason the outline below is — see --pen-artifact in tokens.css. The
     numeral takes --keyline-quiet because that token IS the artifact's own
     paper; it is the third thing on this element reading off the exhibit
     rather than off the page. --ground here would flip the numeral dark in
     the dark polarity while the artifact under it stayed light. */
  background: var(--pen-artifact);
  color: var(--keyline-quiet);
  outline: 2px solid var(--keyline-quiet);
  font-family: var(--apparatus);
  font-weight: 700;
  font-size: var(--text-eyebrow);
  line-height: var(--icon);
  text-align: center;
  transition: opacity var(--dur-micro) var(--ease-out);
}

/* Quieting: ONE rule, driven by the container's data-active. It names no
   marker number, so it holds for any count. The :not() is load-bearing and
   not decoration — the quieting selector is MORE specific than a bare
   `.annot-mark[data-paired]`, so written as an override the paired marker
   dims along with the rest. Quieted and paired must be disjoint sets, never
   a specificity race (§4, mistake 4 — measure, don't read the rule and
   assume). */
.annot[data-active] .annot-mark:not([data-paired]) { opacity: 0.3; }
/* NO SHARPENING KEYLINE ON PAIRING. Stepping the outline from paper to ink
   on the active marker — sharpened, not haloed — at 2px against a busy
   screenshot capture reads as a heavy dark ring circling it, closer to an
   error state than an emphasis. Pairing still exists as a concept and
   site.js still sets data-paired on the marker and the note that match the
   active number (see fix 3 above) — the quieting rule right above this
   comment is what depends on it. The keyline does not answer to it: the
   marker's outline is `--keyline-quiet` always, at rest and paired alike,
   and the active marker reads as active because the OTHER THREE dim to 0.3
   opacity around it, not because it grows a ring of its own. One signal,
   not two. */

/* Notes — the half that carries the meaning. The head's rule opens the group. */
/* THE NOTES ARE PROSE, SO THEY TAKE THE PROSE EDGE.
   The right-edge block at the top of this file declares --measure as
   governing "all prose: lede, dek, body, NOTES" — and this is where the last
   word is enforced. Uncapped, the stacked notes run the full artifact
   column: 704px at 768 and 895px at 959, about 86 and 110 characters per
   line — the longest measure on either site, in the component the page most
   wants read. One declaration is correct at every width: stacked below
   60rem, --measure is the only cap the notes meet; inside the narrower
   400px rail above it, the cap is inert and the rail governs. */
.annot-notes { min-width: 0; max-width: var(--measure); }
/* The head opens the rules beneath it, so it must span them. Its four sibling
   rules belong to `.annot-notes li`, which carries margin-inline: -8px
   so the paired wash can bleed past the text without moving the left axis; the
   head never got the matching pair, so the rule that OPENS the group ran
   8px short at both ends of the sibling rules it opens — measured 864→1184
   against 856→1192 at 1440. The negative margin and the equal padding travel
   together: the padding puts the text back on the left axis, so only the rule
   moves. (This file's own right-edge block treats a 3px near-miss as a
   defect.) */
.annot-notes-head {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  padding-inline: var(--space-xs);
  margin-inline: calc(-1 * var(--space-xs));
  padding-bottom: var(--space-sm);
  border-bottom: var(--rule);
}
.annot-notes ol { list-style: none; }
/* Prose at full --ink, always (§2.4, and fix 4 above). 12px block padding on
   a 24px row reaches a 48px tap target; the negative inline margin lets the
   active wash bleed past the text without moving the left axis. */
.annot-notes li {
  display: grid;
  grid-template-columns: var(--space-lg) minmax(0, 1fr);
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-xs);
  margin-inline: calc(-1 * var(--space-xs));
  border-bottom: var(--rule);
  color: var(--ink);
  transition: background-color var(--dur-micro) var(--ease-out);
}
.annot-notes li[data-paired] { background: var(--pen-soft); }

/* The note's numeral. It takes --pen-ink, the token declared for exactly this
   job — "the pen at reading strength: text ON --pen-soft" — at 6.73:1 light
   and 6.25:1 dark. Not --pen: constraint 2 in tokens.css forbids it on this
   wash, at 4.66:1 light and 4.04:1 dark. Grey is forbidden there too, at
   3.95:1. Quieting drains the wash and leaves the numeral on bare ground,
   which is the one place it appears off --pen-soft; it clears there by a wide
   margin in both polarities (7.97:1 light, 8.7:1 dark).

   PAIRING NO LONGER MATCHES THE MARKER, AND THAT IS DELIBERATE. The paired
   note fills with the room's --pen and inverts to --ground, because the note
   sits on the page. The marker fills with --pen-artifact and inverts to the
   artifact's paper, because it sits on the exhibit, which is light in every
   room. In the light polarity the two still land on the same pair; in the
   dark they diverge, and a numeral keyed to the wrong surface is the thing
   being avoided. */
.annot-n {
  width: var(--icon);
  height: var(--icon);
  border-radius: 50%;
  background: var(--pen-soft);
  color: var(--pen-ink);
  font-family: var(--apparatus);
  font-weight: 700;
  font-size: var(--text-eyebrow);
  line-height: var(--icon);
  text-align: center;
  /* CENTRED ON THE NOTE'S FIRST LINE, so the numeral does not read as raised.
     The grid row top-aligns a 24px circle with a text span whose first line
     box is --lh-body tall — 28px from 40rem up — so the circle's centre
     would sit 2px above the line's.
     Half the difference brings the two centres together; below 40rem, where
     --lh-body is 24, it resolves to 0 and nothing moves. Derived from two
     tokens, not a hand-set 2px — which is why it is allowed off the 4px grid. */
  margin-top: calc((var(--lh-body) - var(--icon)) / 2);
  transition: background-color var(--dur-micro) var(--ease-out),
              color var(--dur-micro) var(--ease-out);
}
.annot[data-active] li:not([data-paired]) .annot-n { background: transparent; }
.annot-notes li[data-paired] .annot-n {
  background: var(--pen);
  color: var(--ground);
}

/* Captions are apparatus, and this one sits on --ground rather than inside a
   filled exhibit, so --ink-2 is the floor for the run-in label's partner. */
.annot-plate figcaption {
  margin-top: var(--space-sm);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
  max-width: var(--measure-apparatus);
}
.annot-plate figcaption b {
  display: block;
  font-weight: 500;
  color: var(--ink);
}

/* The zoom trigger is built by site.js around the frame as a plain .lb-open
   button, so the pattern degrades to a static annotated figure with JS off and
   the cursor affordance comes from .lb-open. No second class for the
   cursor: its only declaration would duplicate .lb-open's `cursor: zoom-in`
   (§4.8, variant residue). */

/* ---------- the annotated proof, in the lightbox ----------
 * The marks must not vanish at the moment the reader looks closest (§2.2).
 * ONLY --lb-* tokens are legal in this room; see fix 5 above and the note in
 * tokens.css. Measured against the worst-case composite (#13181E, the scrim
 * over a light page):
 *   note text      --lb-ink on the room            17.38:1
 *   note text      --lb-ink on the active wash     12.97:1
 *   notes head     --lb-ink-3 on the room           4.58:1
 *   caption        --lb-ink-2 on the room           8.02:1
 *   marker numeral --lb-ground over --lb-pen        4.97:1
 *   marker fill    --lb-pen against the room        4.69:1  (drawing device)
 * For comparison, the two values this replaces: the page's --pen against this
 * room is 3.19:1, and --lb-ink on --pen-soft is 1.17:1. */

.lb-annot { overflow: auto; cursor: default; }
.lb-annot[open] {
  display: block;
  padding: var(--space-2xl) var(--space-md) var(--space-md);
}
@media (min-width: 40rem) {
  .lb-annot[open] { padding: var(--space-2xl) var(--space-xl) var(--space-xl); }
}
/* The close control must not scroll away with the artifact. The plain viewer
   is `overflow: hidden` and never scrolls, so `position: absolute` is safe
   there; this dialog IS its own scroll container, and absolute positions
   against the whole scrollable content box. At 375x812 the annotated content
   runs about 1290px, so after ~76px of scroll the only dismiss control was
   off-screen — and touch has no Esc. Fixed anchors it to the viewport; the
   dialog is inset: 0, so top/right from .lb-close land in the same place. */
.lb-annot .lb-close { position: fixed; }
/* The same room as the page. It has to be the same, and not merely similar:
   the reader arrives here from there, and a lightbox that reflows to a
   different room than the exhibit that was just clicked is an enlargement
   that also rearranges. --page would cap this at 1024, which with a rail
   leaves the stage 664px of room for an 832px floor at 1440 — see
   .lb-annot-stage below.
 *
 * NO RAIL IN HERE. At 72rem the room this element gets is capped at 1152px
 * for every viewport ≥1248px; subtracting a 360px rail-and-gap leaves a
 * 792px stage column, under the --annot-lb-min floor (832px, .lb-annot-stage
 * below) that "genuine enlargement" depends on — the clipping the stage rule
 * below exists to stop would come back. Single-column at every width keeps
 * the stage at up to 1152px, comfortably clear of the floor. */
.lb-annot-inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-lg);
  align-items: start;
  max-width: var(--page-exhibit);
  margin-inline: auto;
}

/* GENUINE ENLARGEMENT, INCLUDING ON MOBILE. The previous attempt rendered the
   image at the same width the page already did — a 311px "enlargement" on a
   375px screen. The frame is floored at --annot-lb-min inside a stage that
   pans, so the artifact is always bigger in here than it was out there,
   whatever the viewport. The stage is a focusable scroll region so the pan is
   reachable from the keyboard as well as by touch. */
.lb-annot-plate { min-width: 0; }
/* THE STAGE PANS, AND IT SAYS SO. The floor is deliberate — an
   "enlargement" that renders at the width the page already used is not one —
   but a stage that clips in silence (scrollWidth 832 against clientWidth
   664, with no fade, no rule and no affordance of any kind saying there is
   more artifact to the right) is a defect. Two rules, in this order:

   1. STOP CLIPPING WHERE THERE IS ROOM. --page-exhibit and the 90rem
      breakout above raise the stage well past the 832px floor at 1440 and
      1600 (both render single-column at 1152px — see .lb-annot-inner
      above), so the floor stops binding at every width from 60rem up and
      the pan disappears along with the clip.
   2. WHERE IT STILL PANS — 320/375/768, where the 832px floor is the whole
      point of the room — SAY SO WITH THE KEYLINE. An artifact framed on four
      sides reads as complete; an artifact whose keyline stops at the edge it
      continues past reads as running off the plate, which is what it is
      doing. That is the editorial bleed convention, and it is built from the
      1px hairline §2.6 already gives every artifact — no gradient, no fade, no
      shadow, no new element. site.js sets data-pan to "start", "end" or
      "start end" on the stage at open, on scroll and on resize, so the side
      that opens is the side with more artifact behind it, and it is a
      measurable attribute rather than a rendering to eyeball.
      scrollbar-width/-color stay underneath as a second, free signal wherever
      the engine draws classic scrollbars — but they are NOT the affordance
      this relies on: macOS Chrome uses overlay scrollbars that are invisible
      at rest, and an affordance that cannot be measured is not one that can be
      claimed (§4.7). --lb-ink-3 is the room's quiet rank, 4.58:1 worst case. */
.lb-annot-stage {
  --annot-lb-min: 52rem;      /* 832 — base-4, and >2x a 375px viewport */
  overflow: auto;
  overscroll-behavior: contain;
  min-width: 0;
  scrollbar-width: thin;
  scrollbar-color: var(--lb-ink-3) transparent;
}
.lb-annot-stage[data-pan~="end"] .annot-frame { border-right-color: transparent; }
.lb-annot-stage[data-pan~="start"] .annot-frame { border-left-color: transparent; }
.lb-annot-stage .annot-frame {
  width: max(100%, var(--annot-lb-min));
  border-color: var(--lb-ink-3);
  background: none;
}
/* The marker's FILL has to switch rooms, and only the fill: the page's --pen
   is 3.19:1 against this scrim, and it would flip value under the reader when
   the page polarity changes behind the dialog. --lb-pen is 4.69:1 worst case,
   and --lb-ground as the numeral composites to #121016 over it — 4.97:1.
   Override the fill, not only the keyline: rgb(192,52,30) left as the fill
   is the 3.19:1 mistake.

   THE KEYLINE IS NOT OVERRIDDEN HERE. --lb-ground or --lb-ink on it would be
   the ROOM's colour on a line that sits on the ARTIFACT — against the
   artifact's own white, a --lb-ground keyline is a 17.84:1 black halo in
   both page polarities. The base rule's --keyline-quiet is fixed in both
   polarities and never changes, so it is already correct in every room and
   there is nothing here to override. The fill is a separate question, and
   only the keyline is at risk of the room-colour mistake. */
.lb-annot .annot-mark {
  background: var(--lb-pen);
  color: var(--lb-ground);
}
.lb-annot-inner[data-active] .annot-mark:not([data-paired]) { opacity: 0.3; }

.lb-annot-cap {
  margin-top: var(--space-sm);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--lb-ink-2);
  max-width: var(--measure-apparatus);
}
.lb-annot-cap b { display: block; font-weight: 500; color: var(--lb-ink); }

.lb-annot .annot-notes-head {
  color: var(--lb-ink-3);
  border-bottom-color: var(--lb-ink);
}
.lb-annot .annot-notes li {
  color: var(--lb-ink);
  border-bottom-color: var(--lb-ink-3);
}
/* The pen wash, mixed for a dark room instead of borrowed from a light one.
   26% is what matches the page's own --pen-soft/--ground surface step (1.34
   here against 1.17 there) while keeping --lb-ink on it at 12.97:1. */
.lb-annot .annot-notes li[data-paired] {
  background: color-mix(in srgb, var(--lb-pen) 26%, transparent);
}
.lb-annot .annot-n {
  background: color-mix(in srgb, var(--lb-pen) 26%, transparent);
  color: var(--lb-ink);
}
.lb-annot-inner[data-active] li:not([data-paired]) .annot-n { background: transparent; }
.lb-annot .annot-notes li[data-paired] .annot-n {
  background: var(--lb-pen);
  color: var(--lb-ground);
}

/* ---------- case grammars ----------
 * One system, three evidence grammars: the chrome and tokens are
 * identical on every case page; what differs is the shape the evidence takes,
 * because the material differs. Trust = diagnostic (compare + proof-on-demand).
 * Design system = generational (numbered eras). Juva = longitudinal (dated
 * record + protocol). */

/* Diagnostic: paired comparison — always two-up ≥40rem, stacked below */
.exhibits.compare { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 40rem) { .exhibits.compare { grid-template-columns: minmax(0, 1fr); } }
/* Both sit on --exhibit-ground, so the quieter of the pair is --ink-2 and not
   --ink-3, which fails on that ground. */
.exhibit figcaption .was { color: var(--ink-2); }
.exhibit figcaption .shipped { color: var(--ink); }

/* Diagnostic: proof on demand — the study's own trust posture, enacted.
   Native <details>; evidence one layer down, never the shipped answer.
   This attaches supporting evidence under a section, it does not open one
   — the rule is the same --rule as every other on the page; the job is what
   differs. The summary is a link, so it takes the link's ink. */
.proof { margin-top: var(--space-lg); border-top: var(--rule); }
.proof summary {
  padding-block: var(--space-sm);
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: var(--lh-body);
  color: var(--ink);
  cursor: pointer;
  list-style: none;
}
.proof summary::-webkit-details-marker { display: none; }
.proof summary::before { content: "+ "; }
.proof[open] summary::before { content: "− "; }
.proof summary:hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.3em; }
.proof > .exhibits { margin-top: var(--space-xs); margin-bottom: var(--space-lg); }

/* Generational: era markers — the four generations are the page's spine.
   No oversized-numeral treatment (a title-size digit with a stacked date)
   and no .gen-eyebrow of its own: a dated marker immediately before an h2
   is the same job the longitudinal page's `.when` does — a sticky running
   note in the page margin from 90rem up, in the reading column below it,
   described in full at "the running margin marker" further down this file
   — and two components doing one job is an inconsistency, not a style. The
   four generation markers are `<span class="when">`, the exact tag and
   class the dated-record page uses, and pick up its behaviour for free: the
   64px-top/8px-to-h2 spacing, the `:first-of-type` full-margin exception on
   Generation 1, and the margin-note treatment at wide viewports. The h2
   carries no "Generation N — " prefix, because the marker already does.
   This note stays so a future author grepping for "Generation" finds why
   there is no .gen-eyebrow to land on. */

/* Longitudinal: dated markers — the record reads in real time */
.when {
  display: block;
  margin-top: var(--space-2xl);
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.when + h2 { margin-top: var(--space-xs); }

/* ---------- the running margin marker (90rem and up) ----------
 * WHAT THE MARGIN IS FOR. A margin is the one thing a document design gets
 * that a web design doesn't, and building one and leaving nothing in it
 * wastes it: at 1440 the prose runs 256→800 and the 640px to its right would
 * otherwise carry no marginalia, no running head, no figure numbers.
 *
 * WHAT GOES IN IT, and why this and not something else. The site's thesis is
 * producible dates: the ledger grades them and these pages are structured
 * by them. But the timeline is only reconstructable by scrolling.
 * Making the argument's spine visible WHILE THE READER IS INSIDE IT is a
 * document-design move, and it fills the margin with the one thing the site
 * is about. The rejected alternative was exhibit numbers ("Ex. 1", "Ex. 2"):
 * numbering the artifacts adds apparatus without adding information, and a
 * margin filled badly is worse than one left empty.
 *
 * CSS, NOT JS, AND NO NEW MARKUP. `position: sticky` on the `.when` span that
 * is already there. This was preferred on three counts, and the third is the
 * one that decided it:
 *   1. CSP is `script-src 'self'` and the site's JS is one external file; a
 *      scroll observer would be a new subsystem for a piece of chrome.
 *   2. Sticky needs no scroll listener, no observer and no rAF, so it cannot
 *      be janky and cannot disagree with the scroll position.
 *   3. ACCESSIBILITY. A JS marker would be a DUPLICATE of text already in the
 *      reading order, which then has to be hidden from assistive technology
 *      and kept hidden. This moves the ORIGINAL element. The DOM is unchanged,
 *      the reading order is unchanged, the accessibility tree is unchanged:
 *      a screen reader hears each date exactly once, in its own place before
 *      the heading it introduces, at every width. There is nothing to mark
 *      aria-hidden because nothing is duplicated.
 *
 * WHERE IT APPEARS, derived rather than picked. It needs a clear page margin
 * right of the content column wide enough for the column (--margin-note,
 * 176px) plus a gutter off the content edge (--space-2xl / 2 = 32px). That
 * margin measures (client width - 1024) / 2 — 0 at 1024, 128 at 1280, 208 at
 * 1440 — and first covers 208px at exactly 1440. So the marker arrives at
 * 90rem, which is also where `.annot` breaks out, and below 90rem this whole
 * block is inert: `.when` is the kicker it has always been, in the reading
 * column, above its h2. It never overlays prose, never overlays an exhibit
 * (the exhibits column ends at the content edge, 32px to its left at 1440 and
 * further left above), and reserves nothing at any width.
 *
 * IT DOES NOT SHARE --page-exhibit's EDGE. Right-aligning the column to the
 * x --page-exhibit declares — the same edge `.annot` breaks out to — would
 * put the two on one outer boundary rather than two edges a few pixels
 * apart, but at 72rem the token becomes the binding term at 1248px, well
 * inside this marker's own range, and 72rem's ceiling leaves only 112px of
 * push-out where the column (176px) plus its gutter (32px) needs 208px:
 * routed through --page-exhibit, the column's left edge lands 64px inside
 * the reading column it is meant to sit beside at 1440px. The marker is not
 * the thing asking for an artifact-width cap — a fixed 176px column of text
 * does not get jarringly wide no matter how much margin surrounds it — so
 * the expression below computes the right edge directly from the raw page
 * margin, uncapped by --page-exhibit. It is the same arithmetic the arrival
 * derivation above already uses (100vw − 2×--pad), now also driving the
 * position: 1216→1392 at 1440, 1376→1552 at 1600. See tokens.css at
 * --margin-note for the token-level record of why.
 *
 * THE LEFT AXIS. Marginalia sits outside the type area by definition, so its
 * x is not the content axis. This is a deviation from §2.6's single left axis
 * of the same species as `.annot`'s, and it is ON RECORD, not an amendment:
 * §2.6 and §5 in the design language carry it. Two mitigations, stated: the
 * marker is chrome rather than content, and it is right-anchored, so its left
 * edge is a consequence of a declared right edge rather than a second axis
 * things line up on.
 *
 * IT COSTS NO VERTICAL SPACE, and this is arithmetic, not a nudge. margin-top
 * drops 64 → 40 and margin-bottom goes negative by exactly
 * (--margin-note-min - --space-md), so the distance from the previous
 * element's bottom to the h2's top is 40 + M + (8 - (M - 16)) = 64px whatever
 * M turns out to be — negative margins collapse as max(positives) +
 * min(negatives), which is what makes M drop out. At every width from 1440
 * up, `.when` bottom to h2 top is -24px and the previous element's bottom to
 * h2 top is 64px, on every marker.
 * 64px is ALSO exactly what a `.case-body h2` with no `.when` before it gets
 * — "What I claim" and "Reflection" measure 64 at every width — so the wide
 * layout is more regular than the narrow one, not less. It is NOT identical
 * to the narrow layout, and should not be: below 90rem the kicker is in the
 * reading column and its row costs 24px (88px from the previous element),
 * because there it is a kicker. At 90rem it is not in the column at all.
 *
 * WHY OPAQUE, AND WHY A MINIMUM HEIGHT. Consecutive sticky siblings do not
 * push each other: they pin at the same `top` and the later one paints over
 * the earlier. That IS the running-head behaviour wanted — the date advances
 * as the reader crosses into the next section — but only if the arriving
 * marker covers the outgoing one completely. --ground and a shared
 * --margin-note-min do that. THE FLOOR HAS TO BE THE TALLEST STRING: at
 * --margin-note every live `.when` string sets 3 lines (48px), so 3rem
 * covers them. A future `.when` long enough to run four
 * lines would be 64px, and a 48px successor would leave its last line
 * showing — raise --margin-note-min with the string, or shorten the string. A marker not yet pinned sits at its natural
 * place in the margin beside its own section, so approaching a section shows
 * the next date below the current one for about a screen. That is the
 * timeline reading, and it is intended.
 *
 * MOTION: none. No transition, no animation, no transform. Sticky is a
 * position, not a movement; nothing here has anything to suppress under
 * prefers-reduced-motion, which is why there is no guard around it.
 *
 * TYPE AND COLOUR ARE UNCHANGED — apparatus face, --text-eyebrow, --ink-3,
 * 4.60:1 light / 4.61:1 dark on --ground (tokens.css constraint 1: --ink-3 is
 * authored against --ground only, and --ground is what it sits on here). It
 * is chrome and must not compete with the reading column, so it gets no size,
 * weight or colour of its own for the move. */
@media (min-width: 90rem) {
  .when {
    position: sticky;
    top: var(--space-2xl);
    z-index: var(--z-raised);
    width: var(--margin-note);
    min-height: var(--margin-note-min);
    margin-left: auto;
    margin-top: var(--space-xl);
    margin-right: calc((100vw - 2 * var(--pad) - 100%) / -2);
    margin-bottom: calc(var(--space-md) - var(--margin-note-min));
    background: var(--ground);
  }
  /* The 64→40 drop above exists so a later .when's reduced margin-top is
     bought back by the negative margin-bottom on the .when before it,
     keeping the distance to each h2 constant once markers start pinning
     over one another mid-scroll. The page's first .when has no earlier
     marker to receive that compensation from, so it's the one instance
     where the reduction isn't paid back — it would just sit 40px off the
     case-body divider instead of the usual 64px, reading as too close to
     the top when the page loads. The full margin is restored here only for
     that one marker; every other .when keeps the tuned 40px. */
  .case-body > .when:first-of-type { margin-top: var(--space-2xl); }
}

/* ---------- the claims ledger (§2.3) ----------
 * ALL THREE GRADES ARE MARKED, AND THEIR WEIGHTS ARE STRICTLY ORDERED.
 * the design language §2.3 states the same rule.
 *
 * THE ALTERNATIVE, AND WHY NOT — it is half right. Leaving a documented
 * claim's grade cell empty (not a word, not a dash, nothing) on the theory
 * that marking every row trains the eye to stop reading the column before
 * it reaches the two rows that matter. The concern is real — it comes from
 * the implied-truth literature (§3): seven identical chips before the two
 * informative rows dilute them. The remedy is wrong. An empty cell under a
 * column headed SOURCE does not read as "the best grade"; read cold, it
 * reads as missing data — two missing values and two flagged ones, the good
 * news invisible and the caveat in red.
 *
 * The dilution that rule fears comes from marking every row THE SAME.
 * Three marks at three strictly ordered weights say the same thing an empty
 * cell was trying to say — this row is unremarkable — while leaving nothing
 * that reads as a gap:
 *
 *   documented    --ink-3, no fill, no border       4.60:1 light / 4.61:1 dark
 *   recollection  --ink-2 on --exhibit-ground       7.37:1 light / 5.96:1 dark
 *   not measured  --pen-ink on --pen-soft            6.73:1 light / 6.25:1 dark
 *
 * The order holds on two axes at once — surface (no wash → grey wash → the
 * pen's wash) and colour (grey → grey → the pen's hue) — and the `not
 * measured` row is still the only thing in the column the pen touches.
 *
 * THE PEN ARRIVES AS A WASH WITH ITS OWN PIGMENT FOR TEXT, NOT AS A FILL
 * AND NOT AT FULL STRENGTH. A reversed `not measured` — --ground text on a
 * solid --pen fill — at table scale reads exactly as a filled, coloured,
 * rounded-corner-free rectangle reads everywhere else on the web: a button,
 * or worse, an error state, competing with the claim it sits beside rather
 * than grading it. --pen text at full saturation, on the wash or on bare
 * ground, reads as the same error state one size down. Ink text on the wash
 * goes the other way: the tint reads as a disabled surface and the red is
 * nowhere the eye lands. --pen-ink is the wash's own hue at text strength
 * and the ink ramp's weight, so the chip is unmistakably the pen's and still
 * a grade, not an alarm.
 *
 * The `.claims-key` sentence beneath the table must not say unmarked rows
 * are documented: every row is marked.
 *
 * Real table markup throughout, on purpose: <caption> (visually hidden,
 * carries the table's accessible name), <thead>, <th scope="col">. The
 * mobile rule below switches <tr> to `display: grid` so a long claim
 * sentence gets the full measure on its own line instead of being squeezed
 * into a ~200px column — see the mobile block for why that switch would
 * normally be a defect and how it stays safe here.
 *
 * Role split (§2.4): "table headers" and "keys" are apparatus; "table
 * cells" are prose, so cell text needs no override — `body` already sets
 * --prose, and this is the one place in the component where silence is
 * correct rather than a leak.
 *
 * The frame border, the header rule below it, and the row dividers all draw
 * the single --rule token (§2.6), same as every other rule and table/list
 * boundary in this file. */
/* Uniform 24px on all four sides, not three of one value and one of
   another.
   WHY THIS IS A LITERAL AND NOT A TOKEN: there is no 24 rung on the space
   scale to name — it steps 16 → 32 with nothing between. --icon is 24 but
   is an icon size, not a padding, and borrowing it here would recreate
   exactly the double duty that token exists to end. So this stays an
   authored 24 until the scale gets a real rung for it. It is base-4 and
   therefore not an entry for the census in this file's header, which tracks
   values that are NOT 4px multiples. */
.claims-ledger {
  margin-top: var(--space-xl);
  max-width: var(--measure-apparatus);
  border: var(--rule);
  padding: 1.5rem;
}
/* A real heading, not a quiet eyebrow-style label — bold, full --ink,
   sentence case, no tracking: the h1-h6 base rule's own -0.02em applies
   instead of the eyebrow's 0.08em, so this rule overrides neither
   letter-spacing nor text-transform. Shared by every claims-ledger on the
   site (homepage, all three case studies). */
.claims-ledger-head {
  font-family: var(--apparatus);
  font-weight: 700;
  font-size: 1rem;
  line-height: 1.75rem;      /* 16 / 28 */
  color: var(--ink);
  margin-bottom: var(--space-md);
}

.claims-table { width: 100%; border-collapse: collapse; }
.claims-table th,
.claims-table td { text-align: left; vertical-align: top; }

.claims-table thead th {
  font-family: var(--apparatus);
  font-weight: 500;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  padding-bottom: var(--space-sm);
  border-bottom: var(--rule);
}

.claims-table tbody td {
  padding: var(--space-sm) 0;
  border-bottom: var(--rule);
  color: var(--ink);
  font-size: var(--text-body);
  line-height: var(--lh-body);
}

/* Claim column takes the row; the grade column is only as wide as its
 * content and never wraps, so a two- or three-word chip stays on one line
 * at every declared width down to the mobile switch below. No width rule
 * on the claim column: `width: 100%` here is a real bug — on an
 * auto-layout table it does not mean
 * "the remaining space," it means "100% of the table," so the table grows
 * to fit that plus the grade column and overflows its container. Auto
 * layout already gives the longer column the room on its own.
 *
 * Scoped by class, not `:last-child` — also a real bug. The detail row's
 * colspanned cell is its row's ONLY child, so it is simultaneously
 * :first-child and :last-child; a :last-child rule catches it and forces
 * its 200+ character sentence onto one `white-space: nowrap` line, which
 * blows the table out to ~1850px. `.claims-grade` matches only the real
 * second-column cells. */
.claims-table thead th.claims-col-source,
.claims-table tbody td.claims-grade {
  padding-left: var(--space-md);
  text-align: right;
  white-space: nowrap;
}

/* The chip. Apparatus, never prose — it is a label, not a sentence.
 * Padding is base-4 (4px / 8px); the prototype this replaces used
 * 3px/6px, which is not a 4px multiple (§2.6). Letter-spacing is 0.08em,
 * not the prototype's 0.04em: every other uppercase apparatus micro-label
 * in this file — kickers, notes head, table head, this component's own
 * head — is 0.08em, and the prototype's value was an unexplained one-off
 * the rest of the system never repeats.
 *
 * The base class still carries NO colour and NO background of its own. That
 * was true when a documented row had no chip at all and it is still true now
 * that it has one: every grade names its own modifier, so a `.grade` written
 * without one renders as inherited body colour on nothing and is visibly
 * unfinished rather than plausibly generic. */
.grade {
  display: inline-block;
  font-family: var(--apparatus);
  font-weight: 600;
  font-size: var(--text-eyebrow);
  line-height: var(--lh-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: var(--space-2xs) var(--space-xs);
  white-space: nowrap;
}
/* Documented, the quietest rank. --ink-3 on --ground, 4.60:1 light / 4.61:1 dark, and
 * that is the whole treatment: no fill, no border, no rule. It is the only
 * grade with no surface of its own, which is what keeps it under
 * `recollection` even though both are grey. --ink-3 is legal here and only
 * here in this component: tokens.css authors it against --ground ONLY (4.21:1
 * light / 3.41:1 dark on --exhibit-ground), and the grade cell sits on bare
 * --ground — the ledger's box has no background. Do not give this chip a fill
 * without moving it to --ink-2 at the same time.
 * The `padding` inherited from .grade is what keeps the three chips on one
 * optical baseline and one left edge in the mobile stack; it costs nothing
 * visually with no fill behind it. */
.grade-documented { color: var(--ink-3); }
/* Recollection: ink-2 on exhibit-ground, 7.37:1 light / 5.96:1 dark.
 * Neutral — this
 * grade is not an alarm, just a citation status, so it does not reach for the
 * pen. The mid rank of three: a real surface, no accent. */
.grade-recall { color: var(--ink-2); background: var(--exhibit-ground); }
/* Not measured: --pen-ink on --pen-soft, 6.73:1 light / 6.25:1 dark. The
 * third rank is the only one the pen touches, and it touches it twice at
 * reduced strength: the wash as the surface, and the wash's own hue at
 * text weight for the word. The designer's own hand (§2.5) admitting an
 * unverified claim, without the tone of an error message.
 * NOT --ink on the wash: dark text on a pale tint reads as a disabled state
 * and hides the one colour the chip exists to carry. NOT --pen on the wash:
 * 4.04:1 in the dark polarity, under the site's floor (tokens.css,
 * constraint 2), and at full saturation it reads as an alert. NOT --ground
 * on a solid --pen fill: a reversed, filled rectangle at table scale reads
 * as a button or an error state and competes with the claim beside it. */
.grade-unmeasured { color: var(--pen-ink); background: var(--pen-soft); }

/* The row that follows a `not measured` claim, always. §2.3: this sentence
 * is what turns a confession into evidence of measurement literacy, and it
 * is "the single most valuable element on the site" — so it stays at full
 * body size and full --ink, the same prose weight as the claims themselves,
 * not demoted to fine print. It is a normal <tr>, immediately after the
 * claim it explains, so proximity carries the relationship for sighted
 * readers and DOM order carries it for a screen reader moving row by row —
 * no colspan trickery beyond the one colspan the layout needs. */
.claims-table tbody tr.claims-detail td {
  /* Pads top like every other row. It sits under that row's rule and
     reads as its own row, so a tighter top edge reads as a mistake
     rather than as binding it to the claim it explains. */
  padding-top: var(--space-sm);
  padding-bottom: var(--space-md);
  color: var(--ink);
  font-size: var(--text-body);
  line-height: var(--lh-body);
}
/* Short paragraphs, not one run-on block. */
.claims-table tbody tr.claims-detail td p { margin: 0; }
.claims-table tbody tr.claims-detail td p + p { margin-top: var(--space-sm); }

.claims-key {
  margin-top: var(--space-md);
  font-family: var(--apparatus);
  font-size: var(--text-fine);
  line-height: var(--lh-fine);
  color: var(--ink-2);
}
.claims-key b { color: var(--ink); font-weight: 600; }

/* Inside a case study the key is caught by `.case-body p`, which caps running
   prose at --measure. The key is not running prose — it is apparatus inside a
   bordered container, and it should take that container's own content box, the
   same edge it takes on the homepage where no such rule exists. Removing the
   cap adds no fourth right edge: the ledger's box is --measure-apparatus and
   its padding is the offset, the nested-container case the right-edge block at
   the top of this file already licenses for a caption inside an exhibit.
   At 1440: 544px under `.case-body p`, against the ledger's own 590px
   content box (640 frame − 48 padding − 2 hairlines). */
.case-body .claims-key { max-width: none; }

/* ---------- mobile: stacked rows, semantics intact ----------
 * The bug this replaces: an earlier attempt set `display: grid` on <tr> and
 * <td> with no explicit role, which strips the implicit table/row/cell
 * roles the moment the computed display value stops matching a table
 * display type — visible in the accessibility tree of the exact markup
 * below with and without the role attributes:
 * without them, <thead>/<tbody>/<tr>/<th>/<td> all collapse to `generic`
 * and a screen reader loses row/column navigation entirely; with them,
 * `rowgroup` / `row` / `columnheader` / `cell` survive unchanged.
 *
 * The fix is not to avoid the grid switch — a long claim sentence genuinely
 * reads better on its own full-width line than squeezed into a ~200px
 * column at 375px — it is to pair the display change with the ARIA roles
 * that mirror the table's own implicit semantics, on every table element,
 * so the accessible tree does not depend on which CSS `display` is live at
 * a given width. The markup contract below carries these roles at every
 * breakpoint, not only inside this media query, so there is one static
 * markup shape and the CSS is the only thing that changes at 40rem. */
@media (max-width: 40rem) {
  /* The header stays in the accessibility tree (it still carries
   * `role="rowgroup"` / `role="row"` / `role="columnheader"`) but is
   * visually redundant once each row is labelled by its own claim
   * sentence, so it is hidden the same way as .sr-only rather than removed. */
  .claims-table thead {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .claims-table, .claims-table tbody { display: block; }
  .claims-table tbody tr {
    display: grid;
    grid-template-columns: 1fr;
    row-gap: var(--space-2xs);
    padding-block: var(--space-sm);
    border-bottom: var(--rule);
  }
  /* Matched to `.claims-table tbody td`'s specificity above (class + two
   * type selectors), not a bare `.claims-table td` — the weaker selector
   * loses the cascade to the desktop rule entirely: border-bottom and
   * padding both stay at their desktop values here, which produces a double
   * hairline under every documented row — the row's own border plus each
   * cell's untouched one. */
  .claims-table tbody td {
    padding: 0;
    border-bottom: 0;
    width: auto;
  }
  /* Scoped by class, same reason as the desktop rule above: the detail
   * row's colspanned cell is both :first-child and :last-child of its row,
   * and a :last-child selector here would strip its left padding too. */
  .claims-table tbody td.claims-grade {
    text-align: left;
    white-space: normal;
    padding-left: 0;
  }
  /* No `.claims-table tbody tr:has(td.claims-grade:empty) { row-gap: 0 }`
     rule: it would close the 4px row gap under a claim whose second cell
     renders as an empty grid track, but no grade cell is empty on any of
     the four ledgers, so the selector would match nothing (§4.8) — every
     stacked row takes the same 4px gap between its claim and its grade. */
  /* At this width the <tr> carries the padding and the hairline (the <td>
   * above is zeroed), so the detail row's spacing has to be written against
   * the row rather than the cell. Values mirror the desktop cell exactly:
   * --space-sm top, matching every other row, and --space-md bottom for the
   * air before the key. Specificity (0,2,2) beats `.claims-table tbody tr`
   * (0,1,2), the same cascade problem the cell rule above solves. */
  .claims-table tbody tr.claims-detail { padding-block: var(--space-sm) var(--space-md); }
  .claims-table tbody tr.claims-detail td { padding: 0; }
}

/* ---------- annotated-proof marker coordinates ----------
 * THIS IS THE ONLY PLACE MARKER COORDINATES MAY LIVE, and it is deliberately
 * the last section in the file. There is no per-page stylesheet — every page
 * links exactly /tokens.css and /styles.css — so without a named home the
 * coordinate rules would either scatter through the component section above or
 * be improvised per page. New exhibits append here and nowhere else.
 *
 * One rule per marker, scoped by the exhibit's data-exhibit name — never by
 * id: the lightbox clones the frame onto <body>, outside the page container,
 * so an id-scoped rule stops matching exactly when the reader looks closest
 * and every marker falls back to the 50%/50% default, stacked. site.js mirrors
 * data-exhibit onto .lb-annot-stage so one name scopes both rooms.
 * A per-breakpoint override is a two-line addition. --mx/--my are the marker's
 * CENTRE, as percentages of the RENDERED image box.
 *
 * Keep each exhibit's rules in one block, under a comment naming the artifact
 * and its source pixel dimensions, so a re-crop is traceable to the block it
 * invalidates. Re-check every block against the pen/product-alert clearance
 * rule in the component section above at EVERY breakpoint — one marker
 * diameter is 24 ÷ the rendered width, which is a different percentage at each
 * one (7.2% at a 333px render, 4.2% at 568px, 2.9% in the lightbox).
 *
 * site.js warns in the console if two markers in a group resolve to the same
 * position — the symptom of a missing or mistyped rule here, and the same
 * signature as the display: inline bug the component section documents.
 *
 * Shape:
 *
 *   /* lift-summary — Orita revenue summary, source 1600x1427 *\/
 *   [data-exhibit="lift-summary"] .annot-mark-1 { --mx: 5%;   --my: 9%; }
 *   [data-exhibit="lift-summary"] .annot-mark-2 { --mx: 36%;  --my: 9%; }
 *   @media (max-width: 60rem) {
 *     [data-exhibit="lift-summary"] .annot-mark-2 { --mx: 36.5%; --my: 9.5%; }
 *   }
 *
 */

/* lift-summary — Orita Email Revenue Lift Summary, source 1598×1427 (a
 * 1600×1427 capture with 2px cropped off the right edge to remove a faint
 * grey seam; the `<img>` width attribute matches. The shift is 2 parts in
 * 1600, 0.13%, well under any rounding this file already carries, so the
 * --mx/--my percentages below and the clearance table are stated against
 * 1600).
 * Homepage, evidence unit 01 — the page's one annotated exhibit.
 * --mx/--my are the marker's CENTRE as a percentage of the RENDERED image
 * box; the source pixels each one was derived from are given below so a
 * re-crop is traceable to the rule it invalidates.
 *
 *   1  (460, 200)   inside the "Total Incremental Email Revenue" card, right
 *                   of the green total, on the same band as marker 2 — the
 *                   two summary-card markers read as a pair. Sampled from
 *                   the source PNG: the green total's glyphs span source
 *                   x103–369/y194–219, and the card's own right boundary
 *                   (the divider before card 2) starts at x546 — 460 sits
 *                   comfortably inside that gap, clear of both.
 *   2  (960, 200)   inside the "Total Untapped Revenue" card, right of the
 *                   greyed total, clear of the amber ⚠ in the card title
 *   3  (52, 440)    the left page margin, on the same horizontal band as the
 *                   plain-language description row — preference (a) in the
 *                   collision rule above: the quiet margin, joined to its
 *                   target by the row. That band is the one place in the
 *                   left margin with no row label to sit on top of.
 *   4  (52, 1277)   the left page margin, on the totals row's band. Same x as
 *                   marker 3 on purpose: two markers in the same margin on
 *                   different axes read as an accident.
 *
 * PEN / PRODUCT-ALERT CLEARANCE. This artifact carries three amber elements
 * of its own: the ⚠ in the untapped stat card (587–607 × 142–162 source px),
 * the ⚠ in the untapped column header (806–824 × 377–395), and the whole
 * Grow List Adoption Rate percentage column (560–628 × 563–1215). The marker
 * is a fixed 24 CSS px, so its footprint in SOURCE pixels grows as the render
 * shrinks — 115 source px at a 333px render, 68 at 566, 46 at 830 — and its
 * clearance in CSS px therefore rises monotonically with render width. The
 * narrowest render (333px, a 375 viewport stacked) is the binding case for
 * every marker, and all four clear one diameter (24 CSS px) there:
 *
 * The table below is the GEOMETRY SURVEY. Not every column occurs at a live
 * breakpoint, but clearance rises monotonically with render width, so a
 * live render between two columns clears by at least the smaller of them,
 * and any render wider than the widest column clears by more than it. The
 * 830px column is the lightbox floor. The 333px column is the binding case.
 *
 *   marker  333px   470px   502px   566px   830px    nearest amber
 *   1        42.5    65.6    71.0    81.9   126.8    adoption-rate column
 *   2        29.7    48.8    53.3    62.2    99.1    ⚠ untapped col. header
 *   3        92.3   135.9   149.8   170.7   256.9    adoption-rate column
 *   4        93.0*  137.4   147.6   168.0   252.3    adoption-rate column
 *   (*) marker 4 at 333px is in its override position, below.
 *
 * THE ONE OVERRIDE is markers 3 and 4 at narrow widths, and it is not about
 * the pen collision — it is about the artifact's own row labels. The left
 * margin is only 105 source px wide before the "Time Period" column starts,
 * and the marker's SOURCE footprint is 115px at a 333px render. So at 3.25%
 * marker 4 clears the word "Total" (105–148 × 1268–1292) by 3.6 CSS px at a
 * 470px render and more above that — every rail width and the lightbox — but
 * below 470px it lands on the "T" of the very row its note names. 470px of
 * image is a 512px viewport while --pad is 1.25rem, which is where the 32rem
 * query comes from: it is derived from the marker's own geometry, not picked.
 * Under it, marker 4 steps down into the row's lower margin (nothing is
 * printed there at any width) and both margin markers go to 4%, which keeps
 * them on one axis and keeps marker 3 inside the image box. */
[data-exhibit="lift-summary"] .annot-mark-1 { --mx: 28.75%; --my: 14%;   }
[data-exhibit="lift-summary"] .annot-mark-2 { --mx: 60%;    --my: 14%;   }
[data-exhibit="lift-summary"] .annot-mark-3 { --mx: 3.25%;  --my: 30.8%; }
[data-exhibit="lift-summary"] .annot-mark-4 { --mx: 3.25%;  --my: 89.5%; }

@media (max-width: 32rem) {
  /* Scoped off the lightbox on purpose: this override's reason is the image's
     RENDER width (470px, where the left margin's "Total" label collides with
     marker 4 — see the geometry note above), not the viewport. The lightbox
     floors its image at --annot-lb-min (830px) no matter how narrow the
     viewport gets, so the collision this fixes never happens in there — the
     base coordinates already clear it (see the clearance table, §2.2). Do not
     drop the :not() to "simplify" this: that would silently reapply a fix
     the lightbox's own render doesn't need. data-exhibit is mirrored onto
     .lb-annot-stage when the lightbox opens (site.js), which is what
     :not(.lb-annot-stage) excludes. */
  [data-exhibit="lift-summary"]:not(.lb-annot-stage) .annot-mark-3 { --mx: 4%; }
  [data-exhibit="lift-summary"]:not(.lb-annot-stage) .annot-mark-4 { --mx: 4%; --my: 95.3%; }
}
