/* Knut drill — restyled against tokens.css (light, one register). Ported from the
   verified mockup (design/screens.css + design/light.css merged into a single
   light pass, since this site ships one theme, not a light/dark cascade) plus
   every class drill.js/stats.js/app.js render, recoloured onto the new tokens.
   See tokens.css for the custom-property source of truth.

   Two documented traps, respected here, plus a third this port found and
   fixed at the root cause (flagged in the task report):
   1. `font:` shorthand mis-parses when its size comes from a var() — the reading
      surfaces (.paper p/li, .statement) use longhand font-family/font-size/
      line-height instead.
   2. `.paper pre` (0,1,1) outranks a bare `.codelight` (0,1,0) — the light code
      variant is written as `.paper pre.codelight`.
   3. NEW FINDING: the actual mechanism behind trap #1's "15.9px instead of
      17px" symptom isn't shorthand parsing — it's that the mockup sets
      `html, body { font: var(--step-0)/1.5 ...}`, i.e. the ROOT element's own
      font-size is a rem-based custom property. Per spec, rem on the root
      resolves against the browser's initial value (16px) exactly once; every
      OTHER rem/var(--stepN) use on the page then resolves against html's new
      computed size instead of 16px, so the whole scale quietly drifts by
      --step-0's ratio (0.94x — 1.06rem measures as ~15.94px, not ~16.96px:
      confirmed by inspecting a live element with Playwright during this
      task). Literal-px escapes (like `.paper p`'s 17px) dodge it locally but
      leave it live for every other --stepN use. Fixed here by never setting
      html's font-size from a --step token — html is pinned to a plain 16px,
      and only body (a descendant) takes --step-0 — so the whole scale
      resolves at its intended absolute sizes everywhere.

   Fix round 1 (reviewed): the mockup's `.spine` background (#dedcd4) is kept
   verbatim — an earlier draft of this file substituted `var(--ink)` to fix a
   contrast failure, but that made the spine visually indistinguishable from
   the content area it's supposed to read as chrome against (1.00 contrast
   between the two backgrounds). Kept #dedcd4 and added `--dim-strong`
   (tokens.css) for the spine's inactive labels only — `--dim` itself stays
   untouched since it passes everywhere else it's used. See `.spine`/`.tab`
   below. */

/* =====================================================================
   THE DIAGRAM VOCABULARY, AT THE TOP, because it is the part of this file
   another pillar depends on.

   `system_design/guide/00-diagram-legend.md` declares it for readers,
   `system_design/pipeline/verify_svg.py` holds the same names as
   DECLARED_CLASSES and DECLARED_PROPERTIES and gates every committed fence in
   BOTH pillars against them (coding/tests/test_guide_svg.py runs that
   verifier over coding/guide/ with coding/guide/diagrams/sprite.svg), and
   coding/drill/guide.test.js reads those two constants straight out of
   verify_svg.py and gates THIS FILE against them. No hand-maintained copy of
   the name list exists anywhere, here included.

   Rename nothing here by hand. Rename it in the legend chapter and in
   verify_svg.py, and the tests will point at this file. */

/* ---------- the four declared properties ---------- */

/* These, plus currentColor, are the only permitted source of colour inside a
   diagram, which is what lets one committed fence serve both pillars' pages.
   Each defers to the token of the same ROLE in tokens.css rather than
   restating a hex: `coding/pipeline/build_flowchart_svg.py` already maps the
   four onto exactly these four tokens when it writes the flowchart fence's own
   root `style`, and two different mappings of one contract onto one page is a
   diagram that recolours depending on which fence you are looking at.

   ONE REGISTER, NOT TWO. Pillar 2's style.css writes these out a second time
   under `@media (prefers-color-scheme: dark)`, because that surface ships a
   light and a dark theme. This site ships one light register on purpose
   (tokens.css's own header states it, and there is no prefers-color-scheme
   block anywhere in coding/drill/), so a dark block here would be four
   declarations nothing can reach. guide.test.js asserts BOTH halves of that:
   the four are defined in the one :root block, and if a
   prefers-color-scheme block is ever added to this file it must define all
   four too — so the day this site grows a second register, the diagrams are
   not left painting paper-coloured fills onto a dark page. */
:root {
  --dg-ink: var(--paper-fg);      /* strokes and text: the default foreground */
  --dg-bg: var(--paper);          /* fills, so a box occludes what passes behind it */
  --dg-accent: var(--diagram-accent);  /* emphasis, paired with dg-emphasis */
  --dg-muted: var(--diagram-faint);    /* de-emphasis, paired with dg-muted */
}

/* ---------- the declared classes ---------- */

/* IN THE DECLARED ORDER, and that is not cosmetic. dg-emphasis and dg-muted
   are modifiers applied ALONGSIDE a base class — the pilot's chapters carry
   class="dg-node dg-muted" on a faint subtree and class="dg-edge dg-emphasis"
   on the dive — so both rules have one class of specificity and source order
   alone decides the winner. List .dg-node after .dg-emphasis and the accented
   edge quietly paints in ink, with nothing anywhere to report it.
   DECLARED_CLASSES already ends with the two modifiers, which is why the
   declared order is the order to hold; guide.test.js holds it.

   EVERY RULE HERE SETS AN INHERITED PROPERTY, AND THAT IS THE WHOLE DESIGN.
   Each symbol in the sprite strokes with `currentColor` and fills with
   `var(--dg-bg, none)`, so setting `color` on a <use> recolours the entire
   shape without a second symbol existing for the accented case, and defining
   `--dg-bg` at :root is what gives every box an interior that occludes.

   Setting `stroke`, `fill`, `stroke-width` or `stroke-dasharray` on one of
   these classes would NOT work and would report nothing. Those properties do
   cross into the <use> shadow tree by inheritance, but every element inside a
   symbol declares its own, and a declared value beats an inherited one.
   Measured here in Chromium at a rendered scale of 1.393 (a 480-unit fence at
   668.8 CSS px in this prose column), on bst-ordered.md's six node boxes:

     .dg-node { stroke: #f00 !important; stroke-width: 6 !important }   NO EFFECT
     .dg-node { color: #f00 }                                           lands
     :root    { --dg-bg: #f00 }                                         lands
     #alg-node rect { stroke: #f00; stroke-width: 6 }                   lands

   The first line is the trap: `!important` does not help, because this is not
   a specificity contest. Only a rule matching the sprite's own element reaches
   a symbol, and the instances then follow the original. diagram.e2e.test.js
   holds all four outcomes as pixels, so the top line stays a fact.

   NO #alg-* RULE SHIPS FOR A SYMBOL, and that is still a ruling rather than an
   omission — but the measurement under it was stale and is corrected here.
   (Exactly one #alg-* rule ships at all, and it is not a symbol: the arrowhead
   marker's `fill`, further down. A marker is not <use> content — it is painted
   from the element that references it — so it is the one place where this
   surface must reach into the sprite to fix a colour, and the alternative was
   an attribute in the sprite that measured inert.)

   Pillar 2 needed a rule because its `dg-boundary` is stretched 3.35x across
   and 4.73x down with a dash of its own, which comes out visibly anisotropic.
   This paragraph used to say Pillar 1's stretches are "mild", `alg-cell` and
   `alg-node` "used at or near 1:1", worst case bst-ordered's two stack cells at
   0.80x/0.77x and "a 3% anisotropy" (48x34 uses of a 60x44 symbol, which
   measures 1.04x; the stroke does read about 21% light).

   Re-measured 2026-09-01 over all 224 cell and node uses in the corpus: 108 are
   at exactly 1:1, and the tail is not mild — stack-parsing's `alg-node` at
   220x44 is 2.37x, sweep-line's `alg-cell` at 120x44 is 2.00x,
   merge-intervals' 34x40 is 1.60x and 03-dp-deep-dive f2's 68x34 is 1.47x. The
   structural visual pass retired the previous holder, shortest-path's 88x30
   heap cells at 2.15x, by re-laying them at the symbol's native 60x44. A 2.37x
   stretch paints the side strokes 2.37x heavier than the top ones, which is the
   same artefact Pillar 2 fixed on its boundary, so the tail is a real finding
   and it is written up in docs/review/2026-09-01-arrowhead-and-legibility.md.
   It is not fixed here, and deliberately: the only rule at this surface that
   reaches it is `#alg-node rect { vector-effect: non-scaling-stroke }`, which
   would change the stroke weight of every node in the pillar to buy back four
   fences' anisotropy, and the cheap fix for four `<use>` sizes is in those four
   fences. What must not happen again is this paragraph claiming 3%.

   `alg-span` WAS the one genuinely visible artefact, and it was fixed IN THE
   SPRITE rather than here, on 2026-08-31. Its path carries
   vector-effect="non-scaling-stroke" so its legs are not stretched 2.5x wider
   than its cap; the side effect was that the bracket painted a flat 1.5 CSS px
   while the cells it brackets paint 1.929 (1.5 scaled by the fence's 1.3933,
   less what the symbol's own viewport clip eats), so the brackets read about a
   quarter light. The sprite now gives the span stroke-width 2.0, measured
   across device pixel ratios 1x/2x/4x; the table and the two known limits are
   in the sprite's own comment. Nothing here changed, and the reason matters:
   the only rule at this surface that could have touched it is
   `#alg-span path { vector-effect: none }`, which trades a 25% weight
   difference for the 2.5x anisotropy the sprite deliberately removed — strictly
   worse. Do not write it. See docs/review/2026-08-31-coding-sprite-host.md and
   docs/review/2026-08-31-rollout-task-1.md.

   dg-boundary-line and dg-edge-async are BOTH used by a Pillar 1 fence as of
   2026-09-01, one each: two-heaps' `th-cut` is the boundary line down the
   middle of the two halves, and scc-bridges' `scc-back-2-0` is the async back
   edge. (This comment used to say no fence used either, which was true when it
   was written and stale by the time the two structural batches landed; the
   dashes and the ink they render are unchanged.) The rule they defend is not
   about usage counts: the class list is a SHARED contract, check 7 permits every
   declared class in a coding/guide/ fence, and an author who writes
   class="dg-edge-async" against an undefined rule gets an async path that
   renders pixel-identical to a synchronous one — the same silent-defect shape
   as the vanished arrowhead. There is deliberately NO gate asserting that
   Pillar 1 uses every declared class; a shared contract may legitimately be
   partly unused by one consumer, and dg-cursor and dg-span still are. */
.dg-node { color: var(--dg-ink); }
.dg-edge { color: var(--dg-ink); }

/* The one thing the legend promises about an async path: it renders dashed.
   These edges are ordinary <line>/<polyline> marks in the fence itself, not
   shadow-tree content, so a declaration here is the declaration that wins. */
.dg-edge-async { color: var(--dg-ink); stroke-dasharray: 5 4; }

.dg-label { color: var(--dg-ink); font-family: var(--sans); }
.dg-boundary-line { color: var(--dg-ink); }
.dg-cursor { color: var(--dg-ink); }
.dg-span { color: var(--dg-ink); }
/* EACH MODIFIER CARRIES A SECOND, NON-COLOUR CHANNEL, added 2026-09-02, and
   that is the more important half of that day's fix. Colour was the wrong sole
   carrier for this distinction three times over: a colour-blind reader gets
   nothing from green-against-grey, a hue difference is weak at the ~2 CSS px a
   1.5-unit stroke actually paints, and the same pair behaves differently in a
   light and a dark register. The precedent is .dg-edge-async above, which has
   carried stroke-dasharray for exactly this reason since it existed.

   WHICH CHANNEL GOES WHERE IS FORCED BY WHAT CAN REACH A MARK, not chosen for
   variety. A <use> takes its strokes from the symbol, and every stroked element
   in either sprite declares its own stroke-width (checked, not assumed), so no
   inherited length reaches inside one -- see the measurement table above
   .dg-node. `opacity` is the ONLY non-colour channel that lands on all four
   mark kinds, because it composites the whole instance, its var(--dg-bg)
   interior included, rather than inheriting into it. So muted takes the channel
   with universal reach, and emphasis takes the two that are strongest where
   they do land: of the corpus's 112 emphasis marks, 49 are <text> and 26 are
   strokes.

   0.85 AND NOT LOWER. The muted token is already faint against the ground on
   its own (3.25:1 light, 3.19:1 dark) and opacity compounds with it, so the
   figure a reader sees is 2.64:1 and 2.67:1. The corpus has 199 muted <text>
   marks carrying values a reader still has to be able to read; a heavier fade
   makes "context" mean "illegible". system_design/tests/test_ink_roles.py holds
   both bounds and the arithmetic. */
.dg-emphasis { color: var(--dg-accent); font-weight: 700; --dg-stroke-scale: 1.5; }
.dg-muted { color: var(--dg-muted); opacity: 0.85; }
/* THE STROKE HALF OF EMPHASIS'S WEIGHT CHANNEL FOR FENCE-LOCAL MARKS, and it is
   TAG-QUALIFIED on purpose. `stroke-width` on a bare `.dg-emphasis` is inert on
   a `<use>` -- measured, forcing `stroke-width: 8` on `.dg-node` moved 0 of a
   node box's 960 ink pixels, because the symbol's own presentation attribute
   beats an inherited value -- and writing a declaration that silently does
   nothing for a third of its subjects is what the guide.test.js gate above
   `#alg-arrow` exists to stop. Qualified to the three tags that can only ever
   be fence-local content, it lands: the same forcing on a `.dg-edge` <line>
   took it from 750 ink pixels to 4319. 1.5 is what every fence declares, so
   2.25 is half again.

   The `<use>` marks get the same channel by the other route, and it is the same
   half-again: `--dg-stroke-scale: 1.5` on `.dg-emphasis` above, consumed by
   every stroke in the sprite. Two mechanisms for one rule is not a nicety --
   a `<use>` cannot be reached by an inherited length and a fence-local line
   does not need a custom property -- but the FACTOR is deliberately shared, so
   an emphasised bracket and an emphasised edge read as the same weight. */
line.dg-emphasis, polyline.dg-emphasis, path.dg-emphasis { stroke-width: 2.25; }

/* ---------- the arrowhead's colour, which takes TWO rules ---------- */

/* THE DEFECT (found by the 2026-09-01 visual pass, fixed here): 28 of this
   pillar's 57 `marker-end="url(#alg-arrow)"` edges are accented or muted, and
   every one of them painted an INK arrowhead on a coloured line — measured
   computed fill rgb(28,31,36) against an edge stroke of rgb(27,110,92), and in
   pixels, where dsu's MUTED arrow had the same darkest pixel as the full-ink
   arrow beside it. A <marker>'s content inherits from the marker's own
   ancestors — the hidden sprite host — and NOT from the element that references
   it, so the sprite's `currentColor` is true for its five symbols and false for
   its one marker. Check 4 cannot see it (nothing is hardcoded) and the browser
   marker check cannot either (the arrowhead does paint).

   WHAT DOES NOT WORK, measured in Chromium 151 on the real page rather than
   reasoned about, per this repo's standing rule:

     fill="context-stroke" in sprite.svg, on its own            NO EFFECT
     #alg-arrow path { fill: context-stroke } on its own        NO EFFECT
     [marker-end] { stroke: var(--dg-*) } on its own            NO EFFECT
     stroke: color-mix(in srgb, currentColor 100%, transparent) NO EFFECT
     stroke: rgb(from currentColor r g b)                       NO EFFECT
     the two rules below, together                             lands

   `context-stroke` means "the paint on the element that referenced me", and
   every edge in the corpus carries stroke="currentColor". Chromium takes that
   keyword UNRESOLVED and then resolves it in the MARKER's context, which is the
   sprite host again — so context paint on its own reproduces the original bug
   exactly, and so does any value that keeps a currentColor dependency
   (color-mix and relative colour both compute to color(srgb …) here and both
   still paint ink). Proved by ablation: forcing `color` on .gd-sprite-host
   turned every arrowhead in the fence that colour, the ink edges' included.
   The second rule is what removes the keyword — a `stroke` declared here is
   already a resolved colour when the marker asks for it, and a CSS declaration
   outranks the fence's own presentation attribute.

   So each half is inert on its own screen: the first paints the head from a
   value that is still ink, the second re-declares a colour the line already
   had. diagram.e2e.test.js ablates them one at a time, because a CSS property
   that does not apply is indistinguishable from one that does until the pixels
   are read.

   The stroke follows the class, exactly as `color` does above, and the two must
   agree. They do today: all 86 markered marks in both pillars carry dg-edge or
   dg-edge-async plus at most one modifier, all 86 carry stroke="currentColor",
   and the 28 recoloured ones carry a color="var(--dg-accent|muted, …)" that
   says what their class says. diagram.e2e.test.js asserts computed `stroke` ==
   computed `color` on EVERY markered mark, so an author who recolours an edge
   by attribute without the modifier class fails there rather than shipping a
   line one colour and its head another. Do not narrow these to `.dg-edge`: the
   generated flowchart's own edges are markered too, and they are ink either way
   (that fence declares the four properties on its own root). */
#alg-arrow path { fill: context-stroke; }
[marker-end] { stroke: var(--dg-ink); }
[marker-end].dg-emphasis { stroke: var(--dg-accent); }
[marker-end].dg-muted { stroke: var(--dg-muted); }

/* ---------- the sprite host ---------- */

/* Hidden by size and overflow. NEVER display:none and never [hidden]: a <use>
   of a <symbol> survives a subtree that is not rendered, but every
   url(#alg-arrow) marker reference into it silently stops painting, so the
   diagrams keep their boxes and lose the direction of every edge. That is the
   classic silent failure of this pattern; diagram.e2e.test.js walks the host's
   ancestors and fails on either one. */
.gd-sprite-host { position: absolute; width: 0; height: 0; overflow: hidden; }

* { box-sizing: border-box; }

html {
  font-size: 16px;
}

body {
  margin: 0;
  background: var(--ink);
  color: var(--fg);
  font-family: var(--sans);
  font-size: var(--step-0);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 { margin: 0 0 .4rem; font-family: var(--sans); font-weight: 600; line-height: 1.25; }
h1 { font-size: var(--step-1); letter-spacing: -.01em; }
h2 { font-size: var(--step-2); }
h3 { font-size: var(--step-1); }
h4 { font-size: var(--step--1); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
p { margin: .35rem 0; }
ul { margin: .3rem 0; padding-left: 1.1rem; }
li { margin: .15rem 0; }

.dim { color: var(--dim); }
.tiny { font-size: var(--step--1); }
.big { font-family: var(--mono); font-size: 1.8rem; font-weight: 700; }
.center { text-align: center; }

/* ---------- eyebrow: the utility register (new vocabulary; ships unused live) */
.eyebrow {
  font: 600 var(--step--1)/1 var(--mono);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--dim);
}

/* ---------- app frame ---------- */
.app { display: grid; grid-template-columns: var(--spine) 1fr; min-height: 100vh; }
.main { display: flex; flex-direction: column; min-height: 100vh; }

/* ---------- spine: the left nav rail ---------- */
.spine {
  border-right: 1px solid var(--line-soft);
  display: flex; flex-direction: column; align-items: center;
  gap: .35rem; padding: .8rem 0; background: #dedcd4;
}
.spine .mark {
  font: 700 var(--step--1)/1 var(--mono);
  /* Final review, B3: --rank1-strong, not --rank1 — the wordmark is
     12.5px/700, which is NOT WCAG large text (needs >=18.66px bold), so the
     4.5:1 floor applies and --rank1 on this background measured only
     4.46:1. --rank1-strong measures ~6.88:1 (tokens.css). */
  color: var(--rank1-strong);
  letter-spacing: .08em; margin-bottom: .9rem;
}
.tab {
  /* Fix round 1: 60px -> 74px alongside --spine's 74px -> 88px widening, and
     white-space:nowrap added defensively — single-word labels shouldn't
     wrap at this width, but a wrapped label silently reads as two stacked
     fragments instead of erroring, so nowrap makes the failure mode "text
     clips" (visible, obvious) rather than "text wraps" (looks like a bug
     nobody noticed). See design.e2e.test.js's no-wrap assertion. */
  width: 74px; padding: .45rem .3rem; display: grid; place-items: center;
  white-space: nowrap;
  /* --dim-strong, not --dim: --dim fails 4.5:1 directly on the spine's own
     #dedcd4 background (see tokens.css). */
  color: var(--dim-strong); text-decoration: none; border-radius: var(--radius);
  font: 500 11.5px/1.2 var(--sans); letter-spacing: .02em; text-align: center;
  background: none; border: none; cursor: pointer;
}
.tab:hover { color: var(--fg); background: var(--slab); }
.tab.active { color: #08130f; background: var(--rank1-fill); font-weight: 700; }
.tab:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }

/* ---------- layer rail: the signature (new vocabulary; ships unused live) ---- */
.rail { position: relative; width: 14px; flex: none; }
.rail::before {
  content: ""; position: absolute; left: 6px; top: 4px; bottom: 4px;
  width: 1px; background: var(--line);
}
.rail i {
  position: absolute; left: 2px; width: 9px; height: 9px;
  border: 1px solid var(--line); background: var(--paper);
  transform: rotate(45deg);
}
.rail i.done { background: var(--rank1-fill); border-color: var(--rank1-fill); }
.rail i.now { background: var(--paper); border-color: var(--rank1); box-shadow: 0 0 0 3px var(--rank1-soft); }

/* ---------- topbar ---------- */
.topbar {
  display: flex; align-items: baseline; gap: 1rem;
  padding: .85rem 1.5rem; border-bottom: 1px solid var(--line-soft);
  position: sticky; top: 0; z-index: 2; background: var(--ink);
}
/* THE LOCATION REGISTER, not a title register, now that this element carries
   the surface's name instead of the wordmark. A wordmark is brand furniture
   and wants title weight; "where am I" is chrome, and several surfaces put
   their own name on the card directly below — Stats, Recognition sprint,
   Pattern queue — so a title-weight copy 90px above the identical string
   read as a stutter rather than as a heading. In the same mono/uppercase/
   letterspaced eyebrow both sites already use for `.sub`, the guide nav's
   group labels and the TOC's "On this page", so the whole bar is one
   register and the panel below keeps the only title on the screen.
   Still an <h1>: it names the page, and that is what it is. */
/* THE STICKY TOPBAR OWNS THE TOP 45px, so a jump to `#some-heading` put that
   heading underneath it -- which is what "the on-this-page links do not scroll
   exactly to the header" is. `scroll-margin-top` is the fix for exactly this:
   it tells the scroll-into-view machinery to stop short, so it applies to the
   TOC links, the in-page deep links and keyboard section jumps at once, without
   any of them doing arithmetic. Measured: .topbar renders 45px, plus a little
   air so the heading is not flush against the bar. */
.paper :is(h1, h2, h3, h4, h5, h6)[id] { scroll-margin-top: 3.75rem; }

.topbar h1 {
  font: 700 var(--step--1)/1 var(--mono);
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--fg); margin: 0;
}
/* Fix round 1: app.js's renderStatus() fills #where with route/chapter
   context (was previously unused, wired up now that the topbar h1 is just
   "Knut" and no longer names the surface itself). Empty outside the guide
   route, same :empty pattern as #keyhint below — no stray empty chip. */
.topbar .where:empty { display: none; }
.topbar .where { color: var(--dim); font: var(--step--1)/1 var(--mono); }
.topbar .spacer { flex: 1; }
.kbd {
  font: 500 var(--step--1)/1 var(--mono); color: var(--dim);
  border: 1px solid var(--line); border-radius: var(--radius); padding: .2rem .35rem;
}
/* #keyhint is a .kbd that's empty outside session routes (app.js sets its
   textContent to '' there) — don't show a stray empty chip in the footer. */
.kbd:empty { display: none; }

/* ---------- buttons (new vocabulary + the legacy chip-button family) -------- */
.btn {
  font: 600 var(--step-0)/1 var(--sans); border: 1px solid var(--rank1-fill);
  background: var(--rank1-fill); color: #08130f; padding: .55rem .9rem;
  border-radius: var(--radius); text-decoration: none; cursor: pointer;
}
.btn:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--line); }
.btn.ghost:hover { background: #dedcd4; border-color: var(--dim); }
.btn:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }

.stat b { display: block; font: 500 var(--step-2)/1.1 var(--mono); }
.stat span { color: var(--dim); font: var(--step--1)/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }

/* ghost/primary/reveal/pick/qrow share the same chip-button base as the
   pre-restyle theme did — recoloured onto --slab/--line/--fg. */
.ghost, .primary, .reveal, .pick, .qrow {
  font: inherit;
  color: var(--fg);
  background: var(--slab);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .35rem .7rem;
  cursor: pointer;
}
.ghost:hover, .reveal:hover, .pick:hover, .qrow:hover { border-color: var(--rank1); background: var(--slab-2); }
.primary { background: var(--rank1-fill); border-color: var(--rank1-fill); color: #08130f; font-weight: 600; padding: .55rem 1rem; }
.primary:hover { filter: brightness(1.08); }

.view { max-width: 900px; margin: 0 auto; padding: 1.1rem; width: 100%; }
/* Guide reader (Task 5) opts out of the generic .view cap — the two-pane
   gnav+paper layout wants full shell width (.gnav's --nav plus .paper's
   up to 1120px would otherwise get squeezed into 900px, which is exactly
   the kind of narrowing that produces horizontal clipping in wide tables/
   code). Toggled by app.js's render() every repaint based on the current
   route kind, so it never sticks after navigating away from Guide. */
.view.gd-full { max-width: none; padding: 0; }

.panel {
  background: var(--slab);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  padding: 1rem 1.1rem;
  margin-bottom: 1rem;
}

.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.row.spread { justify-content: space-between; }

.prompt { font-size: var(--step-2); }
.summary { color: var(--fg); opacity: .9; }

.clock { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 1.1rem; color: var(--dim); }
.clock.urgent { color: var(--alarm); font-weight: 700; }

.filter {
  width: 100%;
  font: inherit;
  color: var(--fg);
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .5rem .6rem;
  outline: none;
}
.filter:focus { border-color: var(--rank1); }

.picks {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
  gap: .3rem;
  margin-top: .5rem;
  max-height: 46vh;
  overflow: auto;
}

.pick { display: flex; align-items: center; gap: .5rem; text-align: left; }
.pick.selected { border-color: var(--rank1); background: var(--rank1-soft); }
/* Final review, B3: .pick.selected's own background is translucent
   (--rank1-soft, #1b6e5c1f) composited over .panel's --slab — a lighter
   effective background than either colour read in isolation. --dim measured
   only 4.35:1 there (it passes everywhere else it's used, including plain
   `.pick` rows — see design.e2e.test.js). --dim-strong, the same token
   already used for the spine's inactive tab label for the identical reason,
   measures ~5.28:1 on this composited background. */
.pick.selected .dim { color: var(--dim-strong); }
.pick-name { flex: 1; }
.key {
  min-width: 1.35rem;
  text-align: center;
  border-radius: 4px;
  background: var(--ink);
  border: 1px solid var(--line);
  color: var(--dim);
  font-family: var(--mono);
  font-size: .78rem;
}

/* The sprint/statement feedback heading — an inline coloured label rendered
   by drill.js as `<h3 class="verdict ok|bad">`. Fix round 1 (reviewed): this
   used to collide with the flowchart's new-vocabulary terminal container,
   which also matched `.verdict` and required an override + an
   `animation: none` patch to stay a plain label. Fixed structurally instead:
   the flowchart vocabulary below is namespaced `.fc-` throughout (ruling
   R18 — see the FLOWCHART section) — `.fc-terminal`/`.fc-rank`/
   `.fc-question`/`.fc-option` — so `.verdict` now belongs to this live rule
   alone and no override is needed. */
.verdict.ok { color: var(--rank1); }
.verdict.bad { color: var(--alarm); }

.signals {
  border-left: 2px solid var(--rank1);
  padding: .1rem 0 .1rem .7rem;
  margin: .7rem 0;
}
.signals.anti { border-left-color: var(--alarm); }

.badge {
  border-radius: 999px;
  padding: .05rem .5rem;
  font-size: .72rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  border: 1px solid;
}
.badge-easy { color: var(--rank1); border-color: var(--rank1); }
.badge-medium { color: var(--rank2); border-color: var(--rank2); }
.badge-hard { color: var(--alarm); border-color: var(--alarm); }
.badge-unknown { color: var(--dim); border-color: var(--line); }

.tier {
  font-size: .72rem;
  color: var(--dim);
  border: 1px dashed var(--line);
  border-radius: 4px;
  padding: 0 .35rem;
}
.tier-anchor { color: var(--rank1); border-color: var(--rank1); }

.qlist, .plist { display: flex; flex-direction: column; gap: .3rem; margin-top: .6rem; }
.qrow { display: flex; align-items: center; gap: .6rem; text-align: left; }
.qname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--dim); }

.prow {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
  padding: .4rem .5rem;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--slab);
}

/* Task 7: a pattern the ramp's coverage triage hasn't bought yet (stats.js's
   per-pattern accuracy table) — dimmed, not dropped, so its history stays
   readable while marking it "not this week's plan." */
.uncovered { opacity: .5; }

.hint { flex-basis: 100%; color: var(--rank2); font-size: .85rem; }
.hint.hidden { display: none; }

.link { color: var(--rank1); text-decoration: none; }
.link:hover { text-decoration: underline; }

.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: .5rem; margin: .6rem 0 1.1rem; }
.tile {
  display: flex;
  flex-direction: column;
  gap: .1rem;
  padding: .6rem .7rem;
  background: var(--slab);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.tile-value { font-family: var(--mono); font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }

.reviewlist { list-style: none; padding: 0; text-align: left; max-width: 34rem; margin: .8rem auto; }
.reviewlist li { border-left: 2px solid var(--line); padding-left: .6rem; }
.reviewlist li.ok { border-left-color: var(--rank1); }
.reviewlist li.bad { border-left-color: var(--alarm); }

.footbar {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  padding: .6rem 1.1rem;
  border-top: 1px solid var(--line-soft);
  font-size: .78rem;
  /* THE BAR BELONGS AT THE BOTTOM OF THE SHELL, not directly under the last
     line of content. `.main` is a 100vh flex column and nothing in it grew,
     so on every short route the bar landed wherever the content happened to
     end and its top border read as a rule drawn across the middle of the
     page, with the remaining shell height as bare `--ink` underneath it.
     Measured at 1440x900: Sprint's idle screen put it at y=390 with 470px of
     empty below, Stats at y=485 with 375px. `margin-top: auto` on this one
     item rather than `flex: 1` on `.view` — the view's own children include
     the guide reader's `.reader`, which already stretches on `flex: 1` and
     would then inherit a taller parent than the content it holds. */
  margin-top: auto;
}

@media (max-width: 620px) {
  .app { grid-template-columns: 1fr; }
  .spine { flex-direction: row; border-right: none; border-bottom: 1px solid var(--line-soft); flex-wrap: wrap; }
  .picks { max-height: none; }
}

/* Statement drill: long interview-voice prose — the one live surface that is
   genuinely "reading," so it gets the serif register with reading measure and
   leading. Longhand on purpose (trap #1): a `font:` shorthand with a var() size
   mis-parses here. */
.statement {
  white-space: pre-wrap;
  max-width: var(--measure);
  font-family: var(--serif);
  font-size: 17px;
  line-height: 1.64;
  color: var(--fg);
}

/* Statement hint ladder (Task G). The two anchored tiers highlight a phrase
   inside the statement above, so their marks live on the reading surface and
   must not shout: a tinted ground plus an underline, no bold, no colour
   change to the text itself. Both tiers restate the token's own text colour
   explicitly — a `mark` left to the UA gets a yellow ground that belongs to no
   register on this site, and the contrast probe climbs from the element's OWN
   background, so the pairing has to be real.

   Colour is never the only channel: t2 (the phrase that carries the property)
   underlines solid, t3 (the phrase you may ignore) underlines dotted, so the
   two stay distinguishable without hue. */
.statement mark.anchor {
  background: var(--rank1-soft);
  color: var(--fg);
  border-radius: 2px;
  padding: 0 .1em;
  text-decoration: underline;
  text-decoration-color: var(--rank1);
  text-underline-offset: .18em;
}
.statement mark.anchor-t3 {
  background: var(--rank2-soft);
  text-decoration-style: dotted;
  text-decoration-color: var(--rank2);
}

/* The hairline separates the ladder from the statement's last line: without
   it the first rung label reads as part of the prose above. */
.hints {
  max-width: var(--measure);
  margin-top: 1.4rem;
  padding-top: 1rem;
  border-top: 1px solid var(--line-soft);
}
.ladder { list-style: none; padding: 0; margin: 0 0 .6rem; }
.rung {
  border-left: 2px solid var(--line);
  padding: .1rem 0 .1rem .7rem;
  margin-bottom: .5rem;
}
/* The rung's rule matches the colour of the phrase it marked in the statement
   above — verdigris for the property tier, copper for the prune tier — so a
   reader can pair a rung with its own highlight. That is why these are keyed to
   the TIER and not to the rung's depth: the climb order is t1, t3, t2, t4
   (hints.js), and re-colouring by depth would break the pairing that makes the
   marks readable. Only t4 is keyed to meaning rather than to a mark: it names
   the chapter, which ends the card. */
.rung-t2 { border-left-color: var(--rank1); }
.rung-t3 { border-left-color: var(--rank2); }
.rung-t4 { border-left-color: var(--alarm); }
.rung-label {
  display: block;
  font-family: var(--sans);
  font-size: var(--step--1);
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--dim);
}
.rung-text { margin: .15rem 0 0; color: var(--fg); }
.hintstep { font-size: var(--step--1); }

/* =========================================================================
   New vocabulary ported from the verified mockup for later tasks (Guide
   reader, decision tree) — no live DOM renders these yet, so they cannot be
   e2e'd from this task; Task 5 and Task 8 verify them once their DOM lands.
   ========================================================================= */

/* ---------- READER (Task 5) ---------- */
.reader { display: grid; grid-template-columns: var(--nav) 1fr; flex: 1; min-height: 0; }
.gnav { border-right: 1px solid var(--line-soft); padding: 1.1rem 0 3rem; overflow: auto; }
.gnav .group { padding: .9rem 1.1rem .3rem; }
.gnav a {
  display: flex; align-items: center; gap: .5rem; padding: .28rem 1.1rem;
  color: var(--dim); text-decoration: none; font: var(--step--1)/1.45 var(--sans);
}
.gnav a:hover { color: var(--fg); background: #dedcd4; }
.gnav a.on { color: var(--fg); background: #dcdad2; box-shadow: inset 2px 0 0 var(--rank1-fill); }
.guide-contents-link { display: none; }
.tick { width: 12px; flex: none; color: var(--rank1-fill); font: 700 var(--step--1)/1 var(--mono); }

.sheet { padding: 2rem 2.5rem 5rem; display: flex; justify-content: center; overflow: auto; }
/* THE READER'S NON-CHAPTER STATES CLAIM THE CHAPTER'S RECTANGLE. Landing,
   loading, chapter-not-found and load-error all render a `.panel`, which is
   content-sized — and `.sheet` is a centring flex row, so at 1440px the
   landing state's two lines of text sat in a 380px card adrift in the middle
   of an 1100px pane with ~360px of empty shell on either side of it. The
   `.paper` that replaces it is `min(1120px, 100%)`; these states take the
   same figure, so the pane stops changing shape between picking a chapter
   and reading one.

   `align-self: start` because `.sheet` is a flex row and the default
   `stretch` made the panel full-pane-TALL as soon as it was full-pane-wide —
   which trades a card that is too small for an empty box that is too big.
   The panel stays the height of its own three lines. */
.sheet > .panel { width: min(1120px, 100%); align-self: start; }

.paper {
  background: var(--paper); color: var(--paper-fg);
  border: 1px solid var(--paper-edge);
  box-shadow: 0 1px 2px #0000000d;
  padding: 2.6rem 3rem 3.4rem; width: min(1120px, 100%);
  display: flex; gap: 1.6rem;
}
/* .body itself is NOT capped to --measure (moved onto p/li below, Task 5
   review finding): the corpus's real python lines run past 90 characters
   with a trailing comment (see 01-foundations/core-algorithms.md), which
   does not fit a 72ch prose column at any paper width this layout can
   reach — capping the whole column at --measure left code with nowhere to
   go but its own overflow-x:auto scrollbar even at the paper's 1120px max.
   Letting .body grow to the flex row's actual remaining width (rail/toc/
   gaps aside) gives pre/table the room; p/li still wrap at a comfortable
   reading measure via their own max-width, so prose is unaffected. */
.paper .body { min-width: 0; flex: 1; }
.paper h1 { font-family: var(--serif); font-size: 2.4rem; font-weight: 400; line-height: 1.1; margin: 0 0 .2rem; letter-spacing: -.02em; }
.paper .sub { color: var(--paper-dim); font: var(--step--1)/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 1.8rem; }
.paper h2 {
  font-family: var(--sans); font-size: 1.06rem; font-weight: 600; line-height: 1.3; margin: 2.2rem 0 .6rem;
  letter-spacing: .01em; padding-bottom: .3rem; border-bottom: 1px solid var(--paper-edge);
}
.paper p, .paper li {
  /* longhands on purpose: the `font:` shorthand mis-parses a var() size (trap #1) */
  font-family: var(--serif); font-size: 17px; line-height: 1.64;
  max-width: var(--measure);
}
.paper p { margin: .7rem 0; }
.paper ul { margin: .6rem 0; padding-left: 1.1rem; }
.paper li { margin: .35rem 0; }
.paper strong { font-weight: 650; }
.paper code { font: var(--step-0)/1.4 var(--mono); background: #e4e3da; padding: .08em .3em; border-radius: 2px; }
/* AND NOT INSIDE A FENCE. The rule above is the inline-code chip -- a tinted
   box around a `name` in a sentence -- and `code` inside `pre` was taking it
   too. Because that background paints per line box and stops where each line's
   text stops, every line of every code block wore a ragged darker band: it read
   as if the whole block were text-selected. The fence already has its own
   surface (`.paper pre`), so the chip has nothing to add inside one. */
.paper pre code {
  background: none; padding: 0; border-radius: 0;
  font: inherit; color: inherit;
}

/* rank-1 / rank-2 as an information device, not decoration (ruling R11).
   One left edge for every item: the label column must scan vertically, so rank
   is carried by colour + text weight ALONE. Equal border widths keep the text
   origin identical — no indent, no border-weight variation. */
.rank { list-style: none; padding: 0; margin: .8rem 0; }
.rank > li {
  padding: .5rem 0 .5rem .9rem; margin: .4rem 0;
  border-left: 3px solid transparent;
}
.rank > li.r1 { border-left-color: var(--rank1); }
.rank > li.r2 { border-left-color: var(--rank2); color: var(--paper-dim); }
.rank .tag { font: 600 var(--step--1)/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; display: block; margin-bottom: .25rem; }
.rank > li.r1 .tag { color: var(--rank1); }
.rank > li.r2 .tag { color: var(--rank2); }

/* dark code block — kept available as a single selector swap; NOT the default
   (see .paper pre.codelight below, which is default-light to keep one register). */
/* Task 5 review finding: the real corpus has python lines past 100 chars
   (assert-with-trailing-comment idioms, see 01-foundations/core-algorithms.md
   and data-structures.md) that don't fit this column at any width the
   .paper/.toc layout can offer — even at .paper's 1120px cap, the widest
   line still overflows the code column by ~150px. overflow-x:auto alone
   would have satisfied that by scrolling, but pick-one whole-page-scroll
   Tab/Home semantics aside, a scrollbar per code block reads as "clipped"
   for this task's horizontal-clip probe, and for the reader too. white-space
   pre-wrap (not `pre`) keeps every space/indent character exactly as
   authored but lets the line actually wrap instead of running off the edge;
   overflow-wrap is the last-resort backstop for a genuinely unbroken token
   (a long string literal, say) that pre-wrap's own break points can't reach.
   overflow-x:auto stays as a final fallback that should now never trigger. */
.paper pre {
  background: #14161a; color: #e4e7ea; padding: 1rem 1.1rem; overflow-x: auto;
  white-space: pre-wrap; overflow-wrap: break-word;
  font: 0.85rem/1.55 var(--mono); border-radius: var(--radius); margin: 1.1rem 0;
}
/* light code block variant — default treatment for code in this design.
   Must outrank `.paper pre` (0,1,1) — a bare `.codelight` (0,1,0) would lose
   (trap #2), so it is written scoped to `.paper pre.codelight`. */
.paper pre.codelight {
  background: #ecebe2; color: #22262c; border: 1px solid #d6d5ca;
}
/* Prism-generated token classes (coding/drill/vendor/prism.min.js) for the
   ```python fences in the guide corpus — Prism emits `class="token <type>"`
   spans, and only these 7 types are used anywhere in the 295 python blocks.
   (The corpus's one ```mermaid block has no grammar vendored for it, so
   Prism leaves it as encoded plain text — no .token spans, no colour rules
   needed here.) Colours stay in this design system, never an upstream Prism
   theme, never an upstream Prism theme. Every value clears 4.5:1 against this
   block's #ecebe2 background; the two that needed care: a #6b7078 comment grey
   measures only ~4.16:1 here, so .token.comment takes --paper-dim (~5.27:1),
   and a #9a5b13 number clears by a thin ~4.52:1, so .token.number takes a
   darker #7a4710 (~6.41:1) for margin. */
.paper pre.codelight .token.keyword { color: #8b3fa8; }
.paper pre.codelight .token.string { color: #1d6b3f; }
.paper pre.codelight .token.function { color: #1f5fa8; }
.paper pre.codelight .token.number { color: #7a4710; }
.paper pre.codelight .token.comment { color: var(--paper-dim); font-style: italic; }
.paper pre.codelight .token.operator { color: var(--dim-strong); }
.paper pre.codelight .token.builtin { color: var(--rank1); }

/* ---------- the copy control on a ```python fence (2026-09-05) ----------
   Every python block in the corpus ends in asserts, and both foundations
   chapters open by telling the reader to run them — which the page then gave
   them no way to do. guide.js's renderPythonBlock wraps each fence in
   `.gd-code` with a bar above it; the WHERE-TO-RUN-IT ruling (a local
   instruction, never a link to an external runner) is written out in full in
   that module, next to the three measurements it rests on.

   PLACED AFTER `.paper pre` AND `.paper pre.codelight`, AND THAT IS LOAD-
   BEARING. `.gd-code pre` is (0,1,1) and `.paper pre` is (0,1,1) — identical
   specificity, so source order is the whole of what decides the margin, and
   this block moved above them would leave a 1.1rem gap between a bar and the
   block it belongs to. Same reasoning as trap #2 at the top of this file, one
   step further: there the fix was a more specific selector, here it is
   position, because the two selectors cannot be ranked.

   THE BAR IS ABOVE THE BLOCK, NOT FLOATING IN ITS TOP-RIGHT CORNER. `.paper
   pre` wraps its lines (`white-space: pre-wrap`, see its own note) rather than
   scrolling them, so the first line of a block runs the full width of the
   column whenever it is long enough — and the corpus has python lines past 100
   characters. An absolutely-positioned button would sit on top of that line's
   text at exactly the widths where the reader most needs to read it. */
.gd-code { margin: 1.1rem 0; }
.gd-code pre { margin: 0; }
.gd-code-bar {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .8rem; margin: 0 0 .3rem;
}
/* The status line, empty until a copy happens (guide.js renders it empty on
   purpose — an aria-live region has to exist before its text changes). `flex:
   1; min-width: 0` so a wrapping sentence takes the column's width and the
   button keeps its own.

   THE BAR GROWS WHEN IT FILLS, and that is accepted rather than designed
   around. Measured 2026-09-05: the line gets 672px at a 1440 viewport and the
   sentence is one row there, so the common case moves nothing; below ~1400 it
   wraps to two and pushes the block down 28px. The alternatives were reserving
   two rows above all 295 blocks (28px of empty column each, permanently) or
   putting the sentence under the block, where a 182-line fence would put it
   off-screen. The button — the thing under the reader's cursor — does not move
   either way, because the bar grows downward from its baseline. */
.gd-code-run {
  margin: 0; flex: 1; min-width: 0;
  font: var(--step--1)/1.45 var(--sans); color: var(--paper-dim);
}
/* A refused clipboard says so in the failure colour rather than reporting a
   copy that did not happen. --alarm is the palette's text-safe vermilion. */
.gd-code[data-gd-copy-failed] .gd-code-run { color: var(--alarm); }
/* Same treatment as the fence opener (`.gd-open` below): both are the reading
   surface's own small controls, so they take --paper/--rank1/--line rather
   than the chrome tokens the app shell uses. */
.gd-copy {
  flex: none;
  font: 600 var(--step--1)/1 var(--sans);
  background: var(--paper); color: var(--rank1);
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: .3rem .7rem; cursor: pointer;
}
.gd-copy:hover { background: var(--slab-2); border-color: var(--rank1); }
.gd-copy:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }
/* Copied: the button steps back, because the sentence beside it is now the
   thing to read. Exactly one block can be on the clipboard, and guide.js
   clears every other bar on each copy, so at most one of these is ever on. */
.gd-code[data-gd-copied] .gd-copy { color: var(--paper-dim); }

.paper table { width: 100%; border-collapse: collapse; margin: 1.2rem 0; font: var(--step-0)/1.45 var(--mono); }
.paper th { text-align: left; font-weight: 600; color: var(--paper-dim); text-transform: uppercase; letter-spacing: .08em; font-size: var(--step--1); border-bottom: 1px solid #bebdb2; padding: .4rem .5rem; }
.paper td { padding: .45rem .6rem; border-bottom: 1px solid var(--paper-edge); vertical-align: top; }
.paper td.nw, .paper th.nw { white-space: nowrap; }

/* Thematic break (markdown `---`) — CSS staged ahead of the markdown.js fix
   that will emit `<hr class="gd-rule">` for it (currently renders as a
   literal "---" paragraph; not this module's fix, see the report). Prefixed
   per this surface's naming rule since it's new vocabulary. */
.paper hr.gd-rule { border: 0; border-top: 1px solid var(--paper-edge); border-radius: 0; margin: 2.4rem 0; }

.toc { position: sticky; top: 0; align-self: start; width: 190px; flex: none; padding-left: 1.4rem; border-left: 1px solid var(--paper-edge); }
.toc .eyebrow { color: var(--paper-dim); margin-bottom: .6rem; display: block; }
/* Fix round 1 / TOC decision (b): one line per entry, ellipsis + a `title`
   attribute (set to the full heading text in guide.js) carrying the rest —
   data-structures.md alone has 15 sections and some run past this 190px
   column on their own text alone. Chose truncate-in-place over
   hiding non-active h3s (option c): the outline stays complete and stable
   while scrolling, which is what "studying a chapter" wants, and it matches
   the same one-line-label call just made for the spine tabs. overflow-wrap
   is now moot for the common case but stays as a backstop for a single
   unbroken word that's still too long even after truncation. */
.toc a {
  display: block; color: var(--paper-dim); text-decoration: none;
  font: var(--step--1)/1.5 var(--sans); padding: .18rem 0; overflow-wrap: anywhere;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.toc a.on { color: var(--paper-fg); font-weight: 600; }
.toc a:hover { color: var(--paper-fg); }

.footnav { display: flex; justify-content: space-between; margin-top: 2.6rem; padding-top: 1rem; border-top: 1px solid var(--paper-edge); font: var(--step--1)/1 var(--mono); }
.footnav a { color: var(--paper-dim); text-decoration: none; }
.footnav a:hover { color: var(--rank1); }

/* The one ```svg fence in the corpus (00-flowchart.md's generated decision
   tree), inlined by guide.js's renderSvgBlock. New surface, so .gd-* per this
   reader's naming rule.

   The diagram carries explicit width/height attributes, so it renders at its
   natural size and scrolls horizontally instead of being scaled down to the
   prose column: at the paper's width a 2068-unit tree would put its 13px
   labels at about 6px, which is a diagram nobody can read. Same reasoning as
   the code column's own overflow-x above, and the same reason
   verify_svg.py's frame check (max 760 units wide) is not applied to it.

   The four --dg-* properties the diagram paints from are declared on its own
   root element, each deferring to the token of the same role in tokens.css,
   so there is nothing to declare here.

   THE MIN-WIDTH, AND WHERE ITS NUMBER COMES FROM. A hand-authored fence carries
   a viewBox and no width, so it is `width:100%` over a fixed frame — a pure
   uniform scale, set by the prose column. Measured 2026-09-01: that column is
   668.78 px at a 1440 viewport (a 480-unit fence scales 1.393), 508.78 px at
   1280 (0.865 for a 588-unit fence) and 252.78 px at 1024 (0.527), where the
   smallest label in the corpus paints between 4.73 and 8.0 CSS px. Every fence
   in the corpus is below any reading floor from 1280 down, and check 5's
   `font-size >= 11` cannot see it, because that floor is in FENCE UNITS and the
   surface is what halves them.

   588px is derived rather than picked: it is the width at which the worst fence
   in the corpus paints its smallest label at exactly verify_svg's own
   MIN_FONT_SIZE — topological-sort is 588 units wide with 11-unit labels, so
   588 px is both its 1:1 scale and its 11.0 CSS px. Every other gated fence is
   narrower or labels larger and so clears the floor by more (the 480-unit mode
   paints its 12-unit labels at 14.7 px). guide.test.js recomputes that maximum
   from the corpus and from MIN_FONT_SIZE and fails if a new fence needs more,
   so this literal cannot go quietly stale the way a round 480 would have.

   The diagram is then WIDER than its column, which is what the container's
   `overflow-x: auto` above is for: wide content scrolls inside its own
   container and the page body never scrolls sideways. At 1024 that is a 588px
   diagram in a 252.8px column — 335px of in-container scrolling, and legible,
   against a diagram that fitted and could not be read. Nothing changes at 1440,
   where the column is already wider than this, so the visual pass's own 1440
   renders stay valid. And it cannot help below about 768, where this reader's
   prose column collapses to 0 px on its own — a defect of the .sheet/.paper
   flex row, measured while choosing this number and not this rule's to fix. */
.gd-diagram { margin: 1.3rem 0; overflow-x: auto; }
.gd-diagram svg { display: block; max-width: none; min-width: 588px; }

/* ---------- an oversized fence: fit in the column, read full screen ---------
   THE ONE FENCE THE COLUMN CANNOT HOLD. guide.js's FENCE_FIT_WIDTH (the same
   588 declared above, and the account of why they are the same number is
   there) marks any fence whose viewBox is wider than the column can honour
   the label floor for. Measured 2026-09-04: 54 of the corpus's 55 fences are
   <=588x400 and fit; 00-flowchart.md's generated decision tree is 2068x3040,
   which in a 741px prose column was 2.8 columns wide and four screens tall —
   a reader met the top-left ninth of a picture and a horizontal scrollbar.

   The rule above is untouched and still sizes every ordinary fence: this
   overrides it only under `[data-gd-preview]`, which guide.js sets on that
   one host. `min-width: 0` is the part that matters — without it the fence
   keeps its 588px floor and the fit does nothing.

   A PREVIEW IS DELIBERATELY BELOW THE LABEL FLOOR, which is why it never
   ships without the button: scaled to the column the tree shows its shape and
   not its words, and the control is the way to a size that has both.
   guide.test.js gates the pair — a preview without an opener would be the
   floor quietly abandoned rather than deliberately spent. */
.gd-diagram[data-gd-preview] { overflow: hidden; position: relative; }
.gd-diagram[data-gd-preview] svg {
  width: 100%; height: auto; min-width: 0; max-width: 100%;
  cursor: zoom-in;
  border: 1px solid var(--paper-edge); border-radius: var(--radius);
}
.gd-diagram[data-gd-preview] .gd-open {
  display: block; margin: .6rem auto 0;
  font: 600 var(--step--1)/1 var(--sans);
  background: var(--paper); color: var(--rank1);
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: .45rem .8rem; cursor: pointer;
}
.gd-diagram[data-gd-preview] .gd-open:hover { background: var(--slab-2); border-color: var(--rank1); }
.gd-diagram[data-gd-preview] .gd-open:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }

/* ---------- the full-screen fence viewer ----------
   A native <dialog>, so the focus trap, the Esc key, the backdrop and `inert`
   on the rest of the page are the platform's job rather than this file's.
   `max-width/max-height: none` because a UA stylesheet caps a dialog at
   calc(100% - 6px - 2em) and the point of this one is the whole screen. */
.gd-lightbox {
  width: 100vw; height: 100vh; max-width: none; max-height: none;
  margin: 0; padding: 0; border: none;
  background: var(--ink); color: var(--fg);
  flex-direction: column;
}
/* `display` ON THE OPEN STATE ONLY, and this is a bug fix rather than tidying.
   The UA stylesheet hides a closed dialog with `dialog:not([open]) { display:
   none }` -- which is MORE specific than `.gd-lightbox` and still loses,
   because origin is settled before specificity and any author declaration
   beats a UA one. So a bare `display: flex` here kept painting the dialog
   after it closed: measured 2026-09-05, a closed .gd-lightbox still reported a
   1440x900 box at (0,0), a blank sheet over the whole page with the chapter
   alive and untouched behind it. Closing the diagram looked like losing the
   chapter. guide.e2e.test.js now asserts the closed dialog paints nothing. */
.gd-lightbox[open] { display: flex; }
.gd-lightbox::backdrop { background: #1c1f24d9; }
.gd-lightbox-bar {
  display: flex; align-items: center; gap: 1rem;
  padding: .6rem 1rem;
  border-bottom: 1px solid var(--line-soft);
  background: #dedcd4;
  flex: none;
}
.gd-lightbox-name {
  margin: 0; flex: 1; min-width: 0;
  font: var(--step--1)/1.3 var(--sans); color: var(--dim-strong);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gd-zoom-set { display: flex; align-items: center; gap: .3rem; flex: none; }
.gd-zoom-btn {
  font: 600 var(--step--1)/1 var(--sans);
  background: var(--paper); color: var(--fg);
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: .35rem .6rem; cursor: pointer; min-width: 2rem;
}
.gd-zoom-btn:hover { background: var(--slab-2); border-color: var(--dim); }
.gd-zoom-btn:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }
.gd-zoom-level {
  font: 500 var(--step--1)/1 var(--mono); color: var(--dim-strong);
  min-width: 3.4rem; text-align: center; font-variant-numeric: tabular-nums;
}
/* The pan surface. `overflow: hidden` and a transformed child rather than a
   scroll container: a scroll port cannot pan past the content's own edges,
   and zooming out inside one leaves the diagram pinned to a corner. */
.gd-stage-pan {
  flex: 1; position: relative; overflow: hidden;
  background: var(--slab); cursor: grab; touch-action: none;
}
.gd-stage-pan[data-gd-dragging] { cursor: grabbing; }
/* transform-origin at the origin, because guide.js's zoomTo does the
   about-a-point arithmetic itself — a percentage origin would apply a second,
   invisible correction on top of it. */
.gd-canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.gd-canvas svg { display: block; max-width: none; min-width: 0; }

/* ---------- a question's own hint, inside the viewer (2026-09-05) ----------
   coding/data/flowchart.json carries one sentence per question of the
   classifier, written in 00-flowchart.md's "What each question is asking"
   table; the interactive walker has rendered it under every question since
   Task 8 (`.fc-hint`), and the chapter's own two fences did not. guide.js's
   per-question-hints section holds the ruling on WHERE it surfaces (here, in
   the viewer, because both fences are previews and a preview's labels paint at
   5.9–8.2 CSS px) with the measurements behind it.

   THE INDICATORS ARE OUTLINES, AND NOTHING INSIDE THE FENCE IS RECOLOURED.
   Two diagram gates isolate a mark by diffing screenshots
   (coding/drill/diagram.e2e.test.js) and the whole diagram vocabulary at the
   top of this file exists so a fence paints from four declared properties
   only — so an open question is ringed from the outside instead of being given
   a fill or a stroke of its own. Verified by pixel diff in Chromium on
   2026-09-05 that CSS `outline` paints on an SVG <g> at all, which is the one
   thing this treatment needs and the reason it is not a synthesized <rect>.

   FOCUS OUTRANKS OPEN, by specificity and on purpose: `g[data-gd-hint]:focus-
   visible` is (0,2,1) against `g[data-gd-hint-open]`'s (0,1,1), so a keyboard
   reader moving to a second question always sees the ring that says where they
   are, never the one that says which sentence is showing. --rank1 is this
   site's focus ring everywhere (.fc-option, .gd-open, .gd-zoom-btn, .gd-step);
   --rank2 is the open mark, so the two are never the same ring in the two
   places they can both be on screen. */
.gd-canvas g[data-gd-hint] { cursor: pointer; }
.gd-canvas g[data-gd-hint-open] { outline: 2px solid var(--rank2); outline-offset: 3px; }
.gd-canvas g[data-gd-hint]:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }
/* The dock: outside `.gd-stage-pan`, so the sentence never zooms with the
   picture. No `display` of its own, which is what lets guide.js hide it with
   the `hidden` attribute for the 54 fences in the corpus that have no
   questions in them. */
.gd-hint-dock {
  flex: none; max-height: 30vh; overflow-y: auto;
  padding: .7rem 1rem .85rem;
  border-top: 1px solid var(--line-soft);
  background: var(--slab);
}
.gd-hint-tip { margin: 0; font: var(--step--1)/1.45 var(--sans); color: var(--dim); }
.gd-hint-q { margin: 0 0 .3rem; font: 600 var(--step-0)/1.35 var(--sans); color: var(--fg); }
/* The node's own id (STRUCT, GOAL_A), which the fence prints beside every
   question and flowchart.json keys on — so a reader can tell the sentence in
   the dock belongs to the diamond they clicked. */
.gd-hint-code {
  font: 600 var(--step--1)/1 var(--mono); color: var(--dim);
  letter-spacing: .06em; margin-right: .55rem;
}
/* Longhand, not the `font:` shorthand — trap #1 at the top of this file. Serif
   at a reading measure, matching `.fc-hint`: it is the same sentence from the
   same table, and it should not read as a different voice for being in a
   dialog. Where it differs is the colour: `.fc-hint` is --dim because it sits
   UNDER the walker's own question heading, and here it is the thing the reader
   asked for. */
.gd-hint-text {
  margin: 0; max-width: 62ch; color: var(--fg);
  font-family: var(--serif); font-size: var(--step-0); line-height: 1.5;
}
/* The hint arrives through markdown.js (guide.js's paintDock), so it is a <p>
   inside this box and can carry inline code — one hint does: "Read `n`, …".
   The margin reset is what keeps the dock's own padding in charge of the
   spacing, and the chip mirrors `.paper code` on the ground this dialog
   actually paints on (--slab, not --paper). */
.gd-hint-text p { margin: 0; }
.gd-hint-text code {
  font: var(--step--1)/1.4 var(--mono); background: var(--slab-2);
  padding: .08em .3em; border-radius: 2px;
}

/* ---------- the step-through sequence (stepper pilot, 2026-09-01) ---------- */
/* Four chapters carry fences that are ONE picture at n points in time. guide.js
   groups each run of them into .gd-steps > .gd-stage and adds
   `data-gd-stepping` from its wiring step; everything that stacks or hides is
   scoped under that attribute, so the structure alone renders n fences down the
   column in document order -- which is exactly what a reader saw before this
   existed, and the whole of the degradation story.

   `visibility: hidden`, AND NEVER `display: none`, and the reason is measured
   rather than stylistic. coding/drill/diagram.e2e.test.js iterates every fence
   and measures geometry twice over: getBBox()+getScreenCTM() for the
   inside-the-frame check, and an element screenshot per mark for the ink
   collision check. A `display:none` fence has no box and no CTM, so the first
   would measure nothing and report nothing, and this repo has already paid twice
   for that class of hide -- `display:none` on the sprite host silently cost
   every arrowhead its paint, and a `min-width` turned a container into a scroll
   port and cropped 13% of every fence out of the ink masks. Hidden by visibility
   and stacked in ONE grid cell, every state keeps the geometry it would have had
   alone in the column, and the inactive ones simply do not paint.

   THE GRID TRACK, AND WHAT ACTUALLY HOLDS IT UP. A mounted fence carries
   `min-width: 588px` (see the .gd-diagram note above), so a track that sized to
   its content would be 588px and would overflow the prose column: the
   .gd-diagram scroll port would never engage, and cropping a fence out of the
   ink masks is the second of the two failures above. `minmax(0, 1fr)` says "do
   not size to content" explicitly.

   Measured 2026-09-01, and the measurement is the point: a bare `1fr` behaves
   IDENTICALLY here, because a grid item's automatic minimum size is zero when
   the item is a scroll container, and `.gd-diagram` is one (`overflow-x: auto`).
   Ablating `minmax(0, 1fr)` to `1fr` fails only the stylesheet gate in
   guide.test.js -- all 23 browser specs stay green, spec 23's per-fence
   "scrolls inside its container at 1024" included. So this is a PAIR that masks
   itself: the track and the container's overflow hold the property up together,
   and either alone would be enough today. It is written explicitly so the stack
   does not silently depend on `.gd-diagram` keeping `overflow-x: auto` -- the
   day that rule moves, the track is what stops 13% of every fence going missing
   again.

   No transition and no animation anywhere in here: the swap is instantaneous, so
   there is nothing for `prefers-reduced-motion` to ask for. Stepping is the
   whole point -- a state a reader cannot dwell on is a state they cannot read. */
.gd-steps { margin: 1.3rem 0; }
.gd-steps[data-gd-stepping] > .gd-stage { display: grid; grid-template-columns: minmax(0, 1fr); }
.gd-steps[data-gd-stepping] > .gd-stage > .gd-diagram { grid-area: 1 / 1; margin: 0; }
.gd-steps[data-gd-stepping] > .gd-stage > .gd-diagram:not([data-gd-active]) { visibility: hidden; }

/* THE DELTA, DERIVED AND NOT AUTHORED. guide.js compares each state against the
   one before it mark by mark over full attribute dicts -- the same comparison
   coding/tests/test_guide_svg.py gates the sequences with -- and stamps
   `data-gd-delta` on every mark that arrived or changed. Only the ACTIVE state
   is painted, because the highlight answers "what did this step do", which is a
   question about the state on screen.

   A recolour and nothing else, which is also measured: the ink-collision gate
   isolates a mark by diffing two screenshots, so a treatment that GREW a mark's
   paint (an outline, a halo, a drop-shadow) would move that gate's masks and
   could invent a collision the fence does not have. Recolouring paints the same
   pixels a different colour, so the masks are untouched.

   The second rule is not redundant. A muted mark carries `color` as a
   presentation attribute, which any CSS declaration beats, so without it a
   newly-arrived crossed-out position -- the corpus has several -- would arrive
   in the accent colour, which is the opposite of what muting it says.

   THE ACCENT USED TO MEAN TWO THINGS HERE, and 2026-09-03 closed it. Accent is
   both "the prose is about this" (authored `dg-emphasis`) and "this arrived in
   this step" (derived, the rule above), and there is no fifth colour property
   to split them with -- so the split is not a colour. Authored emphasis carries
   WEIGHT as well: heavier text, a thicker stroke. A step delta is accent alone.

   Closing it for a `<use>` needed one wrong conclusion undone. `stroke-width`
   on a bare `.dg-emphasis` is inert on a `<use>`, measured at 0 of a node box's
   960 ink pixels, because it is an inherited CSS property and the symbol's own
   presentation attribute beats an inherited value. From that this file
   concluded that NO length could reach inside one, and left the ambiguity open
   for the corpus's 38 emphasised `<use>` marks.

   That conclusion was wrong. A CUSTOM property does inherit into the `<use>`
   shadow tree -- the same route `var(--dg-bg)` has always taken -- so a symbol
   whose stroke is written `calc(N * var(--dg-stroke-scale, 1))` can be
   thickened per instance, and `.dg-emphasis` above sets the scale. Measured in
   Chromium: with the width as a plain attribute two instances differing only by
   class render byte-identically; written as a scaled base they do not, and it
   holds through `vector-effect="non-scaling-stroke"` too.

   AND THE INK GATE SURVIVED IT, which is the reason this was not free. That
   gate isolates a mark by diffing two screenshots, so a treatment which GREW a
   mark's paint could invent a collision the figure does not have -- which is
   why the delta itself still only ever recolours. Growing the 38 emphasised
   marks by half a stroke was the affordable half of that trade, and all 23
   browser specs stay green on it. Proved load-bearing from both ends: reverting
   one symbol to a plain attribute fails a Python gate, and dropping the scale
   from `.dg-emphasis` fails a browser spec that measures the two renders. */
.gd-steps [data-gd-active] [data-gd-delta] { color: var(--dg-accent); }
.gd-steps [data-gd-active] [data-gd-delta].dg-muted { color: var(--dg-muted); }

/* AND THE SAME TWO FOR A MARKERED EDGE, because `color` does not reach one. The
   rules above at `[marker-end]` resolve an edge's stroke from its MODIFIER
   CLASS, and a CSS declaration beats the fence's own `stroke="currentColor"`
   presentation attribute -- so a newly-arrived arrow kept the page ink while the
   caret and the caption beside it went accent. Found by eye, on cyclic-sort
   state 2: half the delta was highlighted and half was not, and every mechanical
   check was green. `stroke` and not `color` for the same reason the modifier
   rules use it, and the arrowhead follows because #alg-arrow paints
   `fill: context-stroke` from a stroke that is now a resolved colour. */
.gd-steps [data-gd-active] [marker-end][data-gd-delta] { stroke: var(--dg-accent); }
.gd-steps [data-gd-active] [marker-end][data-gd-delta].dg-muted { stroke: var(--dg-muted); }

/* The bar sits ON THE PAPER, so it borrows the paper's own tokens rather than
   the chrome's `.btn` family: --line is the dark shell's rule colour and is
   nearly invisible against --paper. `.gd-term-btn` above is the only other
   paper-scoped button in this file and it draws its own edge from --paper-dim
   for exactly that reason. */
.gd-stepbar { display: flex; align-items: center; gap: .6rem; margin-top: .5rem; }
.gd-step {
  font: 600 var(--step--1)/1 var(--sans); background: transparent; color: var(--paper-fg);
  border: 1px solid var(--paper-dim); border-radius: var(--radius); padding: .3rem .6rem;
  cursor: pointer;
}
.gd-step:hover:not(:disabled) { border-color: var(--paper-fg); }
.gd-step:disabled { color: var(--paper-dim); border-style: dashed; cursor: default; }
.gd-step:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }
.gd-step-at { color: var(--paper-dim); font: var(--step--1)/1.4 var(--sans); }

/* The one ```mermaid fence in the corpus (00-flowchart.md), collapsed by
   guide.js instead of dumping ~40 lines of diagram source into the prose —
   see renderMermaidBlock. New surface, so it is prefixed .gd-* per this
   task's naming rule; everything inside (.dim, .link, <details>/<summary>,
   pre.codelight) reuses existing vocabulary as-is. */
.gd-mermaid { margin: 1.1rem 0; }
.gd-mermaid summary { cursor: pointer; color: var(--paper-dim); font: var(--step--1)/1.4 var(--sans); }
.gd-mermaid > p { margin-bottom: .4rem; }
/* The raw mermaid source is a flowchart DSL dump, not python — its longest
   lines run far past any code column this layout can offer (a full node
   label plus arrow syntax), and unlike python indentation, wrapping it
   costs nothing semantically (it is display-only until Task 8's interactive
   walker exists, never executed). Wrap instead of forcing a scrollbar. */
.gd-mermaid pre.codelight { white-space: pre-wrap; word-break: break-word; }

/* The per-question hint table, collapsed by guide.js once the diagram itself
   expands the same sentences (collapseHintTable). Same summary treatment as
   .gd-mermaid's, because it is the same move for the same reason: reference
   the reader can open, not twelve rows in the middle of the argument. */
.gd-hint-source { margin: 1.1rem 0; }
.gd-hint-source summary {
  cursor: pointer; color: var(--paper-dim); font: var(--step--1)/1.4 var(--sans);
}
.gd-hint-source table { margin-top: .6rem; }

/* ---------- SEARCH (Task 6) ---------- */
/* Full-text search over the guide corpus (search.js's rank()), summoned by
   '/' from anywhere in the reader (guide.js's handleGuideKey) and layered
   on top of it as a fixed backdrop + centered panel. New surface, so every
   class here is prefixed .gd-* per this reader's naming rule — an
   unprefixed name risks colliding with drill.js/stats.js's own vocabulary,
   which has already happened once in this project. */
.gd-search-overlay {
  position: fixed; inset: 0; z-index: 20;
  background: #1c1f2499; /* --fg (#1c1f24) at ~60% opacity, a dim scrim */
  display: flex; justify-content: center;
  padding: 10vh 1.5rem 3rem;
  overflow: auto;
}
.gd-search-panel {
  background: var(--paper); color: var(--paper-fg);
  border: 1px solid var(--paper-edge);
  border-radius: var(--radius);
  box-shadow: 0 8px 30px #00000026;
  width: min(640px, 100%);
  max-height: 70vh;
  display: flex; flex-direction: column; gap: .6rem;
  padding: .9rem;
}
.gd-search-input {
  width: 100%;
  font: var(--step-1)/1.3 var(--sans);
  color: var(--paper-fg);
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .6rem .7rem;
  outline: none;
}
.gd-search-input:focus { border-color: var(--rank1); }
/* --paper-dim, not the generic .dim utility (--dim): this panel sits on
   --paper, the reader's reading surface, same reasoning as .paper .sub
   above using --paper-dim rather than --dim. */
.gd-search-status { margin: .3rem .2rem; color: var(--paper-dim); }
.gd-search-results { display: flex; flex-direction: column; gap: .15rem; overflow: auto; }
.gd-search-hit {
  display: block; text-decoration: none; color: inherit;
  border: 1px solid transparent; border-radius: var(--radius);
  padding: .5rem .6rem;
}
.gd-search-hit:hover { background: var(--slab-2); }
/* Selected row (arrow-key navigation) — .rank1-soft/.rank1 border, the same
   selected-state vocabulary drill.js's own `.pick.selected` uses. */
.gd-search-hit.on { background: var(--rank1-soft); border-color: var(--rank1); }
.gd-search-hit-title { font: 600 var(--step--1)/1.3 var(--mono); color: var(--paper-fg); }
.gd-search-hit-snippet { font: var(--step--1)/1.4 var(--sans); color: var(--paper-dim); margin-top: .15rem; }

/* ---------- FLOWCHART (Task 8) ---------- */
/* Canonical class vocabulary (ruling R18): the whole surface is namespaced
   `.fc-` so it can never collide with the unprefixed vocabulary drill.js/
   stats.js own (`.verdict` collided once already — see the comment above
   `.verdict.ok`/`.verdict.bad`). Ported from the design mockup's own names
   (`.walk`, `.walkbox`, `.crumbs`, `.q`, `.opts`/`.opt`, `.verdict`,
   `.vrow`+`.r1`/`.r2`) one-for-one onto `.fc-walk`, `.fc-box`, `.fc-crumbs`,
   `.fc-question`, `.fc-opts`/`.fc-option`, `.fc-terminal`, `.fc-rank`
   (+`.r1`/`.r2`) — no visual change, only the selectors. */
/* min-height, not flex:1 (the mockup's own rule): here `.fc-walk` sits inside
   the shared `.view` container (a plain block, not a flex parent — see
   style.css's `.view`), so `flex:1` would be a no-op. A fixed min-height
   gives `place-items:center` something to centre within regardless. */
.fc-walk { display: grid; place-items: center; padding: 3rem 1.5rem; min-height: 60vh; }
.fc-box { width: 100%; max-width: 720px; }
.fc-crumbs { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: 2rem; font: var(--step--1)/1 var(--mono); }
.fc-crumbs span { color: var(--dim); border: 1px solid var(--line); padding: .25rem .45rem; border-radius: var(--radius); }
.fc-crumbs span.layer { border-color: var(--rank1); color: var(--rank1); }
.fc-crumbs span { cursor: pointer; background: none; }
.fc-crumbs span:hover { border-color: var(--rank1); color: var(--rank1); }
.fc-question { display: flex; gap: 1.4rem; }
.fc-question h2 { font: 400 var(--step-3)/1.2 var(--serif); margin: 0 0 1.4rem; }
/* The secondary line under a question's heading: the Layer-0 screen's
   subtitle, and a tree question's own hint from flowchart.json. Same
   `--dim` + serif treatment the terminal's guidance prose already uses, so
   "this is the explanation, the heading is the question" reads the same on
   every screen of this surface. Its own `.fc-*` class rather than the bare
   `.dim` utility: it needs the spacing too, and R18 namespaces this
   surface's vocabulary. */
.fc-hint {
  margin: -.9rem 0 1.4rem;
  color: var(--dim);
  font-family: var(--serif);
  font-size: var(--step-0);
  line-height: 1.5;
  max-width: 62ch;
}
.fc-opts { display: grid; gap: .5rem; }
.fc-option {
  display: flex; align-items: center; gap: .8rem; text-align: left;
  background: var(--slab); border: 1px solid var(--line-soft); color: var(--fg);
  padding: .8rem 1rem; border-radius: var(--radius); cursor: pointer;
  font: var(--step-1)/1.3 var(--sans); width: 100%;
}
.fc-option:hover { border-color: var(--rank1); background: #e4e3da; }
/* A Layer-0 row's second line: what the tell means, in plain words. The row
   is a flex row centred on its label, so the two lines are stacked in their
   own column rather than added as a third flex child -- otherwise the
   explanation sits beside the tell instead of under it. `align-items:
   center` above keeps the kbd chip centred against the pair. */
.fc-option-body { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
.fc-option-sub {
  font: var(--step--1)/1.45 var(--sans); color: var(--dim);
  text-wrap: pretty;
}
.fc-option kbd { font: 600 var(--step--1)/1 var(--mono); color: var(--dim); border: 1px solid var(--line); border-radius: 2px; padding: .2rem .35rem; }
.fc-option:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }
/* Fix round 2: Fix round 1 capped the 16 Layer-0 rows in their own
   scrollable box (.fc-opts-scroll, max-height + overflow-y:auto) so the
   exit below it never needed scrolling. Reverted — the clip landed mid-row
   (read as a rendering bug, not "there's more below") and macOS hides
   overlay scrollbars until the user actually scrolls, so there was no
   affordance at all on first paint. No inner scroll container now: the 16
   rows are plain document flow (`.fc-opts` alone, no scroll box of its
   own) and the PAGE scrolls instead, one continuous scannable list. */
/* Room for the fixed exit bar below (its own height plus breathing room),
   so scrolling to the last row never leaves it hidden underneath the bar —
   only present on the Layer-0 screen's row list, via this dedicated class,
   not on .fc-opts generally (a tree question's options never share a
   screen with a fixed bar). */
.fc-opts-pad-bottom { padding-bottom: 6rem; }
/* The exit option is a standalone, highlighted action — it is the single
   most common answer, since most problems trip no Layer-0 tell. Fix round
   2: `position: sticky` (tried first) only holds an element once scrolling
   would otherwise carry its OWN natural flow position past the offset —
   with 16 rows above it, that natural position starts below the fold, so
   sticky rendered it off-screen at scroll 0, failing "visible at any
   scroll position" outright (see flowchart.js's own comment on this same
   function for the measurement). `position: fixed` on the wrapping bar
   below has no such threshold. */
.fc-exit-bar {
  position: fixed;
  left: var(--spine);
  right: 0;
  /* Not bottom:0 — the app's own .footbar (status + keyhint) is normal
     flow, so scrolling to the true end of a tall Layer-0 list brings it
     into view right where a fixed bottom:0 bar would also sit, overlapping
     it. 3rem clears .footbar's own rendered height (~41px) with margin. */
  bottom: 3rem;
  display: flex;
  justify-content: center;
  padding: .8rem 1.5rem 1.2rem;
  /* Fades from the shell's own background to transparent, so content
     scrolling underneath softens into the bar instead of cutting off
     abruptly at its edge — the option itself (already opaque, `--slab`)
     is what actually stops that content from showing through the button. */
  background: linear-gradient(to top, var(--ink) 55%, transparent);
  z-index: 5;
  /* The padding above is deliberately wider than the button so the fade
     reads as one continuous bar — but only the button itself should be
     clickable, not the transparent/faded margin around it. */
  pointer-events: none;
}
.fc-exit-bar .fc-option.fc-exit {
  pointer-events: auto;
  width: 100%;
  max-width: 720px;
  border-color: var(--rank1);
  box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
}

/* Flowchart terminal panel — a grid container of ranked candidate rows. */
.fc-terminal { display: grid; gap: 1px; background: var(--line-soft); border: 1px solid var(--line-soft); margin-top: 1.4rem; }
.fc-rank { background: var(--slab); padding: 1.1rem 1.2rem; display: flex; gap: 1rem; align-items: flex-start; }
.fc-rank .n { font-family: var(--mono); font-size: var(--step-2); font-weight: 700; line-height: 1; }
.fc-rank.r1 .n { color: var(--rank1); }
.fc-rank.r2 .n { color: var(--rank2); }
.fc-rank.r2 { background: #eae9e2; }
.fc-rank h3 { margin: 0 0 .3rem; font: 600 var(--step-1)/1.2 var(--sans); }
.fc-rank.r2 h3 { font-weight: 500; color: var(--dim); }
.fc-rank p { margin: 0; color: var(--dim); font-family: var(--serif); font-size: var(--step-0); line-height: 1.5; }
.fc-rank .litmus { color: var(--fg); }
/* The ranked terminal's action row (chapter link / drill link / start over)
   is its own trailing grid row, INSIDE .fc-terminal, not a sibling — so it
   picks up the same hairline separation as the rank rows above it. */
.fc-terminal > .fc-actions { margin-top: 0; background: var(--slab); padding: 1.1rem 1.2rem; }

/* A terminal with no candidates (e.g. the tree's own "verify litmus" dead
   end) — guidance prose plus a restart control, not a ranked grid. */
.fc-terminal.guidance { display: block; padding: 1.1rem 1.2rem; }
.fc-terminal.guidance p { margin: 0; color: var(--dim); font-family: var(--serif); font-size: var(--step-0); line-height: 1.5; }

/* The escalation protocol under that guidance prose — the numbered moves a
   reader who failed to classify actually needs. Several paragraphs and a
   list now share this panel, so the `margin: 0` above (written when it held
   exactly one line) needs its own spacing rules here rather than a change
   to that rule, which the ranked terminal's own single-line case still
   wants. */
.fc-escalate { margin-top: 1.4rem; border-top: 1px solid var(--line-soft); padding-top: 1.2rem; }
.fc-escalate h3 { margin: 0 0 .8rem; font: 600 var(--step-1)/1.2 var(--sans); color: var(--fg); }
.fc-escalate ol { margin: 0; padding-left: 1.4rem; display: grid; gap: .7rem; }
.fc-escalate li { color: var(--dim); font-family: var(--serif); font-size: var(--step-0); line-height: 1.5; }
.fc-escalate li strong { color: var(--fg); font-weight: 600; }
.fc-escalate > p { margin-top: 1.1rem; }

.fc-actions { margin-top: 1.4rem; }

@media (prefers-reduced-motion: no-preference) {
  .fc-question, .fc-terminal { animation: rise 180ms ease-out; }
  @keyframes rise { from { opacity: 0; transform: translateY(8px); } }
}

/* ---------- TODAY (Task 10) ---------- */
/* Namespaced `.today-*` throughout (same ruling as `.gd-*`/`.fc-*` above):
   the verified mockup's own port of this section used bare `.phase`/`.next`/
   `.card`/`.strip`/`.weak`, which is exactly the shape of collision that hit
   `.verdict` once already in this project — none of those five happened to
   collide with drill.js/stats.js's live vocabulary today, but "doesn't
   collide yet" is not the rule this codebase settled on after that incident,
   so every class below is renamed to this surface's own prefix instead of
   trusting today's absence of a collision to hold forever. `.today` itself
   (the root container) and `.onboarding` (the first-run form) are the two
   exceptions — both are required verbatim by the task's own e2e spec
   vocabulary, and neither one is a generic-enough word to realistically
   collide with drill.js/stats.js's own class names. */
.today { padding: 2.4rem 1.5rem 3rem; max-width: 1000px; margin: 0 auto; width: 100%; }
.today-phase { display: flex; align-items: baseline; gap: .6rem; margin-bottom: 1.6rem; flex-wrap: wrap; }
.today-phase b { font: 600 var(--step-2)/1.1 var(--sans); }
.today-phase-change { margin-left: auto; }

/* Coverage honesty (Task 6): a plain ghost button (no rule of its own
   needed) that toggles the in/out list below it. List/row/divider treatment
   copies the checklist's box (`.today-checklist`/`.today-checklist-row`) and
   the weakest table's link colour (`.today-weakest a`) verbatim — the same
   look, not a new one. */
.today-coverage { margin: -.6rem 0 1.6rem; }
.today-coverage-list { display: flex; flex-direction: column; gap: .4rem; margin-top: .6rem; }
.today-coverage-row { padding: .4rem .1rem; border-bottom: 1px solid var(--line-soft); }
.today-coverage-row a { color: var(--rank1); text-decoration: none; }
.today-coverage-row a:hover { text-decoration: underline; }
.today-coverage-divider { padding: .4rem .1rem; }
.today-coverage-list .cut { opacity: .5; }

.today-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line-soft); border: 1px solid var(--line-soft); }
@media (max-width: 760px) { .today-cards { grid-template-columns: 1fr; } }
.today-card { background: var(--slab); padding: 1.4rem 1.5rem 1.3rem; display: flex; flex-direction: column; gap: .55rem; }
.today-card .eyebrow { display: flex; align-items: center; gap: .5rem; }
.today-card h2 { font: 400 var(--step-3)/1.15 var(--serif); margin: .1rem 0 0; letter-spacing: -.01em; }
.today-card .today-act { margin-top: .7rem; display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.today-drill-row { text-align: left; }

/* Rest day (Task 6): takes the drill card's spot in the `.today-cards` grid
   when there is no slot on today's weekday — same box as `.today-card`
   (background/padding/flex), copied rather than shared via class so this
   surface keeps its own name. */
.today-rest { background: var(--slab); padding: 1.4rem 1.5rem 1.3rem; display: flex; flex-direction: column; gap: .55rem; }

/* Mock/dress-rehearsal etc: no route, no due queue (hard requirement) — a
   plain list of label + count/timebox, deliberately NOT styled as a button
   so it never reads as clickable. */
.today-checklist { margin-top: 1.4rem; display: flex; flex-direction: column; gap: .4rem; }
.today-checklist-row { padding: .4rem .1rem; border-bottom: 1px solid var(--line-soft); }

/* Planner notes (Task 6): substitution/overflow/expired-target lines, same
   box treatment as the checklist just above — rows are plain `.dim` text,
   not another bordered-row class, since these are prose, not line items. */
.today-notes { margin-top: 1.4rem; display: flex; flex-direction: column; gap: .4rem; }

.today-progress { display: flex; gap: 2.2rem; margin: 1.6rem 0 0; padding-top: 1.2rem; border-top: 1px solid var(--line-soft); flex-wrap: wrap; }

.today-weakest { margin-top: 2rem; }
.today-weakest table { width: 100%; border-collapse: collapse; font: var(--step-0)/1.5 var(--mono); }
.today-weakest td { padding: .5rem .4rem; border-bottom: 1px solid var(--line-soft); }
.today-weakest td:first-child { color: var(--fg); }
.today-weakest td.n { text-align: right; color: var(--dim); }
.today-weakest a { color: var(--rank1); text-decoration: none; }
.today-weakest a:hover { text-decoration: underline; }

/* First-run onboarding form and the "change phase / restart protocol"
   form it's shared with (session.js's renderProfileForm) — `.onboarding` is
   the exact class the task's own e2e spec locates (`form.onboarding`);
   `.today-onboarding` rides alongside it for this surface's namespacing
   rule, per the section comment above. */
.onboarding { display: flex; flex-direction: column; gap: .7rem; max-width: 30rem; }
.today-field { display: flex; flex-direction: column; gap: .25rem; font: var(--step--1)/1 var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
.today-field input, .today-field select {
  font: var(--step-0)/1.3 var(--sans); text-transform: none; letter-spacing: normal; color: var(--fg);
  background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: .5rem .6rem;
}
.today-field input:focus, .today-field select:focus { border-color: var(--rank1); outline: none; }

/* Availability grid + persona/preset chips (Task 5, styled here as its own
   deferred note): the day×minutes grid had no rules at all, so its two
   number inputs per row rendered at raw browser default width/box with no
   alignment to the day label. Input treatment copies `.today-field
   input`'s own properties verbatim; the persona/preset chip rows need
   nothing new — they are plain `.ghost` buttons inside a `.row`, both
   already styled. */
.today-availability { display: flex; flex-direction: column; gap: .3rem; margin: .2rem 0; }
.today-slot-day { flex: 0 0 3rem; font: var(--step--1)/1 var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
.today-slot-row input[type="number"] {
  width: 4.5rem; font: var(--step-0)/1.3 var(--sans); color: var(--fg);
  background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: .4rem .5rem;
}
.today-slot-row input:focus { border-color: var(--rank1); outline: none; }

/* ---------- MISS LOG (Task 11) ---------- */
/* Namespaced `.ml-*` throughout, per this codebase's own rule after
   `.verdict` collided once already (see the comment above `.verdict.ok`/
   `.verdict.bad`) — every class below is new vocabulary this task
   introduces (the in-drill picker in drill.js's renderFeedback, the
   breakdown panel in stats.js, and the manual "log an offline miss" card
   on Today in session.js), so none of it is left unprefixed. Buttons reuse
   the existing `.ghost` chip-button base (session.js's own Today controls
   already do the same) rather than duplicating that look under a new name. */
.ml-picker { margin-top: .8rem; padding-top: .7rem; border-top: 1px dashed var(--line-soft); }
.ml-layers { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
.ml-note-row { margin-top: .3rem; }
.ml-note {
  flex: 1; min-width: 12rem; font: inherit; color: var(--fg);
  background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius);
  padding: .4rem .6rem; outline: none;
}
.ml-note:focus { border-color: var(--rank1); }
/* Shown once a miss has been logged (or skipped), in place of the picker —
   never both at once, so there is nothing left to click twice. */
.ml-logged { margin-top: .8rem; padding-top: .7rem; border-top: 1px dashed var(--line-soft); }
/* The second axis, asked after the row is already committed. It wraps the
   `.ml-logged` line so the divider is drawn once, not twice — hence
   `.ml-stuck-picker > .ml-logged` losing its own top border. */
.ml-stuck-picker { margin-top: .8rem; padding-top: .7rem; border-top: 1px dashed var(--line-soft); }
.ml-stuck-picker > .ml-logged { margin-top: 0; padding-top: 0; border-top: none; }
.ml-stucks { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin: .4rem 0 0; }
.ml-stucks.hidden { display: none; }

.ml-breakdown { display: flex; flex-direction: column; gap: .3rem; margin: .5rem 0 1rem; }
.ml-row {
  display: flex; justify-content: space-between; gap: .6rem;
  padding: .3rem .5rem; background: var(--slab); border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.ml-layer-name { color: var(--fg); }
/* Middle column of the two-axis row: takes the slack so the count stays
   flush right, exactly as it does in the one-axis rows above. */
.ml-stuck-name { flex: 1; text-align: right; }

/* The manual entry card rides on the existing `.today-card` look
   (background/padding/flex) — no rule of its own needed beyond layout for
   its inner controls. */
.ml-manual-row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-top: .5rem; }
.ml-pattern-select {
  font: inherit; color: var(--fg); background: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius); padding: .4rem .6rem;
}
.ml-manual-status { display: block; margin-top: .4rem; }

/* The shipped build command on the statement drill's empty state — a
   `<details>` so the screen leads with what is wrong, not with a shell
   line, and the command still wraps rather than overflowing its panel. */
.build-note summary { cursor: pointer; }
.build-note code { display: block; margin-top: .4rem; overflow-wrap: anywhere; }

/* The file:// boot notice (boot-check.js) — same treatment as .build-note's
   command line, for the same reason: a shell line and a URL have to wrap
   inside the panel rather than push it sideways. Two rules, no new tokens,
   because this panel is otherwise the site's ordinary .panel/h2/.dim. */
.boot-note code { display: block; margin-top: .4rem; overflow-wrap: anywhere; }
.boot-note code + code { margin-top: .2rem; }

/* Glossary term marks + slug links (Task B) — the reader surface, so `.gd-*`
   per this file's naming rule, and every value comes from the existing token
   vocabulary (no new colours, one light register).

   The mark is a DOTTED UNDERLINE IN --paper-dim, deliberately not link
   colour: a first-use gloss appears in serif body text next to real links,
   and giving it link colour would make every paragraph look like a link
   farm. A dotted rule reads as "there is more here" without competing.
   It is a real <button> (never a title= attribute — invisible on touch,
   unstyleable, inconsistent in screen readers), so the font has to be reset
   back to the surrounding prose: a UA button inherits neither family, size
   nor colour. */
.paper .gd-term { position: relative; }
.paper .gd-term-btn {
  font: inherit; color: inherit; background: none; padding: 0; margin: 0;
  border: 0; border-bottom: 1px dotted var(--paper-dim);
  cursor: help; text-align: left;
}
.paper .gd-term-btn:hover,
.paper .gd-term-btn[aria-expanded="true"] { border-bottom-style: solid; color: var(--paper-dim); }
.paper .gd-term-btn:focus-visible { outline: 2px solid var(--rank1); outline-offset: 2px; }

/* The panel is `hidden` until the button flips it (guide.js), so it costs no
   layout when closed. Absolute + a max-width in ch so a long gloss wraps to a
   readable column instead of the full paper width; `left: 0` with a
   right-edge fallback is deliberately not attempted — the paper's own
   --measure column leaves room on the right at every width this layout
   reaches, and a JS-positioned popover would need a resize observer for one
   tooltip. */
.paper .gd-gloss {
  position: absolute; z-index: 5; top: 1.7em; left: 0;
  display: flex; flex-direction: column; gap: .4rem;
  width: max-content; max-width: 46ch;
  padding: .6rem .7rem;
  background: var(--paper); border: 1px solid var(--paper-edge);
  border-radius: var(--radius); box-shadow: 0 2px 8px #1a1d2114;
  font-family: var(--sans); font-size: var(--step--1); line-height: 1.45; color: var(--paper-fg);
  /* Explicit, not inherited: a first use often lands inside **bold** or *em*
     prose (verified in the browser on sliding-window.md, where the whole
     panel came out bold), and the gloss is not emphasis. */
  font-weight: 400; font-style: normal; text-align: left;
}
.paper .gd-gloss[hidden] { display: none; }
.paper .gd-gloss-exp { font-family: var(--mono); color: var(--paper-dim); }
.paper .gd-gloss-see { color: var(--rank1); text-decoration: none; }
.paper .gd-gloss-see:hover { text-decoration: underline; }
.paper .gd-gloss-known {
  align-self: flex-start; font: var(--step--1)/1 var(--sans); color: var(--paper-dim);
  background: var(--slab); border: 1px solid var(--line-soft);
  border-radius: var(--radius); padding: .3rem .5rem; cursor: pointer;
}
.paper .gd-gloss-known:hover { background: var(--slab-2); color: var(--paper-fg); }

/* A chapter-slug code span that resolved to a real chapter. The <code>
   keeps its own monospace/background treatment (`.paper code`); the anchor
   only adds the affordance, so an unresolved slug beside it looks identical
   minus the underline — which is exactly the signal "that chapter does not
   exist". */
.paper .gd-slug { text-decoration: none; border-bottom: 1px solid var(--rank1); }
.paper .gd-slug code { color: var(--rank1); }
.paper .gd-slug:hover code { color: var(--rank1-strong); }

/* An anchor problem's heading IS a link — "### [LC 3: … (medium)](https://…)"
   in the markdown, an <a> inside the <h3> on the page (see markdown.js's
   heading branch). It has to read as clickable, but in this reader's own link
   vocabulary rather than the browser default's #0000EE, which belongs to no
   palette here. --rank1 on --paper measures 5.5:1, so the accent carries the
   affordance on its own and the underline can stay hairline. No font/size/
   weight of its own: the heading must still scan as a heading first. */
.paper .body h1 a, .paper .body h2 a, .paper .body h3 a, .paper .body h4 a {
  color: var(--rank1); text-decoration: underline;
  text-decoration-thickness: 1px; text-underline-offset: .16em;
}
.paper .body h1 a:hover, .paper .body h2 a:hover,
.paper .body h3 a:hover, .paper .body h4 a:hover { color: var(--rank1-strong); }

/* A drill-problem table's Problem/Hint/Variant-note cell can hold the same
   kind of link, for the same reason: a companion LeetCode problem that has
   no LC number of its own used to cite its URL as bare, unclickable text
   in a table cell instead of a heading. Same fix, same rule, so it paints
   in this reader's link vocabulary rather than the browser default. */
.paper .body td a {
  color: var(--rank1); text-decoration: underline;
  text-decoration-thickness: 1px; text-underline-offset: .16em;
}
.paper .body td a:hover { color: var(--rank1-strong); }

/* A backticked chapter slug inside a hint rung: mono, but no box — the rung is
   prose, and a boxed token mid-sentence reads as code the reader must type. */
.rung-text code { font-family: var(--mono); font-size: .92em; }

/* =========================================================================
   Invariant drill (Task H): a chapter's template code, then four sentences
   about it. Two registers on one screen and nothing else — mono for the code
   the reader is judging, and the app's own sans for the claims. No serif
   here: unlike the statement drill this is not long-form reading, it is one
   screenful compared against four one-liners.

   The prompt reuses the light code treatment the guide's fences already use
   (--paper/#ecebe2 family) rather than the dark variant, to keep the single
   light register the design commits to. It is NOT given Prism's
   `language-python` class: the token colour rules are scoped to `.paper pre`
   (see the trap notes above), so a class with no rules behind it would only
   promise highlighting the card cannot deliver.

   The code column scrolls on its own (overflow-x) rather than wrapping. The
   corpus has python lines past 100 characters and a soft-wrapped `while`
   line reads as a different loop than the one on the page — which on a card
   whose whole question is "what does this loop keep true" is not a cosmetic
   difference.
   ========================================================================= */
.inv-prompt {
  background: #ecebe2;
  color: #22262c;
  border: 1px solid #d6d5ca;
  border-radius: var(--radius);
  padding: .8rem 1rem;
  margin: .6rem 0 1rem;
  overflow-x: auto;
  font: 0.85rem/1.55 var(--mono);
}
.inv-prompt code { font: inherit; background: none; }

.inv-ask {
  max-width: var(--measure);
  font-weight: 600;
  color: var(--fg);
  margin: 0;
}

/* One column, not the sprint's auto-fill grid: an option here is a full
   sentence, and two of them side by side would each get half a measure. */
.inv-options { display: grid; gap: .35rem; margin-top: .3rem; }
.inv-option {
  display: flex;
  align-items: flex-start;
  gap: .6rem;
  text-align: left;
  max-width: var(--measure);
}
.inv-option.selected { border-color: var(--rank1); background: var(--rank1-soft); }
.inv-option .key { flex: 0 0 auto; margin-top: .1rem; }
.inv-option-text { flex: 1; }

.inv-picked, .inv-break, .inv-right { max-width: var(--measure); }
/* The one-step break is the teaching moment on a miss, so it gets the one
   piece of emphasis on the feedback screen: a rule in the alarm colour, the
   same channel `.rung-t4` uses for the rung that ends a card. */
.inv-break {
  border-left: 2px solid var(--alarm);
  padding-left: .7rem;
}

/* =====================================================================
   THE READING COLUMN, BELOW A WIDE SCREEN.

   The defect, measured on `02-patterns/sliding-window.md` in Chromium before
   these three rules existed: `.paper .body` -- and with it every paragraph,
   since `.paper p` is capped by `--measure` and otherwise fills the column --
   rendered 669 CSS px at a 1440 viewport, 509 at 1280, 329 at 1100, 253 at
   1024, 129 at 900 and **29 at 800**. Body text was as squeezed as anything
   else on the page, and at the narrow end there was no reading column left at
   all.

   WHAT IS NOT THE PROBLEM, because an earlier version of this note said it
   was: the diagrams. `.gd-diagram svg` carries `min-width: 588px` and its host
   scrolls (`overflow-x: auto`), so a fence holds its size and its label sizes
   all the way down -- 15.9 CSS px at every width below 1280, which is what the
   1024 spec in diagram.e2e.test.js exists to pin. The page never scrolls
   sideways either. Only the prose collapsed.

   WHERE THE WIDTH GOES, at the default paddings: 88 spine + 80 sheet padding +
   96 paper padding + 238 toc column + 250 chapter nav = **752 px of chrome
   before a single character**. A paragraph wants 681 px (`--measure`, 72ch at
   this face and size, measured rather than derived), so the prose reaches its
   own designed measure only at 1433 px and above. Each stage below reclaims
   one block of chrome, and the width it buys is stated:

     stage 1, <=1440: tighter paddings, -72  -> full measure down to 1361
     stage 2, <=1240: no chapter nav,   -250 -> full measure down to 1111
     stage 3, <=900:  no toc column,    -238 -> full measure down to  873

   TWO RULINGS.

   **1280 is left alone on purpose.** After stage 1 a 1280 viewport gives the
   prose 600 px -- 63ch, inside the 45-75ch band that makes a comfortable
   measure -- so it is not a defect. Reclaiming the last 81 px there would mean
   hiding the chapter sidebar at the most common laptop width, and every
   existing e2e spec drives this site at 1280 and assumes the sidebar is there.
   All three breakpoints therefore sit below it.

   **The chapter nav moves to the contents view at narrow widths.** A chapter
   keeps a visible link back to that view; the contents view shows the full
   chapter list. The toc goes last because it is the open chapter's own outline
   and it tracks scroll position while reading.
   PLACEMENT IS LOAD-BEARING: this block sits at the END of the file. A media
   query adds no specificity, so these rules only win by being later than the
   ones they override -- `.sheet`, `.paper`, `.reader`, `.gnav` and `.toc` are
   all declared above. Written first at line 602, they lost every declaration
   that had a base counterpart, and the one that did apply (`display: none`,
   which `.gnav` never sets) collapsed the reader: with the nav out of flow but
   `.reader` still `grid-template-columns: var(--nav) 1fr`, the sheet took the
   250px column and the body measured 0.
   ===================================================================== */

@media (max-width: 1440px) {
  .sheet { padding: 1.5rem 1.25rem 4rem; }
  .paper { padding: 2.2rem 2rem 3rem; }
}

@media (max-width: 1240px) {
  .reader { grid-template-columns: 1fr; }
  .gnav { display: none; }
  .reader.gd-contents .gnav {
    display: block;
    width: min(100%, 720px);
    margin: 0 auto;
    padding: 0 1.25rem 3rem;
    overflow: visible;
    border-right: 0;
  }
  .reader.gd-contents .gnav a {
    min-height: 44px;
    padding: .55rem .75rem;
    font-size: 1rem;
  }
  .reader.gd-contents .gnav .group { padding: 1rem .75rem .35rem; }
  .reader.gd-contents .sheet { order: -1; padding-bottom: 0; }
  .guide-contents-link {
    display: flex;
    align-items: center;
    min-height: 44px;
    position: sticky;
    top: 0;
    z-index: 4;
    padding: 0 1.25rem;
    background: var(--slab);
    border-bottom: 1px solid var(--line-soft);
    color: var(--rank1);
    font-family: var(--sans);
    font-size: var(--step-0);
    font-weight: 600;
    line-height: 1.3;
    text-decoration: none;
  }
  .guide-contents-link:hover { background: var(--slab-2); }
}

@media (max-width: 900px) {
  .toc { display: none; }
}
