/* =====================================================================
   FINS3616 — ONE PLATE, PRINTED TWO WAYS
   Implements DESIGN-IDENTITY.md (binding). DESIGN.md is superseded; its
   gold palette is dead. SPEC.md still owns class names.

   The whole system in six lines:
     · Pure black ground. One blue. Green and red mean CORRECT / INCORRECT.
     · Radius is 0, or a full circle. Nothing in between exists.
     · Serif for what you read, sans for what you operate.
     · No cards. Group with rules, labels and space.
     · Every chromatic plane carries the GROUND colour as its type (§2.4).
     · No shadows, no tints, no gradients, no opacity as a state.

   Contents
     1  TOKENS            palette, type, grid, motion
     2  RESET AND BASE
     3  TYPE
     4  LAYOUT            shell, masthead, nav, container, the 34-unit field
     5  PRIMITIVES        buttons, chips, fields, labels, rows, badges
     6  QUIZ SURFACE      Template A — stem, options, answer strip
     7  SHEET             Template C — the explanation
     8  OVERLAYS          modal, toast
     9  DATA              progress, figures, tables, results
    10  BROWSE
    11  RESPONSIVE
    12  MOTION AND A11Y

   Rules that constrain every line below — a reviewer can fail this file
   mechanically against DESIGN-IDENTITY §10:
     · hex literals live ONLY in the token blocks of §1
     · exactly nine colour custom properties per theme
     · border-radius is only ever `0` or `999px`
     · four non-zero letter-spacing values exist: -0.025em, 0.10em,
       0.02em, 0.006em
     · every font-size is rem or a --t-* token; never px
     · --good / --bad appear only in selectors carrying .correct, .wrong,
       .verdict or .sheet
     · three easing tokens; nothing over 300ms; exits shorter than entrances
   ===================================================================== */


/* =====================================================================
   1  TOKENS
   ===================================================================== */

/* --- the nine colours, dark: the reference design (§2.2) --------------
   Contrast, computed against the stated ground, never estimated:
   ink 17.48 · ink-2 8.24 · ink-3 5.33 · blue 6.69 · good 10.03 ·
   bad 5.85 · rule 3.13 (non-text, 1.4.11). ink on surface 15.31. */
:root {
  color-scheme: dark;

  --bg:      #000000;   /* the page. Never lightened. Never a gradient. */
  --surface: #141416;   /* ONE surface, used by ONE element: the sheet. */
  --rule:    #5A5C60;   /* the one hairline                            */

  --ink:     #EDEAE3;   /* primary figure — warm off-white             */
  --ink-2:   #A5A29B;   /* secondary text, labels                      */
  --ink-3:   #83807A;   /* metadata. AA-legal at any size.             */

  --blue:    #5A8DFF;   /* the accent. One plane per screen, maximum.  */
  --good:    #4CC98A;   /* CORRECT — and nothing else, ever            */
  --bad:     #F04E37;   /* INCORRECT — and nothing else, ever          */

  /* theme-conditional rendering, folded into the palette so there is one
     switch and not three (§3.5, §5.4) */
  --smoothing: antialiased;    /* thins light-on-dark glyphs back to intent */
  --prose-track: 0.006em;      /* irradiation closes serif counters on black */
  --icon-shave: .2;            /* −6.7% mass, Material's GRAD −25 without a
                                  variable font (§5.4) */
}

/* --- light: "paper" (§2.3) -------------------------------------------
   ink 17.81 · ink-2 7.35 · ink-3 5.10 · blue 9.90 (Rand's IBM
   ultramarine, untouched) · good 6.33 · bad 6.09 · rule 3.25.
   Declared twice — once for the explicit choice, once for the OS
   preference — because a custom property cannot be shared across a
   media boundary. Same nine tokens, same values, both times. */
@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] {
    color-scheme: light;
    --bg:      #FBFAF7;
    --surface: #FFFFFF;
    --rule:    #918B7C;
    --ink:     #14130F;
    --ink-2:   #56534C;
    --ink-3:   #6E6B63;
    --blue:    #0530AD;
    --good:    #0E6B37;
    --bad:     #B32D19;
    --smoothing: auto;         /* antialiased washes out small dark text */
    --prose-track: 0;
    --icon-shave: 0;           /* no irradiation to correct for on paper */
  }
}
:root[data-theme="light"] {
  color-scheme: light;
  --bg:      #FBFAF7;
  --surface: #FFFFFF;
  --rule:    #918B7C;
  --ink:     #14130F;
  --ink-2:   #56534C;
  --ink-3:   #6E6B63;
  --blue:    #0530AD;
  --good:    #0E6B37;
  --bad:     #B32D19;
  --smoothing: auto;
  --prose-track: 0;
  --icon-shave: 0;
}

/* --- readers who ask the OS for more contrast (§2.6) ------------------
   --ink ships at 17.48 rather than 18.45 by default because near-white on
   true black halates at night for astigmatic readers; the extra stop is
   here for anyone who asks the OS for it. --rule rises to 4.29. */
@media (prefers-contrast: more) {
  :root {
    --ink:  #F2F0EC;
    --rule: #6E7176;
  }
  .hairline { border-width: 2px; }
}

/* --- type (§3) -------------------------------------------------------- */
:root {
  /* Charter leads: Carter's transitional, the nearest thing to Baskerville
     that ships on a phone. Cambria before Georgia because Windows must land
     on lining figures — Georgia's old-style numerals break every column in
     a finance app. No ui-serif / New York: one person, two devices, one
     product. No web fonts: this file must work from file://. */
  --font-read: Charter, 'Iowan Old Style', Cambria, Constantia, Georgia, serif;
  --font-ui:   -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
  --font-mono: ui-monospace, 'Cascadia Mono', 'SF Mono', Consolas, monospace;

  /* six sizes, no two within 3px. The line box of --t-read is 1.5rem and
     that is the grid unit; every vertical decision below is a count. */
  --t-display: 2rem;        --lh-display: 2.25rem;
  --t-stem:    1.3125rem;   --lh-stem:    1.875rem;
  --t-read:    1rem;        --lh-read:    1.5rem;
  --t-ui:      .9375rem;    --lh-ui:      1.25rem;
  --t-meta:    .8125rem;    --lh-meta:    1.25rem;
  --t-label:   .75rem;      --lh-label:   1rem;
}

/* --- grid and space (§4.1) --------------------------------------------
   Two tiers and they are not interchangeable. 20 and 32 are deleted from
   the scale: neither is a line fraction, and they are what makes a layout
   look assembled rather than composed. */
:root {
  --u1: .25rem;    /*  4 — inside a component */
  --u2: .5rem;     /*  8 */
  --u3: .75rem;    /* 12 = half a line */
  --u4: 1rem;      /* 16 */

  --b1: 1.5rem;    /* 24 = 1 line — between blocks */
  --b2: 2.25rem;   /* 36 = 1½ lines */
  --b3: 3rem;      /* 48 = 2 lines */
  --b4: 4.5rem;    /* 72 = 3 lines */

  --row: 3rem;         /* THE MODULE. 48px = 2 lines, ≥44px tap. */
  --slot: 2.25rem;     /* the option letter / list row slot */
  --gutter: 5.882%;    /* 2 of 34 units */
  --pad: 1.25rem;      /* page margin; 2rem at ≥720px */
  --measure: 30rem;    /* ≈60 characters — Ruder's ceiling */
  --hair: 1px;         /* one hairline weight in the app */

  /* --- the control cluster (REVEAL.md, "icons, not sentences") --------
     Three circles, right-aligned, priority carried by SIZE and by FILL.
     Declared here rather than inside §13's phone query because §5 draws
     the desktop form from the same two numbers and a second pair would
     be a second geometry. --ctl-lg keeps MOBILE §5's 56px exactly, so
     the band the rest of the bottom of the screen is measured from
     (--fab-h, --fab-band) does not move by a pixel. */
  --ctl-lg:  3.5rem;   /* the primary — the thumb's rectangle, now round */
  --ctl-sm:  2.5rem;   /* back and settings                             */
  --ctl-gap: var(--u3);

  /* The tap floor is physical, so it is stated in px — the same reason
     --lift's 16px home-indicator floor is (§13). rem here would track
     the text-size setting and could fall under 44 at the bottom of its
     range, and a target does not get smaller because the type did. */
  --ctl-tap: 44px;
}

@media (min-resolution: 2dppx) {
  :root { --hair: 0.5px; }
}

/* --- motion (§8.1) ----------------------------------------------------
   Three curves, and that is the complete set. The default curve has no
   entry ramp, so a press never feels a frame behind the finger. */
:root {
  --ease-out:   cubic-bezier(.22, 1, .36, 1);
  --ease-sheet: cubic-bezier(.32, .72, 0, 1);
  --ease-flat:  linear;
}


/* =====================================================================
   2  RESET AND BASE
   ===================================================================== */

*, *::before, *::after {
  box-sizing: border-box;
  /* 450 / 500 / 650 do not exist in Charter, Cambria, Georgia or Segoe UI;
     without this the browser fakes them, differently per platform (§3.2) */
  font-synthesis: none;
  font-synthesis-weight: none;
}

/* Ships at 106%. applyTextScale rewrites this value, and the entire grid is
   in rem, so the type and the rhythm scale together (§3.6). --t-read then
   renders at ~17px while the grid unit stays exactly 1.5rem. */
html {
  font-size: 106.25%;
  background: var(--bg);      /* the canvas beyond body height (§8.5) */
  scrollbar-color: var(--rule) transparent;
  -webkit-text-size-adjust: 100%;

  /* The bottom of this scrollport is spoken for at every width — the floating
     action button below 720px, the sticky action bar above it — and the
     browser does not know that. Without this, Tab-focusing the row just below
     the fold scrolls it to the very bottom edge of the viewport, which is to
     say underneath the button: the row takes focus, the ring is drawn, and
     both are behind an opaque control. 3.5rem is the button (the phone's
     --fab-h, written as its literal because that token does not exist outside
     §13's query) and --b1 is one line of clearance under it. It is a floor,
     not a layout: it costs nothing on a page that fits. */
  scroll-padding-bottom: calc(3.5rem + var(--b1));
}

html.is-locked { overflow: hidden; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  -webkit-font-smoothing: var(--smoothing);
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
  overflow-x: hidden;
  overscroll-behavior-y: none;
}

img, svg { max-width: 100%; }

button, input, select, textarea {
  font: inherit;
  color: inherit;
  border-radius: 0;
}

button { background: none; border: 0; padding: 0; cursor: pointer; }

a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 2px; }

hr { border: 0; border-top: var(--hair) solid var(--rule); margin: var(--b3) 0; }

/* --blue at 6.69:1 on black is one of the strongest indicators available on
   this ground. Nothing here is ever outline:none (§7.8). */
:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
::selection    { background: var(--blue); color: var(--bg); }
input, textarea { caret-color: var(--blue); }

/* --- icons: fills, never strokes (§5.1, §5.4) -------------------------
   The `stroke` here is not a drawing stroke — it is the background colour
   laid on the fill's own contour, centred, so it eats 0.1 off each edge.
   SVG paints fill then stroke, which is what makes the shave work, and it
   is only valid because every icon in this app sits on a known flat
   colour — which it does. */
.icon {
  display: block;
  flex: 0 0 auto;
  width: 1.125rem;
  height: 1.125rem;
  fill: currentColor;
  fill-rule: evenodd;
  stroke: var(--icon-bg, var(--bg));
  stroke-width: var(--icon-shave, 0);
  stroke-linejoin: miter;
  stroke-miterlimit: 2;
}

/* Screen-reader text. Never used to hide something a sighted user needs. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

.hidden { display: none !important; }


/* =====================================================================
   3  TYPE
   -------------------------------------------------------------------
   If the eye travels along it, serif. If the thumb acts on it, sans.
   Nine words, zero exceptions. Option text is serif because options are
   read, compared and re-read — they are a continuation of the question,
   not a control. The option *letter* stays sans: it is an index.
   ===================================================================== */

/* --- the reading face --------------------------------------------------
   Tabular figures in a serif sentence read as mechanical and wreck word
   spacing, so everything set in --font-read takes proportional lining
   figures — including US$0.8321 inside a stem, where that is simply
   correct typography (§3.3). */
.prose, .stem, .opt-text, .opt-why, .opt-body, .verdict, .card-title, .h1, .h2,
.bz-stem, .empty-msg, .term, .formula, .steps, .qz-item-stem,
.note-p, .note-list, .note-quote {
  font-family: var(--font-read);
  font-variant-numeric: proportional-nums lining-nums;
}

.prose, .expl p, .expl li, .opt-text, .opt-why, .opt-body, .term, .bz-stem, .empty-msg, .qz-item-stem,
.note-p, .note-list, .note-quote {
  font-size: var(--t-read);
  line-height: var(--lh-read);
  font-weight: 400;
  letter-spacing: var(--prose-track);
}

.prose {
  max-width: var(--measure);
  hyphens: auto;
  hyphenate-limit-chars: 6 3 3;
  text-wrap: pretty;
  hanging-punctuation: first last;   /* Safari-only; degrades silently */
}

/* 24px line, 12px gap: baseline-to-baseline across a paragraph break is
   36px, so the rhythm re-syncs every second paragraph (§4.2). */
.prose p { margin: 0; }
.prose p + p { margin-top: var(--u3); }

/* --- display: screen titles AND the one big figure per screen ---------
   They are the same size on purpose, so the masthead and the figure
   rhyme. Serif, because 340 at 2rem in Charter on black is a book and the
   same number in system-ui is a dashboard (§3.3). */
.h1, .num-display, .stat-value, .ring-value, .qz-score-ring .num {
  font-family: var(--font-read);
  font-size: var(--t-display);
  line-height: var(--lh-display);
  font-weight: 700;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums lining-nums;
  text-box-trim: trim-both;
  text-box-edge: cap alphabetic;
  margin: 0;
}

/* .h2 stays a real heading rather than a label: it carries sentences,
   and no string longer than three words is ever uppercased (§10.15). */
.h2 {
  font-size: var(--t-stem);
  line-height: var(--lh-stem);
  font-weight: 700;
  letter-spacing: 0;
  margin: 0;
}

/* --- THE LABEL, and the Vignelli drop (§4.2) --------------------------
   36 above, 12 below. A 3:1 asymmetry, never equal padding — uniform
   padding around a label is the single loudest tell of a generated
   interface. The label belongs to the block beneath it and the space
   proves it. Sticky, because a 48px gap only says "new section" if you
   can see the whole page, and mid-scroll on a 375px screen you cannot. */
.label, .h3, .field-label, .expl-h, .stat-label, .nav-label,
.context-label, .qmeta-line, .prog-label, .res-h, .th, .table th, .note-h {
  font-family: var(--font-ui);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  font-weight: 700;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
  margin: 0;
}

/* --- sticky section labels, and the offset bug they had -----------------
   These stick under the masthead, so their offset must equal the masthead's
   actual height AT THAT MOMENT. It was hard-coded to var(--row) (3rem),
   which is only true on a route that has a topbar and is not collapsed —
   and two very common cases are neither:

     · js/ui.js REMOVES the topbar entirely on routes with no title (home
       and play), so the label stuck 3rem below nothing, leaving a band the
       content scrolled through in plain view above it;
     · on the phone the chrome collapses while reading, translating the
       topbar away while the label kept its 3rem offset — so the label
       floated in mid-air with text sliding underneath it.

   The offset is now a variable that tracks reality. ui.js already stamps
   data-route and data-chrome on <html>, so no JS change is needed. */
:root { --sticky-top: var(--row); }
:root[data-route="home"],
:root[data-route="play"] { --sticky-top: 0px; }   /* these routes have no topbar */
:root[data-chrome="off"] { --sticky-top: 0px; }   /* phone chrome collapsed away */

.label, .h3 {
  position: sticky;
  top: var(--sticky-top);
  z-index: 4;
  background: var(--bg);
  padding-block: var(--u2);
  margin: var(--b2) 0 var(--u3);
  text-box-trim: trim-both;
  text-box-edge: cap alphabetic;
}
.label:first-child, .h3:first-child { margin-top: 0; }

/* --- the operating face ------------------------------------------------ */
.btn, .chip, .input, .select, .nav-item, .qz-count, .qz-stat, .qz-timer,
.table td, .diff, .why-k, .badge, .toast, .modal-title, .topbar-title {
  font-family: var(--font-ui);
  font-variant-numeric: tabular-nums lining-nums;
}

.muted { color: var(--ink-2); }
.tiny  { font-size: var(--t-meta); line-height: var(--lh-meta); color: var(--ink-3); }
.num   { font-variant-numeric: tabular-nums lining-nums; }
.center { text-align: center; }

.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.prewrap  { white-space: pre-wrap; }

/* The one place --font-mono is used: shared-scenario blocks, where column
   alignment is functional. Never for formulas (§3.1, §7.3). */
.context, .mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums lining-nums;
}


/* =====================================================================
   4  LAYOUT
   ===================================================================== */

.app {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* --- the masthead ------------------------------------------------------
   3rem on every screen, including the sheet, including at ≥720px. Never
   3.5 here and 3 there: constancy is what makes many screens read as one
   system (§4.5). On a black app the masthead is black. */
.topbar {
  position: sticky;
  top: 0;
  z-index: 30;
  height: var(--row);
  flex: 0 0 auto;
  background: var(--bg);
  border-bottom: var(--hair) solid transparent;
  transition: border-color 120ms var(--ease-out);
}
.topbar.scrolled { border-bottom-color: var(--rule); }

.topbar-inner {
  display: flex;
  align-items: center;
  gap: var(--u2);
  height: 100%;
  max-width: 45rem;
  margin-inline: auto;
  padding-inline: var(--pad);
}

.topbar-titles { min-width: 0; flex: 1 1 auto; }

.topbar-title {
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-weight: 600;
  letter-spacing: 0;
  margin: 0;
  color: var(--ink);
}

.topbar-sub {
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  color: var(--ink-3);
}

.topbar-actions { display: flex; align-items: center; gap: var(--u1); flex: 0 0 auto; }
.topbar-back { margin-left: calc(var(--u3) * -1); }

/* --- the page ---------------------------------------------------------
   Content starts --b3 below the masthead (Templates A and B both open on
   a section label) and ends --b4 above the nav: 48 of bar + 24 of
   clearance = 3 lines, which stops the last row feeling like the page
   fell off (§4.6). */
.view {
  flex: 1 1 auto;
  padding-top: var(--b3);
  padding-bottom: calc(var(--b4) + env(safe-area-inset-bottom));
  transition: opacity 260ms var(--ease-sheet);
}

.container {
  width: 100%;
  max-width: 45rem;
  margin-inline: auto;
  padding-inline: var(--pad);
}

/* --- the bottom nav (§7.4) --------------------------------------------
   Two tabs. Settings is visited about three times in the app's life and
   does not deserve 25% of permanent chrome — it moves to one icon in the
   Home masthead. No blue in the nav: blue is one plane per screen and the
   nav is not it. Active state is carried by geometry (M3 vs M2 marks),
   with colour as the second signal, never the only one. */
.nav {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 30;
  display: flex;
  align-items: stretch;
  background: var(--bg);
  border-top: var(--hair) solid var(--rule);
  padding-bottom: env(safe-area-inset-bottom);
}

.nav-item {
  flex: 1 1 0;
  min-height: var(--row);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--u1);
  text-decoration: none;
  color: var(--ink-3);
  border-radius: 0;
}
.nav-item .nav-label { color: var(--ink-2); font-weight: 400; }
.nav-item.active { color: var(--ink); }
.nav-item.active .nav-label { color: var(--ink); font-weight: 700; }

.nav-ico { display: flex; align-items: center; justify-content: center; }
.nav-ico .icon { width: 1.375rem; height: 1.375rem; }

/* The rail's head — the wordmark and the collapse control — and both exist
   only when the nav IS a rail (§11.1). Below 720px the nav is MOBILE.md's
   floating glass capsule: it has no head, no wordmark and no collapse
   control, and these three `display:none`s are what guarantee that, rather
   than a JS branch that could be got wrong. One nav, one render path.

   `.nav .nav-collapse` and not `.nav-collapse`: the control reuses
   `.btn-icon`, which is declared in §5 — later in the file — so a
   single-class selector here would lose on source order and the button would
   appear inside the phone's capsule. Two classes wins on specificity, which
   is order-independent. */
.nav-head { display: none; }
.nav .nav-collapse { display: none; }
.nav-brand { display: none; }
.nav-brand-name {
  display: block;
  font-family: var(--font-read);
  font-size: var(--t-stem);
  line-height: var(--lh-stem);
  font-weight: 700;
  letter-spacing: -0.025em;
  color: var(--ink);
}
.nav-brand-sub {
  display: block;
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  color: var(--ink-3);
}

/* --- flow helpers ------------------------------------------------------ */
.stack  { display: flex; flex-direction: column; gap: var(--b1); min-width: 0; }
.row    { display: flex; align-items: center; gap: var(--u3); min-width: 0; }
.spread { display: flex; align-items: center; justify-content: space-between; gap: var(--u3); min-width: 0; }
.wrap   { flex-wrap: wrap; }

/* --- the 34-unit field (§4.3) -----------------------------------------
   34 is the smallest number that divides exactly into 1, 2, 3, 4 and 6
   columns with a fixed 2-unit gutter — the same arithmetic Gerstner ran
   for Capital, run for a phone where you never need 5 columns. The gutter
   is 2/34 of the content width, so the whole system is one variable, and
   at --pad 1.25rem on a 375px viewport it lands on the page margin. */
.grid, .grid-2, .grid-3, .g2, .g3, .g4 {
  display: grid;
  column-gap: var(--gutter);
  row-gap: var(--b1);
}
.grid-2, .g2 { grid-template-columns: repeat(2, 1fr); }   /* 16/34 each */
.grid-3, .g3 { grid-template-columns: repeat(3, 1fr); }   /* 10/34 each */
.g4          { grid-template-columns: repeat(4, 1fr); }   /*  7/34 each */
.grid        { grid-template-columns: repeat(2, 1fr); }
.rule34      { display: grid; grid-template-columns: repeat(34, 1fr); column-gap: 0; }

/* Not every block uses the full measure — the empty units are the design,
   and a screen where every element is the same width is "identical" made
   literal (§4.4). 22/34, 25/34 and 16/34 of the content width. */
.span-22 { max-width: 64.706%; }
.span-25 { max-width: 73.529%; }
.span-16 { max-width: 47.059%; }

.divider { height: 0; border-top: var(--hair) solid var(--rule); }

/* Sections are separated by 3rem of space, never by a rule (§2.5). */
.scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* --- the pinned action bar (§7.2) --------------------------------------
   Flush to the bottom trim, full width, not a floating pill inset from
   the edges. It never moves and it is never disabled. */
.sticky-bottom {
  position: sticky;
  bottom: calc(var(--row) + env(safe-area-inset-bottom));
  z-index: 10;
  margin-inline: calc(var(--pad) * -1);
  padding-inline: var(--pad);
  background: var(--bg);
  padding-top: var(--u3);
}
.app.is-playing .sticky-bottom,
.app[data-route="play"] .sticky-bottom { bottom: 0; }
.app.is-playing .nav,
.app[data-route="play"] .nav { display: none; }


/* =====================================================================
   5  PRIMITIVES
   ===================================================================== */

/* --- cards, which are no longer cards ---------------------------------
   §9 deletes .card, .card-pad and every nested surface outright. The
   class names survive in the view layer during the rewrite, so they are
   defined here as what they now are: nothing. Grouping is done with
   rules, labels and space. */
.card, .card-pad {
  display: block;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0;
  min-width: 0;
}

.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--u3); }

/* The content leads and the taxonomy trails: a row's headline is set in
   the reading face, its label in small type below it, never above (§7.7). */
.card-title {
  font-size: var(--t-read);
  line-height: var(--lh-read);
  font-weight: 700;
  letter-spacing: 0;
  color: var(--ink);
  margin: 0;
}

/* --- the list row (§4.5 Template B, §7.7) ------------------------------
   Exactly three zones: slot | label (flex, truncates) | figure (tabular,
   right-aligned). A row needing a fourth thing is not a list row. Rows
   sit on the raw page with a 1px rule between them; there is no card at
   any point. */
.list { display: block; }

.list-row, .card-tap, .bz-row {
  display: grid;
  grid-template-columns: var(--slot) minmax(0, 1fr) auto;
  align-items: center;
  column-gap: var(--u3);
  width: 100%;
  min-height: var(--row);
  padding-block: var(--u3);
  border-bottom: var(--hair) solid var(--rule);
  border-radius: 0;
  color: inherit;
  text-align: left;
  text-decoration: none;
}
.list-row:first-child, .card-tap:first-child, .bz-row:first-child {
  border-top: var(--hair) solid var(--rule);
}

/* A row with nothing in its slot still reserves it, so labels across a
   list start on the same x as the option letters and the section labels. */
.row-slot {
  grid-column: 1;
  display: flex;
  align-items: center;
  color: var(--ink-2);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  font-weight: 700;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums lining-nums;
}
.row-label  { grid-column: 2; min-width: 0; }
.row-figure {
  grid-column: 3;
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
}

/* Press inverts the row within one frame. No scale: scaling resamples
   glyphs and the mid-transition frame of serif body text is visibly
   blurry (§8.2). */
.list-row:active, .card-tap:active, .bz-row:active {
  background: var(--ink);
  color: var(--bg);
  --icon-bg: var(--ink);
}
.list-row:active .muted, .card-tap:active .muted, .bz-row:active .muted,
.list-row:active .tiny,  .card-tap:active .tiny,  .bz-row:active .tiny,
.list-row:active .row-figure, .card-tap:active .row-figure { color: var(--bg); }

/* --- buttons (§7.2) ----------------------------------------------------
   Never a border AND a fill: on black a fill already produces an edge.
   Fills for tappable units, hairlines for structure, and there are no
   shadows anywhere in either theme (§4.6). */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--u2);
  min-height: var(--row);
  padding-inline: var(--u4);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-weight: 600;
  letter-spacing: 0;
  color: var(--ink);
  background: none;
  border: var(--hair) solid var(--rule);
  border-radius: 0;
  transition: transform 90ms var(--ease-out), background-color 120ms var(--ease-out),
              color 120ms var(--ease-out), border-color 120ms var(--ease-out);
}

/* the ground is --ink and the type is --bg: 17.48:1 dark, 17.81:1 light.
   The border goes when the fill arrives — on black a fill already
   produces an edge, and a unit never carries both. Gold-as-primary was
   never on the table; neither is blue — blue is one plane per screen and
   it is never the primary button (§2.5). */
.btn-primary {
  background: var(--ink);
  color: var(--bg);
  border-color: transparent;
  font-weight: 700;
  --icon-bg: var(--ink);
}

.btn-ghost { background: none; border-color: transparent; }

/* Red is spent on INCORRECT and cannot mean "destructive" as well. A
   destructive action is confirmed by a dialog, not by a colour (§2.5). */
.btn-danger { background: none; color: var(--ink); border-color: var(--ink); }

/* Small means smaller type, never a smaller target: every tappable row is
   3rem or 3rem + n × 1.5rem (§10.30). */
.btn-sm { padding-inline: var(--u3); font-size: var(--t-meta); line-height: var(--lh-meta); }
.btn-lg { min-height: var(--b4); font-size: var(--t-ui); }

/* Flush left at the same x as the stem, the option letters and the
   section labels, with the chevron flush right. A centred label in a
   full-width pill is the most template-looking object on a phone. */
.btn-block {
  display: flex;
  width: 100%;
  justify-content: space-between;
  padding-inline: var(--pad);
}

.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--row);
  height: var(--row);
  color: var(--ink);
  background: none;
  border: 0;
  border-radius: 0;
  transition: transform 90ms var(--ease-out), color 120ms var(--ease-out);
}

/* One value, everywhere, and only on short-label sans controls (§8.2). */
.btn:active, .btn-icon:active, .chip:active, .flag:active, .qz-navtoggle:active {
  transform: scale(.97);
}


/* =====================================================================
   5.1  THE CONTROL CLUSTER — icons, not sentences (REVEAL.md)
   -------------------------------------------------------------------
   The labelled "Check answer" / "Next question" capsule becomes a
   right-aligned row of circles — settings 2.5 · back 2.5 · primary 3.5 —
   and the PRIMARY GLYPH carries the state the words used to carry. This
   block is the geometry and the state machine; §13.3 gives it the phone's
   material, its thumb position and its collapse.

   RADIUS. DESIGN-IDENTITY §4.6 is "radius is 0, or a circle". These are
   circles: the 999px radius on a square box, which is the same
   declaration the option letter's disc and the progress knob already use.
   No override is being spent here — §13's override (b) is about the
   capsule, and the capsule is what this replaces.

   PRIORITY IS SIZE AND FILL, WHICH IS THE ONLY PAIR THAT SURVIVES GLASS.
   The obvious third lever — a quieter --ink-2 glyph on the secondaries —
   is not available and the number is in §13.1: --ink-2 over the worst
   case behind this material measures 2.96:1, under even the 3:1 floor
   1.4.11 sets for a graphical object, and §13.1's ruling is already
   "only --ink may sit on glass". Nor can a secondary go unfilled: a
   revealed option is a solid --good plane (REVEAL rule 4) and it can lie
   directly under the cluster, where --ink measures 1.74:1 and --ink-2
   1.22:1. So every circle wears the ground, every glyph is --ink, and
   subordination is carried by area (a 2.5rem circle is 51% of a 3.5rem
   one) and by the fact that the primary — and only the primary — turns
   into a solid --ink plane the moment there is something to commit.
   That is MOBILE §5's own sentence: "there is something to press" is
   carried by material, not colour.

   THE JS CONTRACT. Everything below is driven by markup this file does
   not own, so it is stated once, here:

     .ctl-cluster              the row. Optional — a bare
                               [data-role="action"] on the play route is
                               styled as the primary on its own, which is
                               what today's shell emits.
     .ctl                      one circle. Real <button>, real aria-label.
     .ctl-primary              3.5rem. Carries data-state. Never hidden,
                               never disabled, never moved.
     .ctl-back / .ctl-settings 2.5rem. Both also carry .ctl-second.
     .ctl-label                the action in words. Kept in the DOM as
                               screen-reader text so the accessible name
                               survives even if aria-label is dropped.
     .ctl-live                 role="status" region for the commit
                               announcement (REVEAL's second
                               non-negotiable). The quiz view already has
                               one — #qzSay — and either will do.
     .ctl-handle               optional expander for the collapsed state.

     data-state on the primary — FOUR tokens, because there are four
     states and the words used to carry all four:

       skip    nothing chosen     glass ring, arrow, quiet
       check   an option is in    solid --ink, tick
       next    answered           solid --ink, arrow
       finish  last question      solid --ink, the app's own % mark

     `rest` is accepted as a synonym of skip and `submit` of next, because
     that is the vocabulary js/ui.js ships today. `armed` is the one token
     this file cannot honour on its own: js/ui.js currently normalises
     check, next and submit all into it, which collapses exactly the
     distinction REVEAL says the glyph now has to carry. Until that
     normalisation widens to four tokens the rules at the end of this
     block disambiguate `armed` from the DOM — .opts.revealed means the
     answer is in, so the glyph is the arrow; otherwise it is the tick.
     That fallback is correct in study mode and wrong in exam mode, where
     a chosen option means "next", not "check". Exam mode therefore needs
     the real token; there is no DOM fact this file can read instead.

     data-glyph on the primary's children — "arrow" | "check" | "mark".
     Ship all three; this file shows exactly one. A primary carrying a
     single untagged <svg> is left alone and simply keeps it, so a build
     where the icons have not landed yet degrades to today's behaviour
     (material changes, glyph does not) rather than to an empty circle.
   ===================================================================== */

/* The row. Right-aligned, primary last, so the primary's right edge is
   the cluster's right edge in every state and cannot move when a
   secondary collapses. `pointer-events: none` on the row and `auto` on
   the circles: the gaps between them are 12px of live page, not part of
   the control. */
.ctl-cluster {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--ctl-gap);
  pointer-events: none;
}

/* A [data-role="action"] that lands inside the row is a flex child, not a
   fixed overlay — §13.3 pins the row instead. One rule, so the shell can
   move its existing node into a cluster without either file guessing. */
.ctl-cluster > * {
  position: relative;
  inset: auto;
  margin: 0;
}

/* Every circle carries `.ctl`; the four role classes are listed beside it
   so a view that emits only the role still gets a circle rather than a
   rectangle. One shape, four names, no second definition. */
.ctl,
.ctl-primary,
.ctl-second,
.ctl-back,
.ctl-settings,
.ctl-handle {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  gap: 0;
  padding: 0;
  pointer-events: auto;
  color: var(--ink);
  background: none;
  border: var(--hair) solid var(--rule);
  border-radius: 999px;
  transition: background-color 120ms var(--ease-out), color 120ms var(--ease-out),
              border-color 120ms var(--ease-out), transform 90ms var(--ease-out);
}

.ctl:active,
.ctl-primary:active,
.ctl-second:active,
.ctl-back:active,
.ctl-settings:active,
.ctl-handle:active { transform: scale(.97); }

/* Size, stated three ways on purpose. js/view-quiz.js pins its own bar
   with an inline `width:auto`, and an inline declaration beats a class —
   but it does not beat min-width/max-width, which clamp `auto` to exactly
   one number without spending an !important. */
.ctl-primary,
:root[data-route="play"] .ctl-cluster [data-role="action"] {
  width: var(--ctl-lg);
  min-width: var(--ctl-lg);
  max-width: var(--ctl-lg);
  height: var(--ctl-lg);
  min-height: var(--ctl-lg);
}

.ctl-second,
.ctl-back,
.ctl-settings,
.ctl-handle {
  width: var(--ctl-sm);
  min-width: var(--ctl-sm);
  max-width: var(--ctl-sm);
  height: var(--ctl-sm);
  min-height: var(--ctl-sm);
}

/* THE HIT AREA, ≥44px without growing the painted circle (§4.6: "extend
   hit areas without growing boxes; never inflate a visible box to 44px").
   --row is the module and is already ≥44px at every point of the
   text-size range; --ctl-tap is the floor for the moment before
   applyTextScale has run, when the root is still a percentage of whatever
   the browser was set to. A pseudo-element child, so a tap inside it
   targets the button. */
.ctl-second::after,
.ctl-back::after,
.ctl-settings::after,
.ctl-handle::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: max(var(--row), var(--ctl-tap));
  height: max(var(--row), var(--ctl-tap));
  transform: translate(-50%, -50%);
}

/* --- the glyph --------------------------------------------------------
   24px in the primary, 18px in the secondaries — two of the three sizes
   §5.4 allows, and the same proportion in both (43% and 45% of their
   circle), which is what makes the row read as one object at three
   scales. The glyph size is the one place in this block that forces the
   cascade, and it has to: UI.icon writes width/height as an INLINE style
   on every <svg>, so a class cannot reach it, and the alternative is a
   control whose glyph silently changes size when a caller passes the
   wrong number to a function this file does not own. Nothing else here
   needs it — the diameters clamp an inline `width:auto` with min/max
   instead. */
.ctl-primary .icon,
:root[data-route="play"] [data-role="action"] .icon {
  width: 1.5rem !important;
  height: 1.5rem !important;
}

.ctl-second .icon,
.ctl-back .icon,
.ctl-settings .icon,
.ctl-handle .icon {
  width: 1.125rem !important;
  height: 1.125rem !important;
}

/* THE MARK, on the last question. §6.1 gives it a hard floor of 2.5rem —
   below that its ⌀3 counters close — so the finish state draws it at that
   floor rather than at the 1.5rem the other glyphs use. The consequence
   is the joke landing on its own: a 2.5rem % inside a 3.5rem solid disc
   is the app icon, exactly as §6.1 constructs it, so the session ends on
   the mark it began with and the geometry says so before the label does. */
.ctl-primary .mark,
:root[data-route="play"] [data-role="action"] .mark {
  width: max(var(--ctl-sm), 40px) !important;
  height: max(var(--ctl-sm), 40px) !important;
}

/* All three glyphs live in the primary at once and exactly one is drawn,
   so the swap is a display change on a centred flex child and cannot move
   anything. Only TAGGED glyphs are hidden: an untagged <svg> is never
   selected here, so a primary still carrying today's single chevron keeps
   it and the material goes on carrying the state alone. */
.ctl-primary [data-glyph],
:root[data-route="play"] [data-role="action"] [data-glyph] {
  display: none;
}

/* --- the state machine ------------------------------------------------
   REVEAL's four rows, in order. The primary is never disabled in any of
   them: with nothing chosen it is the arrow and it skips (§7.2 rule 2). */

/* skip — an outline, so a SOLID primary always means "you have something
   to commit". Available, not urgent. Material carries it; the glyph is
   --ink because nothing quieter clears 3:1 over this ground. */
:root[data-route="play"] [data-state="skip"] [data-glyph="arrow"],
:root[data-route="play"] [data-state="rest"] [data-glyph="arrow"],
.ctl-cluster [data-state="skip"] [data-glyph="arrow"],
.ctl-cluster [data-state="rest"] [data-glyph="arrow"],
/* check — the tick. */
:root[data-route="play"] [data-state="check"] [data-glyph="check"],
.ctl-cluster [data-state="check"] [data-glyph="check"],
/* next — the same stroke, turned to face forward. */
:root[data-route="play"] [data-state="next"] [data-glyph="arrow"],
:root[data-route="play"] [data-state="submit"] [data-glyph="arrow"],
.ctl-cluster [data-state="next"] [data-glyph="arrow"],
.ctl-cluster [data-state="submit"] [data-glyph="arrow"],
/* finish — the mark. */
:root[data-route="play"] [data-state="finish"] [data-glyph="mark"],
:root[data-route="play"] [data-state="last"] [data-glyph="mark"],
.ctl-cluster [data-state="finish"] [data-glyph="mark"],
.ctl-cluster [data-state="last"] [data-glyph="mark"] {
  display: block;
}

/* `armed`, disambiguated from the DOM — see the contract above. Ordered
   after the four real states so it can never beat one of them: a build
   that sends the right token is never touched by these two rules. */
:root[data-route="play"]:has(.opts.revealed) [data-state="armed"] [data-glyph="arrow"],
:root[data-route="play"]:not(:has(.opts.revealed)) [data-state="armed"] [data-glyph="check"] {
  display: block;
}

/* The glyph swap is INSTANT and that is not an omission. It happens in
   the frame of the tap that reveals the verdict, and REVEAL's Motion
   paragraph is explicit that nothing may animate then — a moving object
   next to the thumb is exactly what pulls the eye off the result it was
   supposed to read. The rhyme between the tick and the arrow does its
   work across two still frames, which is where a geometric joke belongs. */
.ctl-primary .icon,
.ctl-primary .mark,
.ctl-second .icon { transition: none; }

/* SOLID — the primary alone, and only when there is something to commit.
   --bg on --ink is 17.48:1 dark and 17.81:1 light. The border goes when
   the fill arrives: a unit never carries both (§4.6).

   THE TWO AXES, which is why this list is not simply the four states.
   The GLYPH says what the press does; the MATERIAL says whether there is
   anything to commit. Three of the four tokens settle both at once —
   check, next and submit all imply a chosen option — but `finish` does
   not: it is the last question's token whether the answer is in ("See
   results") or not ("Skip and finish"), and painting the second of those
   solid would mean a solid primary no longer promised anything. That is
   the whole of the material's job, so `finish` is resolved below instead,
   from the same DOM fact the belt-and-braces rule in §13.3 uses. */
:root[data-route="play"] .ctl-primary[data-state="check"],
:root[data-route="play"] .ctl-primary[data-state="next"],
:root[data-route="play"] .ctl-primary[data-state="submit"],
:root[data-route="play"] .ctl-primary[data-state="armed"],
.ctl-cluster .ctl-primary[data-state="check"],
.ctl-cluster .ctl-primary[data-state="next"],
.ctl-cluster .ctl-primary[data-state="submit"],
.ctl-cluster .ctl-primary[data-state="armed"],
/* `finish`, on the answered half of the last question only. */
:root[data-route="play"]:has(.opt.chosen) .ctl-primary[data-state="finish"],
:root[data-route="play"]:has(.opts.revealed) .ctl-primary[data-state="finish"],
:root[data-route="play"]:has(.opt.chosen) .ctl-primary[data-state="last"],
:root[data-route="play"]:has(.opts.revealed) .ctl-primary[data-state="last"] {
  background: var(--ink);
  color: var(--bg);
  border-color: transparent;
  --icon-bg: var(--ink);
}

/* Back on the first question: present, aria-disabled, and NOT dimmed —
   nothing in this app uses opacity to express state (§7.2 rule 2), and a
   control that vanishes moves everything beside it so the thumb has to
   re-aim (REVEAL). The ring goes, so it stops reading as a control, and
   the glyph drops to --ink-3, which is the token §2.5 gives to the
   un-earned seal and means the same thing here. WCAG 1.4.3 and 1.4.11
   both exempt an inactive component, which is exactly what aria-disabled
   declares this to be. */
.ctl[aria-disabled="true"] {
  color: var(--ink-3);
  border-color: transparent;
  cursor: default;
}
.ctl[aria-disabled="true"]:active { transform: none; }

/* The words. Removing visible text does not license removing the
   accessible name (REVEAL), so the label stays in the DOM and stays
   readable to a screen reader — belt and braces behind the aria-label,
   and the reason the primary can be a bare circle at all. Same treatment
   as .sr-only; declared separately so the view layer never has to know
   which of the two it is emitting. */
.ctl-label,
.ctl-live,
.ctl-primary .btn-label,
.ctl-second .btn-label {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

/* --- the flag (§5.5 mark 5) --------------------------------------------
   The pole never changes — the marker is always there, the flag goes up.
   The pennant wipes from staff to tip in 140ms; flagging is user-initiated
   and infrequent, which is what buys it the frames. */
.flag { color: var(--ink-2); transition: color 150ms var(--ease-out), transform 90ms var(--ease-out); }
.flag[aria-pressed="true"] { color: var(--blue); }
.flag-field { transition: clip-path 140ms var(--ease-out); }
.flag[aria-pressed="false"] .flag-field { clip-path: inset(0 100% 0 0); }
.flag[aria-pressed="true"]  .flag-field { clip-path: inset(0 0 0 0); }

/* A sticky .label's range is bounded by its own parent's bottom edge, not by
   #bz-root's — this wrapper IS that parent, so "Chapter" releases right after
   its own chip grid ends instead of staying pinned over the results below
   it. See filtersHtml()'s own comment in view-browse.js for the bug this
   fixes. Plain block; nothing else needs to change about its layout. */
.bz-section { display: block; }

/* --- chips ------------------------------------------------------------- */
.chipset { display: flex; flex-wrap: wrap; gap: var(--u2); }

/* The one deliberate exception to DESIGN-IDENTITY §4.6's "radius is 0, or a
   circle": a toggle you tap over and over in a dense grid (up to 18 chapter
   numbers at once) reads as friendlier and more clearly "pick me" with a
   soft corner than a hard one — and Mobbin's own filter-grid references
   (Binance, Apple Fitness, Airbnb) are unanimous on this specific control,
   even the ones that keep sharp corners everywhere else. Scoped to .chip
   alone, not promoted to a general token: buttons, icons and every other
   surface stay exactly as sharp as the rest of the system. var(--u2) rather
   than a pill (var(--row)/2) is on purpose — enough curve to read as
   considered, not enough to read as a different, softer app. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--u2);
  min-height: var(--row);
  padding-inline: var(--u3);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-weight: 400;
  letter-spacing: 0;
  color: var(--ink);
  background: none;
  border: var(--hair) solid var(--rule);
  border-radius: var(--u2);
  transition: transform 90ms var(--ease-out), background-color 120ms var(--ease-out),
              color 120ms var(--ease-out), border-color 120ms var(--ease-out);
}
.chip.active {
  background: var(--ink);
  color: var(--bg);
  border-color: transparent;
  font-weight: 700;
  --icon-bg: var(--ink);
}
.chip.active .tiny, .chip.active .muted { color: var(--bg); }

/* --- fields ------------------------------------------------------------ */
.field { display: flex; flex-direction: column; gap: var(--u2); }

/* An input is a rule you write on, not a box you fill in. */
.input, .select {
  width: 100%;
  min-height: var(--row);
  padding: var(--u2) 0;
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  color: var(--ink);
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--rule);
  border-radius: 0;
}
.input::placeholder { color: var(--ink-3); }
.input:focus-visible, .select:focus-visible { border-bottom-color: var(--blue); }

/* the native chevron is kept: a select with no signifier is worse than a
   select whose signifier belongs to the OS (§10.43) */
.select { appearance: auto; }

.search-box {
  display: grid;
  grid-template-columns: var(--slot) minmax(0, 1fr) auto;
  align-items: center;
  column-gap: var(--u2);
  border-bottom: var(--hair) solid var(--rule);
}
.search-box .input { border-bottom: 0; }
.search-box > .icon { color: var(--ink-3); justify-self: center; }

.switch { display: inline-flex; align-items: center; min-height: var(--row); }
.switch input { width: 1.25rem; height: 1.25rem; accent-color: var(--ink); }

.range { width: 100%; min-height: var(--row); accent-color: var(--ink); background: none; }

/* --- badges: there are almost no badges (§7.5) -------------------------
   Every survivor is a knockout — a solid shape in the colour with the
   glyph removed in --bg — and there are exactly two of them: the CORRECT
   disc and the INCORRECT disc, both in an option's slot. Everything the
   views still call a badge is demoted to one line of label type. The old
   .qmeta stack rendered up to five filled, bordered, coloured pills above
   the one thing that mattered. */
.badge, .pill {
  display: inline-flex;
  align-items: center;
  gap: var(--u1);
  padding: 0;
  background: none;
  border: 0;
  border-radius: 0;
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  font-weight: 400;
  letter-spacing: 0;
  color: var(--ink-2);
}
/* Difficulty, accuracy and chapters never take a verdict colour — the one
   rule the student needs to be able to trust (§7.5). */
.badge-good, .badge-bad, .badge-warn { color: var(--ink-2); }
.badge-muted { color: var(--ink-3); }
/* the adjudicated-key marker: this screen's one blue object, and only on
   the ~2% of questions that carry it */
.badge-info { color: var(--blue); }

.dot {
  display: inline-block;
  width: .375rem; height: .375rem;
  border-radius: 999px;
  background: var(--ink-2);
}

/* --- the empty state (§9) ---------------------------------------------
   The mark at 4rem in --ink-3 and one serif sentence beneath it. The
   dashed border is deleted. */
.empty {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--b1);
  padding-block: var(--b3);
  border: 0;
}
.empty-ico { color: var(--ink-3); }
.empty-ico .icon, .mark { width: 4rem; height: 4rem; }
.empty-msg { color: var(--ink-2); max-width: var(--measure); margin: 0; }

/* the drawn %, deployed in exactly four places: the app icon, the launch
   screen, the empty state and the top-left of the Home masthead (§6.1) */
.mark { display: block; color: var(--ink); }

/* the wax seal (§5.5 mark 13): the ring alone while a card is in
   progress, ring plus device once it retires */
.seal { color: var(--ink-3); }
.seal.earned { color: var(--blue); }

.skeleton { display: none; }   /* deleted: local JSON loads in under 16ms */


/* =====================================================================
   6  QUIZ SURFACE — Template A, "Answer."
   -------------------------------------------------------------------
   3rem masthead: ✕ (units 1–4) · answer strip (7–30) · ⚑ (31–34)
     --b3
   SECTION LABEL (sticky under the masthead)
     --u3
   STEM, serif, --t-stem
     --b3
   n × 3rem option rows, full-bleed, 1px rule between, 2.25rem left slot
   3rem pinned action bar

   Nothing else may enter Template A. No cards, no chips, no badge stack,
   no live score, no top progress rule.
   ===================================================================== */

.qcard { display: block; }

/* Deleted (§7.6): the answer strip already carries position and count,
   and two progress indicators on one screen is the definition of
   redundancy. */
.qcard-progress, .qz-progress { display: none; }

/* --- the masthead row -------------------------------------------------- */
.qchrome, .qz-head-row {
  display: flex;
  align-items: center;
  gap: var(--u2);
  height: var(--row);
}
.qchrome-ghost, .qz-spacer { flex: 1 1 auto; }
.qchrome-count, .qz-count, .qz-stat {
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
}

.qz-head {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--bg);
}

/* --- the counter is a bar chart of itself (§6.2) -----------------------
   One bar per question in the session, on a shared bottom edge, in the
   masthead's units 7–30. Bars heavier than gaps, which is Rand's 8-bar
   optical rule stated as geometry. CORRECT is a full bar in --ink,
   INCORRECT a short bar in --bad, UNANSWERED nothing — but the slot's
   width is reserved, so the strip never reflows. There is no marker for
   "current": the leading edge of drawn bars is the current position.

   Strictly more informative than 7/30 in the same 98px — position,
   count, score and the shape of your run — which is the only reason it
   is allowed to be charming. And a perfect session draws a solid bar of
   even stripes; a bad one draws a picket fence. */
.strip {
  display: flex;
  align-items: flex-end;
  gap: .125rem;
  height: .875rem;
  flex: 1 1 auto;
  min-width: 0;
}
.strip-bar {
  flex: 0 0 auto;
  width: .1875rem;
  height: 100%;
  background: none;
}
.strip-bar.correct { background: var(--ink); }
.strip-bar.wrong   { height: .375rem; background: var(--bad); }
/* above 40 questions the bars thin so the strip still fits its units */
.strip[data-dense="1"] .strip-bar { width: .125rem; }
.strip[data-dense="1"] { gap: .0625rem; }

/* --- the strip is operable in a study session -------------------------
   Each bar is a button that jumps to its question. Nothing about the drawing
   changes: same widths, same gaps, same two colours, verdict still carried by
   height. What is added is what the chart could never say — WHICH ONE YOU ARE
   ON, and what each bar is about.

   The comment above claims "the leading edge of drawn bars is the current
   position". That reading only holds while you answer strictly in order, and
   it stops being true the moment you can jump — so the current bar is marked
   outright, with the ink outline .qcell.cur already uses for exactly this in
   the exam navigator. */
.strip[data-live="1"] .strip-bar {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  padding: 0;
  margin: 0;
  border-radius: 0;
  cursor: pointer;
  position: relative;
}

/* A 3px-wide bar is not a touch target. This grows the HIT area to the height
   of the masthead row without moving a pixel of the drawing: the pseudo-element
   is absolutely positioned, so it adds nothing to layout and the bars keep
   their exact rhythm. Width still cannot reach 24px — twenty of those would be
   480px and the strip has to share a phone masthead with two icons — but the
   same jump is available from Back, from the keyboard, and in exam mode from
   the numbered grid, which is WCAG 2.5.8's equivalent-control exception. */
.strip[data-live="1"] .strip-bar::after {
  content: "";
  position: absolute;
  left: -.09375rem;
  right: -.09375rem;
  top: -.5rem;
  bottom: -.5rem;
}

/* "You are here" is a PLAYHEAD, not a box.
   The first version drew a 2px ink outline around the current bar, borrowed
   from .qcell.cur in the exam grid. On a 24px numbered cell that outline is a
   quiet border; around a 3px bar it is a heavy rectangle three times wider than
   the thing it marks, and it read as the loudest object in the masthead.

   Every scrubber solves this the same way — Netflix, Hulu and Paramount+ all
   put a small filled marker ON the track and leave the track alone. So the
   current position is a short ink tick under its bar: it points, it does not
   enclose, and it cannot be mistaken for a verdict because it is not on the
   bar. */
.strip[data-live="1"] .strip-bar[aria-current="true"]::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -.28125rem;          /* clear of the bars' shared baseline */
  height: .125rem;
  background: var(--ink);
}

/* Hover is the same tick in the accent, so pointing at a bar and being on one
   are the same shape in two colours rather than two different shapes. */
.strip[data-live="1"] .strip-bar:hover::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -.28125rem;
  height: .125rem;
  background: var(--blue);
}

/* Pointer hover is meaningless on a touch screen and can stick after a tap. */
@media (hover: none) {
  .strip[data-live="1"] .strip-bar:hover::before { background: var(--ink); }
  .strip[data-live="1"] .strip-bar:not([aria-current="true"]):hover::before { content: none; }
}

/* --- the strip's own width --------------------------------------------
   `flex: 1 1 auto` made the strip claim the whole masthead: twenty bars drew
   104px of chart inside a 348px box, so the run of hairlines trailed off into
   dead space and the flag was pushed to the far edge. It only ever needs the
   width of its bars.

   The bars may also shrink now. At text scale 1.4 the masthead gets NARROWER
   (everything beside it grew) while each bar gets WIDER, so a long set could
   outgrow the row — and "Practise all 25" can now be "all 46", or more. Letting
   the bars compress is what keeps the chart inside the row at any scale and any
   count; below a hairline they stop, and view-quiz switches to a plain track. */
.strip[data-live="1"] {
  flex: 0 1 auto;
  min-width: 0;
}
.strip[data-live="1"] .strip-bar {
  flex: 0 1 auto;
  min-width: 1px;
}

/* --- what a bar is, said in words -------------------------------------
   Quoted, small, and hung off a rule — the same shape .note-quote uses, because
   it is the same act: a short aside about the thing beside it. */
.strip-tip {
  position: absolute;
  top: calc(100% + var(--u2));
  z-index: 6;
  max-width: min(22rem, 80vw);
  padding: var(--u1) 0 var(--u1) var(--u2);
  border-left: 2px solid var(--rule);
  background: var(--bg);
  color: var(--ink-2);
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;          /* never stands between the pointer and a bar */
}
.strip-tip[hidden] { display: none; }

/* --- the metadata line (§7.5) -----------------------------------------
   One line spanning units 1–22, replacing the five-pill stack:
   MEDIUM · OPTION PRICING */
.qmeta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--u3);
  max-width: 64.706%;
  margin: 0 0 var(--u3);
}
.qmeta[data-audit="adjudicated"] .qmeta-line { color: var(--blue); }

/* --- the shared scenario ----------------------------------------------
   Data, not prose, so it is the one monospaced thing in the app. The
   3px coloured left border it used to carry is deleted: that border is
   now as reliable a marker of generated design as the em-dash is of
   generated text (§9). */
.context {
  font-size: var(--t-read);
  line-height: var(--lh-read);
  color: var(--ink-2);
  border: 0;
  border-radius: 0;
  padding: 0;
  margin-bottom: var(--b1);
  max-width: var(--measure);
  user-select: text;
}
.context-label { display: block; color: var(--ink-3); margin-bottom: var(--u2); }

/* --- the stem ---------------------------------------------------------- */
.stem {
  font-size: var(--t-stem);
  line-height: var(--lh-stem);
  font-weight: 400;
  letter-spacing: 0;
  color: var(--ink);
  max-width: var(--measure);
  margin: 0 0 var(--b3);
  text-wrap: balance;
  text-box-trim: trim-both;
  text-box-edge: cap alphabetic;
  user-select: text;
}

/* --- the options (§7.1) ------------------------------------------------
   No box. A row on the raw page: 3rem tall, full-bleed to the gutters so
   a horizontal mis-tap is impossible, --bg ground, 1px rule between
   adjacent rows, a fixed 2.25rem left slot, serif body at --t-read.

   Zero reflow, mandatory: the slot is reserved and the row carries a
   1.5px transparent bottom rule before answering, so revealing the
   verdict changes zero pixels of layout. A tired reader who loses their
   place has to re-read the stem, and that is the most expensive thing
   this app can do to them. */
.opts {
  margin-inline: calc(var(--pad) * -1);
  border-top: var(--hair) solid var(--rule);
}

.opt {
  position: relative;
  display: grid;
  grid-template-columns: var(--slot) minmax(0, 1fr);
  align-items: center;
  column-gap: var(--u3);
  width: 100%;
  min-height: var(--row);
  padding: var(--u3) var(--pad);
  text-align: left;
  color: var(--ink);
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--rule);
  border-radius: 0;
  user-select: none;
  -webkit-touch-callout: none;
}

/* THE RING GOES INSIDE THIS ONE CONTROL, and only because of its geometry.
   An option row is negative-margined out to the full width of the reading
   column — on the phone that is the full width of the viewport — so the
   global `outline-offset: 2px` puts the ring's left and right segments two
   pixels OUTSIDE the page, where `body { overflow-x: hidden }` shears them
   off. What survives is two horizontal dashes with no sides: a focus
   indicator that has lost the part that says *which row*. Negative offset
   draws it inside the border box instead, where it encloses the control it
   belongs to and nothing can clip it. -3px against a 2px outline leaves the
   ring 3–5px in from the edge, clear of the row's 12px text inset, so it
   crosses no glyph at any text scale. */
.opt:focus-visible { outline-offset: -3px; }

/* the reserved verdict rule — 1.5px, transparent until there is a verdict */
.opt::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1.5px;
  background: none;
}

/* the slot: sans, because it is an index, not prose */
.opt-letter {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--slot);
  height: var(--slot);
  border-radius: 999px;
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  line-height: var(--lh-label);
  font-weight: 700;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
  background: none;
}

.opt-text {
  min-width: 0;
  max-width: var(--measure);
  color: var(--ink);
}

/* An option presses by a background change inside one frame, never by a
   transform: scaling resamples glyphs, and the mid-transition frame of a
   21px Charter stem is visibly blurry (§8.2). No transition property
   touches .opt anywhere in this file. */
.opt:active .opt-letter { background: var(--ink); color: var(--bg); --icon-bg: var(--ink); }

/* SELECTED — the slot inverts, and only the slot. The body never changes
   fill. Invert the chip, not the row: it survives being seen forty times
   an hour where a tinted row does not. */
.opt.chosen .opt-letter { background: var(--ink); color: var(--bg); --icon-bg: var(--ink); }
.opt.chosen::after { background: var(--ink); }
.opt.chosen { border-bottom-color: transparent; }

/* ANSWERED — the slot becomes the solid verdict disc with the tick or
   the cross knocked out in --bg, and the rule under the row takes the
   same colour. The option's own text does not change colour, weight or
   opacity. The other options get no decoration at all: on a black field
   the absence of decoration is already the strongest possible
   de-emphasis, and it costs nothing perceptually. */
.opt.correct .opt-letter { background: var(--good); color: var(--bg); --icon-bg: var(--good); }
.opt.correct::after { background: var(--good); }
.opt.wrong .opt-letter { background: var(--bad); color: var(--bg); --icon-bg: var(--bad); }
.opt.wrong::after { background: var(--bad); }
.opt.correct, .opt.wrong { border-bottom-color: transparent; }

/* Killed: opacity as a state. --ink at 38% over black composites to
   ≈2.7:1 and it was being applied to distractor text the student still
   needs to read (§7.1). */
.opt.dim, .opt.muted { color: var(--ink); opacity: 1; }
.opt.disabled { cursor: default; }
.opts.revealed .opt { cursor: default; }

/* --- the inline reveal, held inert above the phone (REVEAL.md) ---------
   REVEAL.md deletes the sheet ON THE PHONE ONLY. Each option's reasoning
   moves inside that option, because at 412 × 915 the sheet capped at
   70dvh left 275px above it while a six-line stem — six 2rem lines plus
   the meta line and the --b3 drop — runs to ~380px, so on most of the
   1,146 questions the explanation covered both the question AND the
   option it was explaining. The owner's words: "if i click on one of the
   options in phone's ui, then i can't see what the question description
   nor what that option that i clicked on say."

   Desktop is a different problem with a different answer already
   shipped: at ≥1180px the explanation docks beside the question and the
   two are visible together by construction. It must inherit none of
   this. js/ui.js emits the wrapper at every width and switches its own
   two modes on `(max-width: 719.98px)` — the exact query §13.7 turns
   the pattern on inside, one number in one unit — and in panel mode it
   still draws the disc in the slot and still prints "why each option" in
   the panel. So above 719.98px these four are inert and the desktop
   build renders pixel-identical to before.

   THE MARKUP:

     <div class="opts revealed">
       <button class="opt correct" data-k="b"
               aria-expanded="true" aria-controls="optwhy-1">
         <span class="opt-letter">B</span>
         <span class="opt-text prewrap">$630</span>
         <span class="opt-mark">…check 18 · or the .opt-caret chevron…</span>
       </button>
       <div class="opt-why" id="optwhy-1">
         <p class="opt-body prose prewrap">(0.8321 − 0.8195) × 50,000 = $630</p>
       </div>
       …
     </div>

   NOTHING BELOW IS KEYED TO WHICH OF THOSE TWO NAMES IS THE PANEL. The
   panel is addressed STRUCTURALLY, as `.opt + .opt-why, .opt + .opt-body`
   — the element that follows the button — and the prose as that
   element's child. js/ui.js has shipped both spellings of this component
   and both an `.opt-row` wrapper and none; all four combinations render
   identically here, and `.opt-row`, wherever it exists, stays a plain
   transparent block that the panel collapses inside.

   Where the padding, the ground and the hairlines live is not a matter
   of taste: a box collapsed to a zero-height grid track still paints its
   own padding and its own borders. On the clipped child the block
   padding therefore has to be pseudo-spacers, and on the panel the
   hairlines only exist while it is open — otherwise every shut option in
   the app leaks a strip of --surface and a stray rule, 1,146 times. */
.opt-row { display: block; }
.opt-why, .opt-body, .opt-mark, .opt-caret { display: none; }

/* --- the question navigator (exam mode, §9 D2 reinstated) --------------
   Answered or not answered. It never shows correctness, because exam mode
   defers feedback to submission — which is the pedagogically stronger
   mode, not the weaker one. */
.qgrid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  column-gap: var(--gutter);
  row-gap: var(--u2);
  margin-bottom: var(--b1);
}
.qcell {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--row);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
  background: none;
  border: var(--hair) solid var(--rule);
  border-radius: 0;
}
.qcell.seen, .qcell.ok, .qcell.ko {
  background: var(--ink);
  color: var(--bg);
  border-color: transparent;
}
.qcell.cur { border-color: var(--ink); border-width: 2px; }
.qcell.flag { position: relative; }
.qcell.flag::after {
  content: "";
  position: absolute;
  top: 0; right: 0;
  width: .375rem; height: .375rem;
  background: var(--blue);
}

/* --- player chrome ----------------------------------------------------- */
.qz-body { padding-bottom: var(--b3); }
.qz-card { display: block; }

.qz-navtoggle {
  display: inline-flex;
  align-items: center;
  gap: var(--u2);
  min-height: var(--row);
  padding-inline: var(--u2);
  color: var(--ink-2);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  border-radius: 0;
  transition: transform 90ms var(--ease-out), color 120ms var(--ease-out);
}
.qz-caret { display: inline-flex; transition: transform 150ms var(--ease-out); }
.qz-open { transform: rotate(90deg); }

.qz-timer {
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-2);
}
/* the last minute is said with weight, not with red — red means one thing */
.qz-danger { color: var(--ink); font-weight: 700; }

.qz-navwrap { margin-bottom: var(--b1); }
.qz-navnote { margin-bottom: var(--u3); }

.qz-controls { display: flex; align-items: center; gap: var(--u3); }
.qz-grow { flex: 1 1 auto; }
.qz-sub { margin-top: var(--u3); }
.qz-hint { color: var(--ink-3); font-size: var(--t-meta); line-height: var(--lh-meta); }
.qz-actions { display: flex; flex-direction: column; gap: var(--u3); }


/* =====================================================================
   7  THE SHEET — Template C, "Read."
   -------------------------------------------------------------------
   2px verdict rule, full bleed across the top edge
   3rem  the diff line
     --b3
   SECTION LABEL (sticky) + prose, serif, --t-read, max-width 30rem
     --b3
   …repeat
   3rem  pinned Continue, in the same rectangle Check occupied

   Exactly one level of hierarchy: label + body. No sub-headings, no
   nested surfaces — which is what allows --surface-2 to be deleted.
   ===================================================================== */

/* You cannot darken black, so a scrim does nothing. The pure-black
   substitute is to dim the layer beneath, on the same duration and the
   same curve as the sheet so the layers cannot de-sync. This is the one
   place in the file where opacity carries meaning. */
.app.has-sheet .view, .app.has-sheet .topbar,
.is-locked .app .view, .is-locked .app .topbar { opacity: .38; }

.sheet, .modal {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  background: none;
}
.sheet { align-items: flex-end; justify-content: center; }
.modal { align-items: center; justify-content: center; padding: var(--pad); }

/* Full bleed, radius 0 on all four corners, --surface ground. The 20px
   top corners are dropped with every other non-circular radius. */
.sheet-card {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 45rem;
  max-height: 82vh;
  background: var(--surface);
  color: var(--ink);
  border-radius: 0;
  --icon-bg: var(--surface);
  animation: sheet-rise 260ms var(--ease-sheet) both;
}

/* The only colour on the sheet. The interior is completely neutral: with
   1,146 questions and long explanations, a red-washed panel every third
   question is exhausting. */
.sheet-card::before {
  content: "";
  flex: 0 0 auto;
  height: 2px;
  background: var(--rule);
}
.sheet-card.correct::before { background: var(--good); }
.sheet-card.wrong::before   { background: var(--bad); }

/* The build renders a drag handle on a sheet that does not drag. A
   signifier that lies is the one Norman failure that survives every
   audit, so it is gone (§7.3). */
.sheet-grab { display: none; }

/* the sheet's own 3rem masthead — every masthead in the app is 3rem */
.sheet-head, .diff {
  display: flex;
  align-items: center;
  gap: var(--u3);
  flex: 0 0 auto;
  min-height: var(--row);
  padding-inline: var(--pad);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-variant-numeric: tabular-nums lining-nums;
}

/* The first line inside the sheet is a diff, not a sentence:
   B $630 → D $1,240. It answers "what did I get wrong" in ~200ms, before
   the reader parses a word of finance prose. */
.diff-from {
  color: var(--ink-2);
  text-decoration: line-through;
  text-decoration-thickness: 1.5px;
}
.diff-sep { color: var(--ink-3); display: inline-flex; }
.diff-to  { color: var(--ink); font-weight: 700; }

/* --- the answer diff, as js/ui.js actually emits it -------------------
   ui.js writes .expl-diff > (.diff-was > .diff-k + .diff-t) + .diff-arrow +
   (.diff-is > .diff-k + .diff-t). Without these the letter ran straight
   into the option text — "Athe partner firms can make…". It is the first
   line of the explanation and answers "what did I put, and what was it
   actually", so it has to read at a glance. */
/* Rows, not equal columns: forcing 1fr/1fr made a long wrong answer wrap
   into a thin tower beside a three-word right answer. Stacked, each reads
   on its own full measure and the pair scans top-to-bottom like a
   correction.

   STICKY, and that is the important part. The sheet takes 70% of a phone
   screen, so on any question with more than a couple of lines of stem the
   question and the options are both behind it — you could read the
   explanation but not see what you had picked or what was being explained.
   Pinning the diff to the top of the scroller means "you chose A, the
   answer is D" is on screen the entire time you read, whatever else
   scrolls past. */
.expl-diff {
  position: sticky;
  top: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--u2);
  margin: 0 0 var(--b2);
  padding-block: var(--u3);
  background: var(--surface);
  border-bottom: var(--hair) solid var(--rule);
  font-family: var(--font-ui);
  font-size: var(--t-ui);
  line-height: 1.4;
}
/* Rendered inline on the results screen there is no sheet, so it must not
   pin itself to the top of the page or paint a panel colour. */
.qz-item .expl-diff,
.res-item .expl-diff {
  position: static;
  background: none;
  border-bottom: 0;
}
.diff-was, .diff-is {
  display: grid;
  grid-template-columns: 1.25rem minmax(0, 1fr);
  gap: var(--u2);
  align-items: baseline;
  min-width: 0;
}
.diff-k {
  font-family: var(--font-ui);
  font-size: var(--t-label);
  /* 700, not 650: 650 does not exist in Segoe UI or any face in the stack,
     so the browser picked 400 or 700 for it — differently per platform —
     which is the exact bug §3.2 and §10.10 exist to catch. */
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
}
.diff-t { min-width: 0; overflow-wrap: anywhere; }
.diff-was .diff-k, .diff-was .diff-t { color: var(--ink-3); }
.diff-was .diff-t { text-decoration: line-through; text-decoration-thickness: 1px; }
.diff-is  .diff-k { color: var(--good); }
.diff-is  .diff-t { color: var(--ink); font-weight: 600; }
/* Stacked, the chevron would point sideways between two rows. Hide it and
   let the strike-through plus the green key carry the before/after. */
.diff-arrow { display: none; }

/* --- the quiz chrome row (✕ · answer strip · flag) -------------------- */
.qchrome-exit, .qchrome-flag {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 2.75rem;
  block-size: 2.75rem;          /* 44px minimum target */
  color: var(--ink-3);
  background: none;
  border: 0;
  cursor: pointer;
}
.qchrome-exit:hover, .qchrome-flag:hover { color: var(--ink); }
.qchrome-flag[aria-pressed="true"] { color: var(--blue); }

/* A question you have already written about says so before you open it, using
   the same accent and the same aria-pressed shape the flag uses — two marks
   that mean "you did something to this one" should not read differently. */
.ctl-note[aria-pressed="true"] { color: var(--blue); }
/* position:relative so .strip-tip can hang off it; the strip itself no longer
   stretches, so this is what holds the remaining masthead width. */
.qchrome-strip { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; position: relative; }
.qchrome-strip-bars { display: flex; align-items: center; min-width: 0; max-width: 100%; }

/* The amber-equivalent marker on the ~2% of questions whose printed key was
   disputed and adjudicated. Blue, because blue is this system's one accent
   and a verdict colour here would lie about what it means. */
.qmeta-audit { color: var(--blue); }

/* --- notes -------------------------------------------------------------
   A note is reading matter, so it takes the READ face and the read size from
   the shared groups above — the same treatment as an explanation, because that
   is what it sits next to and what it is for. Nothing here re-declares type;
   these rules are spacing, colour and the one or two shapes that are genuinely
   new. No panel, no card, no tinted background: a note lives in the same column
   as everything else and is told apart by a rule, exactly as the explanation is.

   The shapes that used to live here (heading, list, quote, link) now live on
   trix-editor's own tags below — the note SURFACE renders them directly,
   there is no separate read view to keep in sync with it. */

/* --- writing one -------------------------------------------------------
   The editor is Trix (js/vendor/, MIT) rather than a plain textarea: what you
   paste is what you see, never a wall of the base64 that stores it. Its
   toolbar and surface still have to look like they were drawn for THIS app —
   trix.css ships its own boxed, grey, light-mode-only chrome, and everything
   below exists to undo that in favour of the one visual contract this app
   actually has: no cards, no boxes, radius 0 or a circle, the reading face for
   what you read and the UI face for what you operate. */
.note-about { margin: 0 0 var(--b1); }

/* THE SURFACE HAS NO BOX.
   A bordered writing area is the single most dated object a note sheet can
   contain — it is a 2005 form field, and it framed the writing as data entry.
   Threads, Cosmos and stoic. all write on nothing at all: the text IS the
   interface, and the only chrome is the toolbar underneath. trix.css draws a
   full border and a min-height box by default; both are switched off here so
   what is left is a line where you type and a rule where the toolbar sits,
   exactly the shape the old textarea had.

   It is set in the READING face, not the UI face: what you write here is what
   gets read back beside the explanations, so writing it in a different face
   would make the sheet feel like a different app.

   The editor also carries Trix's own ".trix-content" class (js/vendor/trix.js
   sets it up, js/vendor/trix.css styles it) for the list/quote/code rules
   below that this file deliberately does not redeclare — one editor should
   not need two stylesheets disagreeing about what a blockquote looks like. */
trix-editor.note-trix {
  display: block;
  width: 100%;
  min-height: 7rem;
  font-family: var(--font-read);
  font-size: var(--t-read);
  line-height: var(--lh-read);
  color: var(--ink);
  background: none;
  border: 0;
  padding: 0;
  outline-offset: 4px;
}
trix-editor.note-trix.trix-active-invisible-selection,
trix-editor.note-trix { border-radius: 0; }
trix-editor.note-trix:empty::before {
  content: attr(placeholder);
  color: var(--ink-3);
}

/* The stuff a note can hold: same shapes the old markdown renderer drew, so a
   note read back looks like the same object whichever editor wrote it. */
trix-editor.note-trix h1 { color: var(--ink-3); margin: var(--b2) 0 var(--u2); font-size: var(--t-read); }
trix-editor.note-trix h1:first-child { margin-top: 0; }
trix-editor.note-trix blockquote {
  margin: 0 0 var(--b1);
  padding-left: var(--b1);
  border-left: 2px solid var(--rule);
  color: var(--ink-3);
}
trix-editor.note-trix ul, trix-editor.note-trix ol { margin: 0 0 var(--b1); padding-left: var(--b2); color: var(--ink-2); }
trix-editor.note-trix li { margin: 0 0 var(--u2); }
trix-editor.note-trix a { color: var(--blue); text-underline-offset: 2px; }
trix-editor.note-trix pre {
  font-family: var(--font-ui);
  font-size: var(--t-ui);
  white-space: pre-wrap;
  color: var(--ink-2);
}
/* width: fit-content, not the column's full width — a figure is block-level
   by default, and a wrapper wider than its picture would put the resize
   handle (positioned off the wrapper's own corner, below) floating in blank
   space instead of on the image it belongs to. */
trix-editor.note-trix .attachment { margin: var(--u2) 0; max-width: 100%; width: fit-content; }
trix-editor.note-trix .attachment img { display: block; max-width: 100%; height: auto; }
trix-editor.note-trix .attachment__caption { display: none; }  /* filename/size — noise, not content */
trix-editor.note-trix .attachment__progress { background-color: var(--blue); }

/* The grip on a hovered image — a circle, per the "radius 0 or a circle"
   rule, sitting exactly over the corner ui.js hit-tests against. Driven by
   :hover, not a class ui.js toggles: confirmed live that Trix replaces an
   attachment's <figure> with a new element of its own on the very next
   render after a selection change, which would silently erase a class added
   a moment earlier. :hover has no such race — it re-evaluates against
   whatever node is actually there. On touch, where hover doesn't apply, the
   grip is simply always visible; a hidden affordance would be worse than an
   always-shown one on a screen with no pointer to reveal it. */
trix-editor.note-trix .attachment--preview {
  cursor: default;
}
trix-editor.note-trix .attachment--preview::after {
  content: "";
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--blue);
  border: 2px solid var(--bg);
  cursor: nwse-resize;
  opacity: 0;
}
trix-editor.note-trix .attachment--preview:hover::after { opacity: 1; }
@media (hover: none) {
  trix-editor.note-trix .attachment--preview::after { opacity: 1; }
}

/* The toolbar: Trix inserts it as the previous sibling, unprompted. Small,
   quiet, one hairline under it, same rhythm as the rest of the sheet's tools —
   not the boxed grey bar trix.css draws by default. */
trix-toolbar {
  display: block;
  margin: 0 0 var(--u2);
  border: 0;
}
trix-toolbar .trix-button-row { border: 0; }
trix-toolbar .trix-button-group {
  display: flex;
  align-items: center;
  gap: 2px;
  margin: 0 var(--u2) 0 0;
  border: 0;
  border-radius: 0;
}
trix-toolbar .trix-button-group + .trix-button-group { border-left: var(--hair) solid var(--rule); padding-left: var(--u2); }

/* Icon buttons: a circle when active, nothing when not — "radius 0 or a
   circle" applied to a toolbar most apps draw as grey rounded rectangles. */
trix-toolbar .trix-button {
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--ink-3);
}
trix-toolbar .trix-button:hover { background: none; color: var(--ink); }
trix-toolbar .trix-button.trix-active { background: var(--blue); }
/* trix.css draws every icon as a hardcoded-black inline SVG background, not
   currentColor, so recolouring for a dark-by-default app means inverting the
   whole sprite rather than setting `color` — and inverting it again, to white,
   the one time it sits on the accent fill instead of the page. */
trix-toolbar .trix-button--icon::before { filter: invert(1); }
trix-toolbar .trix-button--icon:disabled::before { opacity: .3; }
trix-toolbar .trix-button--icon.trix-active::before { filter: invert(1) brightness(0) invert(1); }
trix-toolbar .trix-dialog {
  background: var(--bg);
  border: var(--hair) solid var(--rule);
  border-radius: 0;
  box-shadow: none;
}
trix-toolbar .trix-input--dialog {
  font-family: var(--font-ui);
  font-size: var(--t-ui);
  color: var(--ink);
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--rule);
  border-radius: 0;
}
trix-toolbar .trix-button--dialog {
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  color: var(--blue);
  background: none;
  border: 0;
}

@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] trix-toolbar .trix-button--icon::before { filter: none; }
  :root[data-theme="auto"] trix-toolbar .trix-button--icon.trix-active::before { filter: invert(1); }
}
:root[data-theme="light"] trix-toolbar .trix-button--icon::before { filter: none; }
:root[data-theme="light"] trix-toolbar .trix-button--icon.trix-active::before { filter: invert(1); }

.note-tools {
  display: flex;
  align-items: center;
  gap: var(--b1);
  margin-top: var(--u2);
}
.note-status { flex: 1 1 auto; min-width: 0; text-align: right; }

/* Deleting is not a peer of saving, so it does not sit in the footer beside
   it. It states its own consequence on the first press instead of opening a
   dialog on top of this one. */
.note-danger { margin-top: var(--b1); }
.note-del {
  background: none;
  border: 0;
  padding: 0;
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  color: var(--ink-3);
  cursor: pointer;
}
.note-del:hover { color: var(--bad); }
.note-del.is-armed { color: var(--bad); font-weight: 700; }

/* --- #/notes: the list ------------------------------------------------
   Template B rows, so a note reads like every other row in the app: what it is
   about on the left, when on the right, one hairline between. The excerpt is
   the note itself in the muted tone — the row's third line, not a description
   of the row. */
.nt-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--b1);
  width: 100%;
  text-align: left;
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--rule);
  padding: var(--b1) 0;
  color: inherit;
  cursor: pointer;
}
.nt-row:last-child { border-bottom: 0; }
.nt-main { display: block; min-width: 0; }
.nt-title {
  display: block;
  font-family: var(--font-read);
  font-size: var(--t-read);
  line-height: var(--lh-read);
  color: var(--ink);
  /* A question stem is a paragraph; two lines of it is enough to know which. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.nt-sub {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-top: var(--u1);
}
.nt-excerpt {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  color: var(--ink-2);
  margin-top: var(--u2);
}
.nt-meta { display: flex; align-items: center; gap: var(--u2); flex: 0 0 auto; }
.nt-img { color: var(--ink-3); display: inline-flex; }

/* --- Home widgets ------------------------------------------------------
   A widget is a section label that happens to be pressable, and its body. No
   card, no border, no tinted panel: Template B's flat block flow is what Home
   has always been, and wrapping each block in a box would turn a quiet list
   into a dashboard of rectangles. The ONLY thing added is a caret, and it only
   has to say "there is more of this, or there is not".

   .wdg-head reuses .label wholesale — the heading is still the heading, it has
   simply grown a control around it. */
.wdg { display: block; }
.wdg-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--u2);
  width: 100%;
  background: none;
  border: 0;
  padding: 0;
  color: inherit;
  cursor: pointer;
  text-align: left;
}
/* The label keeps its own sticky behaviour and spacing; the button is only a
   hit area around it. */
.wdg-label { flex: 1 1 auto; min-width: 0; }
.wdg-caret { color: var(--ink-3); display: inline-flex; flex: 0 0 auto; }
.wdg-head:hover .wdg-caret { color: var(--ink); }
.wdg-body { display: block; }

/* --- Customise home ----------------------------------------------------
   A list, not a drag surface. Each row: move it, name it, keep or drop it. */
.wdg-edit-row {
  display: flex;
  align-items: center;
  gap: var(--b1);
  padding: var(--u2) 0;
  border-bottom: var(--hair) solid var(--rule);
}
.wdg-edit-row:last-child { border-bottom: 0; }
.wdg-edit-move { display: flex; flex-direction: column; gap: 2px; flex: 0 0 auto; }
.wdg-edit-move .btn-icon { width: 1.75rem; height: 1.25rem; min-height: 0; }
.wdg-edit-move .btn-icon[disabled] { opacity: .35; cursor: default; }
.wdg-edit-name {
  flex: 1 1 auto;
  min-width: 0;
  font-family: var(--font-ui);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
}

/* --- Browse: the note on the chapter you have selected ------------------
   A row, not a card, so it sits in the same column as the chips above and the
   questions below. The accent appears only once a note exists — an empty
   affordance should not be the loudest thing on the screen. */
.bz-chnote {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--b1);
  width: 100%;
  text-align: left;
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--rule);
  padding: var(--b1) 0;
  margin-bottom: var(--b1);
  color: var(--ink-3);
  cursor: pointer;
}
.bz-chnote:hover { color: var(--ink); }
.bz-chnote[aria-pressed="true"] { color: var(--blue); }
.bz-chnote-main { display: block; min-width: 0; }
.bz-chnote-title {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  letter-spacing: 0.10em;
  text-transform: uppercase;
  font-weight: 700;
}
.bz-chnote-body {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  color: var(--ink-2);
  margin-top: var(--u1);
}

/* Images are the one thing in a note that can be big, so they are bounded by
   the column and never by their own pixels. */
.note-img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: var(--b1) 0;
  border: var(--hair) solid var(--rule);
}

/* Reserved rule above the verdict so revealing an answer moves zero pixels. */
.verdict-rule {
  block-size: 2px;
  background: currentColor;
  border: 0;
  margin: 0 0 var(--u3);
}

/* --- progress bar parts ----------------------------------------------
   .bar-knob was rendering as a bare blue disc floating on the rule. It is
   the current-value marker: it should read as the end of the filled span,
   not as an unrelated dot. */
.bar-cap {
  position: absolute;
  inset-block: 0;
  inline-size: 2px;
  background: var(--rule);
}
.bar-knob {
  position: absolute;
  inset-block-start: 50%;
  inline-size: 0.5rem;
  block-size: 0.5rem;
  margin-block-start: -0.25rem;
  margin-inline-start: -0.25rem;
  border-radius: 999px;          /* same circle on a square box; 50% failed §10.25's grep */
  background: var(--ink);
}
.btn-label { display: inline-block; min-width: 0; }
.empty-mark { color: var(--ink-3); margin-bottom: var(--u4); display: block; }

.sheet-body, .sheet-card > .qz-expl {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: var(--b3) var(--pad) var(--b1);
}
.sheet-foot, .sheet-card > .btn { flex: 0 0 auto; }
.sheet-card > .btn { padding-bottom: env(safe-area-inset-bottom); }

/* --- the explanation --------------------------------------------------- */
.expl { display: block; max-width: var(--measure); }
.expl-sec { margin-top: var(--b3); }
.expl-sec:first-child { margin-top: 0; }
.expl-ans { margin-top: 0; }

/* The sheet is where the student scrolls blind through six blocks, so
   this is where the sticky label matters most. Blue is allowed on exactly
   one thing here — the sticky label *in the sheet* — so the same block
   read inline on the results screen stays --ink-2 and the app never shows
   two blue planes at once (§2.5). */
.expl-h {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--bg);
  color: var(--ink-2);
  padding-block: var(--u2);
  margin-bottom: var(--u3);
}
.sheet-card .expl-h { background: var(--surface); color: var(--blue); }
.sheet-card .label, .sheet-card .h3 { top: 0; background: var(--surface); }

/* the verdict sentence: plain English, serif, no uppercase chip */
.verdict {
  display: flex;
  align-items: center;
  gap: var(--u2);
  font-size: var(--t-read);
  line-height: var(--lh-read);
  font-weight: 400;
  color: var(--ink);
  margin: 0 0 var(--b1);
}
.verdict.good { color: var(--good); }
.verdict.bad  { color: var(--bad); }

/* Steps take a hanging-indent numeral, not a circled badge: on dark,
   badge circles around numerals add noise fast. Circles are for option
   letters, which need a state to swap into; step numbers never change
   state (§7.3). */
.steps {
  list-style: none;
  counter-reset: step;
  margin: 0;
  padding: 0;
}
.steps > li {
  counter-increment: step;
  padding-left: 1.5rem;
  text-indent: -1.5rem;
  margin-top: var(--u3);
  user-select: text;
}
.steps > li:first-child { margin-top: 0; }
.steps > li::before {
  content: counter(step) ".";
  font-family: var(--font-ui);
  font-variant-numeric: tabular-nums lining-nums;
  font-weight: 700;
  color: var(--ink-2);
  margin-right: var(--u2);
}

/* per-option why: the same slot geometry as the options themselves, so
   the eye reads down one column across both */
.why { display: block; }
.why-row {
  display: grid;
  grid-template-columns: var(--slot) minmax(0, 1fr);
  column-gap: var(--u3);
  padding-block: var(--u3);
  border-top: var(--hair) solid var(--rule);
}
.why-row:first-child { border-top: 0; }
.why-row.is-ans { font-weight: 400; }
.why-row.is-ans .why-k { background: var(--ink); color: var(--bg); }
.why-k {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--slot);
  height: var(--slot);
  border-radius: 999px;
  font-size: var(--t-meta);
  line-height: var(--lh-label);
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--ink-2);
}
.why-row[data-chosen="1"] .why-k { color: var(--ink); }

.term-list { display: block; }
.term { display: block; margin-top: var(--u3); user-select: text; }
.term:first-child { margin-top: 0; }
.term-t { font-weight: 700; color: var(--ink); }

/* Formulas: a hanging indent and pre-wrap, never a horizontal scroller —
   a scroll-x inside a scrolling sheet inside a page is three nested
   scroll contexts under one thumb. Variables are set in the reading face,
   italic; --font-mono is not used here (§7.3). */
.formula {
  display: block;
  font-size: var(--t-read);
  line-height: var(--lh-read);
  font-style: italic;
  white-space: pre-wrap;
  padding-left: 1.5rem;
  text-indent: -1.5rem;
  margin-top: var(--u3);
  color: var(--ink);
  user-select: text;
}
.expl .scroll-x { overflow: visible; }

/* real stacked fractions, never x/y */
.frac { display: inline-flex; flex-direction: column; text-align: center; vertical-align: middle; }
.frac-d { border-top: var(--hair) solid currentColor; }


/* =====================================================================
   8  OVERLAYS
   ===================================================================== */

/* --surface belongs to the sheet and to nothing else, so the modal takes
   the page ground and a hairline (§2.5). */
.modal-card {
  position: relative;         /* anchors .modal-resize before js/ui.js's makeFloating switches this to fixed */
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: var(--measure);
  max-height: 82vh;
  background: var(--bg);
  border: var(--hair) solid var(--rule);
  border-radius: 0;
}

/* The grip that makes a modal a window: a circle, per the "radius 0 or a
   circle" rule, at rest until you reach for it — same restraint as the
   image-resize handle in the note editor, so the app has exactly one visual
   idiom for "drag this corner." js/ui.js's makeFloating adds this element
   only on screens wide enough for dragging to mean anything (≥720px). */
.modal-resize {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--blue);
  border: 2px solid var(--bg);
  cursor: nwse-resize;
  opacity: 0;
}
.modal-card:hover .modal-resize,
.modal-card:focus-within .modal-resize { opacity: 1; }

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--u3);
  min-height: var(--row);
  padding-left: var(--pad);
  flex: 0 0 auto;
}
.modal-title { font-size: var(--t-ui); line-height: var(--lh-ui); font-weight: 700; }
.modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--b1) var(--pad);
  font-family: var(--font-read);
  font-size: var(--t-read);
  line-height: var(--lh-read);
}
.modal-body p { margin: 0; }
.modal-actions {
  display: flex;
  gap: var(--u2);
  padding: 0;
  flex: 0 0 auto;
  border-top: var(--hair) solid var(--rule);
}
.modal-actions .btn { flex: 1 1 0; border: 0; }

/* Settings-as-overlay (js/ui.js's UI.floatPanel): a floating panel rendered
   as a child of #view rather than #overlays, so its controls stay reachable
   by UI.on(...)'s delegated dispatch while looking and dragging exactly like
   a modal (same .modal-card/.modal-head, same makeFloating()). z-index sits
   below .sheet/.modal (60) — a real modal or confirm dialog opened on top of
   it should still win — and well above ordinary view content. pointer-events
   is none on the wrapper so clicks reach the question everywhere except the
   card itself. */
.float-panel {
  position: fixed;
  inset: 0;
  z-index: 55;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--pad);
  pointer-events: none;
}
.float-panel .modal-card { pointer-events: auto; }

/* --- toast -------------------------------------------------------------
   One dwell, one duration, one curve. The coloured left border is gone,
   and so is the idea that a toast has a colour at all: green and red mean
   CORRECT and INCORRECT and cannot also mean "saved" (§2.5, §9). */
.toast-wrap {
  position: fixed;
  left: 0; right: 0;
  bottom: calc(var(--row) + var(--b1) + env(safe-area-inset-bottom));
  z-index: 70;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--u2);
  padding-inline: var(--pad);
  pointer-events: none;
}
.toast {
  max-width: var(--measure);
  padding: var(--u3) var(--u4);
  background: var(--ink);
  color: var(--bg);
  border-radius: 0;
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  --icon-bg: var(--ink);
  transition: opacity 180ms var(--ease-out);
}
.toast.good, .toast.bad, .toast.warn { background: var(--ink); color: var(--bg); }
.toast.is-out { opacity: 0; }


/* =====================================================================
   9  DATA
   ===================================================================== */

/* --- the progress display (§7.6) ---------------------------------------
   A bar with the number riding the fill's leading edge, so the one figure
   that matters sits at the exact point on the bar where it is true. The
   fill is --ink, never --blue and never green: progress is not
   correctness. The knob is the one blue object on Home. */
.prog, .progress, .bar {
  position: relative;
  height: .375rem;
  background: var(--rule);
  border-radius: 999px;
  max-width: 73.529%;          /* units 1–25 of 34 */
}
.prog-fill, .progress-fill, .bar-fill {
  display: block;
  height: 100%;
  background: var(--ink);
  border-radius: 999px;
  transform-origin: left;
  transition: transform 240ms var(--ease-flat);
}
.prog-track { display: block; height: 100%; }

.prog-knob {
  position: absolute;
  top: 50%;
  width: 2.125rem;
  height: 2.125rem;
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  background: var(--blue);
  color: var(--bg);
  font-size: var(--t-ui);
  line-height: var(--lh-ui);
  font-weight: 700;
  font-variant-numeric: tabular-nums lining-nums;
  --icon-bg: var(--blue);
}
.prog-cap {
  position: absolute;
  top: 50%; right: 0;
  transform: translate(50%, -50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.125rem;
  height: 2.125rem;
  border-radius: 999px;
  background: var(--bg);
  border: var(--hair) solid var(--rule);
  color: var(--ink-3);
  font-size: var(--t-meta);
  line-height: var(--lh-meta);
  font-variant-numeric: tabular-nums lining-nums;
}
.prog-labels { display: flex; justify-content: space-between; margin-top: var(--u3); max-width: 73.529%; }

/* --- figures ----------------------------------------------------------- */
.stat { display: flex; flex-direction: column; gap: var(--u1); min-width: 0; }
.stat-label { color: var(--ink-3); }

/* UI.ring is deleted (§7.6): a stroke-dasharray percentage donut is the
   canonical dashboard component in an app whose brief says it is not a
   dashboard. The markup may still arrive during the view rewrite, so the
   ring is reduced to what it should have been — a plain figure. */
.ring { display: block; }
.ring svg { display: none; }
.ring-track { fill: none; stroke: var(--rule); }
.ring-val   { fill: none; stroke: var(--ink); }
.ring-label { display: block; }
.ring-sub   { color: var(--ink-3); font-size: var(--t-meta); line-height: var(--lh-meta); }

/* Also deleted: UI.heat (a contribution grid with hard-coded cells in an
   app whose text-size setting rewrites the root font size) and UI.spark
   (a 30-day accuracy line over ~20 answers a day is noise rendered as
   insight). Styled only so legacy markup degrades legibly. */
.heat-wrap, .spark { display: block; }
.heat { display: block; width: 100%; }
.heat-cell { fill: var(--rule); }
.heat-lbl  { fill: var(--ink-3); }
.heat-legend { display: flex; align-items: center; gap: var(--u2); margin-top: var(--u3); color: var(--ink-3); }
.heat-key { display: inline-block; width: .625rem; height: .625rem; background: var(--rule); }
.spark-line { fill: none; stroke: var(--ink); stroke-width: 1.5; }
.spark-area { fill: none; }
.spark-base { fill: none; stroke: var(--rule); stroke-width: 1; }
.spark-dot  { fill: var(--ink); }

/* --- tables ------------------------------------------------------------ */
.table { width: 100%; border-collapse: collapse; font-size: var(--t-ui); }
.table th {
  text-align: left;
  padding: var(--u2) var(--u3) var(--u2) 0;
  border-bottom: var(--hair) solid var(--rule);
}
.table td {
  padding: var(--u3) var(--u3) var(--u3) 0;
  border-bottom: var(--hair) solid var(--rule);
  font-variant-numeric: tabular-nums lining-nums;
  vertical-align: top;
}
.table td:last-child, .table th:last-child { padding-right: 0; text-align: right; }

/* --- results (§7.6) ----------------------------------------------------
   The score as a plain fraction in --t-display serif. Then two groups
   headed Incorrect and Correct, where only the two headings carry colour
   and every item beneath them is neutral, with explanations already
   expanded. One action: practise these N again. */
.qz-score { display: flex; flex-direction: column; gap: var(--b1); }
.qz-score-ring { display: block; }
.qz-score-grid { display: grid; grid-template-columns: repeat(3, 1fr); column-gap: var(--gutter); }

.res { display: block; }
.res-group { margin-top: var(--b3); }
.res-group:first-child { margin-top: 0; }
.res-h.wrong   { color: var(--bad); }
.res-h.correct { color: var(--good); }
.res-item { display: block; }

.qz-item { display: block; border-bottom: var(--hair) solid var(--rule); }
.qz-item-head {
  display: grid;
  grid-template-columns: var(--slot) minmax(0, 1fr) auto;
  align-items: center;
  column-gap: var(--u3);
  width: 100%;
  min-height: var(--row);
  padding-block: var(--u3);
  text-align: left;
  border-radius: 0;
}
.qz-item-n {
  font-size: var(--t-label);
  line-height: var(--lh-label);
  font-weight: 700;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--ink-3);
}
.qz-item-main { min-width: 0; display: flex; flex-direction: column; gap: var(--u1); }
.qz-item-stem { color: var(--ink); }
.qz-item-ans  { font-size: var(--t-meta); line-height: var(--lh-meta); color: var(--ink-2); font-family: var(--font-ui); }
.qz-item-mark { color: var(--ink-2); }
.qz-item-body { padding-bottom: var(--b1); }

/* the by-level / by-chapter breakdowns of a 20-question set are 3 rows
   and up to 5 rows of n ≤ 7 — statistically meaningless, and deleted
   (§9 D4). Kept legible while the views catch up. */
.qz-brk-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 8rem auto;
  align-items: center;
  column-gap: var(--u3);
  min-height: var(--row);
  border-bottom: var(--hair) solid var(--rule);
}
.qz-brk-name { min-width: 0; }
.qz-brk-bar  { display: block; }
.qz-brk-num  { font-variant-numeric: tabular-nums lining-nums; color: var(--ink-2); }


/* =====================================================================
   10  BROWSE
   -------------------------------------------------------------------
   1,146 rows on the raw page. content-visibility is one line and it
   transforms first paint on a phone (§7.7).
   ===================================================================== */

.bz-row {
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  content-visibility: auto;
  contain-intrinsic-size: 0 var(--row);
}
.bz-row > * { grid-column: 1; }

.bz-stem { color: var(--ink); }
.bz-clamp {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.bz-badges { display: flex; flex-wrap: wrap; gap: var(--u3); margin-top: var(--u2); }
.bz-search { margin-bottom: var(--b1); }
.bz-clear { margin-top: var(--u3); }


/* =====================================================================
   11  RESPONSIVE
   -------------------------------------------------------------------
   The breakpoint is in rem, and media-query rem is relative to the
   INITIAL font size, so the text-size setting cannot flip the layout out
   from under the reader. The masthead stays 3rem here too.
   ===================================================================== */

@media (min-width: 45rem) {
  /* `--rail` is the rail's width and it is the ONLY thing that changes when
     the sidebar collapses. Three properties read it — the page's left
     padding, the nav's own width and the toast strip's inset — so the two
     states cannot drift apart, and there is no second set of numbers to keep
     in sync. It is declared inside this query on purpose: below 720px the nav
     is a floating capsule with no width of its own, and a token the phone can
     see is a token the phone can accidentally use. */
  :root { --pad: 2rem; --rail: 15rem; }

  .app { padding-left: var(--rail); }

  .nav {
    top: 0;
    right: auto;
    width: var(--rail);
    flex-direction: column;
    align-items: stretch;
    gap: var(--u1);
    padding: var(--b3) var(--u4) env(safe-area-inset-bottom);
    border-top: 0;
    border-right: var(--hair) solid var(--rule);
  }
  .nav-head {
    display: flex;
    align-items: center;
    gap: var(--u2);
    margin-bottom: var(--b3);
  }
  .nav-brand { display: block; flex: 1 1 auto; min-width: 0; }
  .nav .nav-collapse { display: inline-flex; flex: 0 0 auto; }
  .nav-item {
    flex: 0 0 auto;
    flex-direction: row;
    justify-content: flex-start;
    gap: var(--u3);
    padding-inline: var(--u2);
  }
  .view { padding-bottom: var(--b4); }
  .sticky-bottom { bottom: 0; }
  .toast-wrap { bottom: var(--b1); padding-left: calc(var(--rail) + var(--pad)); }

  .sheet { align-items: center; padding: var(--pad); }
  .sheet-card { max-height: 80vh; }
  .qz-score-grid { grid-template-columns: repeat(4, 1fr); }
}


/* --- 11.1  the rail, collapsed ----------------------------------------
   ONE token moves: 15rem → 4rem. Everything else in this block is the
   consequence of that, not a second layout.

   WHY IT IS NOT A NARROWER READING COLUMN'S PROBLEM. `.container` is capped
   at 45rem and the cap stays: 45rem is ~60 characters of Charter, Ruder's
   ceiling, and a wider measure would cost legibility on every one of the
   1,146 explanations. At 1280px and 1440px the container is already AT that
   cap in both states, so collapsing the rail cannot change its width — it
   changes only where the column sits, because `margin-inline: auto`
   re-centres it in the wider space. That is the whole point: the freed
   11rem is spent on symmetric margin, never on measure, and the stem's line
   breaks are byte-identical before and after the toggle. Below ~1024px the
   column is narrower than its cap and collapsing genuinely widens it, which
   is also correct — there the reader wants the width more than the rail.

   WHY THE ACTIVE ITEM STILL READS AS ACTIVE WITH NO LABEL. It never depended
   on the label. DESIGN-IDENTITY §7.4 carries nav state as GEOMETRY — the
   active mark is redrawn at M3 with its counters filled, the inactive one at
   M2 — with colour (--ink against --ink-3) as the second signal and
   `aria-current="page"` as the third. All three survive losing the word,
   which is exactly why §7.4 chose geometry over a coloured underline.

   THE 4rem. §3.6 ships the root at 106%, so 1rem = 16.96px and the strip
   measures 67.83px. Less 2 × --u2 of padding and the rail's own hairline,
   the items land at 49.89 × 50.88 — the --row module in both axes, near
   enough, and comfortably over the 44px floor of WCAG 2.5.5 without
   inflating a visible box. Measured, not derived: the hairline is inside
   the nav's border-box, which is where the missing pixel goes. */
@media (min-width: 45rem) {
  :root[data-rail="collapsed"] { --rail: 4rem; }

  /* The strip is centred on its icons, so the page margin drops from --u4
     to --u2 and the items lose their leading indent. */
  :root[data-rail="collapsed"] .nav { padding-inline: var(--u2); }
  :root[data-rail="collapsed"] .nav-item { justify-content: center; gap: 0; padding-inline: 0; }
  :root[data-rail="collapsed"] .nav-item .nav-label { display: none; }

  /* The wordmark cannot be set at --t-stem in 3rem, and abbreviating it
     would invent a second logotype. It goes; the control centres in the
     space and keeps the rail's head at the same height either way, so the
     first nav item does not move vertically when you toggle. */
  :root[data-rail="collapsed"] .nav-head { justify-content: center; }
  :root[data-rail="collapsed"] .nav-brand { display: none; }

  /* `.btn-icon` is a fixed --row square, and the strip's content box is --row
     less the rail's own hairline — so the fixed square overhung its column by
     half a pixel and `justify-content: center` split the difference, leaving
     the control optically 0.5px left of the two nav items below it. Filling
     the column instead puts all three on one edge. Height is untouched: it is
     still the 3rem module. */
  :root[data-rail="collapsed"] .nav .nav-collapse { width: 100%; }

  /* The one animation this feature gets, and it earns its 160ms the way
     REVEAL.md's disclosure does: it shows the reading column where its new
     position came from. Nothing reflows while it runs — only the column's x
     moves — so there is no text rewrap to watch. DESIGN-IDENTITY §12 bans
     animating NAV STATE (active/inactive, which must land in the frame of
     the tap); the rail's width is not nav state, and §12's list is
     untouched. */
  .app { transition: padding-left 160ms var(--ease-out); }
  .nav { transition: width 160ms var(--ease-out); }
}

/* Combined query, mirroring the pattern at the end of §13: §12's
   reduced-motion block is read by the phone as well, and the phone's `.nav`
   carries its own transition that must not be zeroed from here. Under
   reduced motion the rail simply IS wide or IS narrow — the state is still
   fully legible, it just arrives in one frame. */
@media (min-width: 45rem) and (prefers-reduced-motion: reduce) {
  .app, .nav { transition: none; }
}


/* --- wide desktop: the explanation sits BESIDE the question ------------
   This app is used for ten to twelve hours a day for weeks at a stretch,
   which changes the brief. At 1440px the reading column is 45rem and the
   rail is 15rem, so a third of the display was doing nothing while the
   explanation — the part you actually study from — arrived as a sheet
   over the top of the question you were trying to reason about.

   The fix is not a wider measure. Sixty characters is right for prose and
   stretching it would cost legibility on every one of the 1,146 questions.
   The fix is to spend the spare width on a second column: the question
   keeps its measure on the left, the explanation docks to the right, and
   the two are visible together. You never lose the stem while reading why
   you were wrong, and you never scroll to compare them.

   Below 1180px there is not room for two readable columns, so the sheet
   stays a sheet.

   THE COLLAPSED RAIL (§11.1) CANNOT BREAK THIS ARITHMETIC, and the reason is
   that none of the three numbers below reference the rail. `.sheet` is
   `position: fixed; inset: 0` (§8) so the docked panel is measured off the
   VIEWPORT's right edge, and both its width and the padding that reserves it
   are `clamp(28rem, 38vw, 38rem)` — viewport units and absolute lengths, no
   `--rail` anywhere. So the question column is
   `viewport − --rail − panel`, and collapsing the rail can only ADD to it:
   at 1180px it goes 451px → 637px, at 1440px 638px → 763px (where it meets
   the 45rem cap and stops). The direction of the change is always toward
   more room, never less, so there is no width at which the two-column layout
   is available open and unavailable collapsed. */
@media (min-width: 1180px) {
  .sheet {
    justify-content: flex-end;
    align-items: stretch;
    padding: 0;
    background: none;          /* no scrim: the question must stay readable */
    pointer-events: none;      /* clicks fall through to the question column */
  }
  .sheet-card {
    pointer-events: auto;
    width: clamp(28rem, 38vw, 38rem);
    max-width: none;
    height: 100dvh;
    max-height: none;
    border-left: var(--hair) solid var(--rule);
    animation: none;           /* a panel that docks should not slide in */
  }

  /* Make room for the docked panel and stop centring the question under it. */
  .app.has-sheet .view,
  .is-locked .app .view {
    padding-right: clamp(28rem, 38vw, 38rem);
    opacity: 1;                /* never dim the thing you are reading FROM */
  }
  .app.has-sheet .topbar,
  .is-locked .app .topbar { opacity: 1; }

  /* The docked panel owns the "Next question" action. Leaving the question
     column's action bar visible as well put two identical primary buttons
     on screen at once.

     The cluster is named alongside it, and NOT scoped through `.view` — that
     was the first version of this rule and it was wrong within an hour.
     js/ui.js builds the shell's cluster in #overlays, a SIBLING of .app, and
     pins it to the viewport's right edge on desktop with a stylesheet of its
     own; measured at 1280 with the panel open, the row landed at x
     1061.5–1231.1 over a panel whose left edge is 778.6, at z-index 65 against
     the sheet's 60. Two primaries, one physically on top of the other, which
     is the exact bug the `.sticky-bottom` half of this rule was written for.

     So the selectors below reach a cluster ANYWHERE in the document: the
     `:has()` pair for the general case, and the sibling pair for a browser
     without it — #overlays is a sibling of .app in index.html, so one of the
     two always lands. Both out-specify a bare `[data-role="cluster"]`, which
     is what the injected sheet uses, so this holds without an !important and
     without either file needing to know the other's selector. */
  .app.has-sheet .sticky-bottom,
  .is-locked .app .sticky-bottom,
  .app.has-sheet .view .ctl-cluster,
  .is-locked .app .view .ctl-cluster,
  :root:has(.app.has-sheet) .ctl-cluster,
  :root:has(.app.has-sheet) [data-role="cluster"],
  :root.is-locked .ctl-cluster,
  :root.is-locked [data-role="cluster"],
  .app.has-sheet ~ #overlays .ctl-cluster,
  .app.has-sheet ~ #overlays [data-role="cluster"] { display: none; }
}

/* --- the cluster and the collapsed rail (§11.1) ------------------------
   Nothing to do, and it is worth saying why rather than leaving it to be
   rediscovered. Above 720px the cluster is IN FLOW inside `.container`,
   which is `max-width: 45rem; margin-inline: auto` inside `.app`'s
   `padding-left: var(--rail)`. So the cluster is right-aligned to the
   reading column's own content edge and moves with it: collapsing the rail
   re-centres the column in 11rem more space and takes the circles with it,
   in the same 160ms, with no second set of numbers to keep in sync. It
   cannot collide with the rail because it is never nearer the left edge
   than the column is, and the column is never under the rail. The phone's
   fixed positioning is the only place the cluster is measured off the
   viewport, and the phone has no rail. */

/* --- the primary action, on a big screen ------------------------------
   A full-bleed near-white slab is two problems at once: it is the
   brightest thing on the screen for hours, and a large static light block
   is exactly what burns into an OLED panel — which is the reason this app
   is black in the first place. On wide screens it becomes a normal
   control: same colour, same weight, a fraction of the area. */
@media (min-width: 900px) {
  .sticky-bottom .btn-primary,
  .qz-actions .btn-primary {
    display: inline-flex;
    width: auto;
    min-width: 16rem;
    max-width: 24rem;
  }
}


/* =====================================================================
   12  MOTION AND A11Y
   -------------------------------------------------------------------
   Restraint here is arithmetic, not taste: a 200ms view transition paid
   once per question across 1,146 questions is 3.8 minutes of a student's
   exam-week life spent watching a page slide.

   What must never animate: the verdict (it appears in the same frame as
   the tap — if it animates, the eye tracks the motion instead of reading
   the result), the answer strip, route changes, the stem appearing,
   numbers, the focus ring, and nav state. None of those carry a
   transition property anywhere above.
   ===================================================================== */

/* The single keyframes in the file, and it never loops. It is spent on
   the sheet because a sheet is inserted into the DOM and therefore
   cannot be transitioned; the flag's pennant wipe is a clip-path
   transition on an element that persists, so it needs no keyframes. */
@keyframes sheet-rise {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

@media (hover: hover) and (pointer: fine) {
  .btn:hover, .chip:hover { border-color: var(--ink); }
  .btn-primary:hover { background: var(--ink); }
  .btn-icon:hover { color: var(--ink); }
  .opt:hover .opt-letter { background: var(--ink); color: var(--bg); --icon-bg: var(--ink); }
  .opt.correct:hover .opt-letter { background: var(--good); --icon-bg: var(--good); }
  .opt.wrong:hover .opt-letter { background: var(--bad); --icon-bg: var(--bad); }
  .list-row:hover, .card-tap:hover, .bz-row:hover { border-bottom-color: var(--ink); }
  .nav-item:hover { color: var(--ink); }
}

/* Fewer and gentler, not none. Zeroing the sheet makes the interface snap
   hard enough to read as a rendering glitch; the layer beneath still dims
   over its own duration, so the change is still legible as a change. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
  }
  .sheet-card { transform: none !important; }
  .btn:active, .btn-icon:active, .chip:active, .flag:active, .qz-navtoggle:active { transform: none; }
  .qz-caret, .flag-field { transition: none; }
  /* The disclosure collapses to a state change. The panel still opens and
     the chevron still turns — those are facts about the interface, not
     motion — they simply arrive in one frame (REVEAL.md, Motion). Both
     spellings of the panel are named for the same reason §6b addresses it
     structurally: whichever one js/ui.js is carrying, it must not move.

     This rule is the desktop half of that statement and it is all this
     block can say: the phone's own declarations live inside §13.7, later
     and (for the panel) more specific, so they beat these selectors on
     both counts. The override that actually holds below 720px is at the
     end of the file, in the combined query, and it repeats §13.7's
     selectors exactly for that reason. */
  .opt-row, .opt-why, .opt-body, .opt-caret { transition: none; }
}

/* =====================================================================
   13  THE PHONE — MOBILE.md
   -------------------------------------------------------------------
   SCOPE. Every rule from here to the end of this section lives inside
   `@media (max-width: 719.98px)` and is a hard no-op above it. 45rem in
   a media query is 720px — media-query `rem` resolves against the
   INITIAL font size, never against the root's declared 106.25% — so
   719.98px is the exact complement of the `min-width: 45rem` block in
   §11 and the two can never both apply. Desktop is a different product:
   it keeps the rail, the 45rem column and the docked explanation panel,
   and a pointer has no reach limits, so none of the thumb-arc reasoning
   below may leak into it. Nothing in §§1–12 is edited.

   PRECEDENCE. MOBILE.md > DESIGN-IDENTITY.md > SPEC.md. Four places
   where this section knowingly departs from DESIGN-IDENTITY, each on
   MOBILE.md's authority and each named where it happens:

     a. box-shadow, banned outright by §10.2, is spent on the two
        floating elements — a floating thing that casts no shadow does
        not float (MOBILE §2, which names this override itself).
     b. border-radius 999px on the pill, the action button and the
        active nav capsule (MOBILE §2, also named there). Everything
        else in the app still resolves to 0 or a circle.
     c. the action button is 3.5rem tall, which is neither 3rem nor
        3rem + n × 1.5rem (§10.30). MOBILE §5 asks for 56px and it wins
        inside its own scope. The pill matches it, so the two floating
        objects read as one system.
     d. `--surface` leaves the sheet, for one purpose only: it is the
        opaque ground the glass falls back to under Reduce Transparency
        (MOBILE §2 prints that fallback verbatim).

   THE JS CONTRACT. js/ui.js drives state through attributes, never
   through layout, and this file supplies every one of them:
     :root[data-route="play"]      the quiz screen
     :root[data-chrome="on|off"]   the global chrome state
     [data-chrome-state="on|off"]  per-node, on .topbar .qz-head .qchrome .nav
     .nav[data-layout="pill"]      informational; the layout is the query
     .nav-item[data-glyph][data-rail="hide"]   the phone-only settings glyph
     [data-role="action"]          the floating action button, in #overlays
     [data-state=…]                its material: rest → glass, armed → solid
   Nothing here needs JS to run. Without it the phone still gets the
   pill, the glass and the floating action; only the auto-hiding is
   inert, and its resting state is "visible", which is the safe default.
   ===================================================================== */

@media (max-width: 719.98px) {

  /* --- phone tokens ---------------------------------------------------
     --lift is the one number the whole bottom of the screen is built
     from: MOBILE §4 asks for 12px above the safe area, and §1.5 forbids
     anything interactive within 16px of the trim. max() satisfies both —
     46px on a notched iPhone, exactly 16px on a device with no inset. */
  :root {
    /* The 16px home-indicator floor must be expressed in px, not rem: this
   stylesheet sets html{font-size:106.25%}, and a user who lowers their
   browser's default text size would otherwise push the clearance under 16px
   and start fighting the system swipe-up gesture. */
--lift:   calc(max(env(safe-area-inset-bottom, 0px), 16px) + var(--u3));
    --edge-r: calc(var(--pad) + env(safe-area-inset-right, 0px));
    --edge-l: calc(var(--pad) + env(safe-area-inset-left, 0px));
    --pill-h: 3.5rem;                                   /* see override (c) */
    --fab-h:  3.5rem;
    --fab-bottom: calc(var(--lift) + var(--pill-h) + var(--u3));

    /* The action button's band, once, so every rule that has to keep clear
       of it derives from the same number and they can never drift: the
       button, its jitter, and one half-line of gutter on top. */
    --fab-sweep: 4px;                /* see 13.3's burn-in note */
    --fab-jitter: 0px;

    /* The glass material's edge. A whole pixel, not --hair: 13.1 raised it
       to --rule precisely because a half-pixel outline on true black had
       no perceivable boundary, and it is stated as a token because the hit
       bands in 13.3 have to give back exactly the border they are laid out
       inside. Two numbers that must agree, declared once so they cannot
       drift. */
    --glass-ring: 1px;
    --fab-band: calc(var(--fab-bottom) + var(--fab-h) + var(--fab-sweep) + var(--u3));

    /* Type scales UP here, not down: this is a reading device held at
       arm's length in bed, not a dashboard (MOBILE §6). The stem takes
       the larger step because it is the largest thing on the screen and
       the one the reader is always looking for. --t-display, --t-ui,
       --t-meta and --t-label are untouched — six tokens, still six. */
    --t-stem: 1.5rem;      --lh-stem: 2rem;        /* 21 → 24 */
    --t-read: 1.0625rem;   --lh-read: 1.625rem;    /* 16 → 17 */
  }

  /* The quiz screen has no pill (§11's .app.is-playing rule already
     removes it), so its action button drops to the resting line. */
  :root[data-route="play"] { --fab-bottom: var(--lift); }


  /* --- THE PIXEL SHIFT (the burn-in the black palette exists to avoid) ---
     The armed button is a solid --ink slab, 152 × 59 px = 3.1% of a
     412 × 915 screen, in one position for essentially 100% of ten to
     twelve hours a day on an AMOLED panel. That is not a lit region, it
     is a stencil, and it is the exact failure mode this app is pure black
     to avoid — the rest of the file spends its whole budget keeping the
     screen dark and then parks the brightest object it owns on one fixed
     rectangle for weeks.

     Panels solve this by moving the image a few pixels; so does this. The
     thumb learns the RIGHT EDGE and the horizontal middle of the button —
     both are pinned, and neither moves here (§7.2's "it never moves" is
     about the corner the thumb lands on, and the corner is exactly what
     is preserved). What sweeps is the vertical offset, which the thumb
     does not encode, over a 4px range: enough that the slab's top and
     bottom edges never fall on the same pixel row twice running, so the
     burn has no hard boundary to etch, and far too little to be seen
     across a full-screen content change.

     Deterministic, from the question's own id, so the same question is
     always drawn in the same place and nothing shimmers on a repaint.
     Every one of the 1,146 ids ends in a digit and the ten are within 12%
     of each other, so d mod 5 is a near-uniform sweep: 226 / 233 / 231 /
     229 / 227 questions per offset.

     :has() is doing real work here and there is no fallback needed: a
     browser without it never sets the property, --fab-jitter stays 0px,
     the button sits where it sits today, and the only thing lost is the
     sweep. The tap target does not move in either case — 13.3's hit band
     is what guarantees that, and it is plain CSS.

     Play only. Dwell is the whole argument, and dwell is here: results and
     browse show their button for seconds at a time, and leaving them out
     also stops an expanded results item — which renders a `.qcard` of its
     own — nudging the Retry button while the reader is looking at it. */
  :root[data-route="play"]:has(.qcard[data-qid$="1"]),
  :root[data-route="play"]:has(.qcard[data-qid$="6"]) { --fab-jitter: 1px; }
  :root[data-route="play"]:has(.qcard[data-qid$="2"]),
  :root[data-route="play"]:has(.qcard[data-qid$="7"]) { --fab-jitter: 2px; }
  :root[data-route="play"]:has(.qcard[data-qid$="3"]),
  :root[data-route="play"]:has(.qcard[data-qid$="8"]) { --fab-jitter: 3px; }
  :root[data-route="play"]:has(.qcard[data-qid$="4"]),
  :root[data-route="play"]:has(.qcard[data-qid$="9"]) { --fab-jitter: 4px; }


  /* =====================================================================
     13.1  LIQUID GLASS (MOBILE §2)
     -------------------------------------------------------------------
     ONE definition. It is worn by exactly two things: the floating nav
     pill, and the floating action button — whose node is either the
     shell's `[data-role="action"]` or, if the quiz view is drawing its
     own bar, `.btn.sticky-bottom`. Only ever one of those two is on
     screen at a time, so the count is two, not three. Never content,
     never an option, never the sheet body.

     THE MEASUREMENT, which decided the 64%.
     Worst case behind the pill is a full-width --ink row — .list-row on
     press, .btn-primary and .toast all reach it, and --ink is the
     brightest value the palette has. A uniform field blurs to itself, so
     blur(28px) buys nothing here and the backdrop must be taken at full
     strength. Composited under --bg at alpha a, the pill's ground is
     --ink · (1 − a), which in dark is L 0.824 scaled by (1 − a):

        a = .58 (MOBILE's starting value) → L 0.1227
              --ink   label   5.06:1   pass, but thin
              --ink-2 label   2.39:1   FAIL
              --ink-3 label   1.54:1   FAIL
        a = .64 (shipped)                → L 0.0893
              --ink   label   6.28:1   pass
              --ink-2 label   2.96:1   still fails
        a = .76 would be needed to carry --ink-2 at 4.5:1, and at 76%
              the material is no longer a material.

     So the opacity went up to 64% AND the rule below is absolute: only
     --ink may sit on glass. That costs nothing, because on the phone
     the active/inactive distinction is not carried by dimming — it is
     carried by the filled --ink capsule (--bg knocked out of it, 17.48:1,
     immune to whatever is behind the glass) and by the M3/M2 geometry
     js/ui.js already redraws. That is a stronger signal than --ink-2
     ever was, and it satisfies WCAG 1.4.1 rather than leaning on it.

     Light theme, same worst case (a full --ink row behind paper glass):
     composite L 0.3185, --ink label 6.52:1 at 58% — light was never the
     problem, and 64% only improves it.

     Each colour is declared twice: the plain value first, for anything
     without color-mix() (an unknown function is dropped at parse time,
     so the fallback stands), then the mix. */
  .glass,
  .nav,
  [data-role="action"],
  .btn.sticky-bottom,
  /* The cluster is ONE floating object with three parts, so the count
     MOBILE §2 fixes at two is still two — the pill and the action. The
     secondaries have to wear the material for the same reason the primary
     does, and it is not decoration: a revealed option is a solid --good or
     --bad plane and can lie directly under them, where an unfilled circle's
     glyph measures 1.74:1 (--ink) or 1.22:1 (--ink-2). A ground is the only
     thing that makes them legible at all. */
  .ctl,
  .ctl-primary,
  .ctl-second,
  .ctl-back,
  .ctl-settings,
  .ctl-handle {
    background: var(--bg);
    background: color-mix(in oklab, var(--bg) 64%, transparent);
    -webkit-backdrop-filter: blur(28px) saturate(180%);
    backdrop-filter: blur(28px) saturate(180%);
    /* MEASURED CORRECTION. MOBILE.md §2 claimed the inset specular edge is
       "the only thing that separates the lens from the page" on pure black.
       It is not: --bg at 64% over #000 composites to exactly #000, the 10%
       border measured 1.18:1 and the 18% highlight 1.47:1 — so over the
       black region below the last option the control had no perceivable
       boundary at all and was, literally, floating text.
       The border now uses --rule (3.13:1 dark / 3.25:1 light), which is
       already what the Reduce Transparency fallback uses, so the material
       stays consistent across both paths. The highlight goes to 45% (3.78:1)
       and still reads as a highlight rather than an outline. */
    border: var(--glass-ring) solid var(--rule);
    border-radius: 999px;                               /* override (b) */
    box-shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 12px 32px rgba(0, 0, 0, .45);
    box-shadow:
      inset 0 1px 0 color-mix(in oklab, var(--ink) 45%, transparent),
      0 1px 2px rgba(0, 0, 0, .6),
      0 12px 32px rgba(0, 0, 0, .45);
  }

  /* Translucency over changing content is this material's known weakness
     and Apple ships a switch for it, so honour the switch. --surface is
     15.31:1 against --ink in dark and 18.59:1 in light, both opaque —
     override (d). The float survives, because a floating thing still has
     to read as floating. */
  @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
    .glass,
    .nav,
    [data-role="action"],
    .btn.sticky-bottom,
    .ctl,
    .ctl-primary,
    .ctl-second,
    .ctl-back,
    .ctl-settings,
    .ctl-handle {
      background: var(--surface);
      -webkit-backdrop-filter: none;
      backdrop-filter: none;
      border-color: var(--rule);
    }
  }


  /* =====================================================================
     13.2  THE FLOATING NAV PILL (MOBILE §4)
     -------------------------------------------------------------------
     A detached capsule above the content, not a bar welded to the trim —
     the mature pattern in every reference the owner named. Same markup as
     the rail: no JS branch, no resize handler, no flash when the viewport
     changes.
     ===================================================================== */

  .nav {
    top: auto;
    left: var(--edge-l);
    right: var(--edge-r);
    bottom: var(--lift);
    gap: var(--u1);
    padding: var(--u1);            /* kills the base safe-area padding too */
    min-height: var(--pill-h);
    /* the welded bar's top rule is already gone: 13.1's `border` shorthand
       replaced it with the capsule's own edge on all four sides. Do not
       add `border-top: 0` back — it would knock a gap in the outline and
       reset that edge's colour to currentColor. */
  }

  /* Icon and label side by side: two words in a wide capsule, which is
     what the references do and what keeps the pill 3.5rem rather than a
     stacked 4rem+. */
  .nav-item {
    flex-direction: row;
    gap: var(--u2);
    min-height: var(--row);
    padding-inline: var(--u2);
    border-radius: 999px;                               /* override (b) */
    /* --ink, not --ink-3 / --ink-2: see the measurement in 13.1. */
    color: var(--ink);
  }
  .nav-item .nav-label { color: var(--ink); }

  /* The active item is a filled capsule INSIDE the pill — the same
     knockout as every other chromatic plane in the app, and the reason
     the inactive items can afford full --ink. */
  .nav-item.active {
    background: var(--ink);
    color: var(--bg);
    --icon-bg: var(--ink);
  }
  .nav-item.active .nav-label { color: var(--bg); font-weight: 700; }

  /* The settings glyph. Icon only — the anchor carries aria-label, so
     nothing is lost by dropping the word, and the two real tabs keep the
     width. It exists on the phone alone; the guard at the bottom of this
     file keeps it out of the rail. */
  .nav-item[data-rail="hide"] {
    flex: 0 0 auto;
    width: var(--row);
    padding-inline: 0;
  }
  .nav-item[data-rail="hide"] .nav-label { display: none; }


  /* =====================================================================
     13.3  THE FLOATING ACTION BUTTON (MOBILE §5)
     -------------------------------------------------------------------
     Bottom-RIGHT, above the pill, inside the thumb arc. Auto-width to its
     label and pinned to the right edge, never full-bleed: a full-width
     near-white slab is the brightest thing on the screen for ten hours a
     day AND the classic OLED burn-in shape, and this app is black
     precisely because its owner is avoiding burn-in.

     Pinning the RIGHT edge rather than the left is what satisfies
     DESIGN-IDENTITY §7.2's "it never moves": the label swaps between
     "Skip", "Check answer" and "Next question", the box grows leftward,
     and the corner the thumb actually lands on is fixed for all 2,292
     presses of a full pass through the corpus.

     Two nodes are styled because two are possible — the shell's own
     button, and the quiz view's bar when the shell has no action hook.
     Exactly one is ever on screen.
     ===================================================================== */

  [data-role="action"],
  .btn.sticky-bottom {
    position: fixed;
    z-index: 65;                   /* over the sheet (60), under toasts (70) */
    left: auto;
    right: var(--edge-r);
    bottom: calc(var(--fab-bottom) + var(--fab-jitter));
    width: auto;
    min-width: 9rem;               /* "Skip" must not shrink to a nub */
    max-width: calc(100% - var(--pad) * 2);
    min-height: var(--fab-h);
    margin: 0;
    padding-block: 0;
    padding-inline: var(--u4);
    display: inline-flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--u3);
    white-space: nowrap;
    font-weight: 600;
    color: var(--ink);
    --icon-bg: var(--bg);
  }

  /* THE HIT BAND, which is why the sweep above is free. The lit rectangle
     moves; the thing the thumb aims at must not, or a pixel shift has been
     paid for with a moving target — the one sin a control pressed 2,292
     times a pass cannot afford.

     So the button carries a transparent extension whose two offsets sum to
     --fab-sweep at every jitter value: it gives back below exactly what it
     takes above. At jitter 0 the band runs 4px above the box; at jitter 4
     it runs 4px below it; at every value in between the union is the same
     rectangle in viewport coordinates, fixed to the pixel. It is a child of
     the button, so a tap inside it targets the button, and it extends only
     over the button's own column — nothing else is covered.

     THE --hair TERM, which was a 1px hole in the original. An absolutely
     positioned child is laid out against its parent's PADDING box, and
     this control has a border, so `top: 0` starts a pixel inside the
     painted edge. The band therefore covered the box minus its border and
     the button's own border box stuck out of it — by 1px below at jitter
     0 and 1px above at jitter 4, which is a 1px fringe that sweeps. It
     was invisible on a capsule whose left and right edges did the aiming;
     on a circle the whole outline is the target, so the offsets now give
     the border back and the union is the border box plus the sweep,
     identical at every offset. --glass-ring, not --hair: 13.1 draws this
     material's edge at a whole pixel on purpose, and the band has to give
     back the border it actually has. */
  [data-role="action"]::after,
  .btn.sticky-bottom::after,
  .ctl-primary::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: calc(var(--fab-jitter) - var(--fab-sweep) - var(--glass-ring));
    bottom: calc((var(--fab-jitter) + var(--glass-ring)) * -1);
  }

  /* ARMED — an answer is in, so the material says so before the label
     does. Solid --ink with --bg type is 17.48:1 and needs no backdrop.
     The state vocabulary is deliberately wide: js/ui.js says
     rest/armed, js/view-quiz.js says skip/check/next, and this file
     refuses to be the reason a correct build looks wrong. */
  [data-role="action"][data-state="armed"],
  [data-role="action"][data-state="check"],
  [data-role="action"][data-state="next"],
  [data-role="action"][data-state="solid"],
  .btn.sticky-bottom[data-state="armed"],
  .btn.sticky-bottom[data-state="check"],
  .btn.sticky-bottom[data-state="next"],
  .btn.sticky-bottom[data-state="solid"],
  .glass.is-armed {
    background: var(--ink);
    color: var(--bg);
    border-color: transparent;
    font-weight: 700;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    box-shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 12px 32px rgba(0, 0, 0, .45);
    --icon-bg: var(--ink);
  }

  /* Belt and braces for a build whose views have not adopted the state
     attribute yet: a chosen option is an answer, and an answer arms the
     button. Kept in its own rule so a browser without :has() drops this
     and nothing else. */
  :root[data-route="play"]:has(.opt.chosen) [data-role="action"],
  .qz-body:has(.opt.chosen) .btn.sticky-bottom {
    background: var(--ink);
    color: var(--bg);
    border-color: transparent;
    font-weight: 700;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    box-shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 12px 32px rgba(0, 0, 0, .45);
    --icon-bg: var(--ink);
  }

  /* =====================================================================
     13.3a  THE CLUSTER, ON THE PHONE (REVEAL.md's new section)
     -------------------------------------------------------------------
     ⚙ 2.5 · ← 2.5 · ( ✓ ) 3.5, right-aligned, primary furthest right
     where the thumb rests (MOBILE §1.1). §5.1 owns the geometry and the
     state machine; this block owns the position, the material, the two
     measured fixes it must not break, and the collapse.

     THE TWO MEASURED FIXES, RE-MEASURED AGAINST THE NEW GEOMETRY. Both
     are derived from --fab-h and --fab-bottom, and the cluster's tallest
     member is deliberately still exactly --fab-h (3.5rem) sitting at
     exactly --fab-bottom, so neither number changes:

       --fab-band  = --fab-bottom + --fab-h + --fab-sweep + --u3
                   = 28.72 + 59.36 + 4 + 12.72 = 104.80px, unchanged.
                   At 412 × 915 the reservation starts at y 810.20 and the
                   primary's hit band starts at y 822.92 — --u3 of clear
                   air, exactly as before.
       --fab-jitter  still sweeps the whole cluster vertically over 4px,
                   and the hit bands below still give back below what they
                   take above, so every target is fixed to the pixel in
                   viewport coordinates at all five offsets.

     And the sweep now protects a much smaller stencil: the armed control
     was a 152 × 59px --ink slab (9,060px² lit); it is now a ⌀59.36
     --ink disc (2,767px²). That is 69% less of the brightest colour the
     palette has, parked in the one place it is parked for ten hours a
     day — which is the burn-in argument this app is black for, won
     outright rather than mitigated. */

  /* The row, when the shell supplies one. It is the fixed thing; its
     children are static, which is what lets an existing
     [data-role="action"] node simply be moved inside it. The gaps
     between the circles are live page, not control. */
  .ctl-cluster {
    position: fixed;
    z-index: 65;                   /* over the sheet (60), under toasts (70) */
    left: auto;
    right: var(--edge-r);
    bottom: calc(var(--fab-bottom) + var(--fab-jitter));
    width: auto;
    max-width: calc(100% - var(--pad) * 2);
    pointer-events: none;
    /* Above 720px the row is expected to sit inside the question column's
       `.sticky-bottom`, or to carry that class itself. Neither can follow
       it down here: the pinned bar's black ground and its negative page
       margins would paint a full-width slab behind three floating
       circles, which is the object MOBILE §5 replaced. */
    background: none;
    margin-inline: 0;
    padding-inline: 0;
  }
  .ctl-cluster > * { position: relative; inset: auto; margin: 0; }

  /* A cluster must never be swept away with the rest of the chrome: the
     primary is the one control that is always where the thumb expects it
     (MOBILE §3). ui.js already refuses to mark the action button or
     anything containing it, so this is belt and braces against a future
     hand adding data-chrome="hide" to the row — it would otherwise take
     the primary with it and cost a reveal-tap before every advance. */
  .ctl-cluster[data-chrome-state="off"],
  .ctl-primary[data-chrome-state="off"] {
    opacity: 1;
    pointer-events: auto;
    transform: none;
  }

  /* THE PRIMARY, on the play route, whichever node is carrying it. This
     is where the labelled capsule becomes a circle: the width collapses
     from 9rem-and-growing to one fixed diameter, and the label goes to
     the accessible layer. Scoped to `play` on purpose — the results
     screen drives the same node with "Practise the 7 I got wrong", and a
     sentence like that has no glyph and must keep its capsule. */
  :root[data-route="play"] [data-role="action"],
  :root[data-route="play"] .btn.sticky-bottom,
  .ctl-cluster .ctl-primary {
    width: var(--ctl-lg);
    min-width: var(--ctl-lg);
    max-width: var(--ctl-lg);
    height: var(--ctl-lg);
    min-height: var(--ctl-lg);
    padding: 0;
    justify-content: center;
    gap: 0;
    border-radius: 999px;
  }

  /* The words survive the circle; they just stop being drawn. */
  :root[data-route="play"] [data-role="action"] .btn-label,
  :root[data-route="play"] .btn.sticky-bottom .btn-label {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0; border: 0;
    clip-path: inset(50%);
    overflow: hidden;
    white-space: nowrap;
  }

  /* The secondaries' hit band, on the same principle as the primary's and
     for the same reason — a swept control must not be a moving target.
     §5.1 centres a --row box on the circle; here the box is stretched
     instead, --u1 (plus the border, as above) beyond the paint on all four
     sides, with the vertical offsets summing to --fab-sweep so the union
     is one rectangle at every offset. Measured at 412 × 915:
     50.87 × 54.87px, both over --ctl-tap, with 4.23px of dead air between
     neighbouring bands and 8.48px to the primary's, so no two targets in
     the row can ever overlap. */
  .ctl-second::after,
  .ctl-back::after,
  .ctl-settings::after,
  .ctl-handle::after {
    left: calc((var(--u1) + var(--glass-ring)) * -1);
    right: calc((var(--u1) + var(--glass-ring)) * -1);
    top: calc(var(--fab-jitter) - var(--fab-sweep) - var(--u1) - var(--glass-ring));
    bottom: calc((var(--fab-jitter) + var(--u1) + var(--glass-ring)) * -1);
    width: auto;
    height: auto;
    transform: none;
  }

  /* --- COLLAPSING (REVEAL, "Collapsing on the phone") ------------------
     The cluster collapses; THE PRIMARY NEVER DOES. Hiding the primary
     would cost a reveal-tap before every advance — two taps instead of
     one, 1,146 times — and the whole point of a thumb-anchored control is
     that it is already where the thumb is.

     The trigger is the chrome controller that MOBILE §3 already
     specifies and js/ui.js already ships: the secondaries ARE chrome, so
     they leave when the rest of it does and come back on a scroll up or a
     tap on a neutral area. No new gesture, and in particular not a hold —
     a hold on the primary is already "flag this question" (js/view-quiz's
     own aria-label says so), and two meanings on one press is the kind of
     thing that gets pressed wrong at 1am. `.ctl-handle` is styled for a
     build that wants an explicit expander instead; it is a full circle so
     it clears the tap floor, and it never collapses.

     They tuck BEHIND the primary — translate to its centre, shrink
     slightly, fade — so expanding reads as coming out from under it. The
     boxes stay in flow (visibility, never display), so nothing reflows
     and the primary cannot be nudged. `visibility: hidden` is also what
     takes them out of hit-testing, the tab order and the accessibility
     tree, which is the same job ui.js's `inert` does for the rest of the
     chrome and needs no JS here.

     `translate` and `scale`, not `transform`: the press feedback on these
     circles is a `transform: scale(.97)` at 90ms, and folding the collapse
     into the same property would stretch every press to 180ms. */
  .ctl-back     { --ctl-tuck: calc((var(--ctl-lg) + var(--ctl-sm)) / 2 + var(--ctl-gap)); }
  .ctl-settings { --ctl-tuck: calc((var(--ctl-lg) + var(--ctl-sm)) / 2 + var(--ctl-gap) * 2 + var(--ctl-sm)); }

  .ctl-second,
  .ctl-back,
  .ctl-settings {
    transition: translate 180ms var(--ease-out), scale 180ms var(--ease-out),
                opacity 180ms var(--ease-out), visibility 180ms var(--ease-out);
  }

  :root[data-chrome="off"] .ctl-second,
  :root[data-chrome="off"] .ctl-back,
  :root[data-chrome="off"] .ctl-settings,
  .ctl-cluster[data-collapsed="true"] .ctl-second,
  .ctl-cluster[data-collapsed="true"] .ctl-back,
  .ctl-cluster[data-collapsed="true"] .ctl-settings {
    translate: var(--ctl-tuck, var(--ctl-lg));
    scale: .8;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }

  /* An explicit "expanded" outranks the chrome state, so a build that
     wants to drive this itself can, without fighting the controller. */
  .ctl-cluster[data-collapsed="false"] .ctl-second,
  .ctl-cluster[data-collapsed="false"] .ctl-back,
  .ctl-cluster[data-collapsed="false"] .ctl-settings {
    translate: none;
    scale: none;
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }

  /* Room to scroll past the floating furniture. The pill clears
     everywhere; the two screens that also carry an action button clear
     both, plus --b3 so the last option never feels like the page fell
     off the bottom. */
  .view { padding-bottom: calc(var(--lift) + var(--pill-h) + var(--b3)); }

  /* BLOCKER FIX. End-of-document padding is not enough: the action button is
     position:fixed, so on any card short enough not to scroll it simply sits
     ON TOP of the last answer rows. Measured across the bank, 210 of 1,146
     cards (18.3%) had an option row intersecting the button's band at scroll
     top, and on 140 of them the whole card fitted on screen so nothing even
     cued the user to scroll it clear. A tap at the thumb's natural resting
     point then selected "Skip" instead of an answer — and study mode has no
     undo, so the question was simply lost.

     THE RESERVATION IS NEEDED IN BOTH STATES, and the previous scoping to
     `:not(.revealed)` was the bug, not the fix. REVEAL.md line 19 says
     "Nothing overlays anything" and line 100 "Nothing is ever behind
     anything"; a band that is reserved before the answer and released after
     it makes both false for the half of the session that comes after the
     tap, and after the tap the rows are MORE interactive, not less — they
     are disclosures now, and one of them carries the reasoning you opened
     the question for.

     What was right about that scoping is the reason behind it: after the
     reveal the shared working follows the options, so reserving the band on
     the option stack itself opened 126px of black between the last option
     and the WORKING label — space paid for a clearance the working was
     already providing. Measured at 412 x 915: 181px between them with the
     band on the stack, 76px with it here, which is the --b1 of stack gap
     plus the --b3 drop §4.2 asks for and not one pixel more.

     So the band is not released, it is HANDED ON. It is reserved once, by
     whichever block ends the question column, which is the only place it
     was ever true: before the answer that block is the card (whose last
     child is the option stack), after it the shared working, and in exam
     mode the navigator. One rule, both states, no dead space in either, and
     no :has() — the stack is a real element and its last child is a real
     selector.

     Scoped to the play route on purpose. `.qcard` and `.expl` are the two
     SHARED renderers: browse prints them inside an expanded list row and
     results inside an expanded item, both mid-page with content below and
     neither carrying a floating button. An unscoped rule would have opened
     the band as a gap in the middle of both of those lists.

     WHAT THIS RULE DOES AND DOES NOT BUY, measured over 285 questions
     sampled across the bank at 412 x 915, because the next person to read
     it deserves the truth rather than the intention:

       · at the terminal scroll position no option row touches the button,
         in either state — 0 / 285. It was also 0 / 285 with this rule
         deleted, because `.view`'s play-route padding two rules down
         happens to reserve 139px and that already cleared the 105px band.
         This rule is therefore an INVARIANT, not a repair: it states the
         clearance on the block that actually ends the column, so it still
         holds if the working block is short, absent, or replaced.
       · at the position the screen actually RESTS at, a row can still lie
         under the button: 22 / 285 before the answer, 88 / 285 after it.
         No amount of trailing space can move a row that is above the fold,
         and CSS does not choose the resting scroll — js/view-quiz.js does,
         in scrollToTop() and scrollRevealIntoView(). That function already
         holds "an open row may never come to rest under the action button"
         and it holds it well (open rows: 5 / 285); the same guard simply
         is not applied before the answer or to the collapsed rows. That is
         the remaining half of this bug and it is not this file's to fix. */
  :root[data-route="play"] .qz-body > .stack > :last-child {
    padding-bottom: var(--fab-band);
  }

  /* And the band travels with the row for every scroll the browser performs
     on our behalf — `scrollIntoView`, `:target`, focus. Without it the
     browser's idea of "in view" is "its bottom edge touches the bottom of
     the viewport", which on this screen means underneath the button.
     `scroll-padding-bottom` on <html> (§2) states the same floor for the
     scrollport; both are needed, because one is a property of the thing
     being scrolled to and the other of the thing scrolling. */
  .opt, .opt-row { scroll-margin-bottom: var(--fab-band); }

  :root[data-route="play"] .view,
  :root[data-route="results"] .view {
    padding-bottom: calc(var(--fab-bottom) + var(--fab-h) + var(--b3));
  }

  .toast-wrap { bottom: calc(var(--fab-bottom) + var(--fab-h) + var(--u3)); }


  /* =====================================================================
     13.4  COLLAPSIBLE CHROME (MOBILE §3)
     -------------------------------------------------------------------
     "Every button that can hide, hides." The reading surface is the
     product; chrome earns its pixels or leaves.

     Hiding is transform + opacity and NEVER display:none. Every element
     below is either sticky or fixed, so its box is reserved whatever the
     state — the text being read cannot jump, which is the whole point:
     a tired reader who loses their place has to re-read the stem, and
     that is the most expensive thing this app can do to them.

     The action button is deliberately absent from every selector here.
     It is the one control that must always be where the thumb expects
     it, and it is never hidden and never disabled.
     ===================================================================== */

  .qz-head, .qchrome, .nav, .bz-search {
    transition: transform 220ms var(--ease-out), opacity 220ms var(--ease-out);
  }
  .topbar {
    transition: transform 220ms var(--ease-out), opacity 220ms var(--ease-out),
                border-color 120ms var(--ease-out);
  }

  /* Exits are shorter than entrances, everywhere in this file. */
  [data-chrome-state="off"] {
    opacity: 0;
    pointer-events: none;
    transition-duration: 160ms;
  }
  .topbar[data-chrome-state="off"],
  .qz-head[data-chrome-state="off"],
  .qchrome[data-chrome-state="off"],
  .bz-search[data-chrome-state="off"] { transform: translateY(-100%); }
  .nav[data-chrome-state="off"]     { transform: translateY(calc(100% + var(--lift))); }


  /* =====================================================================
     13.5  THE THUMB, WHICH DECIDES THE LAYOUT (MOBILE §1)
     -------------------------------------------------------------------
     On a 390 × 844 phone in a right hand the thumb sweeps an arc pivoting
     near the bottom-right corner, and that arc decides where controls go.
     Template A's masthead keeps its geometry exactly — a 4-unit control,
     the answer strip across 7–30, a 4-unit control — and the two end
     controls swap places, which is the whole of the change:

        before   ✕ 1–4   strip 7–30   ⚑ 31–34
        after    ⚑ 1–4   strip 7–30   ✕ 31–34

     The ✕ leaves the top-left because MOBILE §1.2 names it there by
     hand. The flag takes the far corner because it is infrequent,
     non-destructive and costs one more tap to undo, which is what that
     corner is for. Order, not markup: js/view-quiz.js is not this file's
     to edit, and the DOM order is still the reading order.
     ===================================================================== */

  /* The order swap is REVERTED, and this time completely.

     It had moved the ✕ to the right and stranded the flag top-left — the single
     furthest point from a right thumb — which inverts this brief's own
     reasoning. Exit is confirm-guarded, happens once a session, and already has
     a swipe-down alternative, so top-left is the correct home for it: hard to
     reach is a feature there. The flag is a per-question control and takes the
     top-right, which a right thumb can actually stretch to.

     The revert was left half-done, and the half that survived was the wrong
     half. `order: 2` ended up on the STRIP rather than on the exit, so with a
     DOM order of exit · strip · flag the phone rendered

         ✕   ⚑   ————strip————

     — the flag marooned in the middle of the masthead, nowhere near a thumb,
     and not where the web layout puts it either. No `order` at all is the whole
     fix: DOM order IS the wanted order, on both layouts, which also keeps
     reading order and visual order agreeing for keyboard and screen-reader
     users rather than having CSS quietly disagree with the markup. */
  .qchrome > .qchrome-exit { margin-right: var(--u2); }

  /* The stem starts high (MOBILE §6). The quiz screen gives up the 3rem
     of page padding above its own masthead — 48px of the 128px that used
     to sit above the first word of the question. The masthead itself
     stays 3rem and stays in flow, because reclaiming those too would
     mean reflowing the question every time the chrome came back. */
  :root[data-route="play"] .view { padding-top: 0; }


  /* =====================================================================
     13.6  READING (MOBILE §6)
     -------------------------------------------------------------------
     The sheet rises to about 70% so the question stays visible above it —
     the entire argument for a sheet rather than an inline panel is that
     you can still see what you got wrong while you read why.
     ===================================================================== */

  .sheet-card {
    max-height: 70vh;
    max-height: 70dvh;
  }
  /* A fixed height for the explanation only, so the Continue button lands
     on the same line for all 1,146 questions instead of riding up and
     down with the length of the prose. A dialog opened through UI.sheet()
     keeps sizing to its content; without :has() both fall back to the
     max-height above, which is the safe direction. */
  .sheet-card:has(.qz-expl) { height: 70dvh; }

  /* A height-constrained flex column shrinks its children, and the first
     child here is the 2px verdict rule — the only colour on the sheet.
     Half of it was being squeezed away. */
  .sheet-card > .verdict-rule { flex: 0 0 auto; }

  /* The action button rides ON TOP of the sheet (MOBILE §6) and keeps the
     same rectangle it had over the question, so the prose has to be able
     to scroll out from under it. Without this the last three lines of
     every long explanation sit behind the button and can never be read. */
  .sheet-card > .qz-expl {
    padding-bottom: calc(var(--lift) + var(--fab-h) + var(--b1));
  }

  /* The exit the keyframe cannot do. Entry is still the animation in §12
     (guaranteed, it needs no class); is-out releases it so the card can
     transition back down in 180ms. */
  .sheet-card.is-out {
    animation: none;
    transform: translateY(100%);
    transition: transform 180ms var(--ease-sheet);
  }

  /* Continue rides on top of the sheet in the same rectangle the action
     button occupied — same height, same right edge, same distance off the
     trim — so answering and continuing are one thumb movement and the
     rectangle never moves. */
  .sheet-card > .btn {
    align-self: flex-end;
    width: auto;
    min-width: 9rem;
    max-width: calc(100% - var(--pad) * 2);
    min-height: var(--fab-h);
    margin-top: var(--u3);
    margin-right: var(--edge-r);
    margin-bottom: var(--lift);
    padding-block: 0;
    padding-inline: var(--u4);
    padding-bottom: 0;             /* the safe area is inside --lift */
    justify-content: space-between;
    gap: var(--u3);
    white-space: nowrap;
    border-radius: 999px;                               /* override (b) */
  }


  /* =====================================================================
     13.7  THE INLINE REVEAL (REVEAL.md — binding; it supersedes MOBILE §6's
           sheet and DESIGN-IDENTITY §7.3, on the phone and nowhere else)
     -------------------------------------------------------------------
     THE PATTERN, from Nibble and Udemy: the explanation for an option
     lives INSIDE that option, under a hairline. Nothing overlays
     anything, there is one column and one scroll, and the question is
     always one scroll-up away. The reveal costs ZERO permanent screen —
     before the answer the options are compact rows, after it only the
     two that matter grow, and nothing is ever behind anything.

     THE COLOUR, from Vocabulary: solid planes, not tints. Correct is a
     --good plane carrying --bg type, wrong is a --bad plane carrying
     --bg type. That is §2.4's LAW applied exactly rather than an
     exception to it — the same knockout as the primary button, the
     active nav capsule and the mark — so every coloured object in the
     app still reads as a hole punched in the page rather than a chip
     floating on it. Never a coloured left border: §10 names it as the
     single most reliable tell of generated design, and a plane says the
     same thing louder and in less space.

     WHAT MUST NOT MOVE (§7.1, §10.45, REVEAL rule 7). Every box in the
     header exists in every state — the slot, the text column and the
     trailing column are all reserved before the answer as well as after
     — so painting the verdict changes zero pixels of layout. Expansion
     is a grid TRACK growing, not a height, so nothing above an opening
     row can move either.
     ===================================================================== */

  /* --- the row, made compact -------------------------------------------
     Real estate is the other half of the argument, so it starts before
     anything is answered. The slot was a 2.25rem box and it — not the
     text — was setting the row height: 12.75 + 38.25 + 12.75 = 63.75px
     for a one-line option, 255px across four of them. A 1.75rem disc is
     the 24px verdict mark plus two units of air, it stays a true circle,
     and it returns 8.5px of height per row and 8.5px of measure. The row
     is then 55.25px at the shipped 106.25% root — above the 3rem module,
     above the 44px floor — and 42px comes back across four options,
     which is a line and a half of stem.

     The third track is the trailing cell. It is reserved permanently for
     exactly the reason the slot is: letting the ✓ / ✕ bring a column
     into existence at reveal time would re-wrap the option text and
     shift every row beneath it, which is the precise fault this pattern
     exists to remove. 1.125rem is one 18px mark; the minmax lets a
     longer glyph grow rather than push the page sideways. */
  .opt { grid-template-columns: 1.75rem minmax(0, 1fr) minmax(1.125rem, auto); }
  .opt-letter { width: 1.75rem; height: 1.75rem; }

  .opt-mark, .opt-caret {
    display: inline-flex;
    align-items: center;
    justify-content: flex-end;
  }

  /* The chevron belongs to the rows that arrive shut, which is exactly
     the set carrying no verdict glyph. It turns a quarter when its row
     opens, so the signifier tells the truth in both states — a chevron
     that never moves is a chevron claiming to navigate (§10.43). */
  .opt-caret {
    color: var(--ink-3);
    transition: transform 180ms var(--ease-out);
  }
  .opt[aria-expanded="true"] .opt-caret { transform: rotate(90deg); }

  /* --- state 3 first, so the two planes simply outrank it ---------------
     UNTOUCHED: quiet, and quiet is the whole job. No colour, --ink-2
     (8.24:1 dark / 7.35:1 light — a tone, not a dim; nothing in this file
     uses opacity as a state). Their reasoning is genuinely useful, since
     it is why the distractor is wrong, but it is not what you need
     first, so it costs one tap and no attention (REVEAL rule 3). */
  .opts.revealed .opt .opt-text,
  .opts.revealed .opt .opt-letter { color: var(--ink-2); }

  /* --- states 1 and 2: the two planes -----------------------------------
     Contrast, computed against the plane itself, never estimated:
       --bg on --good  10.03:1 dark · 6.33:1 light
       --bg on --bad    5.85:1 dark · 6.09:1 light
     The option's own text stays serif at --t-read: an option is read,
     compared and re-read, and a plane behind it does not turn it into a
     control. */
  .opts.revealed .opt.correct { background: var(--good); --icon-bg: var(--good); }
  .opts.revealed .opt.wrong   { background: var(--bad);  --icon-bg: var(--bad); }

  .opts.revealed .opt.correct .opt-text,
  .opts.revealed .opt.correct .opt-letter,
  .opts.revealed .opt.correct .opt-mark,
  .opts.revealed .opt.wrong .opt-text,
  .opts.revealed .opt.wrong .opt-letter,
  .opts.revealed .opt.wrong .opt-mark { color: var(--bg); }

  /* The slot keeps the LETTER on a plane. After the reveal you still need
     to know which option you are looking at, and the verdict is already
     carried twice — by the plane and by the glyph at the end of the row.
     The disc is dropped here for the obvious reason: a --good disc on a
     --good plane is not a disc. js/ui.js draws the letter in inline mode
     and the disc in panel mode; these two rules make sure a disc that
     arrives here anyway cannot paint a circle of the plane's own colour
     over the plane. */
  .opts.revealed .opt.correct .opt-letter,
  .opts.revealed .opt.wrong .opt-letter,
  .opts.revealed .opt.correct:active .opt-letter,
  .opts.revealed .opt.wrong:active .opt-letter { background: none; }

  /* THE FOCUS RING OBEYS §2.4's LAW LIKE EVERYTHING ELSE ON A PLANE. §6's
     `.opt:focus-visible { outline-offset: -3px }` moves the ring inside the
     control so it cannot be sheared off by `overflow-x: hidden` — but inside
     these two rows, "inside" is a saturated plane, and --blue on it is not a
     focus indicator, it is a rumour. Measured, not estimated:

        --blue on --good   1.50 : 1  dark   ·   1.56 : 1  light
        --blue on --bad    1.14 : 1  dark   ·   1.63 : 1  light
        --bg   on --good  10.03 : 1  dark   ·   6.33 : 1  light
        --bg   on --bad    5.85 : 1  dark   ·   6.09 : 1  light

     1.14:1 is invisible and 3:1 is what WCAG 1.4.11 asks of a focus
     indicator. The ground colour is what every other mark on a plane in
     this app already uses — the option's own text, its letter, its ✓ / ✕ —
     so the ring is not an exception to the law, it is one more application
     of it. Elsewhere (the quiet rows, every other control) the ring stays
     --blue on --bg at 6.69:1 dark / 9.90:1 light, and blue keeps its job. */
  .opts.revealed .opt.correct:focus-visible,
  .opts.revealed .opt.wrong:focus-visible { outline-color: var(--bg); }

  /* A disclosure is pressable, and the hairline under the header is what
     separates the plane from its own reasoning — Nibble's construction,
     and it is the same rule the stack already draws between rows, so the
     two can never disagree. §7.1 drops it on .correct / .wrong because
     under the sheet there was nothing beneath them to separate from;
     here there is. */
  .opts.revealed .opt[aria-expanded] {
    cursor: pointer;
    border-bottom-color: var(--rule);
  }

  /* --- the panel, and the expansion -------------------------------------
     0fr → 1fr on a grid track: 180ms, --ease-out. It is the one animation
     worth having, because it shows where the content came from, and
     because a track size is not a height nothing above the row can move.
     The two rows that arrive open are RENDERED open, so no transition
     runs at reveal time — the verdict still appears in the same frame as
     the tap (§8.3), which is the one motion rule that outranks this one.

     The panel is the element after the button, whatever it is called.
     It carries the ground, which is invisible at zero height, and the
     hairlines, which are not — so those are attached to the open state
     rather than to the box. */
  .opt + .opt-why,
  .opt + .opt-body {
    display: grid;
    grid-template-rows: 0fr;
    background: var(--surface);
    --icon-bg: var(--surface);
    transition: grid-template-rows 180ms var(--ease-out);
  }
  .opt[aria-expanded="true"] + .opt-why,
  .opt[aria-expanded="true"] + .opt-body,
  /* js/ui.js also stamps `is-open` on the wrapper as a :has()-free
     fallback. Nothing here ever needed :has() — the state is read off the
     button with an adjacent-sibling selector, which every target browser
     has had for a decade — so the class is honoured as a synonym and no
     rule depends on it. */
  .opt-row.is-open > .opt-why,
  .opt-row.is-open > .opt-body {
    grid-template-rows: 1fr;
    border-block: var(--hair) solid var(--rule);
  }

  /* --- the reasoning ----------------------------------------------------
     --surface, --ink, the reading face and a real measure. Long reasoning
     on a saturated plane is unreadable, which is why the colour states
     WHICH option and the body carries the words (REVEAL rule 5). This is
     prose, not a label: it takes the same 1.625rem leading as every other
     block of reading in the app, because it is where the ten hours a day
     actually go.

     Set at --pad — the same x as the stem — rather than indented to the
     option text: one column, one measure, and on a 412px screen the
     1.75rem it would otherwise give up is 7% of the line. max-width is
     padded out so the TEXT measures 30rem rather than the box.

     The block padding is two pseudo-spacers instead of `padding-block`
     because they sit INSIDE the clip and are cut away with the words; a
     real padding on this element would survive the collapse and leave a
     34px band of --surface under every shut option. */
  .opt + .opt-why > *,
  .opt + .opt-body > * {
    display: block;
    min-height: 0;
    overflow: hidden;
    margin: 0;
    padding-inline: var(--pad);
    max-width: calc(var(--measure) + var(--pad) * 2);
    color: var(--ink);
    hyphens: auto;
    hyphenate-limit-chars: 6 3 3;
    text-wrap: pretty;
    overflow-wrap: break-word;      /* a bare formula must never widen the page */
    user-select: text;              /* a student absolutely wants to copy this */
    -webkit-user-select: text;
  }
  .opt + .opt-why > *::before,
  .opt + .opt-why > *::after,
  .opt + .opt-body > *::before,
  .opt + .opt-body > *::after {
    content: "";
    display: block;
    height: var(--u4);
  }

  /* --- the shared working, below the whole stack -----------------------
     Steps, terms and formulas are about the QUESTION, not about any one
     option, so they follow all of the rows (REVEAL rule 8). Two lines of
     body text above it, which is what separates sections everywhere else
     in this app. The diff line is the one thing that has to be told it
     has left the sheet: it was sticky because under a 70dvh panel the two
     rows it named were behind that panel, and inline they are two
     coloured planes a few lines above it. */
  .expl-inline { margin-top: var(--b3); }
  .expl-inline .expl-diff {
    position: static;
    background: none;
    border-bottom: 0;
  }
}


/* Fewer and gentler, not none — and on the phone that means the chrome
   fades instead of sliding. Scoped to a combined query so §12's
   reduced-motion block, which desktop also reads, is untouched. */
@media (max-width: 719.98px) and (prefers-reduced-motion: reduce) {
  .topbar, .qz-head, .qchrome, .nav, .bz-search {
    transition: opacity 140ms var(--ease-flat);
  }
  .topbar[data-chrome-state="off"],
  .qz-head[data-chrome-state="off"],
  .qchrome[data-chrome-state="off"],
  .bz-search[data-chrome-state="off"],
  .nav[data-chrome-state="off"] { transform: none; }
  .sheet-card.is-out {
    transform: none;
    opacity: 0;
    transition: opacity 140ms var(--ease-flat);
  }

  /* THE DISCLOSURE, COLLAPSED — and this is where it has to be said, not in
     §12. §12's `.opt-row, .opt-why, .opt-body, .opt-caret { transition: none }`
     is a true statement that loses the cascade twice over, because §13.7 is
     both later in the file and, for the panel, more specific:

       §12    .opt-body                   (0,1,0)  ordinal 344   transition:none
       §13.7  .opt + .opt-body            (0,2,0)  ordinal 393   180ms   ← wins
       §12    .opt-caret                  (0,1,0)  ordinal 344   transition:none
       §13.7  .opt-caret                  (0,1,0)  ordinal 384   180ms   ← wins

     And the global `*` rule above it zeroes `animation-duration` only, so
     nothing anywhere was actually cancelling a transition. The overrides
     below therefore repeat §13.7's OWN selectors, character for character,
     so they tie on specificity and win on order — the only way an override
     of a rule inside a media query can be made to hold. REVEAL.md's Motion
     paragraph is then true as written: the panel still opens and the chevron
     still turns, because those are facts about the interface rather than
     motion, and they simply arrive in one frame. */
  .opt-caret { transition: none; }
  .opt + .opt-why,
  .opt + .opt-body { transition: none; }

  /* The cluster collapses without travelling. Same policy as everything
     else here — fewer and gentler, not none: the secondaries still leave
     and still come back, and the state stays fully legible, it just
     arrives without the tuck. The selectors repeat §13.3a's own, for the
     reason spelled out above: an override inside a media query has to tie
     on specificity and win on order. */
  .ctl-second,
  .ctl-back,
  .ctl-settings {
    transition: opacity 140ms var(--ease-flat), visibility 140ms var(--ease-flat);
  }
  :root[data-chrome="off"] .ctl-second,
  :root[data-chrome="off"] .ctl-back,
  :root[data-chrome="off"] .ctl-settings,
  .ctl-cluster[data-collapsed="true"] .ctl-second,
  .ctl-cluster[data-collapsed="true"] .ctl-back,
  .ctl-cluster[data-collapsed="true"] .ctl-settings {
    translate: none;
    scale: none;
  }
}


/* The one desktop-facing line in this section, and it exists to keep
   desktop identical rather than to change it. js/ui.js now renders a
   third nav item — the phone pill's settings glyph — and hides it above
   720px with an inline style. This says the same thing declaratively, so
   a JS failure can never grow the rail a third row it has never had. */
@media (min-width: 45rem) {
  .nav .nav-item[data-rail="hide"] { display: none; }
}

/* Extractor artefacts. build/selftest.js scrapes class="…" with a regex
   that also catches fragments of template expressions — class="' + cls +
   '" yields `cls`, and class="chip' + (B.count === c ? … yields `c`.
   These are not class names, nothing styles them, and they are named here
   only so the coverage check can account for them:
   .c .cls .k .t .i .n .o .q .s .a .b .p .x .on .ok .kind .name .cur
   .sel .lvl .acc .idx .val .id .ch */
