/* SixtyThree Coach — the visual language, built on the family foundation.

   #746 (P4): the palettes, semantic roles and the type, space, radius, line,
   icon and motion tokens are not declared here. They live in the family design
   foundation (`contracts/family/design/foundation.css`), vendored through
   `contracts.lock`, shipped next to this file and linked before it. This
   stylesheet builds the Coach's components on those names and restates none of
   their values: a second copy of a foundation value in an app stylesheet is a
   defect, not a convenience, and `tests/onboarding.test.mjs` holds that no
   custom property declared there is declared here as well.

   What this file relies on the foundation for:
     - `:root` is Flanell. "Follow the system" (no `data-theme`) resolves a
       dark device to Elfenbein, which is a light palette; Graphit is only ever
       a reader's own choice. Elfenbein is written twice there, as a media
       block and a chosen block, and the test compares the twins token for token.
     - A rule reaches colour through a role (`--surface-*`, `--text-*`,
       `--border-*`, the scorecard's `--*-score-*` roles) or through the colours
       the palette already names by role (`--accent`, `--danger`, the `--tone-*`
       pairs and `--die-*`). Primitives such as `--paper` are aliased there once.
     - The low-motion variants of the three animation tokens, which keep a
       colour change and lose the travel.

   Flanell's three bases are Schleswig-Holstein's official Marken-Manual.SH,
   "Farbsystem" (Story 429: SH Blau RGB 0/48/100, SH Weiss RGB 255/255/255,
   SH Rot RGB 212/0/75), and every other Flanell value is a rounded mix of them:
   https://www.schleswig-holstein.de/DE/landesregierung/themen/wirtschaft/landesdachmarke/markenManualSH/arbeiten/_documents/_documents_Farbsystem/Farbsystem

   `data-theme` and `data-motion` are written by `apps/coach/src/presentation.ts`
   before the first paint; their absence means "follow the platform". */

/* The Coach's own tokens: shell geometry the family foundation has no word for.

   Story 154: the shell bar's nominal measurements. Story 323 keeps the
   declared height as the pre-bootstrap fallback, then the shell replaces the
   rendered-height token with the bar's real border-box height. That includes
   wrapped rows and the safe-area padding rather than asking `main` to predict
   either of them. */
:root {
  --nav-height: 3.9rem;
  --nav-rendered-height: calc(var(--nav-height) + env(safe-area-inset-bottom));
  --nav-rail-width: 11rem;
}

/* #733's strong roll is a full-motion choice. The foundation's low-motion
   variants load before this file, so an unguarded rule here would out-order
   them at equal specificity and bring the long roll back for a reader who
   asked for less motion. It therefore applies only where motion is not
   reduced: the system allows motion and the reader did not choose `reduced`,
   or the reader chose `full`. */
@media (prefers-reduced-motion: no-preference) {
  :root[data-dice-intensity="strong"]:not([data-motion="reduced"]) {
    --die-roll-animation: die-roll-strong 560ms var(--ease-mech);
  }
}

:root[data-dice-intensity="strong"][data-motion="full"] {
  --die-roll-animation: die-roll-strong 560ms var(--ease-mech);
}

/* `html`, not a second `:root` block: the Coach's own tokens above are the one
   place that selector appears in this file, so a reader looking for them finds exactly one. */
html {
  background: var(--surface-page);
  color: var(--text-body);
  font-family: var(--font-sans);
  font-synthesis: none;
  /* Story 323: the fixed bar is outside normal flow. Main's matching padding
     makes content reachable; scroll padding makes the browser's own focus
     scroll stop before the same measured box instead of parking a newly
     focused control underneath it. The rail breakpoint removes this inset. */
  scroll-padding-bottom: calc(var(--nav-rendered-height) + 1rem);
  /* Story 215 F5: the type scale is already responsive to the reader's chosen
     root size. WebView text autosizing must not apply a second, layout-dependent
     enlargement on top of it. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

* { box-sizing: border-box; }

/* The trap Story 154 found on `.app-nav` and Story 155 found on `.game-stage`,
   answered once for every element instead of one selector at a time: a `display`
   in an author sheet outranks the user agent's own `[hidden] { display: none }`,
   and the component styles below give a `display` to buttons, status strips,
   pills and cards — all of which the controllers close with that attribute. An
   empty status strip standing open on the play route is what it looks like when
   this rule is missing. The two named rules further down stay: they are what the
   shell's own tests read, and a rule that says it twice costs nothing. */
[hidden] { display: none !important; }

body {
  margin: 0;
  /* Story 155 AC2: `dvh`, not `vh`. On a phone browser `vh` is the tallest the
     viewport ever gets, so a layout sized in it hides its last row behind the
     retracting toolbar — which is the one row a one-screen match cannot spare. */
  min-height: 100dvh;
  /* No gradient and no glow. The scheme is paper: one flat page tone, and the
     cards above it separated by a hairline rather than by a shadow. */
  background: var(--surface-page);
  font-size: var(--text-m);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
}

main {
  /* Story 284: the gutter is chrome, and in `rem` it grew with the reader's
     text — 28px a side at 200% on a 320px screen, a sixth of the display spent
     on empty margin at exactly the size where the words need it most. Capped,
     it returns today's 28px total at the default root size and stops there. */
  width: min(100% - min(1.75rem, 28px), 54rem);
  margin: 0 auto;
  /* Story 154 AC6: the bottom inset moved to `.app-nav`, which is the element the
     home indicator actually overlaps. Story 323 reserves its measured
     border-box height here, so a wrapped bar cannot trap the last row of
     content. The token above preserves the old safe-area-aware value until the
     controller has measured the bar. */
  padding: max(1rem, env(safe-area-inset-top)) 0
    calc(var(--nav-rendered-height) + 1rem);
}

header { display: grid; gap: var(--space-1); margin: var(--space-3) 0 var(--space-4); }
/* Story 264: the shell's own half of the defect Story 253 fixed inside the
   finished card. The tagline is one German compound — "Trainingsrunde." alone
   measured 369px at 200% text — and the rule above makes this header a grid, so
   its single auto track cannot shrink below that word: the track floors at
   min-content, the 369px track hangs out of a 264px header, and the *document*
   is 397px wide on every phone. Story 228 pinned the viewport scale, so a
   reader can no longer pinch back to what left the screen.

   `anywhere` rather than `break-word` is the whole fix: only `anywhere` counts
   the break opportunities when min-content is computed, so only `anywhere`
   lowers the floor the track sits on. `break-word` would break the word on
   screen and leave the track — and the sideways scroll — exactly as it is.
   Both properties inherit, so one declaration covers the eyebrow, the tagline
   and the cache line; where the words already fit, neither does anything. */
#app-header { hyphens: auto; overflow-wrap: anywhere; }
h1, h2, h3, p { margin-top: 0; }
h1 {
  margin-bottom: 0;
  font-size: 1.65rem;
  font-weight: var(--weight-display);
  letter-spacing: -.02em;
  line-height: var(--leading-tight);
}
h2 {
  margin-bottom: var(--space-2);
  font-size: var(--text-xl);
  font-weight: var(--weight-display);
  letter-spacing: -.01em;
  line-height: var(--leading-tight);
}
h3 { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-m); font-weight: var(--weight-strong); }
p { color: var(--text-secondary); line-height: var(--leading-body); }
header p { margin: 0; color: var(--text-hint); font-size: var(--text-xs); }

/* Numbers are the product: wherever one is read against another, it is mono and
   tabular so the digits stand in the same columns from render to render. */
.num, .pill strong, .turn-value, .tile-value, .summary-grid strong, .stats-metric-value,
.replay-dice-line, .verdict-loss {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.eyebrow {
  display: block;
  margin-bottom: var(--space-2);
  color: var(--accent);
  font-size: var(--text-xs);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.card {
  margin-bottom: var(--space-3);
  padding: var(--space-5);
  border: var(--hairline);
  border-radius: var(--radius-m);
  background: var(--surface-card);
}

.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.pill { display: inline-flex; align-items: baseline; gap: .35rem; padding: .2rem .55rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); color: var(--text-supporting); font-size: var(--text-s); white-space: nowrap; }
.pill strong { font-weight: var(--weight-strong); color: var(--text-body); }
.dice { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-2); min-width: 0; margin: var(--space-4) 0; }

/* The die face is a three-by-three pip grid, not a digit: `paintDieFace` in
   `src/die-face.ts` fills the nine cells and marks the ones that carry a pip,
   so all four surfaces that render dice show the same face. */
.die, .die-static {
  display: grid;
  grid-template: repeat(3, 1fr) / repeat(3, 1fr);
  place-items: center;
  aspect-ratio: 1;
  width: 100%;
  min-height: 0;
  padding: 14%;
  border: 1.5px solid var(--die-line);
  border-radius: var(--radius-die);
  background: var(--die-face);
  color: var(--die-ink);
}

/* P4 (#746): a live die is a button and inherits the button's `--space-2` gap.
   At 200% text those two rem gaps ate the whole face and the pips painted at
   0-1px; capped at the 100% value, a die reads at 200% as it does at 100%. */
button.die { gap: min(var(--space-2), 8px); }

.die-pip { width: 86%; aspect-ratio: 1; border-radius: 26%; background: currentColor; }

/* Faded rather than at .45: a die that cannot be held this moment still has a
   value the reader has to read against the board. */
.die:disabled { opacity: .7; cursor: default; }
.die:not(:disabled):hover { border-color: var(--border-control); }
/* Story 44 AC6 — no state carried by colour alone — used to be answered here by
   a filled accent corner mark, which is why the base rule above needed
   `position: relative`. Story 314 replaces both with the die's own negative:
   `--die-face` and `--die-ink` change places. That is a swap of *luminance*,
   not of hue, so it is the stronger answer to the same criterion — it survives
   greyscale and every colour-vision deficiency, where the mark's red only
   survived because it also had a shape and a position. Two carriers remain
   beside it: the lift below, and `aria-pressed` for a reader.

   Story 417 removes the third thing this rule used to do. Story 314 had left an
   accent border here beside the negative, and the reasoning above never named
   it as a carrier — the swap, the lift and `aria-pressed` are the three. So the
   held die declares no border at all now and takes the base rule's own 1.5px
   `--die-line` hairline, which is what a resting die wears; there is no
   held-only border to keep in step with it, and nothing on a held die is
   accent-coloured any more. `border-box` sizing makes that a geometry fact as
   well as a colour one: the edge sits inside the box the 3x3 pip grid divides,
   so the thicker accent border drew a held die's pips smaller than every other
   die's. Both halves are measured rather than reasoned from the declarations —
   the matrix's `Story 417 AC1/AC5` criterion reads the rendered edge and the
   rendered pip off the `held` fixture in both schemes.

   `:hover` is in the selector list because a die is a `button`, and the generic
   `button:hover` background above is (0,2,1) against this rule's (0,2,0). Under
   a pointer it therefore used to win: the face became the shared hover mix
   while the pips stayed `--die-face`, which in a light scheme is nearly the
   colour it had just mixed — a held die that reads as unheld and can barely be
   counted. The corner mark used to cover that up; the negative has nothing to
   cover it with, so the state has to hold its own paint under a pointer. The
   edge is deliberately *not* held that way: with no border here, a hovered held
   die takes `.die:not(:disabled):hover`'s `--border-control` exactly as a
   hovered resting die does, which is the same edge, not a state of its own.

   Story 473 adds the third spelling to this same list rather than beside it.
   The replay stage's dice are a display (D2), so they are `.die-static` spans
   and cannot carry `aria-pressed` — a non-button has no pressed state to read.
   Its reader-facing carrier is the die's own `aria-label`, which says "held" in
   words; its two visual carriers must stay *identical* to a held die's, and the
   only way to guarantee that is one declaration block. A parallel rule would
   drift the first time either look is retuned, and nothing would fail. */
.die[aria-pressed="true"],
.die[aria-pressed="true"]:hover,
.die-static[data-held="true"] {
  background: var(--die-ink);
  color: var(--die-face);
  transform: translateY(-3px);
}

/* Gloss and a bevel add depth inside the existing hit box. Held dice keep
   the same face/ink inversion as Classic, including under a pointer. */
[data-dice-finish="glamour"] .die,
[data-dice-finish="glamour"] .die:not(:disabled):hover,
[data-dice-finish="glamour"] .die-static {
  --glamour-face: var(--die-face);
  --glamour-highlight: var(--die-gloss);
  border-radius: 22%;
  border-color: color-mix(in srgb, var(--die-line) 65%, var(--die-ink));
  --glamour-background: radial-gradient(ellipse 70% 35% at 25% 0%, var(--die-gloss-edge), transparent 75%),
    linear-gradient(160deg, var(--glamour-highlight), transparent 38%, transparent 72%, var(--die-gloss-held)),
    linear-gradient(135deg, transparent 35%, var(--die-shadow)), var(--glamour-face);
  background: var(--glamour-background);
  box-shadow: inset 1px 2px 1px var(--die-gloss-edge),
    inset -3px -4px 3px var(--die-shadow-edge),
    inset 0 0 0 1px var(--die-gloss-held),
    0 2px 0 var(--die-shadow-edge), 0 4px 5px var(--die-shadow);
}
[data-dice-finish="glamour"] .die[aria-pressed="true"],
[data-dice-finish="glamour"] .die[aria-pressed="true"]:hover,
[data-dice-finish="glamour"] .die-static[data-held="true"] {
  --glamour-face: var(--die-ink);
  --glamour-highlight: var(--die-gloss-held);
  background: var(--glamour-background);
}
[data-dice-finish="glamour"] :is(.die, .die-static) > .die-pip {
  border-radius: 50%;
  box-shadow: inset 0 2px 2px var(--die-shadow-pip), 0 1px 1px var(--die-gloss-pip);
}

.dice-settings { margin-block: var(--space-5); padding-block: var(--space-4); border-block: var(--hairline); }
.dice-settings > p:not(.settings-hint) { margin-block: var(--space-3) var(--space-1); font-weight: var(--weight-strong); }
.dice-preview { max-width: min(26rem, 416px); }
.dice-preview > .dice {
  gap: min(var(--space-2), 8px);
  grid-template-columns: repeat(auto-fit, minmax(min(44.8px, 100%), 1fr));
}
#settings-dice-roll { display: block; margin-inline-start: auto; }

button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 2.8rem;
  padding: .55rem 1rem;
  border: 1px solid transparent;
  border-radius: var(--radius-s);
  background: var(--surface-control);
  color: var(--text-body);
  font: inherit;
  font-weight: var(--weight-strong);
  line-height: 1.2;
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-mech), border-color var(--dur-fast) var(--ease-mech), transform var(--dur-fast) var(--ease-mech);
}
button:hover:not(:disabled) { background: color-mix(in oklab, var(--surface-control), var(--text-body) 7%); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: var(--opacity-disabled); cursor: default; }
button:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-offset); }
.primary { width: 100%; border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
.primary:hover:not(:disabled) { border-color: var(--accent-press); background: var(--accent-press); }
.quiet { background: transparent; border-color: var(--border-control); color: var(--text-body); }

/* Recovery copy and diagnostic codes remain readable at enlarged text sizes. */
.game-save-recovery { hyphens: auto; overflow-wrap: anywhere; }
.game-save-recovery button { max-inline-size: 100%; }

.quiet:hover:not(:disabled) { background: var(--surface-sunken); }
.danger { border-color: var(--danger); background: transparent; color: var(--danger); }
.danger:hover:not(:disabled) { background: var(--danger-wash); }

.mode-card h2 { margin-bottom: var(--space-2); }
/* Story 185: the card's own copy, not every paragraph beneath it. As a
   descendant rule this reached `.settings-hint` and `.duel-explainer-title`
   two levels down and, at (0,1,1) against their (0,1,0), overwrote the bottom
   margin each of them declares for itself. */
.mode-card > p { margin-bottom: var(--space-4); }
.mode-actions, .data-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; }
/* Exactly one of roll and keep is ever on screen, so the row is flex and the
   visible control takes the width — a two-column grid left it at half. */
.solo-actions, .duel-actions { display: flex; gap: .65rem; }
/* Story 239: this button is what sets the *whole play surface's* minimum width.
   A flex item's floor is its longest unbreakable word, `weiterwürfeln` is one,
   and at 200% text on a 320px screen a platform sans a little wider than this
   Mac's pushed that floor to 307px inside a 250px column — so the card could not
   fit the viewport and the restart exit, the third scorecard column and the
   fifth die were clipped away. Found by the CI matrix (story 237) on its first
   run against unmodified main; invisible here, because the app ships no webfont
   and macOS hands it a narrower face than the runner does.

   Section 7 orders shorten-then-wrap and shortening is not the answer: a label
   trimmed until it fits one font is a fix that the next font undoes. This is
   the same idiom `.tile-label` and `.coach-area` already use — hyphenate where
   the language offers a break, break inside the word where it does not — plus
   the zero floor that lets the flex item actually take it. The button grows a
   line instead of the surface growing past the screen. */
.solo-actions button, .duel-actions button {
  flex: 1 1 auto;
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
/* Story 215 A3: after the third roll both advancing buttons are deliberately
   hidden. The action track remains a touch-target tall anchor while the player
   chooses a scorecard tile instead of giving that height back to the board. */
.solo-actions { min-height: 2.8rem; }
/* Story 205 AC6: this is the app's icon mechanism. The later category-icon
   story inherits `.app-icon` and its data attribute rather than introducing a
   second vocabulary. The glyph is decorative; the button's translated
   `aria-label` remains its name. */
.app-icon { display: inline-grid; width: 1.1em; place-items: center; font-size: var(--text-l); line-height: 1; }
.app-icon[data-icon="leave"]::before { content: "←"; }
.app-icon[data-icon="restart"]::before { content: "↻"; }
.app-icon[data-icon="help"]::before { content: "?"; }
.icon-button { flex: 0 0 2.8rem; width: 2.8rem; padding: 0; }
/* Story 319: the advancing action's drawn glyph. An SVG rather than an
   `.app-icon` text glyph, because the mockup's circular arrow has no text form
   that is not already the restart exit's `↻`; like the coach cap, the drawing
   is decoration and the button's translated `aria-label` remains its name. */
.action-glyph { display: none; }
.action-glyph svg { display: block; width: 100%; height: auto; }
.action-glyph path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Keep's held die, filled: the two states differ by shape, never by colour. */
.action-glyph rect { fill: currentColor; }
.primary.dice-cup-action {
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
  background: transparent; border-color: transparent; color: var(--text-body); box-shadow: none;
}
.primary.dice-cup-action:hover:not(:disabled) { background: transparent; border-color: transparent; }
.primary.dice-cup-action .dice-cup-icon { display: block; flex: 0 0 auto; width: 2.35rem; height: 2.35rem; background: center / contain no-repeat url("./assets/dice-cup.svg"); }
.primary.dice-cup-action .action-glyph { display: none; }
/* Der rote Würfelbecher kennzeichnet Würfeln auch dann, wenn die Beschriftung
   in der kompakten Würfel-Slot-Ansicht ausgeblendet wird. Seine Farben sind
   bewusst fest an SixtyThrees Produktrot #D4004B gebunden. */
.dice-cup-action-icon { display: inline-block; flex: 0 0 2.35rem; width: 2.35rem; height: 2.35rem; }
.dice-cup-action-icon svg { display: block; width: 100%; height: 100%; }
.game-exits { display: flex; gap: .35rem; }
.mode-back { width: 100%; margin-top: .65rem; }

/* The status strip carries a mark of its own, so "this is the app speaking" is
   not the accent colour alone. */
.status-strip { display: flex; gap: .55rem; align-items: baseline; margin: 0 0 var(--space-3); padding: .6rem .8rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); color: var(--text-supporting); font-size: var(--text-s); line-height: 1.45; }
.status-strip::before { content: "▪"; flex: 0 0 auto; color: var(--accent); font-size: .7em; transform: translateY(-.1em); }
.status-strip.is-error { color: var(--danger); }
.status-strip.is-error::before { color: var(--danger); }

/* The finished summary at 200% text (Story 253) -----------------------------
   Two stat columns are worth keeping only while a column can still hold a
   stat. At 200% text on a phone one cannot: the German labels are single
   compound words, and "Entscheidungen" alone measured 165.6px against a 91px
   column, so the summary hung 40px past a 320px viewport — 60px on the CI
   runner, whose default sans is wider than a Mac's. Story 228 pinned the
   viewport scale, so a reader can no longer pinch out to what left the screen.

   The track floor says both halves of that in one expression. `45%` keeps the
   pair from becoming a third column on a wide layout, where a percentage is
   always the larger term. `7rem` grows with the reader's text size while the
   viewport does not, so at 200% it outgrows the row and leaves one column;
   `min(…, 100%)` is what lets it, because a track floor wider than the grid
   would otherwise hang out exactly like the words did. Both terms are pure
   geometry, so the column count is the same on any platform's font — which is
   the property the ticket asks for, the runner's metrics being the wider ones.

   The cell's side padding stops growing with the text, so the room it would
   have taken goes to the words: at the default root size `min()` returns
   today's `.8rem` unchanged, and only a scaled-up root reaches the cap. */
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(max(7rem, 45%), 100%), 1fr)); gap: var(--space-2); margin: var(--space-4) 0; }
.summary-grid div { display: grid; justify-items: start; gap: .15rem; padding: .7rem min(.8rem, 12.8px); border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.summary-grid span { display: block; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-medium); }
.summary-grid strong { font-size: var(--text-xl); font-weight: var(--weight-strong); line-height: 1.1; }
/* The floor under all of it, and the half the lab could not see. A stat tile is
   a fixed width now, but the card's heading is one German word — the ticket
   photographed "Trainingsrunde." cut at the edge of an iPhone screen — and a
   block-level heading's own box still measures the row's full width with the
   word hanging out of it, so a box-based overhang report reads zero for it. The
   same idiom `.tile-label` and `.coach-area` already use answers both: hyphenate
   where the language offers a break, break inside the word where it does not.
   Both properties inherit, so one declaration per box covers the eyebrow, the
   heading, the button and every stat. At any size where the words fit, neither
   property does anything. */
.finished, .summary-grid { hyphens: auto; overflow-wrap: anywhere; }
.finished h2 { margin-bottom: var(--space-3); }
.diagnostic { margin: var(--space-4) 0; padding: var(--space-4); border: var(--hairline); border-radius: var(--radius-m); background: var(--surface-sunken); }

/* The setup card (Story 187) -----------------------------------------------
   Three labelled rows and one start control, built from the option and button
   styles that are already here rather than from a fourth visual language (AC1).
   `.setup-label` is a label, not a heading: the card carries one heading, and
   `role="group"` with `aria-labelledby` names each row without adding a level
   to the outline (AC8).

   The card opens straight onto its first decision, with no intro paragraph
   between them, so the heading takes the separation the rows take from each
   other rather than the tighter one a heading gives a paragraph. */
.setup-card { min-width: 0; }
.setup-card h2 { margin-bottom: var(--space-4); }
.setup-group { margin-bottom: var(--space-4); }
.setup-label { display: block; margin-bottom: var(--space-2); color: var(--text-body); font-size: var(--text-s); font-weight: var(--weight-strong); }
/* AC10: the card has to fit a 390x844 phone with bot duel chosen, so the rows
   above the start control give up their outer margins rather than their touch
   targets — the two the stylesheet would otherwise stack against each other. */
.setup-card .bot-choice { margin: 0; }
/* The option rows here hold two labels rather than the settings card's four, so
   they are wider and the marker needs no gap of its own on top of its margin.
   Without this "Instant coach" wraps onto a second line at 390px and the two
   halves of one choice stop being the same shape. */
.setup-card .choice { gap: 0; padding-inline: .75rem; }
.setup-card .settings-hint {
  min-width: 0;
  margin-top: var(--space-2);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.setup-card > .duel-explainer { margin-top: 0; }

.duel-explainer { min-width: 0; margin: var(--space-4) 0; padding: var(--space-4); border: 1px solid var(--accent); border-radius: var(--radius-m); background: var(--accent-wash); }
/* Story 187 made the explainer's title a paragraph — one heading per card — so
   the rule that made it read as one is here rather than on an element name. */
.duel-explainer-title { margin-bottom: var(--space-2); color: var(--text-body); font-size: var(--text-m); font-weight: var(--weight-strong); }
/* Story 185: the explainer's copy, not its title. The title is a `p` too, so
   the bare-type rule reached it at (0,1,1) and overwrote the `--text-body` it
   asks for with the supporting tone — the one contrast the title exists to
   carry, lost silently. */
.duel-explainer p {
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.duel-explainer p:not(.duel-explainer-title) { color: var(--text-supporting); }
.duel-explainer-actions,
.result-invitation-actions {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-2);
  margin-top: var(--space-3);
}
.duel-explainer-actions > button,
.result-invitation-actions > button {
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.bot-choice { display: grid; min-width: 0; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; margin: var(--space-4) 0; }
.bot-choice button { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: .2rem; border: var(--rule); background: transparent; overflow-wrap: anywhere; text-align: left; }
.bot-choice strong,
.bot-choice span { min-width: 0; max-width: 100%; hyphens: auto; overflow-wrap: anywhere; }
.bot-choice button[aria-pressed="true"] { border-color: var(--border-selected); background: var(--surface-selected); }
.bot-choice span { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
/* AC6: the chosen bot is marked by a filled ring, not only by the outline
   colour. Same marker as the settings switches below, for the same reason. */
.bot-choice button strong::before, .choice::before, .onboarding-choice::before { content: "○"; margin-right: .4rem; color: var(--text-hint); font-weight: var(--weight-strong); }
.bot-choice button[aria-pressed="true"] strong::before, .choice[aria-pressed="true"]::before, .onboarding-choice[aria-pressed="true"]::before { content: "●"; color: var(--accent); }
/* The training entry page (Story 467, recomposed by Story 475) --------------
   One card now: the offer, and the tutorial at its foot. The rows are the same
   row a bot choice is — name over hint, left-aligned — but never with a
   chosen-ring: they lead somewhere, they are not a selection.

   `.training-targets` keeps one column at every width, which is the whole width
   story for the two full-width rows: "Partieanalyse" is a single long word, and
   at 320px with 200% text a second column leaves it no line to stand on.
   `anywhere` is the floor under that, so a name always breaks before its row
   does.

   `.training-phases` is what Story 475 D2 asks for instead: the three phases
   share one tile, side by side. That is affordable only because D2b shortened
   the names — the row this replaces held "Mittelspiel"/"Middlegame", and no
   layout rule buys a third of 320px for a word that long at 200% text.

   The rung is measured, not chosen (D2a), and it is measured on real copy
   rather than on a stress: three whole names plus the two gaps need 15.80em of
   this card in German and 18.08em in English on the reference Mac, and 16.36em
   / 18.85em with Verdana forced — the stand-in for the CI runner's wider sans,
   used here as a second *face*, never as an additive stress that would inflate
   the threshold it is meant to set. **19em** therefore holds the widest of
   those four with room to spare, and it splits §8 as follows (card widths
   measured at V320 through V390, 2026-08-30, this Mac): V360 at 19.61em, V375
   at 20.59em and V390 at 21.58em stand above it and set the three side by side
   in both languages; V320 at 16.97em stands below it, and so does every 200%
   run — 7.43em at V320 up to 9.74em at V390, because doubling the text divides
   the same card into half as many ems. There the three stand under one another,
   each with the full width for its own name.

   The card is the query container, not the viewport, for the reason story 415's
   cell is: a container query resolves `em` against the container's own font
   size, so it moves with the reader's text-size setting. A `@media` query's
   `em` would not — media queries resolve font-relative units against the
   browser's initial font size, which §8's T200 never touches — so the same
   320px screen would ask the same question at both text sizes and answer it
   with the wrong shape at one of them. The `@container` block stands after the
   rule it overrides because a container query adds no specificity. */
.play-greeting { margin: 0 0 var(--space-1); font-weight: 600; overflow-wrap: anywhere; }
.play-training-rating { display: inline-block; margin-bottom: var(--space-3); font-size: var(--text-s); }
.play-training-rating strong { font-variant-numeric: tabular-nums; }
.training-rating-card { margin-bottom: var(--space-4); overflow-wrap: anywhere; }
.training-rating-card ol { padding-inline-start: var(--space-5); }
.training-rating-card li + li { margin-block-start: var(--space-2); }
.training-menu { container: training-entry / inline-size; }
.training-menu h2 { margin-bottom: var(--space-4); }
.training-targets { display: grid; gap: var(--space-2); }
.learn-menu h2 {
  margin-bottom: var(--space-4);
  /* Story 518 AC10: the long-locale heading is the Learn card's widest
     min-content item at T200. Lower that floor at the heading itself so the
     card grows downward instead of making the document wider than a phone. */
  hyphens: auto;
  overflow-wrap: anywhere;
}
.learn-targets { display: grid; gap: var(--space-2); }
.learn-target {
  display: flex;
  min-width: 0;
  flex-direction: column;
  align-items: flex-start;
  gap: .2rem;
  border: var(--rule);
  background: transparent;
  hyphens: auto;
  overflow-wrap: anywhere;
  text-align: left;
}
.learn-target span { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.learn-back { margin-bottom: var(--space-3); }
.beginner-rules { min-width: 0; container: beginner-rules / inline-size; }
.beginner-rules h2,
.beginner-rules-copy,
.beginner-rules-note,
.beginner-rules-placement-label,
.beginner-rules-placement-note {
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.beginner-rules-copy { margin-bottom: var(--space-4); }
.beginner-rules-content { display: grid; min-width: 0; gap: var(--space-3); }
.practice-surface { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); gap: var(--space-3); }
.practice-note, .practice-status, .practice-held, .practice-roll-count, .practice-result {
  margin: 0; min-width: 0; hyphens: auto; overflow-wrap: anywhere;
}
.practice-note, .practice-held, .practice-roll-count { color: var(--text-supporting); font-size: var(--text-s); }
.practice-status, .practice-result { font-weight: var(--weight-medium); }
.practice-dice { margin: 0; gap: 4px; grid-template-columns: repeat(auto-fit, minmax(min(44.8px, 100%), 1fr)); }
.practice-dice .die { min-height: 44.8px; min-width: 44.8px; }
.practice-surface > button { min-width: 0; width: 100%; overflow-wrap: anywhere; }
[data-view="compact"] .practice-scorecard {
  flex: none; overflow: visible; display: flex; flex-direction: column; gap: var(--space-2);
}
[data-view="compact"] .practice-scorecard .scorecard-standings {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
.beginner-rules-note {
  margin: 0;
  padding: var(--space-3);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
  color: var(--text-supporting);
}
.beginner-rules-actions {
  display: grid;
  min-width: 0;
  gap: var(--space-2);
  margin-top: var(--space-5);
}
.beginner-rules-actions > button { width: 100%; min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
@container beginner-rules (min-width: 30em) {
  .beginner-rules-actions { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.beginner-rules-interaction,
.beginner-rules-worked {
  display: grid;
  min-width: 0;
  /* Story 519 AC5: a grid with an implicit `auto` column inherits the widest
     child's min-content floor. The worked-example heading measured 235px in
     the hosted long/T200 run while its content column was only 226px. Make the
     available inline size authoritative so every child grows downward. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
}
.beginner-rules-dice {
  grid-template-columns: repeat(auto-fit, minmax(min(2.8rem, 100%), 1fr));
  margin: 0;
}
.beginner-rules-hold-status { margin: 0; color: var(--text-supporting); font-size: var(--text-s); }
.beginner-rules-examples-title {
  min-width: 0;
  margin: var(--space-2) 0 0;
  font-size: var(--text-m);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.beginner-rules-placements { display: grid; min-width: 0; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.beginner-rules-placements li {
  display: grid;
  min-width: 0;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--space-1) var(--space-3);
  align-items: baseline;
  padding: var(--space-3);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
}
.beginner-rules-placement-points {
  min-width: 0;
  font-variant-numeric: tabular-nums;
  hyphens: auto;
  overflow-wrap: anywhere;
  text-align: right;
  white-space: normal;
}
.beginner-rules-placement-note { grid-column: 1 / -1; color: var(--text-supporting); font-size: var(--text-xs); }
/* At V320/T200 the container's content box is 7.0625em wide. Keeping the two
   cells beside each other at that magnification would technically contain the
   row only by reducing its label to a zero-width track, which paints one letter
   per line. Stack the pair at the next round rung; V320/T100 is 16.125em wide
   and keeps the ordinary two-column scan. */
@container beginner-rules (max-width: 10em) {
  .beginner-rules-placements li {
    grid-template-columns: minmax(0, 1fr);
    align-items: start;
  }
  .beginner-rules-placement-points { text-align: left; }
  .beginner-rules-placement-note { grid-column: 1; }
}

/* Story 520: one reusable rules-delta module and one neutral result stack. */
.sixtythree-delta { min-width: 0; container: sixtythree-delta / inline-size; }
.sixtythree-delta h2,
.sixtythree-delta h3,
.sixtythree-delta-copy,
.sixtythree-delta-note,
.sixtythree-delta-example-copy,
.sixtythree-delta-rule-list {
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.sixtythree-delta h3 { margin-top: var(--space-3); }
.sixtythree-delta-copy { margin-bottom: var(--space-4); }
.sixtythree-delta-content { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
.sixtythree-delta-rule-list { display: grid; gap: var(--space-2); margin: 0; padding-inline-start: 1.25em; }
.sixtythree-delta-note { margin: 0; color: var(--text-supporting); }
.sixtythree-delta-example {
  display: grid;
  min-width: 0;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-2);
  padding: var(--space-3);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
}
.sixtythree-delta-example-title,
.sixtythree-delta-example-copy { min-width: 0; margin: 0; hyphens: auto; overflow-wrap: anywhere; }
.sixtythree-delta-dice-line { display: grid; min-width: 0; gap: var(--space-1); }
.sixtythree-delta-dice-label {
  min-width: 0;
  color: var(--text-secondary);
  font-size: var(--text-xs);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.sixtythree-delta-dice { gap: min(var(--space-2), 8px); margin: 0; }
.sixtythree-delta-actions {
  display: grid;
  min-width: 0;
  gap: var(--space-2);
  margin-top: var(--space-5);
}
.sixtythree-delta-actions > button { width: 100%; min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
@container sixtythree-delta (min-width: 24em) {
  .sixtythree-delta-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Story 322: the built-only Coach orientation module. */
.coach-orientation { min-width: 0; container: coach-orientation / inline-size; }
.coach-orientation h2,
.coach-orientation h3,
.coach-orientation-copy,
.coach-orientation-note,
.coach-orientation-item-title,
.coach-orientation-item-copy {
  min-width: 0;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.coach-orientation h3 { margin-top: var(--space-3); }
.coach-orientation-copy { margin-bottom: var(--space-4); }
.coach-orientation-content { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); gap: var(--space-3); }
.coach-orientation-ladder,
.coach-orientation-bots { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.coach-orientation-verdict { display: flex; min-width: 0; gap: var(--space-2); align-items: baseline; }
.coach-orientation-verdict .verdict-mark { color: var(--tone, var(--text-supporting)); font-family: var(--font-sans); }
.coach-orientation-verdict-label { min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
.coach-orientation-field,
.coach-orientation-concept,
.coach-orientation-mode,
.coach-orientation-destination,
.coach-orientation-bot {
  display: grid;
  min-width: 0;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-1);
  padding: var(--space-3);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
}
.coach-orientation-item-title { margin: 0; }
.coach-orientation-item-copy { margin: 0; color: var(--text-secondary); }
.coach-orientation-note { margin: 0; color: var(--text-supporting); }
.coach-orientation-balanceboard { min-height: 44.8px; width: 100%; min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
.coach-orientation-actions {
  display: grid;
  min-width: 0;
  gap: var(--space-2);
  margin-top: var(--space-5);
}
.coach-orientation-actions > button { width: 100%; min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
@container coach-orientation (min-width: 24em) {
  .coach-orientation-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.result-invitations { display: grid; gap: var(--space-2); margin: var(--space-4) 0; }
.result-invitation {
  min-width: 0;
  padding: var(--space-3);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--accent-wash);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.result-invitation p { margin: var(--space-1) 0 0; color: var(--text-supporting); }
/* Story 522: this invitation carries three actions where story 520's carries
   two, so it does not reuse that pair's two-column grid — three in two columns
   leaves one action alone on a second row, and each column is half of a 320px
   card at 200% text. One column at every width is the arrangement the governed
   V320/T200 states are measured in. A rule of its own rather than an override
   of the pair's, so neither group can quietly re-lay the other. */
.result-discovery-actions {
  display: grid;
  min-width: 0;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-2);
  margin-top: var(--space-3);
}
.result-discovery-actions > button { min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
.training-group { margin-top: var(--space-4); }
/* The group says its name and its "not yet" on one line while both fit, and
   wraps rather than squeezing either — the hint is a whole sentence, so it is
   the half that goes to the second line. */
.training-group-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 var(--space-2); }
.training-group-head .setup-label { margin-bottom: var(--space-2); }
.training-group-hint { margin-bottom: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); }
.training-phases { display: grid; gap: var(--space-2); }
@container training-entry (min-width: 19em) {
  .training-phases { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Story 475 D3: the repeat, at the foot of the offer and visibly apart from it
   — the rule above it is what says "this is not one of the categories". */
.training-tutorial { display: grid; justify-items: start; gap: var(--space-2); margin-top: var(--space-5); padding-top: var(--space-4); border-top: var(--rule); }
/* A button lays its label out with no break opportunity of its own, so one
   long word makes the control's *min-content* wider than the card it stands
   in: at 200% text in the long locale its box ran to 375px on a 320px screen,
   69px past the card's right edge — measured with each candidate rule removed
   in turn. Breaking inside the word is what brings it back; `max-width: 100%`
   alone leaves it exactly where it was, because the width the control took was
   its minimum and not a percentage of anything. The same pair the rows below
   carry, for the same reason. */
.training-tutorial > button { overflow-wrap: anywhere; hyphens: auto; }
.training-target {
  display: flex;
  min-width: 0;
  flex-direction: column;
  align-items: flex-start;
  gap: .2rem;
  border: var(--rule);
  background: transparent;
  hyphens: auto;
  overflow-wrap: anywhere;
  text-align: left;
}
.training-target span { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
/* The way back off a target's surface, above the surface's own heading — it is
   the first thing in the card because it is the first thing a reader who
   opened the wrong door needs. */
.training-back { margin-bottom: var(--space-3); }
/* The scorecard grid (Story 155) -------------------------------------------------
   Sixteen tiles in one grid: the thirteen categories, and the three summaries
   that used to exist nowhere at all — the upper half against 63, the bonus,
   and the total (AC3, AC4). Story 203 makes three columns the board's one shape
   at every width: the summary row closes the upper half and the seven lower
   categories follow as one block. It goes no narrower, because another column
   change would be bought from the touch target rather than the layout (AC10). */
/* Flex, not a two-row grid: the solo board has no legend above it, and a
   fixed second row would leave the grid in the first one with the height it
   was supposed to grow into standing empty below. */
.scorecard-board {
  display: flex;
  flex-direction: column;
  gap: .3rem;
  min-height: 0;
  /* Story 229: this parent, rather than the grid itself, owns the query so
     the narrow-text rule can rearrange the grid's columns. A query container
     cannot style itself without creating a sizing cycle.

     Story 603 names it. An unnamed `@container` query binds to the nearest
     ancestor container, and since story 412 a lower-matrix cell is one
     (`matrix-cell`), so a rule inside the board's width rung that targets
     something *inside* a cell — a name, a numeral — is measured against the
     cell instead. Measured on the built app: the type rung below reached the
     standings row and the cells under the die faces at V390 and left the two
     lower rows at 15.2px while the rest read 18.4. Every existing rule in that
     rung targets a tile or a track and is unaffected; the name is what lets a
     new one say which box it means. */
  container: scorecard-board / inline-size;
}

/* Category help stays mounted in the game head while score cells rebuild.
   The invitation marks this 44px control without adding a board row. */
.scorecard-help-open {
  position: relative;
  flex: 0 0 min(2.8rem, 44.8px);
  width: min(2.8rem, 44.8px);
  min-height: min(2.8rem, 44.8px);
}
/* The one-time proactive invitation is a mark on the opener, never a row of its
   own: it cannot push dice or fields, and it never takes focus. */
.category-help-mark {
  position: absolute;
  inset-block-start: min(.35rem, 5.6px);
  inset-inline-end: min(.35rem, 5.6px);
  width: min(.55rem, 8.8px);
  height: min(.55rem, 8.8px);
  border-radius: var(--radius-round);
  background: var(--accent);
  outline: var(--border-width) solid var(--surface-card);
  pointer-events: none;
}
/* P4 (#746, study E6/S3): Category help is a native modal dialog in the top
   layer. Below the rail breakpoint it is a bottom sheet; from it, a card hung
   below its opener (`category-help.ts` sets the two anchor properties). The
   scrim is the foundation's ink alpha, as behind the term help. */
.category-help-dialog {
  /* The card's anchor; `category-help.ts` overwrites both inline on open. */
  --category-help-top: var(--space-3);
  --category-help-right: var(--space-3);
  box-sizing: border-box;
  position: fixed;
  inset: auto 0 0;
  width: 100%;
  max-width: 100%;
  max-height: calc(100dvh - max(var(--space-6), env(safe-area-inset-top)));
  margin: 0;
  padding: 0;
  border: var(--rule);
  border-block-end: 0;
  border-radius: var(--radius-m) var(--radius-m) 0 0;
  background: var(--surface-card);
  color: var(--text-body);
  overflow: hidden;
}
.category-help-dialog[open] { display: flex; }
.category-help-dialog::backdrop { background: var(--scrim); }
/* The sheet fills every painted pixel of the dialog box, so a click whose
   target is the dialog itself can only have landed on the scrim. */
.category-help-sheet {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  flex-direction: column;
  gap: min(var(--space-2), 8px);
  padding: var(--space-3) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom));
}
@media (min-width: 48rem) {
  .category-help-dialog {
    inset: var(--category-help-top) var(--category-help-right) auto auto;
    width: min(26rem, calc(100vw - 2 * var(--space-3)));
    max-height: min(38rem, calc(100dvh - 2 * var(--space-3)));
    border-block-end: var(--rule);
    border-radius: var(--radius-m);
  }
}
.category-help-panel-heading {
  display: flex;
  flex: 0 0 auto;
  min-width: 0;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
}
.category-help-panel-heading h3 { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.category-help-panel-heading button {
  flex: 0 1 auto;
  min-width: 0;
  min-height: min(2.8rem, 44.8px);
  padding: min(.4rem, 6.4px) min(.7rem, 11.2px);
  hyphens: auto;
  overflow-wrap: anywhere;
}
/* One scroll container for the whole sheet: all thirteen choices sit in the
   flow above the explanation instead of in a nested scroller. */
.category-help-content {
  display: flex;
  flex: 1 1 auto;
  min-height: 0;
  flex-direction: column;
  gap: var(--space-3);
  overflow: auto;
  overscroll-behavior: contain;
}
.category-help-options {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 7rem), 1fr));
  gap: min(var(--space-1), 4px);
}
.category-help-choice {
  min-width: 0;
  min-height: min(2.8rem, 44.8px);
  justify-content: flex-start;
  padding: min(.45rem, 7.2px);
  border: var(--rule);
  background: var(--surface-card);
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  text-align: left;
  hyphens: auto;
  overflow-wrap: anywhere;
}
.category-help-choice[aria-pressed="true"] {
  border-color: var(--border-selected);
  background: var(--surface-selected);
  color: var(--text-selected);
  font-weight: var(--weight-strong);
}
.category-help-explanation {
  min-width: 0;
  padding-inline: min(var(--space-1), 4px);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.category-help-explanation h4 { margin: 0 0 var(--space-2); font-size: var(--text-m); }
.category-help-explanation p { margin: 0 0 var(--space-2); }
.category-help-hint { color: var(--text-body); font-weight: var(--weight-strong); }
.category-help-filled { color: var(--text-supporting); font-size: var(--text-s); }
.category-help-possible {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  padding-block: var(--space-2);
  border-block: var(--hairline);
  color: var(--text-body);
}
.category-help-possible strong { font-size: var(--text-l); }
.settings-toggle {
  display: flex;
  min-width: 0;
  min-height: 2.8rem;
  align-items: center;
  gap: var(--space-2);
  color: var(--text-body);
  font-weight: var(--weight-strong);
  hyphens: auto;
  overflow-wrap: anywhere;
  cursor: pointer;
}
.settings-toggle input {
  flex: 0 0 auto;
  width: min(2.8rem, 44.8px);
  height: min(2.8rem, 44.8px);
  accent-color: var(--accent);
}
.settings-toggle input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }
/* Story 186: the board is the decision surface, so its rows share the room left
   by the meta line, dice and action instead of handing that room to an empty
   dice track. `min-content` makes each tile's content and 2.8rem touch floor
   authoritative before the `fr` ceilings distribute surplus. If the content
   floors no longer fit, the board scrolls without text crossing a tile
   boundary.

   Story 232: the final row used to take the larger share, because the coach
   tile in it was 10.5rem tall. Its reserve is 6.25rem now, and an `fr` ceiling
   there would leave that much of the row standing empty beside Knaller — the
   coach is the one item whose height is definite and contained, so the row is
   sized to it and the five category rows share what the shrink released. */
.scorecard-grid {
  display: grid;
  flex: 1 1 auto;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: repeat(5, minmax(min-content, 1fr)) min-content;
  gap: var(--space-1);
  min-height: 0;
  overflow: auto;
  scrollbar-gutter: stable;
  /* Stories 226 and 229: the board wrapper is the query container for the
     `@container` rule at the end of this block. Section 5
     forbids sideways scrolling "at any supported width or text size", and this
     grid is the surface's only scroll box, so it is where a text size the
     columns cannot carry turns into one. A media query cannot see that case:
     `em` in a media query resolves against the browser's *initial* font size,
     never the reader's enlarged one, so the same query matches identically at
     100% and 200% text. Measured in the lab, `(max-width: 22em)` reports the
     same value for both at all five viewports. A container query resolves `em`
     against the container's own font, which does scale. */
}

/* One layout, both surfaces (Story 204 AC5). The label takes a line of its own
   and the values stand beside each other under it, so the duel's second value
   is one more child rather than a second display mode — and the tile that does
   not fit both on one line takes a second line instead of cutting one off.
   `justify-content` is stated because the shared `button` rule centres its
   content: that is what used to hold a category tile's label and numeral away
   from the left edge the summary tiles, which are plain `div`s, already stood
   on (AC1). */
.scorecard-tile {
  display: flex;
  flex-wrap: wrap;
  align-content: center;
  align-items: baseline;
  justify-content: flex-start;
  column-gap: .3rem;
  row-gap: .05rem;
  min-width: 0;
  /* AC10: the same floor every other control in this app stands on. */
  min-height: 2.8rem;
  padding: .25rem .35rem;
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-card);
  color: var(--text-body);
  font: inherit;
  font-weight: var(--weight-regular);
  /* Stated after `font`, whose shorthand resets it to the body's 1.5. The
     content-aware grid track, rather than clipping, pays for a wrapped label. */
  line-height: 1.2;
  text-align: left;
}
button.scorecard-tile:not(:disabled):hover { border-color: var(--accent); background: var(--surface-card); }
/* Story 365: the board's three grounds. An open field is a card, an entered
   field carries its B3 entry role, and a result — the classic summary tiles,
   the standings row, the Lower sum — stands on the B3 summary role (P4 #746;
   the page tone before). Painted, not transparent: the stage is itself a card,
   so transparency would hand a result the open field's own tone (decision on
   the ticket, Felix 2026-08-25). The border
   stays transparent rather than removed so the box keeps its size. */
.scorecard-tile.is-summary { border-color: transparent; background: var(--surface-score-summary); }
.scorecard-tile:disabled { opacity: 1; background: var(--surface-sunken); }
/* Story 186: the larger rows carry larger reading type as well as air. Long
   category names may use a second line: hiding "Three of a kind" behind an
   ellipsis while its tile has spare height would waste the room twice. */
.tile-label { flex: 0 0 100%; color: var(--text-secondary); font-size: var(--text-m); font-weight: var(--weight-medium); hyphens: auto; line-height: 1.15; overflow-wrap: anywhere; white-space: normal; }
/* Story 215 A5: the open marker and a scored numeral occupy the same line box,
   so changing a tile's state cannot change its grid track. */
.tile-value { display: flex; align-items: baseline; gap: .2rem; min-height: 1.2em; color: var(--accent); font-size: var(--text-l); font-weight: var(--weight-strong); line-height: 1.2; white-space: nowrap; }
/* Story 233 AC1: Total renders exactly two full-width lines — "Total <score>" then
   "EFS <forecast>" — never side by side, so both its values carry a label and
   `scorecardTile` skips the header it would otherwise print once for the
   tile (see the comment there). The explicit minimum predates this change and
   is kept unchanged: shrinking it to match the shorter two-line content is a
   separate, measured change the geometry suite would need to re-baseline. */
.scorecard-tile.is-total { min-height: 5.5rem; column-gap: .45rem; }
/* Only Total's values ever carry a `label` (duel's total tile does not), so
   this reads as Total-specific despite the generic-looking class name: each
   labelled value claims its tile's whole row — no side-by-side stacking —
   with the label beside its numeral on the one line it names, rather than
   stacked above it.

   Story 339: the pair may wrap — section 7's rung 2, the first rung a numeral
   leaves available: it is no catalog string to shorten, it cannot break, and
   shrinking it is what rung 5 forbids. Where label, gap and numeral outgrow
   the line — a third of a 320px board at 100% text: past the border in the
   long pseudo-locale, into the padding in de and en — the numeral takes the
   next line whole. The tile's own box holds still: the extra line lands
   inside the 5.5rem minimum above. */
.tile-value.is-labelled { display: flex; flex: 1 1 100%; flex-wrap: wrap; align-items: baseline; gap: .3rem; min-width: 0; white-space: normal; }
.tile-value-label { flex: 0 0 auto; color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); line-height: 1.05; white-space: nowrap; }
/* AC6: the mark is what survives colour rendering being switched off — `✓` for
   a category that is entered, `○` for one that is still open. Every tile's
   aria-label says the same thing in words, so neither carrier stands alone.
   Story 204 AC2 moved it behind the numeral rather than dropping it: in front
   it was the one reason a category tile's number started further right than a
   summary tile's, which has no mark to make room for. */
.tile-mark { font-size: .78em; }
/* Story 686: the category just scored, while its decision can be taken back. It is
   a live button on the sunken ground a written cell keeps; the accent edge
   and a small glyph in the cell's top-right corner say so, and the tile's
   accessible name carries the action. The glyph is the stylesheet's, not the
   cell's content (story 316 AC3: no mark in a compact cell — Felix,
   2026-09-07, on the take-back), so the numeral stays alone and centred
   (story 362) and the ink probe measures the same cell it always did. */
button.scorecard-tile.is-take-back { position: relative; background: var(--surface-sunken); border-color: var(--border-score-takeback); }
button.scorecard-tile.is-take-back::after {
  content: "\21B6";
  position: absolute;
  top: .1em;
  right: .3em;
  line-height: 1;
  font-size: .78em;
  color: var(--accent);
}
.scorecard-tile:disabled .tile-value { color: var(--text-body); }
/* The accent on a value means "this is what the dice on the table would score
   here". The three summaries are not offers, so they are read in ink. */
.scorecard-tile.is-summary .tile-value { color: var(--text-body); }
/* The duel's bot value uses the supporting text role; the retained layout's
   size, weight, vertical order and accessible name carry ownership. */
.tile-value.is-opponent { color: var(--text-hint); font-weight: var(--weight-medium); }
.scorecard-tile:disabled .tile-value.is-opponent { color: var(--text-hint); }
/* Story 361: the ghost preview — what this roll would enter, standing in an
   open cell. Two carriers apart from an entered value, in the Story 44
   AC6 family: muted colour and a lighter weight, and no extra glyph, because
   the V320 upper cell has no width to spare. The weight has to land on the
   numeral itself — the browser's own `strong` is what makes every entered
   numeral bold, and a weight on the wrapper never reaches it. The bot's line
   and entered cells never carry the class. */
.tile-value.is-ghost { color: var(--text-score-preview); }
.tile-value.is-ghost strong { font-weight: var(--weight-regular); }

/* Story 470: the Balanceboard's committed-row difference against par. The sign
   lives in the text the builder writes (catalog-built, like the bonus pace),
   so this rule carries only the smaller, quieter type — never the reading.
   Committed tiles are always disabled, and the disabled recolour above outranks
   a two-class selector, so the quieter ink is restated at the higher
   specificity rather than left to source order. */
.tile-value.is-delta { color: var(--text-secondary); font-size: var(--text-m); }
.tile-value.is-delta strong { font-weight: var(--weight-medium); }
.scorecard-tile:disabled .tile-value.is-delta { color: var(--text-secondary); }

/* Story 226: the summary row's own text size against its own width.
   The Total tile carries two quantities (section 4 of the brief, story 205 AC9)
   and the second of them is an unbreakable numeral: `endProjection.toFixed(1)`
   is up to five glyphs at `--text-l`, with no hyphen, space or soft break
   anywhere in it. Section 7's ladder — shorten, wrap, rearrange, scroll, never
   shrink the token — has no rung that a numeral can use: it is not a catalog
   string to shorten, it cannot wrap, and shrinking it is what rung 5 forbids.
   Rearranging is the rung that is left.

   The threshold is measured, not chosen. A three-column tile holds the widest
   projection when the board is at least `3 x (numeral + tile padding and
   border) + 2 x gap` wide; every term there is set in rem, so the whole sum is
   a constant in the container's own `em` — 14.06em at 100% text and 13.87em at
   200%, identical at all five viewports. Below 14em the third of a board a tile
   gets can no longer hold the number, whatever made the board that narrow.

   Measured before this rule, `npm run ui-lab:matrix`: the numeral hung 23px
   past the viewport on V390, 33px on V360 and 47-52px on V320 at 200% text, on
   all three running fixtures rather than only the verdict the ticket found it
   on. After it, all nine of those runs read 0, and the 122 runs at 100% text
   keep byte-identical geometry and byte-identical screenshots — the query does
   not match there.

   No zone moves: section 3 reserves position changes for the 48rem breakpoint,
   and every zone here keeps its place — this is one tile's placement inside Z2.

   Bonus takes the freed pair of cells rather than leaving one empty, which is
   also where its label stops hyphenating. Giving all three summary tiles a row
   each was measured too and is worse: the extra rows squeeze the `1fr` tracks
   until three category labels clip instead. Story 229 uses two columns only
   while this text-scaled container is narrow: `0/63` keeps its complete token
   and Z2 uses its allowed vertical scroll extent for the additional rows,
   instead of squeezing every row. The block sits here, after the tile rules
   above, because it overrides them. */

@container (max-width: 14em) {
  .scorecard-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: repeat(10, minmax(min-content, 1fr)) minmax(min-content, 1.85fr);
  }
  .scorecard-tile.is-upper,
  .scorecard-tile.is-bonus { grid-column: 1 / -1; }
  .scorecard-tile.is-total { grid-column: 1 / -1; }
}

/* Story 205: seventeen grid items fill the board's eighteen cells. The coach
   is the last item and spans the two cells beside Knaller. `contain: size`
   removes its changing copy from row sizing; overflow belongs here, so resting,
   verdict and failure states leave the board, dice and action geometry fixed. */
.coach-area {
  position: relative;
  display: flex;
  flex-direction: column;
  grid-column: span 2;
  contain: size;
  /* D2's reserve, re-measured after Story 232 took the eyebrow and the two
     field names off this tile. The tallest regular face is still the verdict,
     and its worst case is the narrow one: at 320px and 360px the merged first
     line wraps to two rows above a two-line best alternative, which is 98px
     against a 16px root — the largest the lab measured over section 8's
     viewports in both catalogs. Rounded up, that is the 6.25rem below, 4.25rem
     less than Story 215 reserved. A definite reserve keeps resting, verdict and
     error copy out of grid row sizing; the co-occurrence of a verdict and a
     standing warning uses this tile's own scroll, made visible by the layers
     below. */
  height: 6.25rem;
  min-width: 0;
  min-height: 0;
  padding: .35rem .55rem;
  overflow: auto;
  border: var(--hairline);
  border-radius: var(--radius-s);
  /* Story 232: D2 lets this one tile scroll inside its reserve, and on a real
     device that exemption read as a finished sentence — the standing storage
     message stopped at the tile edge with nothing saying more was there
     (screenshot, 2026-08-20). These four layers are the CSS-only scroll
     shadow, so the affordance follows the scroll position instead of a
     measurement this renderer would have to repeat: the two `local` covers
     travel with the content and hide the `scroll` shadow at whichever edge is
     already at its end. Nothing is painted when everything fits. */
  background:
    linear-gradient(var(--surface-sunken) 40%, color-mix(in oklab, var(--surface-sunken), transparent 100%)) local top / 100% 1.2rem no-repeat,
    linear-gradient(color-mix(in oklab, var(--surface-sunken), transparent 100%), var(--surface-sunken) 60%) local bottom / 100% 1.2rem no-repeat,
    linear-gradient(var(--border-control), color-mix(in oklab, var(--border-control), transparent 100%)) scroll top / 100% .55rem no-repeat,
    linear-gradient(color-mix(in oklab, var(--border-control), transparent 100%), var(--border-control)) scroll bottom / 100% .55rem no-repeat,
    var(--surface-sunken);
  color: var(--text-supporting);
  scrollbar-width: thin;
  /* Story 220: this is the narrowest column the app has — at 320px the tile
     reads in under 12rem — and the one field name left on it after Story 232,
     `verdict.field.best`, is a single uppercase word with no space to break
     at. Section 5 of the brief forbids sideways scrolling at every supported
     width, and D2's exemption above is for downward scrolling only. Section 7
     orders the responses shorten-then-wrap, and shortening is not available
     here: that key resolves a governed glossary term, so its wording belongs
     to the glossary rather than to this surface. Wrapping is therefore the
     response, in the same idiom `.tile-label` already uses: hyphenate where
     the language offers a break, and break inside the word where it does not.
     Both properties inherit, so the verdict, the warning and the error copy
     are covered by one declaration. */
  hyphens: auto;
  overflow-wrap: anywhere;
}
.coach-area.has-error { border-color: var(--danger); }
/* When a verdict and a failure coexist, the judgement keeps the initial reading
   position and the failure follows it directly (see the `order` values below);
   what runs past the reserve is the best alternative. This badge marks the
   failure at the tile's corner whichever part of it the reader has scrolled to. */
.coach-area.has-error::after { content: "!"; position: absolute; top: .2rem; right: .3rem; color: var(--danger); font-size: var(--text-xs); font-weight: var(--weight-strong); }
.coach-rest { order: 3; margin: 0; font-size: var(--text-s); line-height: 1.3; }
.coach-error { order: 1; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .2rem .4rem; margin-top: .25rem; color: var(--danger); font-size: var(--text-s); line-height: 1.3; }
.coach-error strong::before { content: "! "; }
.coach-error[hidden], .coach-verdict[hidden], .coach-rest[hidden] { display: none; }
/* Story 232 AC3: a verdict and a standing storage warning share this reserve,
   and D2 sizes the reserve for the verdict alone — so at 320px one of the two
   has to be the part that scrolls. It is the best alternative: a warning that
   the game is no longer being saved outranks what the coach would have played.
   `display: contents` makes the panel's two blocks items of the tile's own
   column, so the warning can stand between them. The selector is scoped rather
   than bare because `.coach-verdict[hidden]` above would otherwise be competing
   with it at equal specificity; `:not([hidden])` cannot match a hidden panel at
   all, so an absent verdict is still `display: none`. Markup order is
   untouched, so what is read aloud is still verdict-then-warning, and the
   `order` values live on the rules those blocks already have. */
.coach-verdict:not([hidden]) { display: contents; }
/* Story 232 AC1: one line for the judgement and what it cost. Neither half
   depends on the other being read: the classification carries its own mark and
   word, and the number is named by the `.visually-hidden` term beside it. */
.verdict-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .1rem .35rem; margin: 0 0 .3rem; }
/* The dot belongs to the judgement, not to the number: at 320px the line wraps
   between the two, and a separator carried by the second half starts that line
   with a stray mark. Trailing the first half, it ends the line instead. */
.verdict-line .verdict-class::after { content: "·"; color: var(--text-hint); }
.coach-area .verdict-line strong { font-size: var(--text-s); font-weight: var(--weight-strong); }
/* The number is counted, the word beside it is read. */
.verdict-loss-word { color: var(--text-hint); font-family: var(--font-sans); font-weight: var(--weight-medium); }
/* Story 416: the marker closes the line, glued to the loss word in markup
   (no whitespace between them) so it can never wrap onto a line of its own —
   the gap here is what the missing text space would have given it. Toned
   directly rather than through a shared parent (`paintVerdictClass` sets this
   class beside `.verdict-class`, not inside it), and its font resets off
   `.verdict-loss`'s mono/tabular digits, which the glyph itself is not. */
.verdict-line .verdict-mark { color: var(--tone, var(--text-supporting)); font-family: var(--font-sans); margin-inline-start: .35rem; }
.coach-area .verdict-grid { order: 2; grid-template-columns: auto minmax(0, 1fr); gap: .2rem .55rem; padding: 0; border: 0; }
.coach-area .verdict-grid .wide { grid-column: 1 / -1; }
.coach-area .verdict-grid > div > span { font-size: var(--text-xs); }
.coach-area .verdict-grid strong { font-size: var(--text-s); }

/* The play surface (Story 155 AC2) ----------------------------------------------
   Four named areas, laid out in one column on a phone — the board above the
   dice, because the dice are read against the board — and reordered at the
   wide breakpoint into a board beside them. One markup tree either way. */
.game-stage {
  display: grid;
  gap: .8rem;
  grid-template-areas: "meta" "board" "dice" "actions";
  /* Story 186: only the board is flexible. Meta, dice and action size to what
     they render; the board receives the remaining viewport height. Its zero
     floor preserves the one-screen contract under scarcity: the grid inside
     it scrolls before the dice or action is pushed out of view. */
  grid-template-rows: min-content minmax(0, 1fr) min-content min-content;
  min-height: 0;
}
/* The same trap `.app-nav` fell into in Story 154: `display: grid` above outranks
   the user agent's own `[hidden] { display: none }`, so the attribute the two
   controllers set to close a surface stops closing it. */
.game-stage[hidden] { display: none; }
.game-stage > .game-meta { grid-area: meta; }
.game-stage > .scorecard-board { grid-area: board; }
/* Centred in the row rather than pinned to its top edge: the height the board
   gave back is air around the dice, not a gap between them and the button. */
.game-stage > .dice, .game-stage > .duel-roll-area { grid-area: dice; align-self: center; margin: 0; }
.game-stage > .solo-actions, .game-stage > .duel-actions { grid-area: actions; }
/* Story 470: the Balanceboard stage adds one row — the balance line between
   the board and the dice. The stage lives inside a scrolling training card
   rather than under `data-playing`, so every row is content-sized and the
   card's own vertical scroll is the overflow path (the analysis family's
   contract, not the one-screen rule). The two status paragraphs are the
   board's own children and never were stage rows; the sentence that stood here
   called them absolutely positioned like the solo stage's, and measured they
   are `static` — the withheld-companion line takes 69.1px of the board's flex
   column and is meant to.

   Story 500 (D14) revises that contract for this surface at the ordinary text
   size, and revises it **for the one viewport it reaches** rather than as a
   promise (Felix, 2026-08-31, on the measurement below). At 100% text the
   running face fits without vertical scrolling at **V390**: measured as content
   height against viewport height with the tab bar's own reserve included, the
   document stands at 799px (`running`) and 823.8px (`running-static`) in German
   against 844px, and lower in English. Everywhere else it still scrolls, and
   that is the shipped answer rather than a miss:

   - V320 is 349px short and V360 149px short (`running`, German). The height
     left is the board's own bands — D6 and D7 — which this slice was told not
     to reopen until the composed face could be looked at.
   - **Both landscape viewports still scroll too**, which is where AC3 was
     written on an unmeasured assumption. VWide is 11.3px short. VLand cannot be
     reached at all: its viewport is 390px tall — shorter than a phone's — and
     this board is 371px there, so nothing a head row, a balance line and a
     five-die row could do fits in the 327.6px the tab bar leaves.

   At 200% text the card scrolls at every viewport, cleanly, and nothing is
   unreachable. `docs/product/ui-brief-statistics-replay.md` carries the same
   sentence with the same numbers.

   Story 555 makes the table the Balanceboard's one view and folds its dice
   into a column on a short screen — the two `@media` blocks after the
   container folds below carry the capped row, the height rule, its
   derivation and the measured table. The sheet renders only for a stored preference now, and
   everything in this comment about it still holds there.

   By id, deliberately: the play surfaces re-template `.game-stage` for wide
   viewports and for `data-playing`, at higher specificity than one class —
   measured before this rule went by id, the 48rem compact rearrangement won
   and the line dropped into an implicit row below the actions. The id is what
   states that this stage opts out of every play-mode rearrangement, present and
   future. Since story 500 it borrows one of them on purpose — story 319's
   six-track row — and writes it out here rather than inheriting it, so the
   opt-out stays total. */
/* The card's name is one word with no break opportunity, and at 200% text on
   a 320px screen "Balanceboard" is wider than the content box — the h2 stayed
   inside its track while its ink ran the document 16px wide. The same pair
   the finished card and the summary grids already carry (story 233's rule):
   hyphenate where the host can, break anywhere where it cannot. */
.balanceboard { hyphens: auto; overflow-wrap: anywhere; }
.balance-details { margin-block: var(--space-3); }
.balance-details > summary { min-height: 44px; padding: .6rem min(.6rem, 9.6px); border: var(--hairline); border-radius: var(--radius-s); cursor: pointer; font-weight: var(--weight-strong); }
.balance-details > summary:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-offset); }
.balance-details > h3 { margin-block: var(--space-3) var(--space-1); }
.balance-details > p { margin-block: 0 var(--space-3); }
/* Story 496 (D2/D3): the card's one head row — the coach's cap, the two turn
   counters beside each other, and the way back as a small control pinned to
   the right edge. It replaces the eyebrow, the heading, the full-width back
   bar and the stage's own header, which is where this surface's height went.
   The row wraps rather than shrinking where it must (D10): the back control
   keeps the tap floor every control on this card holds. */
.balance-head { display: flex; flex-wrap: wrap; align-items: center; column-gap: min(var(--space-2), 8px); row-gap: min(.25rem, 4px); margin-bottom: var(--space-2); }
.balance-turns { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: min(var(--space-3), 12px); row-gap: min(.2rem, 3px); min-width: 0; }
/* The same trap `.game-stage[hidden]` documents: the flex display above would
   outrank the user agent's own `[hidden]` rule. */
.balance-turns[hidden] { display: none; }
.balance-turn { display: inline-flex; align-items: baseline; gap: .35em; white-space: nowrap; }
.balance-back { min-height: min(2.8rem, 44.8px); margin-inline-start: auto; margin-bottom: 0; }
/* Story 500 (D9): the dice row follows Play. The stage is story 319's six-track
   grid — five dice plus the advancing action as the sixth slot — so the action
   row this stage used to carry is gone and the balance line moved up into the
   height it held.

   The stage is also the query container both of this slice's folds are read
   against, and it is the right box for them: its content width IS the width the
   dice row and the balance line stand in, measured equal to the card's own
   content box at every §8 viewport and both text sizes. The card would have
   worked as well arithmetically and not structurally — as an ancestor of
   `#balance-head` it would have handed the head row's cap to the unnamed
   `@container (max-width: 14em)` block further down this file, which hides
   `.coach-cap`, and at 200% text every phone sits under that threshold. The
   stage contains neither the cap nor anything else an unnamed query names:
   what stands inside it and outside `.scorecard-board` is the balance line,
   the dice row and the action, and no unnamed block in this file selects one
   of those. */
#balance-game {
  container: balance-stage / inline-size;
  /* And the spacing that comes with the arrangement (Felix, 2026-08-31): the
     compact play stage holds its rows 3px apart and this one held them at the
     `.game-stage` token's 12.8px, which is two gaps on a face whose whole
     criterion is height. Measured on `running-static` at V390 in German — the
     tightest run AC3 names — the stage cleared the viewport by 0.6px at 12.8px
     and by 20.2px at 3px, and 0.6px is not a margin a criterion can be called
     on: the hosted runner's sans is wider than this Mac's (story 415), and one
     more line in this sheet's own fallback paragraph is 24px. */
  row-gap: min(.45rem, 3px);
  grid-template-areas:
    "board board board board board board"
    "line  line  line  line  line  line"
    "dice  dice  dice  dice  dice  actions";
  /* Story 319's own tracks, and its own reason: the sixth floors at the
     physical tap target so the action keeps 44.8px wherever the row holds it,
     and the column gap is held still at 3px so all six slots are exactly one
     sixth. Where they cannot all hold that floor the action folds to its own
     row instead of the five dice giving it their width — the rung below. */
  grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(min(2.8rem, 44.8px), 1fr);
  grid-template-rows: repeat(3, min-content);
  column-gap: min(var(--space-1), 3px);
}
/* Story 500 (D8): the balance line is one row of four markedly smaller tiles.
   Smaller is measured, not adjectival: the value drops three roles from
   `--text-xl` to `--text-s` and the tile's own padding from `.7rem`/`.8rem` to
   `.35rem`/`.4rem`, which is what lets four of them stand where two used to.
   The label keeps its role and takes the tight leading, because in a quarter of
   this row it is the part that wraps — "Glücksbilanz" alone is 71px against the
   53.7px a V360 tile has for it, so it hyphenates where the host can and breaks
   where it cannot (`.summary-grid`'s own pair, inherited).

   D10's fold, derived from the widest row the rules allow rather than the
   widest the fixture shows — story 499's lesson, on this row's own numbers. The
   thing that may never wrap here is the value, and **two different bounds hold
   the four of them**, which is worth stating because reading it as one was this
   slice's first mistake:

   - the EFS is a final score, so it is in `[0, 375]`, and the Bilanz is that
     score against the start par, so it is in `[−246, +130]`. Three integer
     digits, worst case `−375,0` — the board's own number, and the one this
     threshold is built on;
   - the Glücksbilanz and the Verschenkte Punkte are **cumulative over the whole
     game**: given-away is the sum of every one of thirty-nine decisions' cost,
     and luck is that sum plus the drift. Nothing ties either to what a
     scorecard can hold. Measured through the real engine, pessimal play against
     ordinary dice reaches about 292; against dice chosen to maximise every roll
     they reach four digits, and a four-digit number also carries a group
     separator in both catalogs.

   The threshold is the first bound, and the second is measured and *reported*
   rather than designed for — `analysis-summary.mjs` prints its slack per sweep.
   Three reasons, in the order they decide it: four digits is a measured
   reference and not a proved bound, so a threshold on it would be a threshold
   on a number nobody can defend; a tile wide enough for it moves the four-tile
   rung off every phone and takes D14's one-screen promise at V390 with it; and
   what happens at the rungs where it does not fit is that the number wraps
   inside its own tile, contained by the `overflow-wrap` this grid already
   carries — no overflow, no clipping, nothing off the card. Measured, that is
   16 of the sweep's 44 board runs, tightest at −13px: the four-tile rungs at
   100% text and two of the two-tile rungs at 200%. V320 clears it at both text
   sizes because its line is already halved, and both landscape widths clear it
   with room.

   So: `−375,0` measured in the value's own font with the canvas advance the
   probes use, plus the tile's two paddings and its two borders: **65.6px** at
   the default root once story 415's 7.1% for a host whose face is wider than
   the reference Mac's is on the numerals. Four of them and three `--space-2`
   gaps want 286.4px, halved they want 139.2px.

   In this container's own em, which is **15.2px and not the root's 16** — the
   body sets `--text-m` and nothing between it and the stage changes the size,
   so a threshold written as if `1em` were `1rem` lands 5% wide of where it was
   derived. That is 18.85em and 9.16em, and the rungs are the first values above
   them the file can state plainly: **19em** and **9.2em**. Both hold at 200%
   text too, where the tile's rem terms double and only its 2px of border does
   not: 18.55em and 9.01em there.

   Both sides of both rungs stand in the sweep at 100% text — V360 (19.6em),
   V375, V390 and the two landscape viewports hold four, V320 (17em) halves to
   two — and at 200% text V320 (8.5em) halves again to one while VLand (23.6em)
   still holds four. `analysis-summary.mjs` reads the slack per run rather than
   trusting this comment, so a host whose numerals are wider than the margin
   turns the criterion red instead of shipping a wrapped number. */
#balance-game > .balance-line {
  grid-area: line;
  margin: 0;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-2);
}
/* The four numbers stand on one line across the row, which is what makes it
   read as a calculation rather than as four boxes: the labels are one, two or
   three lines depending on the language and the rung, so a tile that stacked
   from the top put `−52,3` a whole label-line above `+8,9`. `space-between`
   spends the difference above the number instead of below it, and costs no
   height — the row is as tall as its tallest label either way. */
#balance-game > .balance-line > div { align-content: space-between; padding: .35rem .4rem; }
#balance-game > .balance-line span { line-height: var(--leading-tight); }
#balance-game > .balance-line strong { font-size: var(--text-s); }
/* After the rules they override, because a container query adds no
   specificity — and in rung order, so the widest rung wins where both match. */
@container balance-stage (min-width: 9.2em) {
  #balance-game > .balance-line { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container balance-stage (min-width: 19em) {
  #balance-game > .balance-line { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* The dice row's own gap is the stage's, for the reason story 319 gives: six
   equal slots exist only where the gap between two dice and the gap between the
   fifth die and the action are the same number. Story 470 held this at 8px to
   keep the five dice above the floor at 200% text on this card's 258px row;
   3px is the safer side of that measurement, not the riskier one — the five
   dice go from 45.2px to 49.2px at V320, and the folded rung below inherits the
   wider die with the narrower gap. */
#balance-game .dice { gap: min(var(--space-1), 3px); }
/* Story 500 (D10): the narrowest rung folds instead of shrinking.
   Story 470 measured this stage and declined story 319's row for it: inside a
   padded card the six tracks left 33.2px dice against the 44.8px floor at V320
   and 200% text. That measurement predates story 284's physical padding cap and
   the cap changed the row, not the rule — the row is 258px wide at V320 at
   **both** text sizes now, and six 44.8px slots with five held-still 3px gaps
   need 283.8px of it. Physical on both sides, so unlike this stage's other fold
   there is no font in the derivation and no host allowance to add, and the
   query folds *at* that width rather than below it — the safe reading of a
   boundary where the slack is zero. The fold is therefore a width and not a
   text size: the six-slot row stands wherever the stage is wider than 283.8px —
   V360 (298px) upwards, including both landscape viewports at either text
   size — and V320 (258px) keeps the arrangement story 470 shipped, at both text
   sizes.

   The fold moves the action rather than re-templating the stage, because the
   stage cannot answer a query about its own width. `grid-column: 1 / -1` with
   an auto row places it in the implicit fourth row under the full-width dice,
   which is exactly where it stood before this slice; `aspect-ratio: auto` is
   what stops the square the sixth slot wants from becoming the full-width box
   story 470 measured as tall as the card is wide. */
@container balance-stage (max-width: 283.8px) {
  /* The dice take the sixth track with them. Left spanning five of six, the
     `1fr` distribution froze the action's track at its 44.8px floor and shared
     what was left five ways — measured before this line: 39.6px dice at V320,
     under the floor this fold exists to keep. Spanning the row absorbs the
     column gaps too, so the five dice stand at 49.2px there. */
  #balance-game > .dice { grid-column: 1 / -1; }
  #balance-game > .solo-actions {
    grid-area: auto;
    grid-column: 1 / -1;
    aspect-ratio: auto;
    align-self: center;
    justify-self: center;
  }
  /* The classic action row lets its buttons share the track (`flex: 1 1 auto`),
     and the compact button square then inherits that width — measured before
     this rule: the roll control as wide as the card. A die-sized square is the
     compact vocabulary for this control (story 393's cap), held physical at
     200% text like every tap floor on this card. */
  #balance-game .solo-actions button {
    flex: 0 0 auto;
    width: min(3.5rem, 56px);
    height: min(3.5rem, 56px);
  }
}
/* Story 555: the table's dice fold into a column when the screen is short.

   The Balanceboard has one view since this story, the table, and on a short
   screen only one of its two arrangements fits: with the dice in story
   500's row under the board the running face asks for more height than the
   phones have, with the dice in a column at the board's right edge and the
   turn's action in the corner below them it asks for the table's height and
   nothing more. So the arrangement is a rule and not a choice (Felix,
   2026-09-02) — the same kind of rule the balance line's and the dice row's
   folds are above, read against the viewport's height rather than the
   stage's width because height is what decides it.

   The row the table folds FROM is capped first (Felix, 2026-09-03, on Codex
   round 1 of pull request 585): on the table board the six slots are at most
   the `min(3.5rem, 56px)` square the compact vocabulary already uses for the
   action, and the capped row keeps to the stage's end — the corner the
   action already owns, and the edge the column stands at below the
   threshold. Story 500's six equal slots stand unchanged on the phones,
   where a slot is narrower than the cap; from 413px of viewport — 351px of
   stage with the phone's chrome, the width at which those slots reach 56px
   — the stage takes the template below: a flexible slack track first, then
   five capped dice tracks and the action's, so the board still spans the
   whole stage while the six slots stand at its end. Without the cap the row
   grew with the width (VWide paid a 125.8px die) and its need with it, and
   no single height could be the threshold; with it the need is one number
   per width class, and the rule can be what AC1 asks for — the tallest
   height at which the row arrangement does not fit, at ANY width.

   The threshold is measured, not read off a fixture's own number, and "fit"
   is the fit rule's own two-part sentence: no vertical page scroll, and the
   card's bottom clear of the fixed navigation. Of the two the page scroll
   binds: `main` reserves the bar's height plus one rem below the card, so
   the document is the card's bottom plus 90.4px, and it scrolls until the
   viewport is that tall. That document height — `contentHeightPx`, read by
   the analysis probe on the `table-running` fixture at 100% text on the
   reference Mac, the row arrangement standing, swept across the widths:

   - 320–345px wide: **885.6 to 890.6px** — story 500's narrowest rung, the
     action on a row of its own under the five dice, and the head row on two
     lines (V320's 80.8px);
   - 346–358px: 825.3–826.8px — the six-slot row, the head row still wrapped;
   - 359–412px: 791.4–800.3px — the six-slot row at the phones' own slots
     (V360 791.6, V375 794.1, V390 796.6);
   - 413–499px: 799.9–800.4px — the capped slots on the phone's chrome;
   - 500–920px: **816.4px** — the card's wider padding on a screen that still
     carries the bar;
   - 921px and wider: 786px — the bar is a rail and reserves no height.

   Two clauses, each the tallest height at which the row does NOT fit in its
   width class: **816px** at every width (the 500–920px class binds; every
   wider or narrower class needs less, and folds there too, conservatively —
   the column fits wherever the row does), and **890px** up to 360px wide,
   where the narrowest rung asks for up to 890.6px. The width bound is 360
   and not the Mac's 358, because the hosted runner's wider sans wraps the
   head row at V360 as well (story 487's class) and asks for 36px more there;
   with the bound at 360 that host has no band of its own either. Measured on
   both sides of both clauses: 550x816 folds and fits by 89.6px, 550x817
   keeps the row, does not scroll and clears the bar by 28.6px; 345x890 folds
   and fits by 143.6px, 345x891 keeps the narrow rung and clears the bar by
   28.4px; the same at 390x816/817 and 1024x816/817. V390 (844px) keeps the
   row and fits by 75.4px; VWide (768px) folds and fits by 104px — on the row
   it missed the bar by 27.9px. `analysis-summary.mjs` holds every table run
   to both clauses by the run's own viewport, and the lab plans the two
   pairs (`V555WideBelow` / `V555WideAbove`, `V555NarrowBelow` /
   `V555NarrowAbove`).

   The column's width is derived, not chosen: the slot the row would have
   given each die at this width — one sixth of the stage less the five
   held-still gaps — floored at the physical tap target and capped at the
   same 56px square. So the die a phone shows in the column is the die it
   showed in the row (52.2px at V390, 49.7 at V375, 47.2 at V360), V320
   stands on the 44.8px floor, and the two landscape faces take the cap.
   Both bounds are physical, so 200% text moves neither.

   The equal-slot rule stories 319 and 470 pinned for the row holds in the
   block direction: the dice grid stands five equal rows of that one width
   over one shared 3px gap, and the action's slot is its own row of the same
   width — no slot derived from a different number than the others. The
   board spans both rows and gives the column its height: the flexible first
   row takes what the table is tall, the dice stack from its top, and the
   action's content-sized row ends at the board's bottom, which is where the
   corner is. Nothing moves when the dice land there either: a die's box is
   the track's, whatever face it shows, so a six is exactly as wide as an
   empty die (story 554's identity, extended to the column).

   Scoped by the stage's `data-board`, which the controller stamps with the
   board it painted: the sheet keeps story 500's row at every height and
   width, and the criteria written on it pass unchanged. Every rule below
   outranks the container folds above by the attribute alone, so the
   narrowest rung — where both queries match at V320 — resolves to the
   column and not to the folded row; the two arrangements never mix. */
@media (min-width: 413px) {
  #balance-game[data-board="table"] {
    grid-template-areas:
      "board board board board board board board"
      "line  line  line  line  line  line  line"
      "slack dice  dice  dice  dice  dice  actions";
    grid-template-columns:
      minmax(0, 1fr)
      repeat(5, minmax(0, min(3.5rem, 56px)))
      minmax(0, min(3.5rem, 56px));
  }
}
@media (max-height: 816px), (max-width: 360px) and (max-height: 890px) {
  #balance-game[data-board="table"] {
    grid-template-areas:
      "board dice"
      "board actions";
    grid-template-columns:
      minmax(0, 1fr)
      clamp(min(2.8rem, 44.8px), calc((100% - 5 * min(var(--space-1), 3px)) / 6), min(3.5rem, 56px));
    grid-template-rows: minmax(0, 1fr) min-content;
  }
  #balance-game[data-board="table"] > .dice {
    grid-area: dice;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: repeat(5, minmax(0, 1fr));
    align-self: start;
  }
  #balance-game[data-board="table"] > .solo-actions {
    grid-area: actions;
    aspect-ratio: 1;
    align-self: end;
    justify-self: stretch;
  }
  /* The container fold's die-sized square would overrun a 44.8px track at
     V320; in the column the button is its slot, as it is in the row. */
  #balance-game[data-board="table"] .solo-actions button {
    flex: 1 1 auto;
    width: auto;
    height: auto;
  }
}
/* The board is not this surface's scroll box — the card scrolls (§1 of the
   analysis brief), so the grid gives its height to the document. The coach
   field this rule used to keep in normal flow left the surface with story
   496 (D4). */
#balance-game .scorecard-grid { overflow: visible; }

/* Story 473: the replay stage, on the terms Story 470 set for the Balanceboard
   directly above and by id for the same stated reason — it stands inside a
   scrolling analysis card rather than under `data-playing`, so its rows are
   content-sized and the card's own scroll is the overflow path, and the id is
   what opts it out of every play-mode rearrangement present and future.

   Two rows, not five: there is no balance line, there is no actions row
   because a replay has no advancing control at all, and since story 505 there
   is no meta row either — the round and the roll stand in the card's head row.
   The transport above the stage is the only control on this surface (D2), so
   the compact stage's corner-action slot would reserve a sixth column for a
   button that does not exist — measured before this rule, the dice row kept
   five of six tracks and left the sixth empty. */
#replay-play-stage {
  grid-template-areas: "board" "dice";
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: repeat(2, min-content);
}
/* The head row's own rules are not here but below the compact header block
   they take back, next to the declarations they override. */
#replay-play-stage .scorecard-grid { overflow: visible; }
/* Relative, not static: it still takes the compact rule's `sticky` back — a
   coach field pinned inside an ordinarily scrolling page is what that undo is
   for — but stays a positioned box, because the compact cap (story 493) is
   absolute and anchors to this field; on a static area it anchored to the
   page and drew over the head row. */
#replay-play-stage .scorecard-grid > .coach-area { position: relative; }
/* The same reasoning as the Balanceboard's reserve above: on a card that
   scrolls, a definite height only clips the verdict at 200% text. */
#replay-play-stage .coach-area { contain: none; height: auto; min-height: 6.25rem; }
#replay-play-stage .dice { gap: min(var(--space-2), 8px); }

.game-meta { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: min(var(--space-2), 8px); min-width: 0; }
/* Story 315: the retained header's middle column. */
.game-mode { display: none; }
/* Story 306: round and roll sit close together at the start of the first track
   instead of stretching across it, so the exits share this row again — in the
   other grid column, never competing with the counters, the reserve or the duel
   marker for width. Because the exits own a grid column rather than a place in
   the counters' flow, nothing inside `.game-turn` can push them onto a row of
   their own: they stay centred against whatever height that box takes.
   Story 298's stability fix survives in that shape — the duel marker still
   always breaks onto its own line (`flex: 1 0 100%` below), but that line now
   grows the flex box instead of adding a second `.game-meta` row, so the
   marker's language still cannot move anything.

   The spacing here is written as `min(<rem>, <px>)` on purpose, the same idiom
   `.onboarding-choice` uses: at 200% text a rem gap doubles too, and three
   doubled gaps are most of the width this row is trying to win back. Type keeps
   its tokens; only the air between things is held still.

   `.turn-estimate` is the room story 306 owes stories 299 and 302, and renders
   nothing into. It is a real element rather than a `::after` because a criterion that
   cannot be measured is not one: a pseudo-element has no box any probe can
   read. Its floor is stated in `ch` of its own bold value font, so it tracks
   the widest whole-percent figure ("100%") through every text size instead of
   pinning a pixel count that is only right at one of them. The count is 5ch
   because it must bound "100%" on every platform sans, not fit it on one:
   digits advance exactly 1ch by `ch`'s definition, and "%" stays under 2ch in
   any text face. The earlier 4.5ch fit the reference Mac but ran 2px under
   "100%" on the CI runner's wider sans — unseen while story 306's uncapped
   play-mode gap crowded that row into wrapping the reserve, measured the
   moment story 330's cap let the reserve sit inline again. It may grow into
   spare width and, where a line cannot hold it, take the next line under the
   counters — section 7's wrap order, applied to the one item on this row whose
   content does not exist yet.

   `flex-wrap` is what that order is spelled with, and it reaches the counters
   too. At 200% text on anything narrower than V390 the two labels are wider
   than this track on their own, and there the counters stand one above the
   other rather than overflowing it. Deliberate and measured: at that size the
   alternatives are a clipped word or a type size under section 7's floor. How
   far the wrap reaches depends on the platform's sans — the CI runner's is wide
   enough to move V360 across that line and the reference Mac's is not — so the
   lab stresses the labels by the advance story 273 measured between the two. */
.game-turn { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: min(.55rem, 9px); row-gap: min(.3rem, 5px); min-width: 0; }
/* Story 405: a counter is one centred stack — its bare number belongs under
   the middle of its own label in both classic and compact headers. Grid item
   alignment changes neither the counter's intrinsic size nor its grid area. */
.turn-counter { display: grid; align-content: start; justify-items: center; min-width: 0; }
.turn-label { color: var(--accent); font-size: var(--text-xs); font-weight: var(--weight-strong); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.turn-value { color: var(--text-body); font-size: var(--text-m); font-weight: var(--weight-strong); overflow-wrap: normal; }
.turn-estimate { container: coach-outlook / inline-size; flex: 1 1 auto; min-width: 5ch; font-size: var(--text-m); }
.duel-game .turn-estimate { flex-basis: 100%; min-width: 0; }
.coach-policy-outlook {
  display: grid;
  gap: min(.12rem, 2px);
  justify-items: center;
  width: fit-content;
  min-width: 0;
  line-height: var(--leading-tight);
}
/* Story 369: the unavailable face is absent to sight and accessibility while
   retaining the exact box of the translated available face. Clearing the
   dynamic values and flooring their line below keeps this a reserve, never a
   fabricated placeholder probability. */
.coach-policy-outlook[aria-hidden="true"] { visibility: hidden; }
.outlook-heading { min-width: 0; color: var(--accent); font-size: var(--text-xs); letter-spacing: var(--tracking-caps); overflow-wrap: normal; text-transform: uppercase; white-space: nowrap; }
/* Story 443 (Felix, 2026-08-28, decided on device): three letters over three
   numbers, centred under the heading. The words left the surface — S/R/N and
   W/D/L follow the governed outcome terms, which everything spoken still
   says — and with them left the whole width machinery this face used to
   need: the loss word's full/short span pair and its 8.9em switch, and the
   per-part wide-face calibration for labels and values (a letter line and a
   digit line have no wrap to calibrate; the caption keeps its stand-in like
   any text). The one dynamic element is the value line, so story 369's
   arrival-cannot-move-the-header reserve works exactly as before: the empty
   line keeps its floor, the hidden face keeps the box.

   The unit is content-sized (the `fit-content` and centring longhands live
   on the one `.coach-policy-outlook` rule above) and centres its three lines
   on one axis; where it stands — in the header's stack row, or on its own
   row where the row cannot hold it — is the story 411 container rung below,
   re-derived. */
.outlook-grid {
  display: grid;
  justify-items: center;
  min-width: 0;
  row-gap: min(.12rem, 2px);
}
.outlook-letters { min-width: 0; color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-strong); letter-spacing: var(--tracking-caps); overflow-wrap: normal; white-space: nowrap; }
.outlook-values-line { min-width: 0; min-height: calc(var(--text-m) * var(--leading-tight)); font-size: var(--text-m); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; overflow-wrap: normal; white-space: nowrap; }
.turn-marker { flex: 1 0 100%; max-width: 100%; color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); overflow-wrap: normal; }

/* Family play head: mode and help, centred progress/forecast, retained exits.
   The head owns no game state. Enlarged text stacks the centre intact below
   the controls, without shrinking words or touch targets. */
[data-view="compact"] .game-stage > .game-meta {
  align-items: center;
  column-gap: min(var(--space-2), 8px);
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
  grid-template-areas: "mode centre exits";
  row-gap: min(var(--space-2), 8px);
}
.game-head-center { grid-area: centre; display: grid; justify-items: center; min-width: 0; gap: min(.3rem, 5px); }
.game-head-center .game-turn { justify-content: center; }
[data-view="compact"] .game-mode { grid-area: mode; display: grid; justify-items: start; min-width: 0; gap: min(.25rem, 4px); }
[data-view="compact"] .mode-stack { display: grid; min-width: 0; }
[data-view="compact"] .turn-marker { display: none; }
.game-forecast { display: grid; justify-items: center; min-width: 0; width: 100%; gap: min(.1rem, 2px); }
.forecast-label { color: var(--text-hint); font-size: var(--text-xs); overflow-wrap: anywhere; text-align: center; }
.forecast-values { display: flex; flex-wrap: wrap; justify-content: center; gap: min(.5rem, 8px); min-width: 0; }
.forecast-item { display: flex; flex-wrap: wrap; justify-content: center; align-items: baseline; gap: min(.2rem, 3px); }
.forecast-owner { font-size: var(--text-xs); color: var(--text-hint); overflow-wrap: anywhere; }
.forecast-value { font-size: var(--text-m); line-height: var(--leading-tight); }
.forecast-item.is-opponent { color: var(--text-hint); }
.game-head-center .turn-estimate { width: 100%; min-width: 0; }
.game-head-center .coach-policy-outlook { margin-inline: auto; max-width: 100%; }
.game-head-center .outlook-heading { white-space: normal; overflow-wrap: anywhere; text-align: center; }
[data-view="compact"] .game-exits {
  grid-area: exits;
  justify-self: end;
  flex-direction: column;
  gap: min(.35rem, 6px);
  min-height: calc(2 * min(2.8rem, 44.8px) + min(.35rem, 6px));
}
[data-playing] .solo-game,
[data-playing] .duel-game { container: play-stage / inline-size; }
@container play-stage (max-width: 14rem) {
  [data-view="compact"] .game-stage > .game-meta {
    grid-template-areas: "mode exits" "centre centre";
    grid-template-columns: minmax(0, 1fr) auto;
  }
  [data-view="compact"] .game-mode { display: flex; flex-wrap: wrap; align-items: center; }
  [data-view="compact"] .game-exits {
    flex-direction: row;
    justify-content: flex-end;
    min-height: min(2.8rem, 44.8px);
    width: calc(2 * min(2.8rem, 44.8px) + min(.35rem, 6px));
  }
  .solo-game .game-forecast { grid-template-columns: minmax(0, max-content) max-content; justify-content: center; column-gap: min(.5rem, 8px); align-items: baseline; }
}
/* Story 473, moved by story 505: the replay's round and roll take this whole
 * template back. They are the same two counters wherever they stand, so the
 * takeback moved with them from the stage to the head row rather than being
 * written a second time.
 *
 * The compact header interleaves the two counters with the two mode stacks in
 * one four-column grid, which is why `.game-turn` and `.turn-counter` become
 * `display: contents` above and why each label and value is then placed into a
 * *named* area. The replay's pair has neither mode stack nor exits — a replay's
 * coach mode is a fact of the record and the way out is the head row's own
 * control — so it stands in flow rather than in a template with two empty
 * columns.
 *
 * Both halves are needed, and the second is the one that is easy to miss. With
 * only the containers restored, the four `grid-area` names still resolve
 * against a template that no longer declares them, all four land in the same
 * implicit slot, and each caption prints on top of its own numeral: measured at
 * V390, RUNDE and WURF 17px tall instead of 34px, both counters legible only as
 * overstruck glyphs. An override has to give back every longhand the earlier
 * block set, not only the ones that changed the box it set them on.
 *
 * Written *after* that block rather than beside `#replay-play-stage`'s other
 * rules: id specificity would win from anywhere, but `tests/coach-slice.test.mjs`
 * reads `.turn-value {`'s declarations by first occurrence, so a copy standing
 * earlier in the file would answer for the base rule and quietly retire that
 * assertion. */
/* The compound selector is deliberate: story 330's gap scan reads every rule
   whose selector *ends* in `.game-meta`, and a bare id would have taken this
   row's two capped gaps out of that net while the row kept the class. The id
   is what carries the specificity; the class is what keeps the check. */
#replay-meta.game-meta {
  align-items: baseline;
  column-gap: min(var(--space-2), 8px);
  display: flex;
  flex-wrap: wrap;
  row-gap: min(.3rem, 5px);
}
/* The trap story 154 documents, and the reason this line is not optional: the
   `display` above outranks the user agent's own `[hidden] { display: none }`,
   so the attribute `replay-app.ts` writes to close this row would stop closing
   it — and an empty offer would print RUNDE and WURF over nothing. */
#replay-meta.game-meta[hidden] { display: none; }
#replay-meta .game-turn {
  align-items: baseline;
  column-gap: min(.55rem, 9px);
  display: flex;
  flex-wrap: wrap;
  min-width: 0;
  row-gap: min(.3rem, 5px);
}
#replay-meta .turn-counter { align-content: start; display: grid; justify-items: center; min-width: 0; }
#replay-meta .turn-label,
#replay-meta .turn-value { grid-area: auto; }
[data-view="compact"] .turn-label,
[data-view="compact"] .turn-value,
[data-view="compact"] .mode-label,
[data-view="compact"] .mode-value { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
/* Story 411: two content-sized stacks replace one wrapping composed line. The
   captions share the counters' accent caps; values remain at the meta step so
   their widest form never takes room from the outlook. */
.mode-label { color: var(--accent); font-size: var(--text-xs); font-weight: var(--weight-strong); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.mode-value { color: var(--text-body); font-size: var(--text-xs); font-weight: var(--weight-strong); }
/* Story 366 (Felix, 2026-08-29, decided on device): the header's type moves one
   rung so the row is a touch easier to read over the grown board — captions and
   mode values step from the meta floor to `--text-s`, the counter values from
   `--text-m` to `--text-l`. The move rides the same regime guard as the grown
   board below, and the guard is what keeps AC6 whole rather than caution: at
   200% text the header is text-tall, not exits-tall, so an unguarded rung grew
   the V320 header 18px and pushed a nowrap RUNDE caption into the mode lane —
   measured before the guard, `npm run ui-lab:matrix`. Where there is no
   reserve, the header keeps the roles it has; the classic header keeps them
   everywhere. The outlook's own heading does not ride either way: its size is
   Story 443's slot arithmetic, and a taller outlook would move the duel header
   height this rung is asserted not to move at the default text size. */
@media (height > 30em) {
  @container (width > 14em) {
    [data-view="compact"] .game-meta .turn-label,
    [data-view="compact"] .game-meta .mode-label { font-size: var(--text-s); }
    [data-view="compact"] .game-meta .turn-value { font-size: var(--text-l); }
    [data-view="compact"] .game-meta .mode-value { font-size: var(--text-s); }
  }
}
[data-view="compact"] .game-meta .turn-label,
[data-view="compact"] .game-meta .turn-value,
[data-view="compact"] .game-meta .mode-label,
[data-view="compact"] .game-meta .mode-value { line-height: var(--leading-tight); }
/* The scorecard (Story 316) --------------------------------------------------
   The thirteen categories are arranged as two flat matrices. The board is a stack of
   blocks — upper matrix, the standings row story 317 reshaped into a 1x4,
   lower matrix, coach — so each block can carry its own column count
   without the others being bought from it. It remains the one scroll container
   and the flexible zone.

   The upper matrix names its columns once, above them: six die faces painted
   from the same pip layout the dice use, and the six value cells under them.
   The lower matrix is the 2x4 Felix decided on 2026-08-24 — the lower-half sum
   opening the first row, then the seven lower fields in the board's own reading
   order. */
[data-view="compact"] .scorecard-grid {
  display: flex;
  flex-direction: column;
}
/* Nothing on this board gives up height to make the stack fit: the board is the
   zone that scrolls (§5), and a flex column would otherwise shrink its items
   first — which would take the coach reserve below the definite height D2 gives
   it and squeeze the tap floor out of the cells above it. */
[data-view="compact"] .scorecard-grid > * { flex: 0 0 auto; }
/* The column gap is held still rather than scaled, in the `min(<rem>, <px>)`
   idiom story 315 documents on the compact header. It is what decides whether
   six columns clear the 2.8rem tap floor at the narrowest phone: the board is
   284.4px wide at V320, six floors are 268.8px, and the five gaps have 15.6px
   between them to share. A gap that doubled with the text size would take that
   room from the cells at exactly the size a finger needs it most. */
[data-view="compact"] .scorecard-matrix {
  display: grid;
  column-gap: min(var(--space-1), 3px);
  row-gap: var(--space-1);
  min-width: 0;
}
[data-view="compact"] .is-upper-matrix { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-view="compact"] .is-lower-matrix { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@container scorecard-board (width > 14em) {
  [data-view="compact"] .is-lower-matrix.is-play-lower { grid-template-columns: repeat(12, minmax(0, 1fr)); }
  [data-view="compact"] .is-play-lower > .scorecard-tile:nth-child(-n+4) { grid-column: span 3; }
  [data-view="compact"] .is-play-lower > .scorecard-tile:nth-child(n+5) { grid-column: span 4; }
}

/* One upper column: the face over the cell it names. The pair is a grid item so
   that a narrower column count keeps them together — see the `@container` rule
   at the end of this block. */
[data-view="compact"] .matrix-column {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: min(var(--space-1), 3px);
  min-width: 0;
}
/* The column header. It is decoration for the cell below it — the cell's own
   `aria-label` names the category in words — and it is sized against the column
   rather than left to grow with it. Story 360 (Felix, 2026-08-25) made it
   negative — `--die-face` and `--die-ink` change places, the same luminance
   swap the held die wears (story 314) — and capped it at the 2.8rem die-size
   *floor* the corner-roll row set. Here it carries no state: the accent
   border, the lift and `aria-pressed` stay the held die's own, and what tells
   this row from the play dice is exactly this constant negative against the
   play row's positive-by-default.

   Story 393 (Felix, 2026-08-26, decided on device) revises that one number and
   nothing else. 2.8rem is the floor a die may not go under, not the size a die
   is: measured on this build, the upper column and the die being rolled are
   the same width on every portrait phone the matrix names — 44.9px at V320,
   51.6px at V360, 54.1px at 375x667, 56.6px at V390 — so a face held at
   44.8px was the one thing on the row *smaller* than the dice it copies. The
   cap becomes 3.5rem: 56px is the largest whole tenth of a rem that still sits
   under the widest of those columns, so a face now fills its column, and
   therefore matches the die, at V320, V360 and 375x667, and stands 0.6px short
   of it at V390. The cap keeps the job story 360 gave it at the 48rem reorder,
   where column and die part company: 79px and 82.8px of column there against
   dice of 48.8px and 51px, and the header holds at 56px instead of following
   the column. */
[data-view="compact"] .matrix-face {
  justify-self: center;
  width: min(100%, 3.5rem);
  background: var(--die-ink);
  color: var(--die-face);
}
/* Story 317 AC1: where the game stands, in one row of four — upper sum, bonus
   pace, expected final score, total. The column gap is held still in the same
   `min(<rem>, <px>)` idiom as the matrices above and for the same reason: at
   V320 the board is 284.4px, and a gap that doubled with the text size would
   take that room out of the four cells at exactly the size the numerals in them
   are widest.

   Story 316 stated one thing here and it is still the one thing that has to be
   stated: Total's classic 5.5rem reserve is the height of a tile carrying two
   labelled lines, and in this row it carries one value like the three cells
   beside it. Left standing, that reserve would set the height of the whole row.
   The reserve itself is untouched — the classic board is where the two lines
   still are (story 233). */
[data-view="compact"] .scorecard-standings {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  column-gap: min(var(--space-1), 3px);
  row-gap: var(--space-1);
  min-width: 0;
}
[data-view="compact"] .scorecard-standings .scorecard-tile.is-total { min-height: 2.8rem; }
/* Story 499 (D5a, Felix 2026-08-30): the Balanceboard's own row is three cells.
   Its Gesamt tile already carries where the board is heading, so the expected
   final score had a cell of its own *and* a column beside Gesamt *and* a place
   in the balance line under the board — `BALANCE_STANDINGS_SLOTS` drops the
   duplicate. Solo, duel and replay keep story 317's four.

   The rung rules below are restated for this board rather than shared, and they
   have to be: this rule carries an id, so it outranks every `@container` copy of
   the four-column rule whatever the width says. The ladder is the same one — 3,
   then 2, then 1 — measured on the same two thresholds. */
[data-view="compact"] #balance-game .scorecard-standings { grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* Story 317 AC5: the row's cells take one column each, always.

   Three of the four wear the class names story 229 gave the classic board's
   summary tiles, and story 229's narrow-container rule — `.scorecard-tile
   .is-upper`, `.is-bonus`, `.is-total` take `grid-column: 1 / -1` — carries no
   view of its own. It was written for the classic grid, whose tiles are its own
   direct children; below 14em it reached into this row instead and laid the
   whole of it out one cell per line. Measured before this rule,
   `npm run ui-lab:matrix`: the compact duel at V320 and 200% text stood 1 column
   x 4 rows and the forecast cell clipped its own stacked pair. After it, 2 x 2
   there, nothing clipped, and every run at 100% text keeps the decided 1x4 —
   the query does not match at that size.

   Stated here rather than by narrowing story 229's selectors: this is the row
   that has to hold, the reset says so in this row's own block, and the classic
   board's cascade is left exactly as it was measured. It is unconditional
   because `auto` is what a standings cell wants at every width. */
[data-view="compact"] .scorecard-standings .scorecard-tile { grid-column: auto; }
/* AC1: the coach renders as a full-width block below the lower matrix. Its
   `grid-column: span 2` is a grid property and this board is a flex column, so
   what places it is the column's own stretch; its D2 reserve — a definite
   height a face swap cannot change — comes with the element. Story 318's own
   section below is this area's contract: the field is pinned to the board
   window's lower edge while the matrices scroll. */
/* AC4: the two values of a duel cell, stacked rather than side by side. That
   order is one of the three things carrying which value is whose here — see
   the story 394 block below for the other two, and for why the brackets story
   316 named beside it are gone. */
[data-view="compact"] .is-duel .scorecard-tile > .tile-value { flex: 0 0 100%; }
/* Story 362 (Felix, 2026-08-25): the whole board
   centres horizontally — category cells, the standings row and the Lower sum,
   label lines and value lines alike. The board reads as a table meant to be
   read, so a column of scores lines up under its die face instead of hugging
   the left edge. Two rules carry it: the tile centres each of its flex lines
   and its text (a wrapped label centres every one of its lines), and the value
   wrapper centres its own content for the lines the duel stretches to the full
   cell width — the reader's line and the bot's line each centre on
   the cell's axis, and the ghost numeral is one more line the first rule
   already reaches. */
[data-view="compact"] .scorecard-tile { justify-content: center; text-align: center; }
[data-view="compact"] .scorecard-tile .tile-value { justify-content: center; }
/* Story 394 (Felix, 2026-08-26, decided on device): which of a cell's two values is whose is
   carried by **size, weight and position**. That is the recorded revision of
   story 316's carrier, and the
   position half is the stacking rule above. The tone `.is-opponent` has always
   worn joins the carrier here, which is why the decision constrains it: the
   bot's value is data, not decoration, so that tone is held to the same 4.5:1
   floor the play dice hold. The lab measures it on the grounds a bot numeral
   actually stands on — an open card and a result cell's paper, in both schemes;
   the entered cell's sunken tone needs both players to have scored the same
   category and no §8 fixture reaches it, so that pair is held at the token
   instead (`tests/onboarding.test.mjs`). All of it is in the brief's §11
   carrier contract.

   The result cells needed this rule to reach them: `.scorecard-tile.is-summary
   .tile-value` paints in body ink at the same specificity, so the standings row
   and the Lower sum carried no tone difference at all until it stood here,
   after that rule.

   The line box is held at the reader's own — `--text-l` at this file's own
   1.2 value leading — instead of following the smaller font down. The numeral
   shrinks and the box it stands in does not, so every cell keeps the height it
   had before this story, which is what the row rule below is measured against.
   The ~3.8px that leaves under each bot numeral at the default root is spare
   height for story 366 to distribute, not height this story spends. */
[data-view="compact"] .tile-value.is-opponent { min-height: calc(var(--text-l) * 1.2); color: var(--text-hint); font-size: var(--text-m); }
/* The weight has to land on the numeral itself, exactly as story 361's ghost
   found: the browser's own `strong` is what makes an entered numeral bold, and
   a weight on the wrapper never reaches it. */
[data-view="compact"] .tile-value.is-opponent strong { font-weight: var(--weight-medium); }
/* Story 394 AC2: within a matrix row, every value stands on one line. A cell is
   stretched to its grid row's height and its content was centred inside it, so
   a row holding one category name that wraps to two lines and one that does not
   put its numbers at two different heights — `Kleine Straße` beside `Chance`,
   and differently again per catalog and text size. Anchoring the content to the
   cell's end is what lines them up: every value line is the same height in
   every cell of the row, so the last one ends on the row's own edge and each
   name hugs the numbers it belongs to.

   `space-between` would line the names up too, and is rejected: with three
   lines it opens its gap *between* the reader's value and the bot's, which is
   the pairing the carrier above is built on. `align-content` distributes free
   space and never makes any, so no box moves — the ticket asks for the row's
   numbers, not for a taller board.

   The upper matrix is deliberately not in this rule: its cells carry no name at
   all — the die face above the column is the name — so they have no wrap to
   absorb and no free space to distribute. */
[data-view="compact"] .is-lower-matrix .scorecard-tile,
[data-view="compact"] .scorecard-standings .scorecard-tile { align-content: end; }
/* Story 603 (Felix, on device, 2026-09-04): where a cell carries a name and a
   single value, the name hangs from the cell's ceiling and the value keeps the
   floor the rule above gives it. Measured before this rule on the built app at
   V390 and 100% text, the state the report came from: every tile of the
   standings row and of both lower rows stood 94.1px tall with its name 43.7px
   below the cell's top padding edge and its value flush on the bottom one — a
   name and a numeral pressed together with the cell's whole spare height
   collected above them.

   This is not the `space-between` the block above rejects. That rejection is
   about a cell with *three* lines, where the gap would open between the
   reader's value and the bot's and break story 394's pairing, and it stands
   exactly as written. This rule cannot reach such a cell: it names the solo
   board by its id, the way `#balance-game` and `#replay-play-stage` name
   theirs when they take a shared rule back.

   The id is the review's own correction (PR 604, round 1). The first draft
   said `.scorecard-grid:not(.is-duel)`, reasoning that the boards which stack
   two values are the duel and the Balanceboard's sheet and both carry
   `is-duel`. An exclusion names what is *not* meant, so it also reached a
   board nobody had looked at: the replay stage's own board is
   `#replay-categories.scorecard-grid` with no `is-duel`, and it builds the
   same one-value cells through `scorecardTile`. That surface has its own
   contract (story 473) and this story's pack declared it out of scope, so the
   rule now says which board it means instead of which boards it excludes.

   Story 394 AC2 is untouched by construction rather than by measurement alone:
   the last line of a wrapped tile is where `end` already put it, so a row's
   values still end on one offset. The evidence pack measures it anyway, on
   both boards and at both text sizes, because a rule that only reasons about a
   row it does not test is what that criterion exists to catch.

   The rule outlives the board's spare height and is not a second spelling of
   the rung further down this file. Where that rung applies, the rows are
   content-sized and a tile has nothing left to distribute; where it does not,
   a row is still as tall as its tallest cell, and every shorter cell in it has
   slack of its own. Measured on the built app at V320 and 100% text, where the
   rung is off: the three standings cells beside the one whose name wraps
   carried 17.5px over their names and now carry none. `align-content`
   distributes free space and never makes any, so where a cell has none —
   every run at 200% text among them — nothing moves at all. */
[data-view="compact"] #solo-categories .is-lower-matrix .scorecard-tile,
[data-view="compact"] #solo-categories .scorecard-standings .scorecard-tile {
  align-content: space-between;
}

/* B3: the compact duel distinguishes committed own/opponent entries from
   previews. Intrinsic grid floors preserve room for wrapped names and larger
   text; the existing board tracks still distribute the available height. */
[data-view="compact"] #duel-scorecards:not(.is-table-view) .scorecard-tile {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(min-content, 38fr) minmax(min-content, 34fr) minmax(min-content, 28fr);
  align-content: stretch;
  align-items: stretch;
  gap: 0;
  padding: 0;
}
[data-view="compact"] #duel-scorecards:not(.is-table-view) .is-upper-matrix .scorecard-tile {
  grid-template-rows: repeat(2, minmax(min-content, 1fr));
}
[data-view="compact"] #duel-scorecards:not(.is-table-view) .scorecard-tile > .tile-label {
  align-self: start;
  padding: .25rem .15rem 0;
}
[data-view="compact"] #duel-scorecards:not(.is-table-view) .scorecard-tile > .tile-value {
  align-items: center;
  padding: .2rem .1rem;
}
/* Both play surfaces share entry colors and header state. Solo keeps its
   existing one-value layout, including label/value spacing and summary cells. */
[data-view="compact"] :is(#duel-scorecards, #solo-categories) button.scorecard-tile {
  background: var(--surface-score-open);
}
[data-view="compact"] :is(#duel-scorecards, #solo-categories) button[data-own-entered] {
  background: var(--surface-score-own);
}
[data-view="compact"] :is(#duel-scorecards, #solo-categories) button[data-own-entered] > .tile-label,
[data-view="compact"] :is(#duel-scorecards, #solo-categories) button[data-own-entered] > .tile-value:not(.is-opponent) {
  color: var(--text-score-own);
}
[data-view="compact"] #duel-scorecards button > .tile-value.is-opponent {
  background: var(--surface-score-open);
  border-radius: 0 0 var(--radius-s) var(--radius-s);
  border-top: 1px solid transparent;
}
[data-view="compact"] #duel-scorecards button[data-opponent-entered] > .tile-value.is-opponent {
  background: var(--surface-score-opponent);
  color: var(--text-score-opponent);
  border-top-color: var(--die-face);
}
[data-view="compact"] :is(#duel-scorecards, #solo-categories) button.is-take-back[data-own-entered]::after {
  color: var(--text-score-own);
}
[data-view="compact"] :is(#duel-scorecards, #solo-categories) .matrix-face {
  background: var(--die-face);
  color: var(--die-ink);
}
[data-view="compact"] :is(#duel-scorecards, #solo-categories) .matrix-column:has(> [data-own-entered]) > .matrix-face {
  background: var(--die-ink);
  color: var(--die-face);
}
/* Story 412 (Felix, 2026-08-27): a category name never takes more than two
   lines, and is never broken inside a word. Where the cell is too narrow for
   the full name, it writes a short form of it instead.

   The cell is the query container, not the board. The board is what story 229's
   rule already measures, and it is the wrong ruler for this question twice
   over: a name is broken by the width of the box it stands in, and that box is
   the board's width divided by a column count the `14em` rule below halves at
   200% text. Measured, the same board width therefore hands a name two
   different boxes — 55.8px of a 284.4px board at V320/100%, 108.6px of a
   268.4px board at V320/200% — so one threshold on the board would need two
   values, and one threshold on the cell needs one. `container-type:
   inline-size` contains the inline axis only, so the row heights the tiles are
   stretched to are untouched, and the columns are `minmax(0, 1fr)` fractions
   that never asked the tile for an intrinsic width.

   The threshold is a measurement, not a taste, and story 415 re-derived it
   (Felix, 2026-08-28, on the ticket: the doctrine — the full name wherever
   every measured face fits it — stays the rule, and the measurement decides)
   against the box the label has since that slice: below the 6.7em rung the
   label takes the tile's inline padding back, so its box is the cell plus
   .7rem — 0.737 of its own em. Re-measured on merged main at 00113b1:
   "Straight" needs 3.723em in the reference Mac face, 3.986 / 3.988em on the
   runner's own sans, and ≈4.03em under the calibrated stand-in. The switch
   is therefore **3.3em of the cell**: the width at which the grown box
   (cell + 0.737em ≈ 4.04em) stops holding the stand-in's widest word. Every
   §8 phone cell stands above it — the nearest is V320 at 200% text with
   3.572em, 0.27em clear — so the full names now render at every §8 width
   and the short side of the switch lies below the narrowest measured phone,
   held in real layout by the lab's V320/300% name-sweep runs. At V320 the
   full straights write one word per line in the grown box ("Straight"
   ≈4.03em stand-in against 4.31-4.41em), two lines, never a third — the
   decided text change story 415's AC3 names.

   German writes the same string on both sides of the switch — "Kleine Straße"
   needs 3.11em and fits the narrowest box — so the rule is a width rule and not
   a language rule, exactly as the copy it moves is.

   The `@container` block comes after the unqualified rule it overrides because
   a container query adds no specificity, the same reason the `14em` block below
   is its own block. */
[data-view="compact"] .is-lower-matrix .scorecard-tile { container: matrix-cell / inline-size; }
.tile-label-short { display: none; }
@container matrix-cell (max-width: 3.3em) {
  .tile-label-full { display: none; }
  .tile-label-short { display: inline; }
}
/* Story 414 (Felix, 2026-08-27, both halves decided on the ticket): where a
   row of the compact lower matrix can hold names of different line counts,
   every name begins on the row's first name line — and a multi-word name in
   a two-line row writes one word per line instead of collapsing onto one.

   One switch serves both halves, because both need the same fact — whether
   this cell's row can be two name lines tall — and a lone cell knows it only
   through its width: the row wraps exactly where its widest name stops
   fitting one line. Measured in the cell's own type (canvas advance,
   reference Mac face): the widest lower name is the English "Large Straight"
   at 6.625em (German "Große Straße" 6.249em; below story 412's switch — 3.3em
   since story 415 — the short "Large Str." still needs 4.51em, so every band
   under the switch wraps). The §8 cells stand at 3.67-4.79em on the phones (3.57-4.72 at
   200% text) — all under 6.7em — and at 7.026em (VLand) / 7.421em (VWide),
   both above it, so the wide viewports keep their one-line rows: the
   decision's own condition, because an unconditional word-per-line rule
   would make the block taller exactly where there is room to spare.

   What the switch cannot cover is recorded rather than smoothed over: a
   wider face moves the wrap point up (the runner's "Large Straight" reaches
   ~7.16em, past VLand's 7.026em cell), so on such a face VLand's English
   row can wrap while the switch stays off. No §8 criterion measures that
   corner — story 412 scoped the printed-name criteria to the four phone
   viewports — and its cost is the pre-story-414 hang, never a new one.

   The two declarations, on the second-row cells only (`:nth-child(n+5)`,
   which is rows three and four at the halved 200% rung — the same four
   cells on both rungs):
   - `min-height: 2.3em` is the name zone's reserve, two 1.15 lines of the
     label's own type. The values keep story 318's `align-content: end`
     anchoring untouched, so the reserve opens between a short name and its
     numbers and never between the reader's value and the bot's — the reason
     `space-between` stays rejected (story 394's carrier note above).
   - `word-spacing: 100cqw` is the second half: a space wider than the cell
     puts every word on its own line. Single-word names ("Chance") carry no
     space, the straights already broke at theirs, and line-end spaces hang,
     so every measured line centre stays exactly where it was.
   Row one — the sum and the three of-a-kind short forms — carries no rule:
   its names are one line at every width the matrix has (story 413), so a
   reserve there would only spend height. */
@container matrix-cell (max-width: 6.7em) {
  [data-view="compact"] .is-lower-matrix .scorecard-tile:nth-child(n+5) .tile-label {
    min-height: 2.3em;
    word-spacing: 100cqw;
  }
}
/* The family row has three wider cells. At phone widths its two-word
   names still share two lines; the desktop row keeps natural single lines. */
@container matrix-cell (max-width: 8.8em) {
  [data-view="compact"] .is-play-lower .scorecard-tile:nth-child(n+5) .tile-label {
    min-height: 2.3em;
    word-spacing: 100cqw;
  }
}
/* Story 415 (Felix, 2026-08-27, decided on the ticket): "Chance" stays one
   word. On the runner's own sans it needs 3.741em against V320's 3.671em
   cell — the one width and the one face where a lower name outgrew its box —
   and `overflow-wrap: anywhere` then cut it mid-word, "Chanc" over "e",
   31 observations in the 00113b1 artefact and no other break anywhere.

   The decided lever is geometry, not copy: below the same 6.7em rung the
   reserve above rides, a lower label takes the tile's inline padding back.
   `flex-basis: calc(100% + .7rem)` grows the box — a negative inline margin
   would shrink the item's *outer* size instead and let a value climb onto
   the name's line — and the centred flex line overflows the too-wide item
   symmetrically into the padding, so the text stays centred without a
   margin of its own. The grown V320 box is 4.41em at 100% text and 4.31em
   at 200%, and every face fits "Chance" (3.541 reference, 3.741 / 3.742
   runner, 3.769 stand-in). Above the rung nothing changes; below it the
   only other names wider than their old box are the full straights the
   re-derived 3.3em switch now lets through, and story 414's
   `word-spacing` keeps every multi-word name at one word per line no
   matter how wide the box — which is what makes this lever safe at every
   width (the pre-story-414 branch measured "Full House" collapsing one step
   before "Chance" was repaired; that conflict is retired, not dodged).
   Line counts against the 00113b1 artefact move in exactly the two decided
   places on the printed-name contract set — both V320-only: "Chance" 2 → 1
   on the faces that broke it, and the English straights' short forms give
   way to the full names — while the stress fixtures may only shed lines
   into the grown box (AC3 as amended on the ticket, Felix 2026-08-28; the
   brief's measured record names the observed sheds). */
@container matrix-cell (max-width: 6.7em) {
  [data-view="compact"] .is-lower-matrix .scorecard-tile .tile-label { flex-basis: calc(100% + .7rem); }
}
/* The Balanceboard's row pattern (Story 499) ----------------------------------
   Decided by Felix on 2026-08-30 over rendered mockups of the narrow rungs and
   scoped to this board alone (D12).

   D5 — the standings row and the lower matrix's first row wear their label
   above and their three values **in one row**: standing, signed difference
   against the start par, and the third column. The stacking those cells had is
   story 394's duel rule further up in this file — `[data-view="compact"]
   .is-duel .scorecard-tile > .tile-value` — which this board wears because its
   markup wears the duel's class; an id is what takes it back here without
   touching the surface that rule was written for.

   D6 is the other half of the same decision and is why the second lower row is
   not in these selectors: Full House through Chance keep their stacked values,
   so `:nth-child(-n+4)` is the whole of what D5 rearranges.

   D10 — where a cell cannot hold the three side by side they stack again,
   rather than shrinking, clipping or wrapping two-and-one. The switch is a
   measurement on the cell, in the cell's own em, the same ruler and the same
   reason story 412 gives in the block above: the board's width divided by a
   column count the standings rungs halve is not the box a value stands in.

   The number is the **widest row a game can reach**, not the widest the
   fixture happens to show, and that distinction is the whole of the rule. A
   threshold set from the seeded board would leave a band where three ordinary
   values fit and three big ones do not, and `flex: 0 1 auto` answers that band
   with the two-and-one wrap D10 forbids — the Codex review of pull request 511
   named exactly that hole in this rule's first draft.

   Three integer digits bound every number this board prints: the highest score
   reachable under these rules is 375 (105 upper, 35 bonus, 235 lower), so no
   standing, no difference and no reference can be wider than `−375,0`. Measured
   in the cells' own fonts with the canvas advance the outlook probe already
   uses, letter-spacing and both column gaps included: the widest a rearranged
   cell can ever be is **11.09em** of its own font, and it is the same number in
   both catalogs and at both text sizes, because every term is set in rem.

   To that goes the one host difference this repo has measured: story 415 found
   the hosted runner's sans 7.1% wider than the reference Mac's ("Straight",
   3.723em here against 3.986 there). 11.09em plus that is 11.88em, and the
   threshold is the first whole em above it — **12em**. The lab's own wide face
   cannot narrow this further: it stresses labels, not numerals (story 412), so
   it reports the same 11.09em, and a criterion that read it as a numeral stress
   would be measuring the simulation.

   Both sides of the switch are in the §8 sweep at 100% text: every phone cell
   stands between 3.2em and 6.2em, the first-row lower cells reach 11.5em at
   VWide, and only the standings cells at VLand (15.58em) and VWide (15.71em)
   stand above 12em and hold their three values on one line — with 4.5em of
   slack against the worst case, so no game state wraps them. At 200% text every
   viewport folds, the two landscape ones included. `analysis-summary.mjs` reads
   that slack per run rather than trusting this comment: a host whose numerals
   are wider than the margin above turns the criterion red instead of shipping a
   ragged row. */
[data-view="compact"] #balance-game .scorecard-standings .scorecard-tile > .tile-value,
[data-view="compact"] #balance-game .is-lower-matrix .scorecard-tile:nth-child(-n+4) > .tile-value { flex: 0 1 auto; }
/* The two halves of D5's row answer one query, so the switch is one number. The
   lower cells are already `matrix-cell` containers (story 412) and the name is
   a list, so the second name joins rather than replaces it; the standings cells
   were no container at all and become one here. `inline-size` contains the
   inline axis only, so the row heights these tiles are stretched to are
   untouched. */
[data-view="compact"] #balance-game .scorecard-standings .scorecard-tile { container: balance-values / inline-size; }
[data-view="compact"] #balance-game .is-lower-matrix .scorecard-tile:nth-child(-n+4) { container-name: matrix-cell balance-values; }
/* After the rule it overrides, because a container query adds no specificity. */
@container balance-values (max-width: 12em) {
  [data-view="compact"] #balance-game .scorecard-standings .scorecard-tile > .tile-value,
  [data-view="compact"] #balance-game .is-lower-matrix .scorecard-tile:nth-child(-n+4) > .tile-value { flex: 0 0 100%; }
}
/* D7 — the upper matrix cells keep all three stacked values and lose the air
   above and below them. Two levers, both measured at V360 and 100% text on the
   `running` fixture, where the cell's own box stood at 74px:

   - the block padding, `.25rem` top and bottom, which a cell whose three values
     already carry their own line boxes does not need: −8px;
   - the reserve story 394 put under the bot's numeral
     (`min-height: calc(var(--text-l) * 1.2)`), which holds a `--text-m` value in
     a `--text-l` line box. That story called the ~3.8px it leaves "spare height
     for story 366 to distribute, not height this story spends"; D7 is the
     decision to spend it, on this board and in this matrix only: −3.9px.

   Together the cell's own box is 62.1px, and no value and no digit leaves it.
   The 2.8rem tap floor is untouched and never reached — 62.1px against 44.8. */
[data-view="compact"] #balance-game .is-upper-matrix .scorecard-tile { padding-block: 0; }
[data-view="compact"] #balance-game .is-upper-matrix .tile-value.is-opponent { min-height: 0; }

/* The coach zone (Story 318) --------------------------------------------------
   The named Story 205 amendment (Felix, 2026-08-24): the
   coach speaks from one full-width field between the lower matrix and the dice,
   instead of sharing a board row with Knaller. The element is the same aside on
   both surfaces and `coach-area.ts` is untouched — what changes is where the
   field stands while the board scrolls.

   `sticky` rather than a zone outside the scroll container is a
   height budget: the field keeps its place in the board's scroll
   extent, so it adds no pinned height of its own — at 200% text the
   duel header alone reaches 253px of a 640px screen, and a coach zone pinned
   outside the board would break the one-screen rule §11 says applies unchanged.
   Stuck to the window's lower edge instead, the field stays on screen in every
   §8 combination at the ordinary text size while the matrices scroll behind it:
   its background is the opaque sunken surface, so what slides under it is
   covered, and the hairline edge marks the fold.

   The reserve is 6.25rem, re-derived for this width rather than
   inherited by luck: at the narrowest §8 board (V320, 284.4px) the merged
   verdict line and the best-alternative label each hold one row beside the
   cap's column in both catalogs, so D2's tallest regular face is one line
   (19.9px), the field name (17.3px), and a two-line best alternative (39.4px),
   plus margins, padding and borders — 97.8px against a 16px root, under the
   100px reserve. */
[data-view="compact"] .scorecard-grid > .coach-area {
  position: sticky;
  inset-block-end: 0;
  /* The cap's column: start padding, the 3.58rem drawing, and air. Measured
     before this value was chosen: at V320 the field's content is 203.6px with
     this indent, and the verdict line, the field name and the longest
     best-alternative sentence each keep one row in both catalogs — the reserve
     arithmetic above holds with the cap standing. */
  padding-inline-start: 4.5rem;
}
.coach-cap { display: none; }
[data-view="compact"] .coach-cap {
  position: absolute;
  top: .35rem;
  display: block;
  inset-inline-start: .55rem;
  height: 2.25rem;
  color: var(--die-ink);
}
.coach-cap svg { display: block; width: auto; height: 100%; }
/* Story 430/442: the filled crown, accent details and Graphit-safe edges. */
.coach-cap .mark-side { fill: var(--die-face); stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-crown { fill: var(--die-face); stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-brim { fill: var(--accent); stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-panel { fill: none; stroke: currentColor; stroke-width: .75; stroke-linecap: round; opacity: .55; }
.coach-cap .mark-eyelet { fill: currentColor; }
.coach-cap .mark-brim-shine { fill: none; stroke: var(--die-face); stroke-width: .75; stroke-linecap: round; opacity: .72; }
.coach-cap .mark-pip { fill: var(--accent); stroke: currentColor; stroke-width: 1.3; }
.coach-cap .mark-letter-edge { fill: none; stroke: currentColor; stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-letter { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-side-number-edge { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.coach-cap .mark-side-number { fill: none; stroke: var(--accent); stroke-width: .85; stroke-linecap: round; stroke-linejoin: round; }
/* Story 496 (D3): the Balanceboard head row wears the cap —
   it is the mark that the whole board is the coaching, now that no verdict
   renders there (D4). The base rule above hides the cap and the field rule
   pins it absolutely inside a coach field this card no longer has, so the id
   turns it back on, puts it in the row's own flow at the row's height, and
   pins its ink to the same token the coach-field caps wear. After the two
   rules it overrides, deliberately. */
#balance-head .coach-cap { display: block; position: static; flex: 0 0 auto; height: 2rem; color: var(--die-ink); }

/* Story 316: §7's rung 3, on the same threshold and by the same mechanism story
   229 used for the scorecard — and reached for the same reason, one text
   size later. At 200% text a two-digit score is 44.3px wide at the
   emphasised-value token, against a 46px cell at V360: no padding makes that
   fit, a numeral cannot wrap, and shrinking below the role is what rung 5
   forbids. So the matrices take half their columns and twice their rows, which
   is what the column groups above exist to make possible.

   Story 318 rides the same block: below this threshold the root text has
   outgrown the board's width, the natural verdict face is taller than any
   fixed reserve (269.5px against a 200px reserve at V360 and 200% text), and
   the header has already spent the height a pinned field would need — so the
   coach field rejoins the board's scroll flow and the cap's column yields its width
   back to the copy. The reserve itself is untouched: a face swap moves
   nothing in either regime.

   Its own `@container` block rather than a pair of declarations inside the one
   further up this file: a container query adds no specificity, so a rule that
   has to beat the unqualified compact rules above has to come after them.
   Measured with `npm run ui-lab:matrix`: at every §8 viewport at 100% text the
   board is far above this threshold and the rule does not match, so the decided
   2x6 and 2x4 are what a reader gets wherever they can be had. */
@container (max-width: 14em) {
  [data-view="compact"] .is-upper-matrix { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  [data-view="compact"] .is-lower-matrix { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Story 317 AC5: the standings row takes the same rung on the same threshold.
     It is the row that needs it most — a duel stacks two values in every one of
     its four cells, and its numerals are the board's longest: a three-digit
     total and a three-digit forecast, neither of which can wrap. Two columns of
     two rows is what §7 has left before rung 5, and nothing is lost by it — the
     four cells, both values in each and every label are all still there, one
     row of the scrolling zone further down. */
  [data-view="compact"] .scorecard-standings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Story 499: the Balanceboard's three cells take the same rung — two columns
     and a second row — for the same reason and at the same width. */
  [data-view="compact"] #balance-game .scorecard-standings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Story 318: the coach field rejoins the scroll flow and its cap leaves —
     see the block comment above this rule's condition. `relative` rather than
     `static`, and it is measured, not taste: this is the classic rule's own
     value, and a *static* scroll container whose copy overflows its reserve
     leaks that overflow into the document's scroll height in Chromium — the
     T200 verdict face made the page scrollable while every zone stood inside
     the viewport. A relative box contains it, and its inert `inset-block-end`
     shifts nothing. */
  [data-view="compact"] .scorecard-grid > .coach-area { position: relative; padding-inline-start: .55rem; }
  [data-view="compact"] .coach-cap { display: none; }
}

/* Story 317 AC5: the row's second rung, and the last one before §7 forbids the
   alternative. Two columns are not always enough for it, because the widest
   thing on this board is a *duel* standings value — a three-digit forecast
   inside the brackets that say whose it is, at the emphasised-value token, with
   no hyphen, space or soft break in it. Measured with `npm run ui-lab:matrix`
   at 200% text: `(246)` is 123.6px, and a cell holds it when its client width
   is at least that plus the left padding the value starts after — 134.8px. Two
   such cells, their borders and the held-still gap need 273.6px, which is 9em
   of this container's own font at every text size, because every term in the
   sum is set in rem.

   The rule is written at that measured threshold rather than folded into the
   14em block above, because the difference is the whole point: at V360 and V390
   the row measures 9.6em and 10.6em and keeps its 2x2, and only V320 at 200%
   text — 8.3em, D1's own corner — falls to one column. Taking the whole 14em
   band to one column instead would add two standings rows at V360 and V390,
   where §5's one-screen rule still has to hold and D1 grants no exemption.

   Before this rule the duel's forecast cell overflowed its own box by 13px at
   V320/200%, on both sides of the shared/split swap; after it, nothing on the
   row clips at any combination section 8 runs. Nothing is lost by the
   rearrangement: four cells, both values in each and every label are still
   there, further down the zone section 5 lets scroll.

   Story 394 changed the string that derivation was taken from, and the
   threshold is deliberately left where it stands. `(246)` no longer exists:
   the brackets are gone from this view and the bot's numeral is a type role
   smaller, so the widest thing in a standings cell is now the *reader's* own
   three-digit forecast at the emphasised token. 9em is therefore wider than
   the row strictly needs, and the row keeps rearranging at exactly the
   combination it did before — V320 at 200% text and nothing else. Moving it
   would move the row, which story 394's own AC2 forbids; re-deriving it is a
   layout decision for whoever wants the two-column rung to reach further
   down, not a side effect of the carrier change. */
@container (max-width: 9em) {
  [data-view="compact"] .scorecard-standings { grid-template-columns: minmax(0, 1fr); }
  /* Story 499: and the last rung, again for this board's own three cells. */
  [data-view="compact"] #balance-game .scorecard-standings { grid-template-columns: minmax(0, 1fr); }
}
/* The grown board (Story 366) --------------------------------------------------
   On a tall phone the board window is taller than the stack of blocks, and
   story 316's pinned flex column pooled the whole difference in one hole
   between the coach and the dice. Decided (Felix, 2026-08-29): the reserve is
   spent in two phases — five gaps grow evenly first, each to its own cap, and
   the growing rows absorb the remainder uncapped, proportionally, on the
   classic board's own `minmax(min-content, 1fr)` precedent.

   One grid runs both phases, because the grid track algorithm IS the decided
   model: after the intrinsic floors, free space first maximizes the capped
   tracks — equal increments across all five gap tracks, each freezing at its
   own max — and only the remainder expands the flexible rows. So the five
   gaps and the four growing rows have to be tracks of one grid, which is why
   this block flattens the board: the three block wrappers and the upper
   matrix's face/cell columns become `display: contents`, the faces, cells and
   the coach are placed onto a 12-track column grid (the LCM of six upper and
   four lower columns — spans of 2 and 3 reproduce today's cell widths and the
   held-still 3px gaps to the pixel), and the five gaps are rows of it.

   The regime guard is the point, not decoration. Below 480px of height
   (VLand) and at or below the 14em container rung (every §8 phone at 200%
   text) there is no reserve to distribute, and those regimes keep story 316's
   flex column with all of its decided behaviour — the sticky field, the
   rearranged matrices, story 318's rejoin — untouched as the base rules above
   this block. Both conditions are the complements of the thresholds those
   rules already stand on, written in range syntax so neither existing block
   gains a twin. Inside the guard the grid replaces the flex column, so the
   `flex: 0 0 auto` pin and the blocks' own grids are inert here, not fought.

   The five gaps and their bases, top to bottom — the growth tracks carry only
   the part a gap grows by, so every base keeps living exactly where earlier
   stories put it and the scarcity regimes read it from there:
   1. header-upper: base = the stage row gap plus story 393's start margin
      (11.8px solo / 10.8px duel); the first track below adds up to twice
      that base, capping the gap at 3x — 35.4px solo / 32.4px duel.
   2. upper-standings, 3. standings-lower, 4. lower-coach: base
      `var(--space-1)`, in the gap track itself, capped at 3x its base.
   5. coach-dice: base = the stage row gap (3px solo / 2px duel); the last
      track adds up to twice that, capping the gap at 9px solo / 6px duel.
   The face-value gap inside an upper column and the lower matrix's own row
   gap keep their bases and do not grow: they separate a name from its cell,
   not one zone from another. Under scarcity every growth track is at zero or
   base, every flexible row at its floor, and the overflow scrolls — story
   186's contract, asserted by the matrix rather than assumed here.

   The growing rows are the four value rows — upper cells, standings, both
   lower rows — at one `1fr` each, so the surplus tends to equal row heights
   the way the classic board's five category rows share theirs. The faces row
   (max-content, story 393's cap), the coach row (D2's definite 6.25rem, the
   same number `.coach-area` itself carries) and the header and dice outside
   the board stay fixed.

   The coach is the one item that is not simply placed in its row: sticky
   inside a grid can slide only within the item's own grid area, so a coach
   whose area were just row 12 would stop pinning to the window's lower edge
   the moment anything scrolls. Spanning it from the faces row to the end of
   its own row and end-aligning it puts it exactly where its row is while the
   board fits, and hands story 318's sticky rule the whole scrolled extent to
   slide in when it does not. */
@media (height > 30em) {
  @container (width > 14em) {
    [data-view="compact"] .scorecard-grid {
      --board-gap-grow-head: 23.6px;
      --board-gap-grow-dice: 6px;
      display: grid;
      grid-template-columns: repeat(12, minmax(0, 1fr));
      column-gap: min(var(--space-1), 3px);
      row-gap: 0;
      grid-template-rows:
        minmax(0, var(--board-gap-grow-head))
        max-content
        min(var(--space-1), 3px)
        minmax(min-content, 1fr)
        minmax(var(--space-1), calc(3 * var(--space-1)))
        minmax(min-content, 1fr)
        minmax(var(--space-1), calc(3 * var(--space-1)))
        minmax(min-content, 1fr)
        var(--space-1)
        minmax(min-content, 1fr)
        minmax(var(--space-1), calc(3 * var(--space-1)))
        6.25rem
        minmax(0, var(--board-gap-grow-dice));
    }
    [data-view="compact"] .duel-game .scorecard-grid { --board-gap-grow-head: 21.6px; --board-gap-grow-dice: 4px; }
    [data-view="compact"] .is-upper-matrix,
    [data-view="compact"] .scorecard-standings,
    [data-view="compact"] .is-lower-matrix,
    [data-view="compact"] .matrix-column { display: contents; }
    [data-view="compact"] .matrix-face { grid-row: 2; }
    [data-view="compact"] .matrix-column > .scorecard-tile { grid-row: 4; }
    [data-view="compact"] .matrix-column:nth-child(1) > * { grid-column: 1 / span 2; }
    [data-view="compact"] .matrix-column:nth-child(2) > * { grid-column: 3 / span 2; }
    [data-view="compact"] .matrix-column:nth-child(3) > * { grid-column: 5 / span 2; }
    [data-view="compact"] .matrix-column:nth-child(4) > * { grid-column: 7 / span 2; }
    [data-view="compact"] .matrix-column:nth-child(5) > * { grid-column: 9 / span 2; }
    [data-view="compact"] .matrix-column:nth-child(6) > * { grid-column: 11 / span 2; }
    [data-view="compact"] .scorecard-standings > .scorecard-tile { grid-row: 6; }
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(-n+4) { grid-row: 8; }
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(n+5) { grid-row: 10; }
    [data-view="compact"] .scorecard-standings > .scorecard-tile:nth-child(4n+1),
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(4n+1) { grid-column: 1 / span 3; }
    [data-view="compact"] .scorecard-standings > .scorecard-tile:nth-child(4n+2),
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(4n+2) { grid-column: 4 / span 3; }
    [data-view="compact"] .scorecard-standings > .scorecard-tile:nth-child(4n+3),
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(4n+3) { grid-column: 7 / span 3; }
    [data-view="compact"] .scorecard-standings > .scorecard-tile:nth-child(4n),
    [data-view="compact"] .is-lower-matrix > .scorecard-tile:nth-child(4n) { grid-column: 10 / span 3; }
    [data-view="compact"] .is-lower-matrix.is-play-lower > .scorecard-tile:nth-child(5) { grid-column: 1 / span 4; }
    [data-view="compact"] .is-lower-matrix.is-play-lower > .scorecard-tile:nth-child(6) { grid-column: 5 / span 4; }
    [data-view="compact"] .is-lower-matrix.is-play-lower > .scorecard-tile:nth-child(7) { grid-column: 9 / span 4; }
    [data-view="compact"] .scorecard-grid > .coach-area { grid-row: 2 / 13; grid-column: 1 / -1; align-self: end; }
    /* Story 496 (D4): the Balanceboard board has no coach field any more, and
       its stage is content-sized on a scrolling card rather than pinned to
       the window — so the definite 6.25rem coach track above would stand as
       an empty hole between the lower matrix and the balance line (measured:
       112px at V360/100% before this rule). The board keeps the flattened
       grid and its gap model; only the coach row and the gap before it close,
       which is rows eleven and twelve of the template above.

       Story 499 (D7) takes the four value rows off `1fr` on this board, and
       that is the same observation carried one step further. The block above
       distributes a *reserve*, and a reserve is what a play surface has: its
       board is the stage's flexible row inside a screen. This stage's rows are
       `min-content` (`#balance-game` at the top of this file), so the grid's
       height is indefinite, and an indefinite container does not distribute —
       it sizes every `1fr` track to the tallest of them. Measured at V360 and
       100% text: four bands of 109.7px each, set by the second lower row alone,
       while the upper cells needed 74 and the standings row 92.3. The three
       shorter bands were carrying 35.7, 17.4 and 17.4px of air apiece with
       nothing to distribute. On `min-content` each band is its own height, the
       gap model above is untouched, and the board stands at 464.1px instead of
       546.7 — the 82.6px this slice frees, which is D14's ledger for slice C
       (story 500). Under scarcity the flex column outside this guard already
       sized these blocks by content, so nothing there changes. */
    [data-view="compact"] #balance-game .scorecard-grid {
      grid-template-rows:
        minmax(0, var(--board-gap-grow-head))
        max-content
        min(var(--space-1), 3px)
        min-content
        minmax(var(--space-1), calc(3 * var(--space-1)))
        min-content
        minmax(var(--space-1), calc(3 * var(--space-1)))
        min-content
        var(--space-1)
        min-content
        minmax(0, var(--board-gap-grow-dice));
    }
    /* Story 499 (D5a): three standings cells across the same twelve tracks. The
       four-cell placement above puts this row's three tiles in columns 1, 4 and
       7 and leaves 10 to 12 empty; four tracks each is what reproduces the
       row's own thirds and the held-still 3px gaps. */
    [data-view="compact"] #balance-game .scorecard-standings > .scorecard-tile:nth-child(3n+1) { grid-column: 1 / span 4; }
    [data-view="compact"] #balance-game .scorecard-standings > .scorecard-tile:nth-child(3n+2) { grid-column: 5 / span 4; }
    [data-view="compact"] #balance-game .scorecard-standings > .scorecard-tile:nth-child(3n+3) { grid-column: 9 / span 4; }
  }
}
/* The board with height to spare (Story 603) ---------------------------------
   Felix, on device, 2026-09-04. Two readings from one screenshot, one cause,
   and one answer that also decides what the freed height is for.

   The row of cells under the die faces read as taller than the rows below it,
   and in the rows that begin with `Oben` and with `Unten` the name sat
   directly on its numeral with the cell's spare height collected above it.
   Measured on the built app at V390 and 100% text: all four value bands stood
   at 94.1px, the cells under the faces asking for 44.8 of that and the named
   rows for 50.3, 50.3 and 67.8, with 43.7px of paper over every name. The
   bands were equal because they were four `1fr` tracks of a definite board,
   and a flexible track distributes surplus whatever its content is — four
   equal bands over four different contents is exactly the reading the report
   describes, from both ends.

   Content-sized rows answer both at once, and answer the second by
   construction rather than with a second rule: a cell with no spare height
   has nowhere to put a name except above its own numeral. What that leaves is
   the real finding — 236px of a 603px board at V390, a third of the play
   surface, with no content in it. Felix, same session, over rendered
   variants: air between the bands is the least that height can do, so the
   board spends part of it on a rung of type — names at `--text-l`, numerals
   at `--text-xl`. Measured, the rung costs 35.1px of it: the four bands go
   from 44.8/50.3/50.3/67.8 to 44.8/60.8/60.8/81.9. The rest stays between the
   bands, in gaps that become flexible here for that purpose, and it is stated
   in the evidence pack as room a later story has to fill rather than as a
   thing this one solved. No board grows anywhere: the board is the stage's
   flexible row, and every number here moves height inside it.

   BOTH rules stand behind one rung, because they are one decision: a board
   with height to spare spends it, and a board without it is untouched. Two of
   the twenty measured runs change — solo at V390 and at VWide, both at 100%
   text, both with no board scroll, no page scroll and no clipped cell before
   or after. Every other run, the duel included, is identical to the number.

   The threshold is measured rather than chosen: the smallest viewport height
   at which the enlarged board fits with neither scroll, swept per width on
   the built app at 100% text — 41.25em at 320px wide, 42.06em at 360, 41.13em
   at 375, 41.38em at 390, 41.75em at 412, 37.75em at 1024. Re-swept with the
   platform sans forced to Verdana, the stand-in for the hosted runner's wider
   face, two widths rise: 42.44em at 375 and 42.69em at 390. The tallest
   measured height at which the enlarged board does NOT fit is therefore
   42.63em, and the rung opens at the next whole em above it. V375 (667px,
   41.69em) falls under it and keeps today's type: on the wide face its
   enlarged board needs 679px, which its own 667 does not have.

   The width rung is the text size's own gate, and it is needed because the
   height rung cannot be one: `em` in a media query is the browser's initial
   font size, never the root the text-size setting moves, so `min-height` sees
   the same number at 100% and at 200% text. A container query does read the
   container's own font size, and that is exactly the discriminator — this
   board measures 23.32em at V390 and 33.65em at VWide with 100% text, and
   19.47em at VWide with 200%, where the same pixels buy half the em. At 20em
   the rung takes the boards it was measured on and leaves a doubled-text
   phone (11.13em at V390) and a doubled-text VWide outside. Measured on the
   window where it stops working: from about 1100px wide at 200% text the
   board reaches 21em and takes the rung again, and its own scroll grows from
   257px to 335px at 768px of height — 213px at 1200x900, 113px at 1400x1000,
   no page scroll anywhere. That surface scrolls at 200% by standing decision,
   and this is a bounded amount more of it, named here rather than hidden.

   The rung names the solo board by its id, and that is twice deliberate. The
   duel is left out on measurement: its cells stack two values and keep
   `align-content: end` (the story 394 block above), so the height these
   tracks give up would land as air *above* their names — measured at
   V390/100% while an earlier draft still reached it, the freed 44px raised
   the remaining bands from 99px to 113.6 and the gap over a name from 25.8px
   to 40.4, which is the very reading story 603 was raised to end. The story
   named solo and duel together as an author's call open to correction; this
   is the correction, and a cell that stacks two values needs its own answer
   in its own story.

   The replay stage is left out because it is a different surface with its own
   contract (story 473) — a reading document rather than a play face, with no
   coach reserve and no one-screen rule to spend height against. An earlier
   draft wrote this rung as `.scorecard-grid:not(.is-duel)` and reached
   `#replay-categories` with it, which the round 1 review caught (PR 604).
   An exclusion cannot say which board a rule is for; the id can.

   The template is restated whole rather than by track, because
   `grid-template-rows` is one declaration and a track of it cannot be
   overridden on its own. Every other board keeps the shared template above
   untouched — the Balanceboard has carried content-sized rows of its own
   since story 499, and this rung never reaches it or any other id. */
@media (min-height: 43em) {
  @container scorecard-board (width > 20em) {
    [data-view="compact"] #solo-categories {
      grid-template-rows:
        minmax(0, var(--board-gap-grow-head))
        max-content
        min(var(--space-1), 3px)
        min-content
        minmax(var(--space-1), 1fr)
        min-content
        minmax(var(--space-1), 1fr)
        min-content
        minmax(var(--space-1), 1fr)
        min-content
        minmax(var(--space-1), 1fr)
        6.25rem
        minmax(0, var(--board-gap-grow-dice));
    }
    [data-view="compact"] #solo-categories .tile-label { font-size: var(--text-l); }
    [data-view="compact"] #solo-categories .tile-value { font-size: var(--text-xl); }
  }
}
/* The compact dice row (Story 319) --------------------------------------------
   The corner roll, decided by Felix on 2026-08-24 ahead of the parked spike's
   measurement: dice and action render as one row of six equal slots — five
   dice plus the advancing action as the sixth slot at the row's end, rounded
   toward the device corner. No separate action row renders in compact; the
   freed height goes to the board, which is the stage's one flexible row.

   The stage itself becomes a six-track grid so the sixth slot is a track of
   the same grid the dice fill: `.dice` spans five tracks and keeps its own
   five columns inside them, and with the two gaps equal every slot is exactly
   one sixth. The gap is held still in the `min(<rem>, <px>)` idiom the compact
   board documents, and it is what decides the fit at the narrowest phone: the
   stage is 284.4px wide at V320, six 2.8rem tap floors are 268.8px, and the
   five gaps have 15.6px between them to share — so 3px each, and a slot
   measures 44.9px against the 44.8px floor. A gap that doubled with the text
   size would take that room from the slots at exactly the size a finger needs
   it most. */
[data-view="compact"] .game-stage {
  /* The sixth track floors at the physical tap target — `min(2.8rem, 44.8px)`
     is the rem floor at the default root and stays 44.8px when the text
     grows. Everywhere §8 measures, 1fr is at or above it and all six tracks
     stay equal; only in D1's corner (V320 at 200% text, the row 268.4px wide
     since Story 328 capped the stage's inline inset — 250.8px when Felix
     decided this on 2026-08-25, review round 1 of story 319 — against six
     89.6px rem floors) does the floor bite, and there the advancing action
     keeps 44.8px while the five dice tracks give ~0.5px each (41.7px,
     against the classic dice's 40.9px at the same corner). */
  grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(min(2.8rem, 44.8px), 1fr);
  grid-template-areas:
    "meta  meta  meta  meta  meta  meta"
    "board board board board board board"
    "dice  dice  dice  dice  dice  actions";
  grid-template-rows: min-content minmax(0, 1fr) min-content;
  column-gap: min(var(--space-1), 3px);
}
[data-view="compact"] .game-stage .dice { gap: min(var(--space-1), 3px); }
/* Story 393 (Felix, 2026-08-26, decided on device): air between the header and
   the board it stands over. The stage's row gap is the only thing that has
   ever separated the two — 3px on the solo surface, 2px on the duel, where
   story 369 spends the dense rung — and at that distance the upper matrix
   reads as part of the chrome above it rather than as the top of the play
   surface.

   The increase is a start margin on the board rather than a larger stage row
   gap, because that gap is one number for two boundaries: raising it would
   move the board-to-dice distance with it, which is story 366's reserve to
   distribute and not this story's to spend. It is held still in the
   `min(<token>, <px>)` idiom, at the same `.55rem` the stage already holds its
   own inline inset at since story 328 — so the top of the play surface gets
   the inset its sides have, and gets it at every text size instead of doubling
   to 17.6px at 200% on exactly the heights with least room. Story 366's
   "the header keeps its base gap" then reads this base: 11.8px on the solo
   surface, 10.8px on the duel. */
[data-view="compact"] .game-stage > .scorecard-board { margin-block-start: min(.55rem, 8.8px); }
/* The sixth slot. Square whether or not a button is on screen: in the
   must-score state the controller hides both actions exactly as it always has,
   and the slot's own aspect ratio is what keeps the zone's box — an A3 the
   flexed container would otherwise give up the moment it empties. Centred like
   the dice, so the two boxes share one row at every width, the wide reorder's
   flexible track included. */
[data-view="compact"] .game-stage > .solo-actions,
[data-view="compact"] .game-stage > .duel-actions {
  aspect-ratio: 1;
  align-self: center;
  min-height: min(2.8rem, 44.8px);
}
/* The action as the sixth die: die-sized — the slot's own width, exactly as a
   die takes its column — and the asymmetric rounding as the mockup draws it:
   the die's radius on three corners, the drawn 20px toward the device corner,
   written as 1.25rem so the proportion survives the text size. The glyph sits
   in the rounded corner, as drawn.

   The floor on slot and button is AC3's, written physical: `2.8rem` alone
   grows to 89.6px at 200% text, and a slot that kept that forced its
   aspect-ratio width past the track and the surface past the screen
   (measured before this value: 89.6px in a 45.5px track at V320/T200). At
   `min(2.8rem, 44.8px)` the control keeps the rem floor where it exists and
   the 44.8px target everywhere else, and the track floor above is what makes
   the room for it in D1's corner. The dice beside it follow their tracks
   like the board's cells at 200% — 46px at V360, measured by the matrix
   rather than assumed here. */
[data-view="compact"] .solo-actions button,
[data-view="compact"] .duel-actions button {
  aspect-ratio: 1;
  min-height: min(2.8rem, 44.8px);
  padding: .35rem;
  border-radius: var(--radius-die) var(--radius-die) 1.25rem var(--radius-die);
  align-items: flex-end;
  justify-content: flex-end;
}
/* The label's two faces change places: the glyph carries the compact slot, the
   text stays the classic row's. The `aria-label` names the control in both. */
[data-view="compact"] .solo-actions .action-glyph,
[data-view="compact"] .duel-actions .action-glyph { display: block; width: 55%; }
[data-view="compact"] .solo-actions .primary.dice-cup-action,
[data-view="compact"] .duel-actions .primary.dice-cup-action {
  padding: 0; gap: 0; align-items: center; justify-content: center;
}
[data-view="compact"] .solo-actions .dice-cup-action-icon,
[data-view="compact"] .duel-actions .dice-cup-action-icon { display: block; flex-basis: auto; width: 100%; height: 100%; }
[data-view="compact"] .solo-actions .primary.dice-cup-action .dice-cup-icon,
[data-view="compact"] .duel-actions .primary.dice-cup-action .dice-cup-icon { width: 100%; height: 100%; }
[data-view="compact"] .solo-actions .primary.dice-cup-action .action-glyph,
[data-view="compact"] .duel-actions .primary.dice-cup-action .action-glyph { display: none; }
[data-view="compact"] .solo-actions .action-label,
[data-view="compact"] .duel-actions .action-label { display: none; }
.duel-roll-area > .dice { margin: 0; }

/* The verdict reads as three fields on one hairline-ruled row rather than as
   boxes: class, what it cost, and what the coach would have played. */
.verdict-grid {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  gap: var(--space-3) var(--space-5);
  padding: var(--space-3) 0;
  border-top: var(--hairline);
  border-bottom: var(--hairline);
}
.verdict-grid div { display: grid; justify-items: start; gap: .15rem; }
/* The field label only — `> div >` and not a descendant match. `.verdict-class`
   wraps its symbol and word in spans of their own, and a descendant selector
   reaches them: they are children of this grid too, so they took the label's
   colour, size, weight, tracking and uppercase off the parent that had already
   set the tone. */
.verdict-grid > div > span { display: block; color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.verdict-grid strong { font-size: var(--text-m); font-weight: var(--weight-strong); }
/* Three fields already fill the three tracks; `.wide` is what the two-column
   layout needed and is now simply the last of them. */
.verdict-grid .wide { grid-column: auto; }
/* The class carries the tone — beside the symbol the controller renders, never
   instead of it (#44 AC6). */
.verdict-class { display: flex; align-items: baseline; gap: .35rem; color: var(--tone, var(--text-supporting)); }

/* Onboarding (#44) ---------------------------------------------------------
   Same rule as the replay badges below: a tone is only ever colour, and every
   surface that wears one also renders a symbol or a word. */
.onboarding-progress { color: var(--text-hint); font-size: var(--text-xs); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.onboarding-choices { display: grid; gap: var(--space-2); margin: var(--space-4) 0; }
/* Story 284: at 200% text on a 320px screen these three answers stood 57px past
   the viewport edge, and the first round a beginner ever sees was cut off on
   the right. Story 284 first contained that surface by returning space from
   chrome rather than breaking reader-facing words. At the time,
   `overflow-wrap: anywhere` turned the longest answer into 11 line boxes and
   `hyphens: auto` into 17 — and neither contained the whole document because a
   heading below the answers was also too wide.

   Story 284 therefore kept the word whole and made the box give way first.
   Everything below is chrome —
   the ring, the space beside it, the button's own side padding — and every one
   of them was in `rem`, so all three grew by the same factor as the text they
   frame. `weiterwürfeln` is 192px at 200% and cannot become narrower; the
   chrome around it can. Each `min()` returns exactly the pixels this button has
   at the default root size and stops growing above it, which is the idiom the
   tab bar has used since Story 273. The ring is the clearest of the three: at
   200% it was 29px wide with a 13px margin and a 16px gap behind it, 58px of a
   320px screen for one small circle. Capped it is 15.2px, and the margin goes
   because the margin and the gap are one space said twice. That last one is
   the story's one change a reader sees at the default text size as well: the
   space between ring and label is 8px rather than 14.4px, on this control
   only. It is spent where the budget is tightest and nowhere else.

   The ring's cap is `1em` and not `1rem` on purpose, and the pixels are the
   button's rather than the root's: this control reads `.95rem`, so a `1rem`
   cap would have *grown* the ring from 15.2px to 16px at the default root
   size — 0.8px of change bought for nothing, in the one story whose whole
   claim is that the default size is untouched. Caught in review of this
   slice, because a Range over the text nodes does not see generated content
   and the first measurement therefore could not have caught it.

   Story 323's long-form matrix found the remaining emergency case after all
   that chrome was spent: a heading, two explanatory paragraphs and the action
   labels can each exceed the viewport under the combined long/T200 stress.
   The onboarding surface gets the last-resort break below; it changes no copy
   and does nothing while a word fits. Buttons also lower their automatic
   grid/flex minimum so the break can contain their control box. */
.onboarding { overflow-wrap: anywhere; hyphens: auto; }
.onboarding button { min-width: 0; }
.onboarding-choice { justify-content: flex-start; padding-inline: min(1rem, 16px); gap: min(var(--space-2), 8px); border-color: var(--border-control); background: transparent; text-align: left; }
.onboarding-choice[aria-pressed="true"] { border-color: var(--border-selected); background: var(--surface-selected); }
.onboarding-choice::before { margin-right: 0; font-size: min(1em, 15.2px); }
.onboarding-outcome { margin: var(--space-4) 0; padding: var(--space-4); border: var(--hairline); border-radius: var(--radius-m); background: var(--surface-sunken); animation: var(--reveal-animation); }
.onboarding-outcome-heading { display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-3); color: var(--text-body); font-size: var(--text-l); }
.onboarding-outcome-symbol { color: var(--tone, var(--text-supporting)); font-size: var(--text-l); font-weight: var(--weight-strong); }
.onboarding-insight { margin: var(--space-3) 0 0; color: var(--text-supporting); font-size: var(--text-s); }
.onboarding-steps { display: grid; gap: .35rem; margin: 0 0 var(--space-4); padding: 0; list-style: none; }
.onboarding-step-current, .onboarding-step-passed, .onboarding-step-ahead { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-s); }
.onboarding-step-current { color: var(--text-body); font-weight: var(--weight-strong); }
.onboarding-step-passed { color: var(--text-secondary); }
.onboarding-step-ahead { color: var(--text-hint); }
.onboarding-step-marker { min-width: 1rem; color: var(--accent); font-weight: var(--weight-strong); }
.onboarding-step-ahead .onboarding-step-marker { color: var(--text-hint); }
/* Story 521: the first-launch chooser's four answers. Stacked, left-aligned
   and of one weight — no `.primary` among them, so no answer reads as the
   expected one. Chrome is capped in pixels the way `.onboarding-choice` caps
   its own, so at 200% text the reader's words get the width; the `.onboarding`
   surface above supplies the last-resort break and lowers the buttons' minimum. */
.first-launch-choices { display: grid; gap: min(var(--space-2), 8px); }
.first-launch-choice { justify-content: flex-start; padding-inline: min(1rem, 16px); border-color: var(--border-control); background: transparent; text-align: left; font-weight: var(--weight-medium); }
.first-launch-choice:hover:not(:disabled) { background: var(--surface-sunken); }

/* Presentation settings (#44 AC5) ------------------------------------------ */
.choice-row { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--space-2); }
.choice {
  flex: 1 1 6rem;
  min-width: 0;
  justify-content: flex-start;
  border: var(--rule);
  background: transparent;
  font-weight: var(--weight-medium);
  hyphens: auto;
  overflow-wrap: anywhere;
}
.choice[aria-pressed="true"] { border-color: var(--border-selected); background: var(--surface-selected); font-weight: var(--weight-strong); }
.settings-hint { margin: var(--space-2) 0 0; color: var(--text-hint); font-size: var(--text-xs); }

/* Story 518 AC10: at V320/T200 the German data-safety heading and explanation
   are each wider than the card's content box. The card owns that prose and its
   two actions, so one inherited emergency break contains all of them without
   changing the language selector or any other More surface. */
.data-safety { min-width: 0; hyphens: auto; overflow-wrap: anywhere; }

/* Voluntary Phase-1 measurement (Story 311) ------------------------------- */
.telemetry-settings .data-actions { align-items: center; }
.telemetry-settings .data-actions a { color: var(--text-secondary); font-size: var(--text-s); }
.consent-overlay {
  position: fixed;
  z-index: 20;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: auto;
  padding: max(var(--space-4), env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) max(var(--space-4), env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left));
  background: var(--surface-page);
}
.consent-card { width: min(34rem, 100%); margin: auto; }
.consent-card > a { color: var(--text-secondary); font-size: var(--text-s); }
.consent-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-4); }
.consent-actions button { min-width: 0; white-space: normal; }

/* Replay (#40) ------------------------------------------------------------
   §4.8: colour is never the only carrier. The four .tone-* rules below are the
   ONLY place a feedback class becomes colour; every badge that wears one also
   renders .replay-badge-symbol and .replay-badge-label as text, so the class
   survives with colour rendering switched off. */
.tone-optimal { --tone: var(--tone-optimal); }
.tone-near-optimal { --tone: var(--tone-near-optimal); }
.tone-mistake { --tone: var(--tone-mistake); }
.tone-blunder { --tone: var(--tone-blunder); }

.replay h4 { margin: var(--space-5) 0 var(--space-2); color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
/* Story 279: the hosted runner's wider sans left the dated game title 11px
   beyond V320 at 200% text, although the same detail fit on the reference Mac.
   The replay surface owns the picker, timeline, playback, stage and round
   rows, so one inherited break policy lowers every descendant's min-content
   width without clipping or removing any of their content. Strings that
   already fit keep their existing line breaks. */
.replay { hyphens: auto; overflow-wrap: anywhere; }
/* Story 493, widened by story 505: the head row is the surface's one chrome
   line, and since 505 it carries the game's state as well — the way back as a
   small icon at the start, the round and the roll beside it, the game picker
   at the end. The picker never shrinks below its own widest option (Codex
   round 1: `min-width: 0` let the native field clip the points, and at 200%
   text part of the date, out of the one visible carrier of which game is
   open); where the four no longer share a line the row wraps and the picker
   takes one of its own — P6's fold, which spends a row rather than shrinking
   type or breaking a tap floor. The icon keeps a fixed square that scales
   with the root, so it holds the 44px control floor at 200% text.

   The column gap is 4px and held physical, which is the difference between
   holding V320 on this Mac only and holding it on a wider sans as well: with
   the token's 8px the row wants 259px there against 258px of card — measured
   on the lab's wide face, a one-pixel fold. The row gap keeps the token: it
   only ever applies where the row has already folded, and a fold reads better
   with air in it. Both items are bordered boxes, so 4px still separates them
   as boxes rather than as text. */
.replay-head { display: flex; flex-wrap: wrap; align-items: center; column-gap: min(.25rem, 4px); row-gap: var(--space-2); margin-bottom: var(--space-3); }
/* The fold's own rule, and the basis is the point of it: `auto` makes the
   picker's own closed face the width the row is measured against, so the four
   items share a line exactly while that face fits beside them and the picker
   takes a line of its own the moment it does not. A fixed basis decides the
   fold by a number nobody measured — at 176px it folded every phone at 100%
   text, 36px before the content needed it (measured: the row wants 292px, and
   V390 offers 328px). Growing is what the picker does with the line it then
   has to itself. */
.replay-head > .replay-picker-box { flex: 1 1 auto; }
/* Story 505: the state pair sits between the two controls and never grows —
   what is elastic in this row is the picker. */
.replay-head > .replay-meta { flex: 0 1 auto; }
/* The floor is `min(2.8rem, 44.8px)` and not 2.75rem, which is 44px and 0.8px
   short of it (Codex review of PR 513). The lab's own control predicate reads
   44px and runs at 200% text only, so neither the matrix nor the T100 pass
   could see the shortfall — the criterion says 44.8px, so the control is
   44.8px. `min()` keeps it physical below the reference root and lets it grow
   with the root above, which is the same shape every other tap floor on this
   card wears. */
.replay-back { flex: 0 0 auto; display: grid; place-items: center; inline-size: min(2.8rem, 44.8px); block-size: min(2.8rem, 44.8px); padding: 0; }
.replay-back svg { inline-size: 1.4rem; block-size: 1.4rem; }
.replay-back svg path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Codex round 1: the visible face of the picker is the label span, because a
   native select's closed field is a single line that clips what does not fit
   — and at 320px with 200% text no `date · points` value fits one line. The
   label wraps instead; the select stretches invisibly over the box and keeps
   the popup, the keyboard and the accessible name. The caret is drawn by the
   label's ::after, so no glyph enters the markup.

   Story 505 narrows the inline padding from .45rem to .3rem, and that is a
   measured 8px rather than a tidy-up: with the shortened face the head row
   clears V320 by 7px in German and 9px in English, which a system sans wider
   than this Mac's would spend on its own. The block padding is untouched, so
   the field keeps its `min(2.8rem, 44.8px)` tap floor. */
.replay-picker-box { position: relative; flex: 1 1 auto; min-inline-size: 0; border-radius: var(--radius-s); }
.replay-picker-box:focus-within { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.replay-picker-label { display: flex; align-items: center; justify-content: flex-end; gap: .35rem; min-block-size: min(2.8rem, 44.8px); padding: .4rem .3rem; border: var(--rule); border-radius: var(--radius-s); background: var(--surface-card); color: var(--text-body); font-size: var(--text-xs); text-align: end; }
.replay-picker-label::after { content: ""; flex: 0 0 auto; border-inline: .28em solid transparent; border-block-start: .34em solid var(--text-hint); }
/* The select's own text is transparent — the label over it is what a sighted
   reader sees — and the popup's options take their ink back explicitly,
   because Chromium lets them inherit the transparent otherwise. */
.replay-picker { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; margin: 0; padding: 0; border: none; appearance: none; background: transparent; color: transparent; font: inherit; }
.replay-picker option { color: var(--text-body); background: var(--surface-card); }

/* Story 711: small quality circles share one row; transparent enclosing targets
   keep the controls separate and preserve the stored colour/symbol pair.
   The 8px reserve contains the 2px outline and 2px offset on both sides. */
.replay-duel-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-around; gap: .5rem; margin-block-end: 0; }
.replay-timeline { display: flex; flex-wrap: wrap; justify-content: center; margin-top: var(--space-3); }
.replay-segment {
  display: flex;
  align-items: center;
  justify-content: center;
  inline-size: calc(100% / 13);
  /* One row at ordinary text size. At enlarged text the rem floor
     leaves room for each glyph and its ring, wrapping into at most two rows. */
  min-inline-size: 1rem;
  min-block-size: min(2.8rem, 44.8px);
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--surface-page);
  line-height: 1;
}
.replay-segment-symbol {
  display: grid;
  place-items: center;
  inline-size: min(1.5rem, 36.8px, calc(100% - 8px));
  flex: none;
  min-block-size: 0;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--tone, var(--border-control));
  font-size: var(--text-xs);
  font-weight: var(--weight-strong);
}
.replay-segment:hover:not(:disabled) { background: transparent; }
.replay-segment:hover:not(:disabled) .replay-segment-symbol { background: color-mix(in oklab, var(--tone, var(--border-control)), var(--text-body) 7%); }
/* Current round and keyboard focus remain distinguishable without colour. */
.replay-segment[aria-current] .replay-segment-symbol { outline: 2px solid var(--text-body); outline-offset: 2px; }
.replay-segment:focus-visible { outline: 2px dashed var(--text-body); outline-offset: -2px; }

/* Story 167: playback. The stage shows the current decision on its own, under the
   controls, so playing moves nothing on the page; each frame is a fresh
   element wearing the shared reveal animation, whose low-motion variant the
   `data-motion` blocks above already resolve.

   Story 506 (P8) gives the row its own query container. It is the row itself
   and not the card around it on purpose: an unnamed `@container` query binds
   to the nearest ancestor container of any name, and this card's play stage
   carries `.scorecard-grid`, `.scorecard-tile` and `.game-meta` — every
   subject of the unnamed 14em/9em blocks further up this file. A container on
   `#replay-detail` would hand the replay board those rules at 200% text, where
   its own ancestors give it none today. Here the only descendants are the
   three controls this fold is about. */
.replay-playback {
  container: replay-transport / inline-size;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  margin-top: var(--space-3);
}
/* Three labels share the row wherever it can hold them: one row in both
   shipped languages from the fold below upwards. */
.replay-playback button { flex: 1 1 auto; padding-inline: var(--space-2); white-space: nowrap; }
.replay-playback .primary { width: auto; flex-grow: 2; }
/* The glyph is a drawn path, so it is the same shape on every platform and
   carries no font's idea of "⏸". It stands beside the word at the wide rung
   and alone at the folded one; `data-transport` is written by the controller,
   whose state it is. */
.replay-transport-glyph { flex: 0 0 auto; inline-size: 1.15em; block-size: 1.15em; fill: currentColor; }
.replay-transport[data-transport="play"] .is-pause,
.replay-transport[data-transport="pause"] .is-play { display: none; }
/* Story 506 (P8): the fold. Where the three labelled controls cannot share a
   row they become glyphs on the `min(2.8rem, 44.8px)` floor, and their words
   stay on `aria-label` and `title` — the same shape the head row's way back
   already wears, so the accessible name does not move with the rung.

   The threshold is the labelled row's own requirement rather than a width a
   fixture happened to show: the three max-content labels plus their paddings
   and the row's two gaps, taken as the widest the copy can be. That is the
   wider of the two shipped languages *and* the wider of the play button's two
   states, because the label alternates and is the state — measured 319.13px
   for German with "Abspielen" against 290.61 with "Pause", and 247.88/261.22
   for English, so German's play state is the case the row is built for. Plus
   story 415's 7.1% hosted-sans allowance: 341.8px, which is 22.49em of this
   row's own 15.2px font, so the fold stands at 22.5em.

   In `em` of the row rather than in pixels because both terms scale with the
   reader's text: the labels do, and so does the container the query reads. At
   200% text every phone is under 10em of its own font and the row is folded
   there too, which is the same rung and not a second rule.

   Measured against the §8 viewports, the fold is every phone — V320 is 16.97em
   of card, V360 19.61 and V390 21.58, all under 22.5 — and neither landscape
   viewport, at 50.4em and 53.6em. Both sides of it are in the matrix. V390 is
   the rung the allowance buys: its 328px of card holds the German row's 319px
   on this Mac and would not hold it on a sans 7.1% wider, which is exactly
   what an unfolded row must never be asked to do. */
@container replay-transport (max-width: 22.5em) {
  .replay-playback button {
    flex: 0 0 auto;
    inline-size: min(2.8rem, 44.8px);
    min-block-size: min(2.8rem, 44.8px);
    padding: 0;
  }
  /* The play control is the same square as its two neighbours here. Its own
     `width: auto` above outranks the `inline-size` on `.replay-playback
     button` — same property, one class more specific — so the folded width is
     restated on the same selector rather than left to lose the cascade. */
  .replay-playback .primary { inline-size: min(2.8rem, 44.8px); flex-grow: 0; }
  .replay-transport-word { display: none; }
}
/* The same fade `button:disabled` wears — a control the transport has run out
   of road for should not read as a different kind of unavailable. */
.replay-playback [aria-disabled="true"] { opacity: var(--opacity-disabled); }
.replay-stage { margin-top: var(--space-3); }
#replay-detail.is-duel-replay .replay-stage { margin-top: 0; }
.replay-stage-frame { padding: var(--space-3); border: 1px solid var(--accent); border-radius: var(--radius-m); background: var(--surface-sunken); animation: var(--reveal-animation); }
.replay-stage-round { margin: 0; font-size: var(--text-m); font-weight: var(--weight-strong); }
.replay-stage .replay-decision { margin-top: var(--space-2); }
.replay-decision[aria-current] { outline: 2px solid var(--accent); outline-offset: 2px; }

/* P5b: the set overview of an imported series. One bordered box under the
   head row; each played set is a full-width button on the same tap floor the
   head row's controls wear, a forfeited set is a plain line. The open set is
   marked by `aria-current`, so removing colour leaves it marked. */
.replay-series { margin: 0 0 var(--space-3); padding: .6rem .8rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.replay-series-heading { margin: 0 0 .2rem; font-size: var(--text-s); font-weight: 600; color: var(--text-body); }
.replay-series-result { margin: 0 0 .5rem; font-size: var(--text-s); color: var(--text-supporting); }
.replay-series-sets { display: grid; gap: .3rem; margin: 0; padding: 0; list-style: none; }
.replay-series-item { margin: 0; }
.replay-series-set { display: flex; align-items: center; inline-size: 100%; min-block-size: min(2.8rem, 44.8px); margin: 0; padding: .4rem .6rem; border: var(--rule); border-radius: var(--radius-s); background: var(--surface-card); color: var(--text-body); font: inherit; font-size: var(--text-s); text-align: start; }
.replay-series-set.is-unplayable { border-style: dashed; color: var(--text-supporting); }
.replay-series-item[aria-current="true"] .replay-series-set { border-color: var(--accent); font-weight: 600; }
.replay-series-unplayed { margin: .5rem 0 0; font-size: var(--text-s); color: var(--text-supporting); }
.replay-rounds { display: grid; gap: .4rem; }
.replay-round { padding: .55rem .7rem; border: var(--hairline); border-left: 3px solid var(--tone, var(--border-control)); border-radius: var(--radius-s); background: var(--surface-sunken); }
.replay-round summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.replay-round-title { font-size: var(--text-s); font-weight: var(--weight-strong); }
.replay-round-meta { color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.replay-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .1rem .45rem; border: 1px solid var(--tone, var(--border-control)); border-radius: var(--radius-s); color: var(--tone, var(--text-supporting)); font-size: var(--text-xs); font-weight: var(--weight-strong); white-space: nowrap; }
.replay-badge-symbol { font-size: 1em; }

.replay-decision { display: grid; justify-items: start; gap: .25rem; margin-top: .7rem; padding: .7rem .8rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-card); }
/* Story 185: every decision line is flush, but the size belongs to the lines
   that read at body size. Carrying `font-size` on the bare-type descendant put
   it at (0,1,1) against `.replay-loss`'s (0,1,0), so the loss line rendered at
   --text-s where it asks for --text-xs. Naming the exception keeps the default
   for any line added later. */
.replay-decision p { margin: 0; }
.replay-decision p:not(.replay-loss) { font-size: var(--text-s); }
.replay-step { color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.replay-dice-line { color: var(--text-body); font-size: var(--text-s); line-height: 1.4; }
.replay-optimal { color: var(--text-supporting); }
.replay-loss { color: var(--text-hint); font-size: var(--text-xs); }

/* Story 375: this block belongs to the stored position inside one replay
   decision. It has no hidden face or reserved height: historical and explicit
   unavailable entries create no element at all. The three values use equal
   tracks so their visible W/D/L label line and programmatic per-value labels
   describe the same order at every width. */
.replay-policy-outlook {
  display: grid;
  justify-self: stretch;
  gap: var(--space-1);
  min-width: 0;
  margin-top: var(--space-1);
  padding: var(--space-2);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
  line-height: var(--leading-tight);
}
.replay-outlook-heading { color: var(--accent); font-size: var(--text-xs); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.replay-outlook-context { color: var(--text-body); font-size: var(--text-s); font-weight: var(--weight-medium); }
.replay-outlook-premise, .replay-outlook-labels { color: var(--text-hint); font-size: var(--text-xs); }
.replay-outlook-labels { font-weight: var(--weight-strong); }
.replay-outlook-values { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: min(.35rem, 6px); min-width: 0; }
.replay-outlook-value { min-width: 0; color: var(--text-body); font-size: var(--text-m); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; text-align: center; white-space: nowrap; }

/* Story 262: every metric is an auto-sized item in `.stats-grid`, so its
   longest unbreakable descendant used to become the grid's minimum width. At
   200% text that floor was 402px, leaving the same right edge beyond 320px,
   360px and 390px phones; the long locale made the same defect visible at the
   default text size. The surface owns all of those labels, values and detail
   lines, so one inherited rule gives every one a legal break while leaving
   strings that already fit unchanged. The grid can then honor the width of the
   card instead of expanding it. */
.stats { hyphens: auto; overflow-wrap: anywhere; }
/* Story 703: each dimension is one quiet selection rail. Native checkboxes keep
   independent multi-selection and a full label hit area. Help sits inside the
   visual segment, but remains a separate control outside the checkbox label. */
.stats-filters { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-4); margin-bottom: var(--space-4); }
.stats-filter-group { display: grid; min-width: 0; max-width: 100%; gap: .35rem; }
.stats-filter-label { color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); }
.stats-filter-row { display: flex; flex-wrap: wrap; gap: 3px; padding: 3px; border-radius: var(--radius-m); background: var(--surface-sunken); }
.stats-filter { position: relative; display: flex; flex: 1; align-items: center; gap: .35rem; min-height: 46px; padding: .35rem 8px; border: 0; border-radius: var(--radius-s); cursor: pointer; font-weight: var(--weight-medium); }
.stats-filter:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
.stats-filter input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.stats-filter-check { flex: 0 0 1em; text-align: center; visibility: hidden; }
.stats-filter input:checked + .stats-filter-check { visibility: visible; }
@media (forced-colors: active) {
  .stats-filter-help { outline: 1px solid ButtonText; outline-offset: -1px; }
  .stats-filter-help:has(input:checked) { outline: 2px solid Highlight; }
}
.stats-sections { display: grid; gap: var(--space-4); }
/* At 320px/T200 a long sample pill and selection title need separate rows.
   Their shared heading must not widen every metric and its help control. */
.stats-sections > .section-heading { flex-wrap: wrap; }
/* No `overflow-wrap` of its own, deliberately. The prettier `break-word` was
   tried here and is exactly the Story 262 defect returning: this line shares a
   flex row with the sample pill, so its min-content width is part of the
   heading's, and a name that refuses to break mid-word made `#stats-sections`
   278px wide inside a 200px box at V320/200% under the CI runner's wider sans.
   Story 262's card-wide `anywhere` keeps the whole card able to honour its
   width; the cost is a name broken mid-word on the narrowest phone at the
   largest text, and that is the trade this card already made everywhere else. */
.stats-selection-title { margin: 0; font-size: var(--text-m); font-weight: var(--weight-strong); }
.stats-empty { margin: .6rem 0 0; color: var(--text-supporting); font-size: var(--text-s); }
.stats-group { margin-top: .9rem; }
.stats-group-title { margin: 0 0 var(--space-2); color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.stats-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
.stats-metric { display: grid; justify-items: start; gap: .15rem; padding: .7rem .8rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-card); }
.stats-metric-label { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-medium); }
.stats-metric-value { font-size: var(--text-xl); font-weight: var(--weight-strong); line-height: 1.1; }
.stats-metric-detail { color: var(--text-hint); font-size: var(--text-xs); }
.stats-note-line { margin: var(--space-2) 0 0; color: var(--text-hint); font-size: var(--text-xs); }
/* The card-level notes stand above the numbers, so they lead with their own
   gap instead of trailing one. */
#stats-note .stats-note-line { margin: 0 0 var(--space-2); }

/* Glossary (Stories 166 and 518) ------------------------------------------------
   The card is Learn's first built target. The Statistics opener remains a
   full-width control so it reads as the end of that card rather than as a stray
   button beside it; both paths open this one surface.

   Restated in this palette's own names. Story 166 was written against the
   scheme this slice replaces and reached for `--ink-dim`, `--border`,
   `--sunken` and `--deep`, none of which are declared here — CSS resolves an
   undefined custom property to nothing, so the entry cards would have lost
   their ground and the formula block its own. The field follows `textarea`,
   the group heading follows `.replay h4`, and the entry follows the sunken
   panels the replay stage already uses. */
.stats-glossary-open { width: 100%; margin-top: var(--space-4); }
.glossary-search { display: grid; gap: .35rem; margin: var(--space-4) 0; }
.glossary-search input {
  width: 100%;
  padding: var(--space-3);
  border: 1px solid var(--field-border);
  border-radius: var(--radius-s);
  background: var(--console);
  color: var(--text-body);
  font: inherit;
}
.glossary-count { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.glossary-list { display: grid; min-width: 0; gap: var(--space-2); }
.glossary-group { min-width: 0; padding: var(--space-3); border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.glossary-group[open] { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-2); }
/* Story 518 AC8: the disclosure row itself is the control. Its small caps
   label is only about 17px tall at T100, so the details box's surrounding
   padding cannot stand in for the summary's hit area. The minimum and balanced
   block padding keep the native marker while giving that actual control the
   same 2.8rem floor as buttons and fields. */
.glossary-group-title {
  min-width: 0;
  max-width: 100%;
  min-height: 2.8rem;
  padding-block: .85rem;
  color: var(--text-hint);
  cursor: pointer;
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  hyphens: auto;
  letter-spacing: var(--tracking-caps);
  overflow-wrap: anywhere;
  text-transform: uppercase;
}
.glossary-entry { min-width: 0; padding: var(--space-3); border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); hyphens: auto; overflow-wrap: anywhere; }
.glossary-term { margin: 0 0 var(--space-1); font-size: var(--text-m); font-weight: var(--weight-strong); }
.glossary-body { margin: 0; color: var(--text-supporting); font-size: var(--text-s); }
/* The formula sits on the same sunken ground as the entry around it, so the
   hairline is what separates them — the design spends borders where a shadow
   would otherwise go. Mono and tabular because a formula is figures read
   against each other. */
.glossary-formula {
  margin: var(--space-2) 0 0;
  padding: var(--space-1) var(--space-2);
  border: var(--hairline);
  border-radius: var(--radius-s);
  background: var(--console);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: var(--text-s);
  font-variant-numeric: tabular-nums;
  line-height: 1.45;
}
/* Story 165: the rules table's one-line answer. Words rather than figures, so it
   keeps the entry's own type and earns its separation from the sentence above
   with weight and the accent the board gives a category's value. */
.glossary-hint {
  margin: var(--space-2) 0 0;
  color: var(--accent);
  font-size: var(--text-s);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
}
/* Story 172: what the field is worth under perfect play. Supporting colour and
   the entry's own type, deliberately quieter than the hint above it: this is
   background about the rules, not an instruction, and it must never read as a
   verdict on how the reader filled the field. */
.glossary-odds {
  margin: var(--space-2) 0 0;
  color: var(--text-supporting);
  font-size: var(--text-s);
  font-variant-numeric: tabular-nums;
  line-height: 1.45;
}
.glossary-terms { display: flex; min-width: 0; flex-wrap: wrap; gap: .35rem; margin: var(--space-2) 0 0; padding: 0; list-style: none; }
/* Story 518 AC10: the generic pill stays on one line when it reports a compact
   value. Here it carries governed vocabulary, including German compounds, and
   at 200% text one term can be wider than a phone card. Let that one word grow
   downward while its pill remains one contained item in the wrapping list. */
.glossary-terms .pill { box-sizing: border-box; width: fit-content; min-width: 0; max-width: 100%; overflow-wrap: anywhere; white-space: normal; }
.glossary-empty { margin: 0; color: var(--text-supporting); font-size: var(--text-s); }

details summary { cursor: pointer; font-weight: var(--weight-strong); }
/* Story 185: `details summary` above is right for every disclosure — a replay
   round is one, and it should look clickable too. This spacing is not. It was
   written for the measurement harness and, as `details[open] summary`, reached
   all thirteen rounds the replay controller builds and opens: a round already
   spaces its rows with `.replay-decision`'s own .7rem top margin, so the
   --space-4 landed on top of that inside a card padded .55rem.
   Named positively rather than excluding the round. Saying "every open
   disclosure except a replay round" would keep the same bare-type descendant
   shape this story exists to remove, and would hand harness spacing to the next
   `<details>` anyone adds unless its author knew to opt out. A panel that wants
   this spacing asks for it. */
.harness[open] > summary { margin-bottom: var(--space-4); }
.controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; margin: var(--space-4) 0; }
.start-inputs { display: grid; gap: .45rem; margin: var(--space-4) 0; }
.start-input-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .6rem; }
.start-input-row input { min-width: 0; padding: .7rem .8rem; border: 1px solid var(--field-border); border-radius: var(--radius-s); background: var(--surface-card); color: var(--text-body); font: inherit; }
textarea { width: 100%; padding: .8rem; border: 1px solid var(--field-border); border-radius: var(--radius-s); background: var(--console); color: var(--text-body); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.45; resize: vertical; }

a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-press); text-decoration: underline; }
:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-offset); }

@media (max-width: 32rem) {
  .card { padding: var(--space-4); }
  /* Story 284: the same reading of the same budget, one level out. The card's
     own side padding is chrome too, and at 200% text it took 64px of a 320px
     screen for the tutorial card alone. Capped here rather than on `.card`,
     because this is the one card whose longest word does not fit what is left
     over: the other two onboarding cards and every solo surface are measured
     contained at this width and this text size, before and after this story. */
  #onboarding-tutorial { padding-inline: min(var(--space-4), 16px); }
  /* Story 470: the same reading again for the Balanceboard card. Its game
     stage carries a five-die row, and at 200% text the rem padding took 64px
     of a 320px screen — measured before this cap: 33.2px dice against the
     44.8px floor. With the physical cap the row keeps 260px and every die
     clears the floor. */
  #balanceboard { padding-inline: min(var(--space-4), 16px); }
  /* Story 284: the card's second face. Once an answer is chosen the outcome
     panel opens, and its two fields sit in the verdict grid's `auto` first
     track — which floors at the label's own min-content and, inside a panel
     already padded within the card, left the second field a zero-width track
     and 178px of it outside the display. That is a wider miss than the answers
     this story was filed for, and it is the same reading of the same budget: a
     track floor that grows with the reader's text inside a box that does not.

     The idiom is `.summary-grid`'s, above: `min(6rem, 100%)` lets the floor
     drop to the panel's own width when the two no longer fit beside each
     other, so the fields stack instead of the panel reaching past the screen.
     At the default root size 6rem is 96px and both fields keep the single row
     they have today — but they share it evenly now (100px each at V320)
     instead of the first field taking its content width and leaving the second
     44px. That redistribution is visible at the default size, and it is the
     same asymmetry that became a zero-width track under text pressure. */
  .onboarding-outcome .verdict-grid { grid-template-columns: repeat(auto-fit, minmax(min(6rem, 100%), 1fr)); }
  /* Story 284: the heading is the one place where no amount of chrome is
     enough. "Entscheidung," is 313px at `--text-xl` and 200% text — wider than
     the 320px display itself — so its ink ran 54px past the edge while its box
     sat innocently inside the card, which is why every box-only measurement
     until now called this card contained. Capping the heading's growth is a
     deliberate trade against the reader's text setting, and it is the only one
     in this story: the answers, the intro and the prompt keep every pixel the
     reader asked for. `--text-xl` is 24px at the default root size, so this
     changes nothing until the text is enlarged. The same step already exists
     for this heading while a game is running, at the end of this file. */
  #onboarding-tutorial h2 { font-size: min(var(--text-xl), 24px); }
  /* Story 284: `.mode-actions` is the third element of the card, and its two
     controls carried the same `rem` side padding the answers did. Uncapped,
     "Einführung überspringen" left 4px of the card's content box to spare at
     200% text on this Mac's face and stood 5px past the display on a face a
     fifth wider — the failure mode Story 239 recorded, when the CI runner's
     sans found a floor no local run had ever seen. Capped, the same control
     has 36px of that box to spare and holds on both faces. Same cap, same
     pixels at the default root size. */
  #onboarding-tutorial .mode-actions button { padding-inline: min(1rem, 16px); }
  .controls, .mode-actions, .data-actions, .consent-actions { grid-template-columns: 1fr; }
  .start-input-row { grid-template-columns: 1fr; }
  .stats-grid { grid-template-columns: 1fr; }
  .bot-choice { grid-template-columns: 1fr; }
  .verdict-grid { grid-template-columns: auto minmax(0, 1fr); }
  /* Two tracks here, and the best-alternative value is the longest string the
     verdict carries. Left on `auto` it takes one of the two and wraps inside
     roughly half the row, which is what it did before this rule existed. */
  .verdict-grid .wide { grid-column: 1 / -1; }
}

/* AC2's reorder. The same four areas, in a different order: the board moves
   into a column of its own beside the dice instead of standing above them. */
@media (min-width: 48rem) {
  .game-stage {
    grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
    grid-template-areas:
      "meta  meta"
      "board dice"
      "board actions";
    grid-template-rows: auto minmax(0, 1fr) auto;
  }
  /* Story 319: the same reorder, with the corner roll intact — the compact
     row's six tracks stand where the classic right column stood, in the same
     1.7:1 split (10.2fr against six 1fr tracks). The board's end margin gives
     back the air the held-still 3px column gap does not, in the same
     `min(<rem>, <px>)` idiom; the row itself keeps the uniform gap that makes
     the six slots equal. Written inside this block because the compact base
     rule above carries the same specificity and would otherwise win by order. */
  [data-view="compact"] .game-stage {
    grid-template-columns:
      minmax(0, 10.2fr) repeat(5, minmax(0, 1fr)) minmax(min(2.8rem, 44.8px), 1fr);
    grid-template-areas:
      "meta  meta meta meta meta meta meta"
      "board dice dice dice dice dice actions";
    grid-template-rows: auto minmax(0, 1fr);
  }
  [data-view="compact"] .game-stage > .scorecard-board { margin-inline-end: min(.55rem, 9px); }
}

/* Story 318 AC3: on a landscape phone the board is the one zone left to give
   and already scrolls. The trigger is the physical viewport, not the reader's
   text size: 30em is 480px, under every supported portrait height and over the
   390px landscape one. */
@media (max-height: 30em) {
  /* Measured at VLand, where the 48rem
     reorder stands the board beside the dice: the window is 263px, the 100px
     reserve stands whole inside it, and the matrices keep 158px of their 246px
     — the board scrolls with under two thirds of it in view. Under 480px of
     height every further row is bought from that window, so the drawing
     yields its column back to the copy; the field itself stays, at its full
     reserve. */
  [data-view="compact"] .scorecard-grid > .coach-area { padding-inline-start: .55rem; }
  [data-view="compact"] .coach-cap { display: none; }
}

.die { transition: var(--die-transition); }

/* Stories 164 and 213: a roll has to look like one. `settings.motion.hint` in both
   catalogs already promised this — story 564 rewrote the sentence in the
   Coach's voice and it still does: "With Reduced you get a calm variant instead
   of the dice and reveal animations" — while only the reveal half of that
   promise existed. Nothing marked which dice a roll had thrown, so a die that came up on
   the face it already showed was indistinguishable from one nobody rolled.

   Same shape as `--reveal-animation` and deliberately no second mechanism: one
   custom property, redefined in both reduced branches above, so `data-motion`
   and `prefers-reduced-motion` reach this without a rule of their own.

   `data-rolled` is written by `markRerolled` in `src/roll-motion.ts` on exactly
   the dice the roll rerolled, so a held die never moves and a rerolled one moves
   whatever it landed on (AC1, AC2). The controllers rebuild their dice on every
   render, which is what starts the animation — there is no `revealPanel` restart
   to do here.

   Story 213 strengthens Story 164's almost imperceptible eight-degree lean into one short
   tumble: lift, turn, contact, a small rebound, then rest. Every moving value
   still lives inside `transform`, so it cannot reflow the board; no fill mode
   means the die ends on the resting transform its own rules give it, and the
   lift `[aria-pressed]` puts on a held die is left to the transition it already
   had (AC2, AC8). */
.die {
  --die-roll-turn: 1;
}
/* The neighbours turn against each other, so five dice read as a handful thrown
   rather than as one shape rotated five times. */
.die:nth-child(even) {
  --die-roll-turn: -1;
}
.die[data-rolled] { animation: var(--die-roll-animation); }

@keyframes die-roll {
  from { transform: translateY(-.5rem) rotate(calc(var(--die-roll-turn) * -24deg)) scale(.94); }
  38% { transform: translateY(.08rem) rotate(calc(var(--die-roll-turn) * 14deg)) scale(1.04); }
  68% { transform: translateY(-.14rem) rotate(calc(var(--die-roll-turn) * -6deg)) scale(.99); }
  to { transform: none; }
}

@keyframes die-roll-strong {
  from { transform: translateY(-1.2rem) rotate(calc(var(--die-roll-turn) * -150deg)) scale(.88); }
  36% { transform: translateY(.1rem) rotate(calc(var(--die-roll-turn) * 32deg)) scale(1.06); }
  60% { transform: translateY(-.55rem) rotate(calc(var(--die-roll-turn) * -20deg)) scale(.97); }
  80% { transform: translateY(.05rem) rotate(calc(var(--die-roll-turn) * 8deg)) scale(1.02); }
  to { transform: none; }
}

/* AC4: the calm variant is a variant, not a removal — the die still marks that
   it was thrown, it just settles in rather than turning. Same relationship
   `reveal-fade` has to `reveal-rise`. */
@keyframes die-settle {
  from { opacity: .45; }
  to { opacity: 1; }
}

/* The reveal cards. `revealPanel` in `presentation.ts` restarts the animation
   when a panel goes from hidden to shown, because an animation declared here
   would otherwise run once, at load, on a card nobody has seen yet. */
.verdict, .onboarding-outcome { animation: var(--reveal-animation); }

@keyframes reveal-rise {
  from { opacity: 0; transform: translateY(.4rem); }
  to { opacity: 1; transform: none; }
}

@keyframes reveal-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Story 154: the shell's navigation — ONE element with two presentations. Below
   921px it is a bar along the bottom edge; from 921px up the same element and
   the same links stand as a rail at the side. There is deliberately no second
   markup tree for the wide case (AC5).

   The bottom safe-area inset lives here rather than on `main` (AC6): the bar is
   the element the home indicator overlaps, and `main` instead reserves the room
   the bar occupies so its last row is never trapped underneath. */
.app-nav {
  position: fixed;
  inset: auto 0 0 0;
  z-index: 10;
  display: flex;
  /* Stories 273/518: where five tabs cannot stand side by side, the row that cannot
     hold them wraps. This is what contains the bar now — see the tab rule
     below, which no longer buys containment by cutting words in half. */
  flex-wrap: wrap;
  min-height: var(--nav-height);
  padding-bottom: env(safe-area-inset-bottom);
  border-top: var(--hairline);
  background: var(--surface-card);
}

/* AC4: while a game is running the shell sets `hidden` on the bar, and the
   display rule above would otherwise outrank the attribute's own. */
.app-nav[hidden] { display: none; }

/* Story 264 gave the bar back its containment at 200% text, and Story 273 pays
   the bill it left standing. `flex: 1 1 0` hands every tab an equal share
   of the bar whatever its word needs, and 264 made the quarters hold by letting
   the word give way instead: `overflow-wrap: anywhere` breaks at any character
   at all, and `hyphens: auto` lowers a word's min-content to its longest
   hyphenation fragment. Both lower the automatic minimum `min-width: auto`
   gives a flex item, which is exactly why they contained the bar — and exactly
   why "Spielen" came apart as "Spi / ele / n" and "Mehr" as "Meh / r".

   `hyphens: auto` is the sharper half, because it only ever covers some of the
   words and which ones is the machine's to decide. Where the engine has a
   hyphenation opportunity inside the word it takes it and prints a hyphen;
   where it has none, `anywhere` cuts at whatever character the line ends on and
   prints nothing at all. Measured on this Mac at V320/200%, from the then-four
   labels of one bar: German gave "Train- / ing" and "Anal- / yse" with a
   printed hyphen 12px wide, and "Spie / len" with no hyphen at all; English —
   whose patterns this build does not carry — gave "Traini / ng" and
   "Analy / sis", both blind. The CI runner divides the same four labels
   differently again, from the same stylesheet. A fix that leans on hyphenation
   is therefore a fix for some words on some machines.

   So neither property is here any more, and the minimum is a whole word again.
   A tab that cannot stand beside its neighbours moves to the next flex line;
   it never cuts its label or reaches beyond the viewport.

   Story 518 adds the fifth word and measures the whole bar again. The block
   cap preserves the 44.8px target floor and the physical one-pixel inline
   inset spends only the space the label does not need. The current mark below
   is positioned paint, so it cannot enlarge one flex item. The type cap is the
   remaining phone-only bound: 6vw is 19.2px at V320 and 21.6px at V360, below
   T200's 26.24px but above T100's 13.12px. It therefore leaves ordinary text
   untouched while containing the wider hosted system face without breaking a
   word. The shipped languages then take at most two T200 rows and the long
   locale at most three. Story 323 still writes the resulting real border-box
   height into `--nav-rendered-height`, so `main` reserves whichever rows the
   bar actually needs. */
.app-nav-link {
  position: relative;
  display: flex;
  flex: 1 1 0;
  align-items: center;
  justify-content: center;
  min-height: min(2.8rem, 44.8px);
  padding-block: min(.55rem, 8.8px);
  padding-inline: min(.0625rem, 1px);
  color: var(--text-secondary);
  font-size: min(var(--text-s), 6vw);
  font-weight: var(--weight-strong);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-mech);
}

.app-nav-link:hover { color: var(--text-body); }
.app-nav-link[aria-current="page"] { color: var(--accent); }
/* Which tab is current is carried by `aria-current` for a reader and by this
   inset rule for an eye, so the accent colour is never the only carrier. It is
   positioned outside flex sizing: five whole labels can therefore share one
   V320 row at T100, while the line cannot collide with or split a label. */
.app-nav-link[aria-current="page"]::before {
  position: absolute;
  inset: auto min(.2rem, 3.2px) 0;
  height: 2px;
  background: currentColor;
  content: "";
}
.app-nav-link:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }

/* The panel takes focus on a route change (AC11); it is a destination, not a
   control, so only an explicit keyboard focus draws a ring. */
.route-panel:focus { outline: none; }
.route-panel:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: .35rem; }

@media (min-width: 921px) {
  html { scroll-padding-bottom: 0; }
  body { padding-left: var(--nav-rail-width); }

  /* Story 273's two bar-only values, given back at this breakpoint rather than
     leaked across it. The rail runs down the side, so its cross axis is the
     11rem width: a wrapped rail would put a tab beside the rail instead of
     under the one above it. And it has the height for a floor that grows with
     the reader's text, which is the thing the bar had to cap. */
  .app-nav {
    inset: 0 auto 0 0;
    width: var(--nav-rail-width);
    flex-direction: column;
    flex-wrap: nowrap;
    justify-content: flex-start;
    gap: .2rem;
    padding: max(1rem, env(safe-area-inset-top)) var(--space-2) max(1rem, env(safe-area-inset-bottom));
    border-top: 0;
    border-right: var(--hairline);
  }

  .app-nav-link { flex: 0 0 auto; justify-content: flex-start; width: 100%; min-height: 2.8rem; padding: .6rem .8rem; border-radius: var(--radius-s); }
  .app-nav-link[aria-current="page"] { background: var(--surface-sunken); }
  main { padding-bottom: max(3rem, env(safe-area-inset-bottom)); }
}

/* Legal pair (Story 283, moved by Story 453) --------------------------------------
   Two links to two standalone documents — the app's only exits, and the URL
   App Store Connect is given before external TestFlight. Story 283 put them on
   two surfaces: this card and a footer under every route. Story 453 removed
   the footer, so the card is the single surface these rules serve and the
   focus-mode exception the footer needed is gone with it.

   The card takes the wrapping pair the rest of the app uses on long German
   compounds (Story 264): `overflow-wrap: anywhere` is what actually holds a
   320px card at 200% text, because "Datenschutzerklärung" is wider on its own
   than the content box, and `hyphens: auto` improves where the break lands
   wherever the engine hyphenates. `flex-wrap` is the row's half of the same
   answer the tab bar got in Story 273: a row that cannot hold both labels side
   by side wraps instead of cutting one. */
.card.legal {
  hyphens: auto;
  overflow-wrap: anywhere;
}

.legal-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
}

.legal-links a { color: var(--text-secondary); }

.legal-links a:hover { color: var(--text-body); }

/* Focus mode (Story 155) ---------------------------------------------------------
   `data-playing` is written by `navigation.ts` on exactly the same condition
   that hides the tab bar: this route is the play route and a game of it is on
   screen. Everything below is what that costs the rest of the page.

   The whole play route becomes one viewport-tall column with the play surface
   as its only running-game card. Story 205 moved the coach into the board, so
   no message can add a sibling below the stage and displace the dice. */
[data-playing] body { padding-left: 0; }

[data-playing] main {
  width: min(100% - 1rem, 54rem);
  height: 100dvh;
  /* `calc(env(…) + …)` and not `max(.3rem, env(…))`: the second form computed to
     zero on the reference run, which put the last card's border on the very
     last pixel row. Same shape as the shell's own padding above. */
  padding: env(safe-area-inset-top) 0 calc(env(safe-area-inset-bottom) + .3rem);
  /* Story 239: `hidden` here, and the surface that does not fit is *cut off*
     rather than reachable. D1 spends exactly one exemption on this — V320 at
     200% text may fall back to page scrolling — and adds that nothing may be
     lost there, which a clip cannot honour. `visible` spends it: the column is
     still one viewport tall, so nothing scrolls at any width or text size where
     the surface fits, and where it does not the document grows and the action
     is reachable instead of gone. Measured at V320/V360/V390 x 100%/200% and on
     a deliberately wider sans: the page scrolls in that one corner and nowhere
     else. */
  overflow: visible;
}

[data-playing] #route-play {
  container: play-route / inline-size;
  display: flex;
  flex-direction: column;
  gap: .4rem;
  height: 100%;
  min-height: 0;
}

[data-playing] .game-stage {
  flex: 1 1 auto;
  min-height: 0;
  margin: 0;
  /* Story 328: the inline half of this inset is frame, and in `rem` it doubled
     with the reader's text — 17.6px a side at 200% on the very widths where
     the scorecard has least room. Capped at its default-root 8.8px, the same
     `min(<token>, <px>)` idiom the Story 284 gutter uses. Written as the two
     longhands so the block half keeps resolving exactly as the shorthand did:
     the compact duel's 2px and the magnified classic duel's 4px
     `padding-block` caps below override this rule the same way they overrode
     `padding: .55rem`. */
  padding-block: .55rem;
  padding-inline: min(.55rem, 8.8px);
  gap: .45rem;
}
/* Story 319: the shorthand above resets the compact row's column gap, and six
   equal slots at V320 exist only over the held-still 3px — measured before
   this rule: the stage's columns fell back to .45rem while the dice kept 3px,
   so the sixth slot was narrower than the five it must equal. Story 369 keeps
   the compact row gap physically bounded for the same reason. */
[data-playing][data-view="compact"] .game-stage {
  column-gap: min(var(--space-1), 3px);
  row-gap: min(.45rem, 3px);
}
/* Story 369 hosted convergence: the dense duel spends the spacing rung before
   it can spend board space. Story 409's reduced outlook no longer makes the
   header height face-dependent, but the physical vertical card insets and two
   portrait gaps remain the compact rhythm; inline padding, type roles and every
   44.8px target remain untouched. */
[data-playing][data-view="compact"] .duel-game {
  padding-block: min(.55rem, 2px);
  row-gap: min(.45rem, 2px);
}

/* Story 330: play mode restates the head row's gap, so it must restate the
   held-still cap too — 5.6px is .35rem at the default root size, per the
   min(<rem>, <px>) idiom story 306 documents on this row's base rule. */
[data-playing] .game-meta { gap: min(.35rem, 5.6px); }

@container play-route (max-width: 10em) {
  #duel-scorecards { min-height: min(2.8rem, 44.8px); }
}

/* #746 (Story 411 AC3 on the hosted runner): the family's .75rem `--text-xs`
   floor widened the counter and mode captions at V320/T200, where the compact
   header has no slack — counters, mode, the zero-width middle track and the
   exits already fill the 268.4px stage. On the runner's DejaVu face the RUNDE
   ink then reached 2.45px into the mode lane (#747 CI; a Verdana stand-in on
   the reference Mac reproduces 2.83px). The captions keep their size; only
   their caps tracking tightens here, from .1em to .04em, which the stand-in
   measures as about 3px clear. The token is set on the two caption roles
   themselves, not on the header: the duel outlook inside `.game-meta` keeps
   its own calibrated tracking (Story 377). The lab's wide face still adds its
   .09em on top, because it reads the same token on the same elements. */
@container play-route (max-width: 10em) {
  [data-view="compact"] .game-stage > .game-meta :is(.turn-label, .mode-label) { --tracking-caps: .04em; }
}

/* The duel offer (Story 44 AC3) is an invitation into a *different* game, so it
   is one of the things a match is not. It steps aside here rather than being
   answered or dismissed: `offerVisible` is untouched, and the card is waiting
   on the play route the moment the reader leaves or finishes the match.
   The shell hides what the shell owns — the resume card, in `navigation.ts`;
   this is what focus mode costs a surface the shell does not own. */
[data-playing] #onboarding-duel-offer { display: none; }

/* The guided first round (Story 44) is the one other card that shares this
   screen, and it is coaching rather than chrome: it is compacted here instead
   of being hidden, and capped so that whatever it has to say, the board below
   it keeps the height its sixteen tiles need. */
[data-playing] .onboarding { flex: 0 0 auto; max-height: 24dvh; margin: 0; padding: .55rem .7rem; overflow: auto; }
[data-playing] .onboarding h2 { font-size: var(--text-m); }
[data-playing] .onboarding p { margin-bottom: .4rem; font-size: var(--text-s); line-height: 1.4; }
[data-playing] .onboarding .eyebrow { margin-bottom: .25rem; font-size: .64rem; }
[data-playing] .onboarding-steps { gap: .15rem; margin-bottom: .45rem; }
[data-playing] .onboarding-steps li { font-size: var(--text-s); }
/* Story 298: focus mode keeps the two exits at the 44.8px floor when the
   reader enlarges text. Their icons need no extra reading width, and letting
   the rem-based square double consumed the space both counter columns need.
   At the default root size every cap resolves to the existing 2.8rem. */
[data-playing] .game-exits .icon-button {
  flex-basis: min(2.8rem, 44.8px);
  width: min(2.8rem, 44.8px);
  min-height: min(2.8rem, 44.8px);
  padding: 0;
  font-size: var(--text-s);
}

/* The open analysis (Story 506, P4) ----------------------------------------------
   `data-reviewing` is written by `navigation.ts` on exactly the condition that
   hides the tab bar here: the training route is showing, and the Partieanalyse
   is the target open on it. Felix took that on 2026-08-31, parting from the
   Balanceboard's own D1 (story 496) for this one surface — the bar's reserved inset
   is the largest thing the chrome can give back, and the head row's own back
   control is already the way out. Story 612 (below) took the same for the
   Balanceboard on 2026-09-04, on an attribute of its own.

   **This is a second attribute rather than a second condition on
   `data-playing`, and that is the whole point of AC5.** The replay's play
   stage carries `.game-stage`, so reusing the match's attribute would hand it
   every `[data-playing] .game-stage` rule above — a viewport-tall flex column,
   a re-specified padding pair, two compact gap caps — none of which was
   measured on this surface. Story 319's own note at the compact stage rule is
   the same shape. So the rules the analysis takes are exactly the two below,
   written out, and nothing arrives by inheritance.

   Rule one: the rail's reserve. From 921px up the bar is a rail down the side
   and `body` reserves 11rem for it; with the bar gone that reserve is width
   the card can have.

   Rule two: the bar's own reserve, which is the height this decision was taken
   for. `main` reserves `--nav-height` plus a safe-area inset plus 1rem at the
   bottom (78.4px at the reference root) and 1rem at the top; with the bar gone
   both are room the analysis can stand in. The value is focus mode's own, for
   the reason focus mode states it: `calc(env(…) + .3rem)` rather than
   `max(.3rem, env(…))`, whose second form computed to zero and put the card's
   border on the last pixel row.

   What is deliberately **not** taken is focus mode's `height: 100dvh`. A match
   is a column that must not scroll; the analysis is a document that scrolls
   wherever it does not fit, and P5 binds the one-screen promise to the rungs
   where the measurement says it holds rather than to a rule that would cut off
   the rest. */
[data-reviewing] body { padding-left: 0; }
[data-reviewing] main {
  padding-block: env(safe-area-inset-top) calc(env(safe-area-inset-bottom) + .3rem);
}

/* The open Balanceboard (Story 612) ---------------------------------------------
   `data-balanceboard` is written by `navigation.ts` on exactly the condition
   that hides the tab bar there since story 612: the training route is showing
   and the Balanceboard is the target open on it — setup, running and finished
   alike, the same condition story 496 (D1) hid the shell header on. Felix
   amended D1's other half on 2026-09-04: the Balanceboard takes the bar as
   well, and the head row's ‹ Training control (D2) is already the way out.

   A third attribute, for story 506's reason (its AC5): the board's stage
   carries `.game-stage`, so reusing `data-playing` would hand it the whole
   focus-mode cascade above, none of which was measured here. The surface
   takes exactly story 506's two rules, written out again rather than shared
   through a selector list, so that a later change to one surface's reserve
   cannot silently move the other's — and, deliberately, both ends of `main`'s
   padding: with the bar gone, the 1rem above the card is room as much as the
   78.4px below it, which focus mode has shipped on the play view since story
   155 and story 506's own comment records. On a device with a top inset the
   card still stands below it; only on a host with no inset does the 1rem go.

   What the rules are worth: 89.6px on the phones and VLand at the reference
   root (78.4px below plus 16px above, against the 4.8px the rule keeps) and
   59.2px at the rail rung, where `main`'s own 3rem was the reserve and the
   rail's 11rem of `body` padding is width the card gets back. None of it
   stays free: story 601's `fitTableRows` re-reads the strip and spends it on
   the scoring rows, floor(free / 15) whole pixels each, so the room comes
   back as row height and the fit's margin shrinks rather than grows. Not
   taken, for story 506's reason: focus mode's `height: 100dvh` — this surface
   is a document that scrolls where it does not fit.

   `--nav-rendered-height` keeps its last real measurement while the bar is
   hidden (`updateNavClearance`: leaving the surface needs safe clearance
   before the ResizeObserver reports the shown box), which is why the reserve
   is taken back by overriding `main`'s padding here and not by the token
   falling to zero. */
[data-balanceboard] body { padding-left: 0; }
[data-balanceboard] main {
  padding-block: env(safe-area-inset-top) calc(env(safe-area-inset-bottom) + .3rem);
}
/* Story 154 (and #43): the language switch. It moved out of the shell header
   onto the "Mehr" route with the rest of what is not a game — the header now
   carries only the two shell status lines. */
.language-choice {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

.language-choice label {
  color: var(--text-secondary);
  font-size: var(--text-s);
}

.language-choice select {
  min-width: 0;
  max-width: 100%;
  background: var(--surface-sunken);
  border: var(--rule);
  border-radius: var(--radius-s);
  color: inherit;
  font: inherit;
  min-height: 2.75rem;
  padding: 0.35rem 0.6rem;
}

/* Story 547: the Balanceboard's table view — since story 555 the
   Balanceboard's one view, with no switch left on the setup card. The scroll
   wrapper is the one horizontal scroller the table's contract names: width
   the five columns cannot get is spent inside it, and the page itself never
   scrolls sideways for the table. */
/* `contain: inline-size` is what keeps the promise "width the columns cannot
   get is spent inside the wrapper": without it the table's min-content width
   climbs through the board's grid item (`min-width: auto`) and pushes the
   whole card past a phone viewport — measured 49px at V320 on this slice's
   first full matrix run. With it the wrapper contributes nothing to its
   ancestors' intrinsic width, takes the width layout gives it, and the table
   scrolls inside. */
/* Story 601: and the wrapper is where the rows' share of the face's free
   height is written. `fitTableRows` in `balance-table.ts` measures the strip
   of paper left under the card and sets this property to what one row may
   take of it; every rule below that spends it reads it from here, so a
   wrapper nobody measured — the test DOM, an unmeasured first frame — prints
   exactly the table this file printed before the story. */
.balance-table-scroll {
  --balance-row-lift: 0px;
  contain: inline-size;
  overflow-x: auto;
}

.balance-table {
  border-collapse: collapse;
  min-width: 100%;
}

.balance-table th,
.balance-table td {
  text-align: right;
  white-space: nowrap;
}

/* Story 554: a label cell keeps its own padding; a value cell hands it to the
   one laid-out child it holds — see `.balance-table-value` below. */
.balance-table th { padding: var(--space-1) var(--space-2); }

.balance-table th[scope="row"],
.balance-table thead th:first-child {
  font-weight: var(--weight-regular);
  text-align: left;
}

.balance-table thead th {
  color: var(--text-secondary);
  font-size: var(--text-s);
  font-weight: var(--weight-regular);
}

.balance-table tbody tr { border-top: 1px solid var(--border-hairline); }

.balance-table td { font-variant-numeric: tabular-nums; padding: 0; }

/* Story 601: the scoring rows grow into the room the face has.

   The lift is spent on the **line box**, not on the cells' padding, and that
   is the whole of why this rule is one line in `tbody` rather than a padding
   term in three places:

   - every cell of a row gets it at once, the row header and the four value
     cells alike, so the rows stay equal without a second rule saying so;
   - the score control inherits it (`.balance-table-score` sets `font:
     inherit`, and the `font` shorthand carries the line height), so the
     control still *is* the whole roll cell — story 554 AC4, which a padding
     term on the value alone would have left standing 1px short of its cell
     on the rows whose header grew;
   - and the category glyph below can grow with the row, because what bounds
     it is the line box it is aligned inside. Padding leaves the line box
     where it was, and a bigger sign in an unchanged line box makes a sign
     row taller than a word row — story 559 AC3's identity, measured broken
     at 1.5em on the unlifted table (+1.61px at V390, 100% text).

   `tbody` and not the table: the header row is not a scoring row and keeps
   the leading the rest of the app reads at. The term is the body's own
   `--leading-body`, written as a length so the lift can be added to it — at
   the cells' 15.2px that is the same 22.8px line box as before, and every
   descendant inherits the computed length rather than re-resolving a ratio,
   which is what keeps the smaller `thead` type out of it.

   The lift itself is measured, never a rung: what a face has to spare is a
   fact of the reader's device, text size, language and host font, and the
   only number that is true on all of them is the one read off the laid-out
   page (`fitTableRows`, and story 559's own reason for measuring rather than
   ruling the category column). */
.balance-table tbody {
  line-height: calc(var(--leading-body) * 1em + var(--balance-row-lift));
}

/* Story 554 (AC1/AC2): the dice change numbers, never boxes.

   Every value cell holds exactly one laid-out child — the value's span, or
   the entry control while the row can be scored — and that child carries the
   cell's padding, so the swap between the two changes what is printed and
   nothing about the box. The line box is the same either way: the control
   inherits the cell's font and line height, and the edge it wears is painted
   by `.balance-table-score` below without a layout cost. Measured on the
   `table-running` fixture at V390 and 100% text before this rule: every
   scoreable row grew from 31.8px to 53.8px when the dice landed, ten rows at
   once, and the roll column widened by 14.7px — the whole table 198px taller. */
.balance-table-value {
  box-sizing: border-box;
  display: block;
  padding: var(--space-1) var(--space-2);
}

/* And the other half of "nothing moves": a column's width is reserved for the
   widest number the rules allow in it, not for the number on screen, because
   what changes when the dice land is not only the roll column. The roll
   column gains ten controls; the EVL column takes the turn's pending cost;
   the Glück column and the EFS column move with the projection, and their
   Gesamt cells are the Glücksbilanz and the expected final score, which move
   with every roll (story 602). Any of those can gain or lose a digit between
   the two states of one turn.

   The reserve is a ruler rather than a `ch` sum: a zero-height, invisible
   `::before` that prints the widest string the column can carry, in the
   cell's own font, weight and numeral spacing. `ch` was measured and refused
   — it is the font's "0" advance without the tabular feature the cells set,
   9.56px against the 9.33px a laid-out digit actually takes here, and a
   whole-digit multiple of it costs 27px across the four columns at V390
   for nothing. The ruler costs exactly the bound and moves with any host's
   sans, so it needs no allowance.

   The bound is three integer digits and a tenth, and every column shares
   it: the roll column's widest value is a scorecard's 375, the EFS column's
   is bounded by the same 375, the EVL column's total is the game's
   given-away points, and the Glücksbilanz is the drift in `[−246, +130]`
   plus the given-away points. Story 602 (AC2): the two columns that carry a
   sign are EVL — a loss prints negative — and Glück; Wurf and EFS are
   unsigned. The reserves cost in sum exactly what story 554's four columns
   cost (two signed, two unsigned), which is what keeps the table fitting
   where it fit before. The two cumulative quantities, the EVL total and the
   Glücksbilanz, can pass three digits in a game played to lose (story 500
   measured about 292 against ordinary dice); a fourth digit is *recorded*
   by the lab's slack reading and not reserved, for story 500's reasons: it
   is a measured reference rather than a proved bound, and reserving it would
   cost every phone width the table does not have. The analysis probe
   measures each column against the same bound per run (`columnSlackPx`)
   rather than trusting this file. */
.balance-table .balance-table-value::before {
  content: "";
  display: block;
  height: 0;
  overflow: hidden;
  visibility: hidden;
}

.balance-table .is-roll > .balance-table-value::before,
.balance-table .is-efs > .balance-table-value::before { content: "000,0"; }

.balance-table .is-evl > .balance-table-value::before,
.balance-table .is-luck > .balance-table-value::before { content: "−000,0"; }

/* Story 602: a written row's EFS is its standing — the same points the Wurf
   column shows, printed again because the column's additivity to the Gesamt
   EFS is the point of the table — set off in the secondary colour so the eye
   reads a restatement, not news. */
.balance-table .is-efs > .is-settled { color: var(--text-secondary); }

.balance-table .is-ghost { color: var(--text-score-preview); font-weight: var(--weight-regular); }

.balance-table tr.is-bonus th,
.balance-table tr.is-total th {
  font-weight: var(--weight-medium);
}

.balance-table tr.is-total { border-top: 1px solid var(--border-control); }

.balance-table tr.is-total td { font-weight: var(--weight-strong); }

/* Story 559: the category column speaks in short forms and signs.

   Nine rows carry both forms in one cell — the six number fields their die
   face, Full House a house, the two straights a rising run of four and of
   five steps — and the wrapper's `data-form` attribute decides which one is
   painted. The attribute is written by a measurement, not by a rung
   (`fitCategoryColumn` in `balance-table.ts`; Felix, 2026-09-03, on Codex
   round 1 of pull request 576): the table is laid out at its max-content width with
   every row printing its word, and where that is wider than the wrapper the
   wrapper says `sign`. Story 412's em rung was considered and refused for
   this column — a rung is one font's number, and "the column cannot hold
   the word" is a fact of every host at every width: measured on the
   reference Mac the table with words needs 24.68em of the wrapper's font in
   English and 24.33em in German at 100% text (375.2px and 369.8px at
   15.2px), while the CI runner's wider sans needs 26.28em for the same
   German rows at 200%. No single number is right on both, and the
   measurement is right on each. Below: the glyph is painted and the word
   becomes the cell's visually hidden text (kept in the accessibility tree,
   so the row's name never moves — AC4); with `word`, or with no attribute at
   all, the word is printed and the glyph is `display: none`, so an
   unmeasured layout prints what it always printed. A wide screen keeps its
   words.

   Where the matrix looks, the phones' wrapper stands at 258–328px against
   369.8–375.2px of words at 100% text (signs, and 8/5px of sideways scroll
   at V390 in German/English where story 554 recorded 42/53), the two
   landscape faces at 757–761px (words), and at 200% text VLand's 709px and
   VWide's 537px against 720.5–731.7px of words (signs). `analysis-probe.ts`
   asks the same question with the same function and holds the attribute to
   its own answer per run.

   The glyph is an inline-block one and a quarter em tall, aligned to the
   middle of the text, so its box stays inside the line box the word already
   pays for (AC3): a cell showing a sign is exactly as tall as a cell showing
   a word, and the table's height cannot depend on which form the wrapper
   chose. Its ink is `currentColor` — the row's own text colour in either
   theme — and the die face wears the dice's own tokens. The word's hidden
   form is the `.visually-hidden` recipe spelled out here, because the state
   is the wrapper's and not the element's; the cell is positioned so the
   one-pixel box stays inside the wrapper's scroll (story 554's lesson, learnt
   on its since-retired row under Gesamt). */
.balance-table th[scope="row"] { position: relative; }

.balance-table-glyph { display: none; }

.balance-table-glyph > svg {
  display: block;
  block-size: 100%;
  fill: currentColor;
}

.balance-table-face { block-size: 100%; }

.balance-table-label > .balance-table-word { white-space: nowrap; }

/* Story 601: the sign grows with its row, and stops where the row stops.

   1.25em was the largest sign the unlifted line box could hold — measured on
   this Mac at V390 and 100% text, 1.35em already made a sign row 0.47px
   taller than a word row and 1.5em made it 1.61px taller. The bound is the
   line box, not the em: `vertical-align: middle` hangs the box from the
   text's own middle, so what it may be is roughly the leading plus the
   x-height, and every pixel the row's lift adds to the line box is a pixel
   the sign may take. Hence the sum rather than a second measurement — the
   lift is already the room the face gave the row.

   And a ceiling at 1.5em, which is the size story 601 asks for: a sign is a
   short form of a word, and a sign that outgrew its own row's type would
   read as an icon column. Where the face has no room to give — every phone
   at 100% text but V390, and every face at 200% — the lift is 0px, the term
   is 1.25em, and the column prints exactly the signs it printed before. */
.balance-table-scroll[data-form="sign"] .balance-table-label > .balance-table-glyph {
  display: inline-block;
  block-size: min(1.5em, calc(1.25em + var(--balance-row-lift)));
  vertical-align: middle;
}

.balance-table-scroll[data-form="sign"] .balance-table-label > .balance-table-word {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
}

/* Story 554 (AC3/AC4): the scoring control fills its cell so the tap target
   is the cell, and wears the ghost numeral exactly as the sheet's tile does.

   No `min-height` and no `border`, and both absences are the story: story
   547's control carried the app's 2.8rem floor — added in Codex review round
   2 of that story after the first cut measured 30.4px — and its own 1px
   border, which is what made the roll cell's box larger than a plain cell's
   in both directions and grew every scoreable row at once when the dice
   landed — thirteen at a game's start, ten on the lab's fourth-round fixture.
   The affordance is the same sunken ground and the same edge, painted as an
   inset shadow so it costs the line box nothing; the target is the whole
   cell, 30.8px tall at 100% text on a face with nothing to spare and about
   62px at 200%, never under 24px in either direction — WCAG 2.5.8 AA's
   floor. Since story 601 the cell grows with its row where the face has room
   under the card — the rule is on the row's line box above, and the control
   inherits it with the cell's font — so the recorded height is that floor
   and the 44.8px cap, not one number. That is the recorded exception
   to the card's 44.8px control floor (`docs/qa/quality-policy.md`, stories
   554 and 601), and the analysis probe partitions this control out of the shared
   floor and measures its own minimum instead, exactly as it does the replay
   spine's segments. */
.balance-table-score {
  appearance: none;
  background: var(--surface-sunken);
  border: 0;
  border-radius: var(--radius-s);
  box-shadow: inset 0 0 0 1px var(--border-control);
  color: inherit;
  font: inherit;
  font-variant-numeric: tabular-nums;
  /* Undoes the app-wide `button` floor by name: without this line the
     control is 44.8px tall again and every scoreable row grows 14px when the
     dice land (measured 31.8px to 45.8px at V390 and 100% text). */
  min-height: 0;
  text-align: center;
  width: 100%;
}
/* Story 686: the same control on the written row just scored, taking the decision
   back instead of entering it. The mark is taken out of the line box on
   purpose: inline, its glyph's own ascent grew the row by 2px on the V320
   face, where story 601 has no lift to absorb it, and every row below moved
   with it across the roll (measured by the dice-landing pair). Absolute in
   the cell's empty left reserve (the numerals stand right, the `000,0`
   reserve leaves the left free), it costs the box nothing. */
.balance-table-score.is-take-back { position: relative; box-shadow: inset 0 0 0 1px var(--border-score-takeback); }
.balance-table-score.is-take-back::after {
  content: "\21B6";
  position: absolute;
  left: .35em;
  top: 50%;
  transform: translateY(-50%);
  line-height: 1;
  color: var(--accent);
  font-size: .78em;
}

/* Practice scrolls as lesson content. It has no live Coach panel to reserve a row for. */
@media (height > 30em) {
  @container (width > 14em) {
    [data-view="compact"] .practice-scorecard {
      grid-template-rows: 0 max-content min(var(--space-1), 3px) min-content
        var(--space-2) min-content var(--space-2) min-content var(--space-1) min-content;
    }
    [data-view="compact"] .practice-scorecard .scorecard-standings > .scorecard-tile:nth-child(1) { grid-column: 1 / span 4; }
    [data-view="compact"] .practice-scorecard .scorecard-standings > .scorecard-tile:nth-child(2) { grid-column: 5 / span 4; }
    [data-view="compact"] .practice-scorecard .scorecard-standings > .scorecard-tile:nth-child(3) { grid-column: 9 / span 4; }
  }
}
.sixtythree-delta-example-choices, .sixtythree-delta-scoring,
.sixtythree-delta-next-round-answer { display: grid; min-width: 0; gap: var(--space-3); }
.sixtythree-delta-next-round-answer[hidden] { display: none; }
.sixtythree-delta-example-choices { grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); }
.sixtythree-delta-example-choices button, .coach-orientation-destination-action {
  min-width: 0; width: 100%; hyphens: auto; overflow-wrap: anywhere;
}
.sixtythree-delta-scores { display: grid; min-width: 0; gap: var(--space-2); list-style: none; padding: 0; }
.sixtythree-delta-scores li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); }
.coach-orientation-details { min-width: 0; }
.coach-orientation-details > summary { cursor: pointer; min-height: 44.8px; padding-block: var(--space-3); }
.bonus-lesson-compare-actions, .bonus-lesson-result { display: grid; min-width: 0; gap: var(--space-3); }
.bonus-lesson-compare-actions button { min-width: 0; width: 100%; hyphens: auto; overflow-wrap: anywhere; }
.bonus-lesson-upper { margin: 0; color: var(--text-supporting); }
.bonus-lesson-breakdown { display: grid; min-width: 0; gap: var(--space-2); margin: 0; }
.bonus-lesson-breakdown > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); }
.bonus-lesson-breakdown dt { min-width: 0; hyphens: auto; overflow-wrap: anywhere; }
.bonus-lesson-breakdown dd { margin: 0; font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; }
.foundations-choices { display: grid; min-width: 0; gap: var(--space-3); }
.foundations-choices button { min-width: 0; width: 100%; text-align: start; white-space: normal; hyphens: auto; overflow-wrap: anywhere; }
.foundations-option-title, .foundations-option-hint { display: block; }
.foundations-option-title { font-weight: var(--weight-strong); }
.foundations-option-hint { margin-top: var(--space-2); color: var(--text-supporting); }
.foundations-feedback { border-inline-start: 3px solid var(--accent); padding-inline-start: var(--space-3); }

/* Story 636: a tester can select the artifact identifier in either language. */
.build-identity {
  margin: 0 0 var(--space-3);
  color: var(--text-hint);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  overflow-wrap: anywhere;
  -webkit-user-select: text;
  user-select: text;
}

.build-identity code { font: inherit; }

/* Story 321: help is independent of a choice and uses the glossary's renderer. */
.setup-option-help { display: flex; flex: 1 1 9rem; min-width: 0; align-items: stretch; gap: 2px; }
.setup-option-help > button:not(.term-help) { flex: 1; min-width: 0; }
.stats-filter-help { display: flex; flex: 1 1 auto; min-width: 0; max-width: 100%; align-items: stretch; gap: 0; border-radius: var(--radius-s); color: var(--text-supporting); }
.stats-filter-help:has(input:checked) { background: var(--surface-card); color: var(--text-body); }
.stats-filter-help:has(input:checked) .stats-filter-check { color: var(--accent); }
.stats-filter-help .stats-filter { min-width: 0; }
.stats-filter-help .stats-filter span { min-width: 0; }
.term-help,
.bot-choice .term-help { min-width: 44px; min-height: 44px; padding: 2px; border-color: transparent; background: transparent; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; }
.term-help-icon,
.bot-choice .term-help-icon { flex: 0 0 44px; align-items: center; justify-content: center; align-self: center; }
.term-help-mark { flex: 0 0 auto; width: 16px; height: 16px; }
.term-help-label { max-width: 100%; justify-content: flex-start; gap: 3px; text-align: inherit; hyphens: auto; overflow-wrap: anywhere; }
.term-help-label > span { min-width: 0; }
.term-help-label .term-help-mark { width: 12px; height: 12px; }
.balance-table th:has(.term-help-label) { padding-inline: 0; }
.balance-table .term-help-label { align-items: center; width: 100%; flex-direction: column; justify-content: center; gap: 0; }
.term-help-dialog {
  box-sizing: border-box;
  position: fixed;
  inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
  margin: auto;
  width: min(34rem, calc(100% - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right))));
  max-height: calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)));
  padding: 12px;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-m);
  background: var(--surface-card);
  color: var(--text-body);
  overflow: hidden;
}
.term-help-dialog[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; }
.term-help-dialog::backdrop { background: var(--scrim); }
.term-help-close { justify-self: end; max-width: 100%; hyphens: auto; overflow-wrap: anywhere; }
.term-help-content { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 3px; }

/* Story 703: values and frameless historical curves share compact related cards. */
.stats-quality-grid { grid-template-columns: minmax(0, 1fr); }
.stats-history-card { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: .4rem .75rem; }
.stats-history-card > .stats-metric-label { grid-column: 1 / -1; }
.stats-history-card > .stats-metric-value { grid-column: 1; grid-row: 2; }
.stats-history-card > .stats-metric-detail { grid-column: 1 / -1; grid-row: 3; }
.stats-average-mark { font-size: var(--text-m); color: var(--text-supporting); }
.stats-history-chart { justify-self: stretch; grid-column: 2; grid-row: 2; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .15rem .3rem; margin: 0; min-width: 0; color: var(--accent); }
.stats-history-chart svg { width: 100%; height: 70px; overflow: visible; }
.stats-history-chart path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
.stats-history-chart circle { fill: currentColor; }
.stats-chart-range { white-space: nowrap; display: flex; flex-direction: column; justify-content: space-between; font-size: var(--text-xs); color: var(--text-hint); }
.stats-chart-sequence { grid-column: 2; display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--text-hint); }
.stats-recent { grid-column: 1 / -1; display: grid; gap: .2rem; margin: .3rem 0; font-size: var(--text-s); }
.stats-recent-label { color: var(--text-supporting); }
.stats-recent-value { font-weight: var(--weight-medium); }
.stats-history-details { grid-column: 1 / -1; min-width: 0; font-size: var(--text-xs); color: var(--text-supporting); }
.stats-history-details > summary { display: list-item; min-height: 44px; align-content: center; cursor: pointer; }
.stats-history-details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.stats-history-explanation { margin: .4rem 0; }
.stats-history-details .stats-metric { border: 0; padding: .4rem 0; }
.stats-error-card { border: var(--rule); border-radius: var(--radius-s); padding: var(--space-3); min-width: 0; }
.stats-error-title { margin: 0; font-size: var(--text-m); }
.stats-error-card > .stats-metric-detail { margin: .4rem 0; }
.stats-history-compact { border: 0; border-radius: 0; padding: .5rem 0; }
.stats-history-compact + .stats-history-compact { border-top: var(--rule); }
.stats-history-compact > .stats-metric-label { grid-column: 1; }
.stats-history-compact > .stats-metric-value { font-size: var(--text-l); }
.stats-history-compact > .stats-history-chart { grid-column: 2; grid-row: 1 / 3; }
.stats-history-compact .stats-history-chart svg { height: 44px; }

.stats-history-coverage { grid-column: 1 / -1; margin: 0; font-size: var(--text-xs); color: var(--text-supporting); }

/* At reading widths the error rows share a line for label, rate and curve.
   Narrow screens retain two columns so long labels can wrap freely. */
@media (min-width: 600px) {
  .stats-history-compact { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.5fr); }
  .stats-history-compact > .stats-metric-value { grid-column: 2; grid-row: 1; }
  .stats-history-compact > .stats-metric-detail { grid-column: 1 / 3; grid-row: 2; }
  .stats-history-compact > .stats-history-chart { grid-column: 3; grid-row: 1 / 3; }
}

/* A full-width curve keeps its axes and the value readable on narrow phones,
   including the 200% text tier. Wider reading views keep them side by side. */
@media (max-width: 480px) {
  .stats-history-card { grid-template-columns: minmax(0, 1fr); }
  .stats-history-card > .stats-history-chart { grid-column: 1; grid-row: 3; }
  .stats-history-card > .stats-metric-detail { grid-column: 1; grid-row: 4; }
}

.stats-results-panel { min-width: 0; padding: var(--space-3); border: var(--rule); border-radius: var(--radius-s); }
.stats-results-overview { grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); }
.stats-results-overview .stats-metric { align-content: start; min-width: 0; border: 0; padding: .3rem 0; }
.stats-histogram { min-width: 0; margin-top: var(--space-3); }
.stats-histogram-title { margin: 0; font-size: var(--text-m); }
.stats-histogram-figure { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .3rem; margin: var(--space-3) 0 .5rem; }
.stats-histogram-figure svg { display: block; width: 100%; height: 160px; }
.stats-histogram-figure rect { fill: var(--text-hint); fill-opacity: .45; }
.stats-histogram-figure path { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-dasharray: 5 4; }
.stats-histogram-axis { grid-column: 2; display: flex; justify-content: space-between; gap: .1rem; font-size: var(--text-xs); color: var(--text-hint); }
.stats-histogram-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: .4rem; font-size: var(--text-xs); }
.stats-histogram-actual::before { content: ""; display: inline-block; width: 1rem; height: .6rem; margin-right: .4rem; background: var(--text-hint); opacity: .45; }
.stats-histogram-reference::before { content: ""; display: inline-block; width: 1rem; margin-right: .4rem; border-top: 2px dashed var(--accent); vertical-align: middle; }
.stats-histogram-values { display: grid; gap: .5rem; padding-left: 1rem; }
@media (max-width: 480px) {
  .stats-histogram-axis > span:not(:first-child):not(:last-child) { display: none; }
}

.stats-compact-panel { display: block; min-width: 0; padding: var(--space-3); border: var(--rule); border-radius: var(--radius-s); }
.stats-compact-panel > .stats-metric { width: 100%; }
.stats-reference-note { grid-column: 1 / -1; margin: .1rem 0; font-size: var(--text-xs); color: var(--text-supporting); }
.stats-reference-note::before { content: ""; display: inline-block; width: 1rem; margin-right: .4rem; border-top: 2px dashed var(--text-hint); vertical-align: middle; }
.stats-history-chart line { stroke: var(--text-hint); stroke-width: 1.5; stroke-dasharray: 4 3; }
.stats-compact-panel > .stats-history-details { margin-top: .35rem; }

/* Optional family account controls stay within the More card at large text. */
#family-account > summary { cursor: pointer; min-height: 44px; font-weight: var(--weight-strong); }
#family-account > summary:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-offset); }
#family-account label, #family-account input, #family-account select, #family-account button, #family-userid { display: block; width: 100%; }
#family-account input, #family-account select { box-sizing: border-box; min-width: 0; margin-block: var(--space-2); padding: min(.7rem, 11.2px); border: 1px solid var(--field-border); border-radius: var(--radius-s); background: var(--surface-card); color: var(--text-body); font: inherit; }
#family-account button { margin-block: var(--space-2); }
#family-userid { overflow-wrap: anywhere; }

/* Issue 716: long pinned model identities remain readable inside their disclosure. */
[data-group^="duel-metrics-"] .stats-note-line { overflow-wrap: anywhere; }

/* Scorecard table preference: the controls and values are shared with compact. */
[data-view="compact"] .scorecard-grid.is-table-view {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-2);
}
[data-view="compact"] .scorecard-grid.is-table-view > .coach-area {
  align-self: stretch;
  margin-top: auto;
}
.score-table-blocks {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: min(var(--space-2), 8px);
  align-items: start;
}
.score-table { width: 100%; border-collapse: collapse; font-size: var(--text-s); }
.score-table th { padding: var(--space-1); font-weight: var(--weight-regular); text-align: left; }
.score-table thead { color: var(--text-secondary); }
.score-table thead th:not(:first-child) { text-align: center; }
.score-table tbody tr { border-top: var(--hairline); }
.score-table td { padding: 0; text-align: center; width: 1%; }
[data-view="compact"] .score-table .scorecard-tile {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: min(2.8rem, 44.8px);
  min-height: min(2.8rem, 44.8px);
  width: 100%;
  padding: var(--space-1);
  border: 0;
  border-radius: 0;
  background: transparent;
}
[data-view="compact"] .score-table .scorecard-tile:not(:disabled):hover { background: var(--surface-card); }
[data-view="compact"] .score-table .tile-value {
  display: block;
  min-height: 0;
  font-size: var(--text-m);
  line-height: var(--leading-tight);
  white-space: nowrap;
}
.score-table td > .tile-value { min-width: min(2.8rem, 44.8px); }
.score-table .balance-table-label { position: relative; }
.score-table .balance-table-glyph { display: inline-block; height: 1.5em; vertical-align: middle; }
.score-table .balance-table-word { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.score-table-totals { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: min(var(--space-1), 3px); }
[data-view="compact"] .score-table-totals .scorecard-tile {
  grid-column: auto;
  min-height: 0;
  align-content: center;
  padding: var(--space-1);
  gap: 0;
}
[data-view="compact"] .score-table-totals .tile-label { font-size: var(--text-s); }
[data-view="compact"] .score-table-totals .tile-value { font-size: var(--text-m); min-height: 0; }
.score-table-final { display: grid; grid-template-columns: minmax(0, 1fr); background: var(--surface-card); }
@container scorecard-board (width <= 20em) {
  .score-table-blocks { grid-template-columns: minmax(0, 1fr); }
  .score-table-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .score-table-final { grid-column: 1 / -1; }
}

/* Center both header and values on the same column axis. */
.balance-table thead th:not(:first-child),
.balance-table td { text-align: center; }
.balance-table-scroll[data-form="sign"] .balance-table-label[data-sign="house"] > .balance-table-glyph {
  transform: scale(1.15);
  transform-origin: center;
}

/* Replay has no definite board height to distribute between fractional rows. */
@media (height > 30em) {
  @container scorecard-board (width > 14em) {
    [data-view="compact"] #replay-categories:not(.is-table-view) {
      --board-gap-grow-head: 6px;
      grid-template-rows:
        minmax(0, var(--board-gap-grow-head))
        max-content
        min(var(--space-1), 3px)
        min-content
        var(--space-2)
        min-content
        var(--space-2)
        min-content
        var(--space-1)
        min-content
        var(--space-2)
        minmax(6.25rem, min-content)
        minmax(0, var(--board-gap-grow-dice));
    }
  }
}

/* A coaching pause precedes all game effects and keeps both actions reachable. */
.coach-correction-dialog {
  box-sizing: border-box;
  width: min(30rem, calc(100% - 24px));
  max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px);
  overflow: auto;
  padding: var(--space-4);
  border: 1px solid var(--border-control);
  border-radius: var(--radius-m);
  background: var(--surface-card);
  color: var(--text-body);
}
.coach-correction-dialog::backdrop { background: var(--scrim); }
.coach-correction-actions { display: grid; gap: var(--space-3); }
#setup-coach-instant { display: inline-flex; align-items: center; gap: var(--space-3); }
#setup-coach-instant::before { content: ""; width: 2em; height: 1.2em; border: 1px solid var(--border-control); border-radius: 1em; background: radial-gradient(circle at .6em center, var(--text-body) .4em, transparent .43em); }
#setup-coach-instant[aria-checked="true"] { border-color: var(--border-selected); background: var(--surface-selected); }
#setup-coach-instant[aria-checked="true"]::before { background: radial-gradient(circle at 1.4em center, var(--accent-ink) .4em, transparent .43em), var(--accent); }

/* Family statistics: production modules shared by all three Coach targets.
   Intrinsic columns respond to text size as well as viewport width. */
.stats-dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr)); gap: var(--space-5) var(--space-4); align-items: start; }
.stats-dashboard > .stats-group { min-width: 0; margin: 0; }
.stats-dashboard-wide { grid-column: 1 / -1; }
.stats-development-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: var(--space-3); }
.stats-development-grid .stats-history-card { grid-template-columns: minmax(0, 1fr); padding: var(--space-4); }
.stats-development-grid .stats-history-chart { grid-column: 1; grid-row: auto; width: 100%; margin: var(--space-3) 0 var(--space-2); }
.stats-development-grid .stats-history-chart svg { height: 7rem; }
.stats-development-grid .stats-metric-value { font-size: calc(var(--text-xl) * 1.3); }
.stats-development-grid .stats-metric-label { color: var(--text-body); font-size: var(--text-s); }
.stats-development-grid .stats-metric-detail { grid-column: 1; }
.stats-categories { padding: var(--space-3) var(--space-4); border: var(--hairline); border-radius: var(--radius-m); background: var(--surface-card); }
.stats-categories > summary { padding: var(--space-2) 0; font-weight: var(--weight-strong); }
.stats-category-invitation { display: block; margin-top: var(--space-1); color: var(--text-supporting); font-size: var(--text-s); font-weight: var(--weight-regular); }
.stats-category-intro { margin: var(--space-3) 0; color: var(--text-supporting); font-size: var(--text-s); }
.stats-comparison-legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); margin-bottom: var(--space-3); color: var(--text-supporting); font-size: var(--text-xs); }
.stats-comparison-own, .stats-comparison-reference { display: inline-flex; align-items: center; gap: var(--space-2); }
.stats-comparison-own::before, .stats-comparison-reference::before { content: ""; display: inline-block; width: 1.3rem; height: .5rem; border-radius: var(--radius-s); }
.stats-comparison-own::before { background: var(--accent); }
.stats-comparison-reference::before { border: 2px solid var(--text-supporting); }
.stats-category-block { border-top: var(--hairline); padding: var(--space-2) 0; }
.stats-category-block > summary { padding: var(--space-2) 0; }
.stats-category-block > summary > .stats-comparison-row { display: inline-grid; width: calc(100% - 1.5rem); vertical-align: top; padding: 0; border: 0; }
.stats-comparison-row { container: stats-comparison / inline-size; display: grid; min-width: 0; padding: var(--space-3) 0; gap: .3rem; }
.stats-category-block > .stats-comparison-row { border-top: var(--hairline); }
.stats-comparison-heading { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); align-items: baseline; justify-content: space-between; }
.stats-comparison-label { min-width: 0; font-size: var(--text-s); font-weight: var(--weight-strong); }
.stats-comparison-unit { color: var(--text-supporting); font-size: var(--text-xs); }
.stats-comparison-line { display: grid; grid-template-columns: minmax(0, 1fr); align-items: center; gap: var(--space-1); font-size: var(--text-s); }
.stats-comparison-track { display: block; height: .5rem; background: var(--surface-sunken); border-radius: var(--radius-s); }
.stats-comparison-bar { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-s); }
.stats-comparison-line.is-reference .stats-comparison-bar { border: 2px solid var(--text-supporting); background: var(--surface-card); }
.stats-comparison-value { grid-row: 1; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.stats-comparison-axis { display: flex; justify-content: space-between; font-size: var(--text-s); color: var(--text-hint); }
.stats-comparison-axis > span { font-size: var(--text-xs); white-space: nowrap; }
/* At 15 container em there is room for an aligned value column and useful bars.
   Below this, both series keep the full row width, including at 200% text. */
@container stats-comparison (min-width: 15em) {
  .stats-comparison-line { grid-template-columns: minmax(0, 1fr) 5em; gap: var(--space-2); }
  .stats-comparison-value { grid-row: auto; }
  .stats-comparison-axis { margin-right: calc(5em + var(--space-2)); }
}
.stats-comparison-sample { color: var(--text-hint); font-size: var(--text-xs); font-weight: var(--weight-regular); }
