/* SixtyThree Coach — the palette, and the visual language built on it.

   #44 AC5, restated for the three named schemes this stylesheet now carries:
   every colour in this file is a token, and the tokens are declared in exactly
   four places — the Elfenbein default on `:root`, the Graphit palette in the
   two blocks below it, and Kobalt in the one after that.

   Graphit appears twice on purpose and cannot be written once: one block is a
   media query (the platform asked for dark, and the reader has not chosen a
   scheme), the other is an attribute selector (the reader chose Graphit), and
   CSS has no way to join a media condition and a selector into one rule.
   `tests/onboarding.test.mjs` compares the two blocks token for token, so the
   copy is a checked invariant rather than a place to drift.

   Only the *primitives* — the raw paper, ink, line, accent, die and tone
   values — are restated per scheme. Rules below reach the surface, text and
   border roles through a semantic alias declared once on `:root` as
   `var(--primitive)`; because both sides live on `<html>`, the alias resolves
   against whichever scheme won on that element. Colour the palette already
   names by role — `--accent`, `--danger`, the `--tone-*` pairs and `--die-*` —
   is used directly and gets no second name, because wrapping it would add an
   indirection with nothing behind it. Either way a colour is written once per
   scheme and nowhere else, and `tests/onboarding.test.mjs` holds both halves:
   that no rule reaches an aliased primitive, and that every scheme keeps
   ordinary text at 4.5:1 against all three of its surfaces.

   `data-theme` and `data-motion` are written by `apps/coach/src/presentation.ts`
   before the first paint; their absence means "follow the platform", which is
   what the media queries below are for. */

:root {
  color-scheme: light;

  /* Elfenbein — paper & ink */
  --paper: #f2efe6;
  --paper-raised: #faf8f2;
  --paper-sunken: #e9e5d9;
  --ink: #21201c;
  --ink-soft: #3c3a35;
  --ink-muted: #525049;
  --ink-faint: #67655f;
  --line: #d9d4c5;
  --line-strong: #b4afa0;

  /* Elfenbein — signal. Colour is functional only: selection, status, the one
     primary action. Nothing below decorates with it. */
  --accent: #b43c28;
  --accent-ink: #ffffff;
  --accent-press: #963120;
  --accent-wash: #f9e0d9;

  /* Elfenbein — dice */
  --die-face: #fdfcf8;
  --die-ink: #21201c;
  --die-line: #c9c4b4;

  /* Elfenbein — verdict tones */
  --tone-optimal: #3a722c;
  --tone-near-optimal: #7f6100;
  --tone-mistake: #9f4f12;
  --tone-blunder: #b3271e;
  --danger: #b3271e;
  --danger-wash: #f2dbd8;

  /* Semantic aliases: the surface, text and border roles, so a rule can say what
     an element *is* rather than which shade it borrows. Colour that the palette
     above already names by role — `--accent`, `--danger`, the `--tone-*` pairs
     and the `--die-*` family — is consumed directly and deliberately gets no
     second name: a rule asking for the accent is asking for exactly that, and
     wrapping it would add an indirection that carries no meaning. What holds
     either way is the property this file depends on — a colour is written once
     per scheme and nowhere else. */
  --surface-page: var(--paper);
  --surface-card: var(--paper-raised);
  --surface-sunken: var(--paper-sunken);
  --surface-control: var(--paper-sunken);
  --text-body: var(--ink);
  --text-secondary: var(--ink-muted);
  --text-hint: var(--ink-faint);
  --text-supporting: var(--ink-soft);
  --border-hairline: var(--line);
  --border-control: var(--line-strong);
  --focus-ring: var(--accent);
  --field-border: var(--line-strong);
  --console: var(--paper-sunken);

  /* Type. No webfont is loaded: the shipped Content-Security-Policy is
     `default-src 'self'`, and a PWA that has to work with the radio off cannot
     depend on a font host. The design's two families are named first so a
     vendored or system-installed copy is used where it exists, and the stack
     behind each one keeps the same role — a neutral grotesque for everything
     readable, a mono for everything counted. */
  --font-sans: "Schibsted Grotesk", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  --text-xs: .72rem;
  --text-s: .82rem;
  --text-m: .95rem;
  --text-l: 1.15rem;
  --text-xl: 1.5rem;

  --weight-regular: 400;
  --weight-medium: 500;
  --weight-strong: 650;
  --weight-display: 750;

  --tracking-caps: .1em;
  --leading-body: 1.5;
  --leading-tight: 1.15;

  /* Disciplined spacing, restrained radii, hairlines instead of shadows. */
  --space-1: .25rem;
  --space-2: .5rem;
  --space-3: .75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --radius-s: 4px;
  --radius-die: 6px;
  --radius-m: 8px;
  --hairline: 1px solid var(--border-hairline);
  --rule: 1px solid var(--border-control);
  --ease-mech: cubic-bezier(.3, 0, .2, 1);
  --dur-fast: 120ms;

  --die-transition: transform 140ms var(--ease-mech), border-color 140ms var(--ease-mech);
  --die-roll-animation: die-roll 320ms var(--ease-mech);
  --reveal-animation: reveal-rise 220ms var(--ease-mech) both;

  /* Story 154: the shell bar's two measurements. They are tokens because `main` has
     to reserve exactly what the bar occupies, and one number written twice is
     how the last row of content ends up under it. */
  --nav-height: 3.9rem;
  --nav-rail-width: 11rem;
}

/* Graphit, because the platform asks for dark and the reader has not chosen a
   scheme. Elfenbein and Kobalt are both light, so "follow the platform" can
   only ever mean this one. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;

    --paper: #232420;
    --paper-raised: #2b2c27;
    --paper-sunken: #1c1d19;
    --ink: #f1eee5;
    --ink-soft: #d2cfc2;
    --ink-muted: #b3b0a2;
    --ink-faint: #979486;
    --line: #3d3e37;
    --line-strong: #57584f;
    --accent: #e2a23d;
    --accent-ink: #201a0e;
    --accent-press: #cd8f2e;
    --accent-wash: #3b3222;
    --die-face: #f1eee5;
    --die-ink: #232420;
    --die-line: #57584f;
    --tone-optimal: #94c465;
    --tone-near-optimal: #ddba50;
    --tone-mistake: #de8a4f;
    --tone-blunder: #f2695d;
    --danger: #f2695d;
    --danger-wash: #3c2724;
  }
}

/* Graphit, because the reader chose it. Same values as the block above. */
:root[data-theme="graphit"] {
  color-scheme: dark;

  --paper: #232420;
  --paper-raised: #2b2c27;
  --paper-sunken: #1c1d19;
  --ink: #f1eee5;
  --ink-soft: #d2cfc2;
  --ink-muted: #b3b0a2;
  --ink-faint: #979486;
  --line: #3d3e37;
  --line-strong: #57584f;
  --accent: #e2a23d;
  --accent-ink: #201a0e;
  --accent-press: #cd8f2e;
  --accent-wash: #3b3222;
  --die-face: #f1eee5;
  --die-ink: #232420;
  --die-line: #57584f;
  --tone-optimal: #94c465;
  --tone-near-optimal: #ddba50;
  --tone-mistake: #de8a4f;
  --tone-blunder: #f2695d;
  --danger: #f2695d;
  --danger-wash: #3c2724;
}

/* Kobalt. A light scheme like Elfenbein, so it needs no media-query twin — the
   default above is already what an unchosen light platform renders. */
:root[data-theme="kobalt"] {
  color-scheme: light;

  --paper: #eceef0;
  --paper-raised: #f8f9fa;
  --paper-sunken: #e1e4e8;
  --ink: #1d2126;
  --ink-soft: #353a3f;
  --ink-muted: #4a5055;
  --ink-faint: #61656a;
  --line: #d2d6da;
  --line-strong: #adb3ba;
  --accent: #2247ad;
  --accent-ink: #ffffff;
  --accent-press: #1b3b93;
  --accent-wash: #d9e0f2;
  --die-face: #ffffff;
  --die-ink: #1d2126;
  --die-line: #c2c7cd;
  --tone-optimal: #2e6b46;
  --tone-near-optimal: #7b6100;
  --tone-mistake: #9d4f12;
  --tone-blunder: #b02a24;
  --danger: #b02a24;
  --danger-wash: #f0dcda;
}

/* Elfenbein needs no block of its own: `:root` already is it, and choosing it
   only has to stop the media query above from speaking for the platform, which
   setting `data-theme` at all does. */

/* #44 AC5, the low-motion variant. Dice keep a colour transition and lose the
   travel; a reveal fades in instead of rising. Neither is switched off — a
   surface that appears with no change at all is harder to notice, not calmer.
   Same two-block shape, and the same test, as the palettes above. */
@media (prefers-reduced-motion: reduce) {
  :root:not([data-motion="full"]) {
    --die-transition: border-color 120ms linear;
    --die-roll-animation: die-settle 120ms linear;
    --reveal-animation: reveal-fade 120ms linear both;
  }
}

:root[data-motion="reduced"] {
  --die-transition: border-color 120ms linear;
  --die-roll-animation: die-settle 120ms linear;
  --reveal-animation: reveal-fade 120ms linear both;
}

/* `html`, not a second `:root` block: the palette above is the one place that
   selector appears, so a reader looking for the tokens finds exactly one. */
html {
  background: var(--surface-page);
  color: var(--text-body);
  font-family: var(--font-sans);
  font-synthesis: none;
  /* 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 {
  width: min(100% - 1.75rem, 54rem);
  margin: 0 auto;
  /* Story 154 AC6: the bottom inset moved to `.app-nav`, which is the element the
     home indicator actually overlaps. What is left here is the room the bar
     occupies, so the last row of content is never trapped underneath it. */
  padding: max(1rem, env(safe-area-inset-top)) 0
    calc(var(--nav-height) + env(safe-area-inset-bottom) + 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, .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 {
  position: relative;
  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);
}

.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); }
.die[aria-pressed="true"] { border: 2px solid var(--accent); transform: translateY(-3px); }
/* AC6: held is carried by a filled corner mark as well as by the colour, so the
   state survives greyscale rendering — `aria-pressed` carries it to a reader. */
.die[aria-pressed="true"]::after {
  content: "";
  position: absolute;
  right: 9%;
  bottom: 9%;
  width: 12%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--accent);
}

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: .45; cursor: default; }
button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.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); }
.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: "↻"; }
.icon-button { flex: 0 0 2.8rem; width: 2.8rem; padding: 0; }
.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 — including the replay detail's fact grid,
   which is a `.summary-grid` outside any finished card. 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 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 { margin-top: var(--space-2); }
.setup-card > .duel-explainer { margin-top: 0; }

.duel-explainer { 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:not(.duel-explainer-title) { color: var(--text-supporting); }
.bot-choice { display: grid; 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; text-align: left; }
.bot-choice button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-wash); }
.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 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. */
  container-type: inline-size;
}
/* Reads in the order a tile does: the category, your value, then the bot's in
   the same brackets the tiles put around it. */
.scorecard-legend { display: flex; gap: .35rem; padding: 0 .2rem; color: var(--text-hint); font-size: var(--text-xs); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.scorecard-legend .is-opponent::before { content: "("; }
.scorecard-legend .is-opponent::after { content: ")"; }
/* 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); }
.scorecard-tile.is-summary { border-color: transparent; background: var(--surface-sunken); }
.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. */
.tile-value.is-labelled { display: flex; flex: 1 1 100%; 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; }
.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 second column. The brackets are the carrier, not the dimming —
   the tile it stands in is the same tile the solo board uses (AC5). */
.tile-value.is-opponent { color: var(--text-hint); font-weight: var(--weight-medium); }
.tile-value.is-opponent::before { content: "("; }
.tile-value.is-opponent::after { content: ")"; }
.scorecard-tile:disabled .tile-value.is-opponent { color: var(--text-hint); }

/* 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. */

/* Story 234: the cell Knaller shares with the coach reserve's row. The reserve
   made that row 6.25rem, and Knaller — a grid item like any tile — stretched to
   it: twice a category row wherever the board sits at min-content. This cell is
   the grid item now, a column of the tile and the coach mark, so the tile keeps
   a category tile's own height rules (content-sized on the shared 2.8rem floor)
   and the mark is the one thing sized by what the reserve's row leaves over. */
.knaller-cell { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; min-height: 0; }
.knaller-cell .scorecard-tile { flex: 0 0 auto; }
/* The mark is a wink, not a control: `aria-hidden` in the markup, no cursor
   and no focus stop here, and only palette roles the board already reads —
   hairline ink for the lines, the accent for the pip and the C. It is a size
   container so the drawing can leave when its slot is too short to read (a
   wrapped label, a wider platform sans) instead of squeezing into it. */
.coach-mark { container: coach-mark / size; display: flex; flex: 1 1 0; align-items: center; justify-content: flex-end; min-height: 0; padding-right: .15rem; overflow: hidden; color: var(--text-hint); }
.coach-mark svg { width: auto; height: 100%; max-height: 3rem; }
.coach-mark .mark-line { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.coach-mark .mark-pip { fill: var(--accent); stroke: none; }
.coach-mark .mark-letter { fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; }
@container coach-mark (max-height: 1.5rem) {
  .coach-mark svg { display: none; }
}

@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 234: two columns put Knaller on a row of its own, away from the
     reserve — no leftover exists to decorate, so the mark leaves and the tile
     fills its cell like every other. */
  .coach-mark { display: none; }
  .knaller-cell .scorecard-tile { flex: 1 1 auto; }
}

/* 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); }
.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; }
.game-meta { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); min-width: 0; }
/* At large text the two status phrases wrap inside their shrinking grid track;
   the exits retain their touch floor without imposing their combined width on
   the whole stage. */
.game-turn { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; min-width: 0; }
.game-turn > * { min-width: 0; }
/* Story 233: the round text steps up from the meta/status floor to the
   reading role, and the roll count — the action-relevant fact — steps up
   further to an emphasised role. At those larger sizes the pair no longer
   reliably shares one line at ordinary text (the ready-state "Bereit zum
   Wurf"/"Ready to roll" phrase is the widest of the roll strings, and V320's
   narrower track never fit them beside each other even before this story) —
   an every-string-fits budget would have to shrink the roll pill's role to
   guarantee it, which is the token floor Story 233 raises it out of. The round
   text is given the whole first line instead of a width it sometimes loses,
   so the zone's height stays the same *state to state* (Anchor A1) whether or
   not a given round/roll string happens to fit beside its neighbour — the
   thing an incidental fit cannot promise. F4 (Story 215) still holds: both
   remain free to wrap or stack rather than overflow at 200% text. */
.game-turn .eyebrow { margin: 0; font-size: var(--text-s); flex: 1 0 100%; }
.game-turn .pill { font-size: var(--text-m); white-space: normal; }
.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; }
.onboarding-choice { justify-content: flex-start; border-color: var(--border-control); background: transparent; text-align: left; }
.onboarding-choice[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-wash); }
.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); }

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

/* 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 h3 { margin: var(--space-5) 0 var(--space-2); font-size: var(--text-l); font-weight: var(--weight-strong); }
.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 list, facts, 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 263: at 200% text the dated detail title and the close control cannot
   share a phone-width row. Let that one heading become two rows; the button
   keeps the global 2.8rem floor and the document keeps its horizontal edge. */
#replay-detail > .section-heading { flex-wrap: wrap; }
.replay-list { display: grid; gap: var(--space-2); margin-bottom: var(--space-4); }
.replay-item { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: .2rem; border: var(--rule); background: transparent; text-align: left; }
.replay-item[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-wash); }
/* §4.8: the selected game is a filled ring, not only a wash. The item stacks
   its title over its meta line, so the marker goes on the title's own line
   instead of the item box, where it would sit alone above the title.
   Story 185: the marker hangs on the title by name, not on document order.
   `span:first-child` put it on whichever span came first, so inserting a span
   ahead of the title would have moved the marker to it. */
.replay-item > .replay-item-title::before { content: "○"; margin-right: .4rem; color: var(--text-hint); font-weight: var(--weight-strong); }
.replay-item[aria-pressed="true"] > .replay-item-title::before { content: "●"; color: var(--accent); }
.replay-item-meta { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.replay-mode { margin-bottom: var(--space-3); font-size: var(--text-s); }

.replay-timeline { display: flex; flex-wrap: wrap; gap: .35rem; }
.replay-dot {
  display: grid;
  min-width: 2.8rem;
  padding: .45rem .3rem;
  justify-items: center;
  gap: .1rem;
  border: 1px solid var(--tone, var(--border-control));
  border-radius: var(--radius-s);
  background: var(--surface-sunken);
  color: var(--text-body);
  line-height: 1.1;
}
.replay-dot strong { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-s); }
.replay-dot-symbol { color: var(--tone, var(--text-supporting)); font-size: var(--text-s); font-weight: var(--weight-strong); }
/* Story 167: the current position on the spine. An outline rather than the
   border-and-wash the pressed items wear: the dot already spends its border on
   the round's tone and its one symbol on the class, so the current position
   needs a carrier neither of those is using. It stays a shape, so it reads with
   colour rendering off, and `aria-current` is what carries it to assistive
   technology. */
.replay-dot[aria-current] { outline: 2px solid var(--accent); 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. */
.replay-playback { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-3); }
/* Three labels share a 375px card: one row there in both languages, and a
   longer label wraps to a second row rather than overflowing the card. */
.replay-playback button { flex: 1 1 auto; padding-inline: var(--space-2); white-space: nowrap; }
.replay-playback .primary { width: auto; flex-grow: 2; }
/* 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: .45; }
.replay-position { flex: 1 1 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-s); }
.replay-stage { margin-top: var(--space-3); }
.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; }

.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 262: every context is an auto-sized item in `.stats-sections`, 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, objectives
   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; }
.stats-sections { display: grid; gap: var(--space-4); }
.stats-context { padding: .9rem; border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.stats-context.is-unnamed { border-style: dashed; }
.stats-context-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-objective { color: var(--text-hint); font-size: var(--text-xs); letter-spacing: .04em; }
.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); }

/* Glossary (Story 166) ----------------------------------------------------------
   The card sits under the statistics in the same panel, and the opener above
   is a full-width control so it reads as the end of the statistics rather than
   as a stray button beside them.

   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; gap: var(--space-2); }
.glossary-group { padding: var(--space-3); border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.glossary-group[open] { display: grid; gap: var(--space-2); }
.glossary-group-title { color: var(--text-hint); cursor: pointer; font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.glossary-entry { padding: var(--space-3); border: var(--hairline); border-radius: var(--radius-s); background: var(--surface-sunken); }
.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;
}
.glossary-terms { display: flex; flex-wrap: wrap; gap: .35rem; margin: var(--space-2) 0 0; padding: 0; list-style: none; }
.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: 2px solid var(--focus-ring); outline-offset: 2px; }

@media (max-width: 32rem) {
  .card { padding: var(--space-4); }
  .controls, .mode-actions, .data-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 234 AC4: on a landscape phone the board is the one zone left to give
   and already scrolls; decoration in that scroll extent is noise, so the mark
   hides and the Knaller tile takes its cell back — no hole stands where the
   drawing was. The trigger is the physical viewport, not the reader's text
   size, which is exactly the case where a media query's `em` (resolved against
   the browser's initial font size — see the container-query note on
   `.scorecard-grid`) is the right unit: 30em is 480px, under every supported
   portrait height and over the 390px landscape one. */
@media (max-height: 30em) {
  .coach-mark { display: none; }
  .knaller-cell .scorecard-tile { flex: 1 1 auto; }
}

.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 — "Reduced replaces the dice and reveal
   animations with a calm variant" — while only the reveal half of that sentence
   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; }
}

/* 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;
  /* Story 273: where four 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 exactly one quarter
   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 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 no longer cuts its label; the
   bar wraps it onto a second row (`flex-wrap` above), which contains the bar
   without spending the word. Nothing overflows: a flex line that cannot hold
   its items breaks, it does not reach past the viewport.

   The two `min()` caps are what keep that second row free. The bar's own
   `min-height: var(--nav-height)` and `main`'s matching reserve at the top of
   this file are both in rem and both already grow with the reader's text; a tab
   whose *floor* and vertical padding grew with it as well would push two rows
   past that reserve and park the bar over the last line of content. Capped,
   they stop at the pixels they resolve to at the default root size, and two
   rows of whole words fit inside the same `--nav-height` the bar has always
   asked for. Measured on `finished` at 200% text, in German and in English, at
   V320/V360/V390, on the reference face and on a deliberate width stress: every
   label stands on one line, the bar measures exactly `--nav-height`, and each
   tab is 61.9px tall — above the 44.8px floor, which is the number the cap
   holds rather than the number it sets. At the default root size every `min()`
   here returns today's value, so nothing moves until a reader scales up. */
.app-nav-link {
  display: flex;
  flex: 1 1 0;
  align-items: center;
  justify-content: center;
  gap: min(.3rem, 4.8px);
  min-height: min(2.8rem, 44.8px);
  padding: min(.55rem, 8.8px) min(.3rem, 4.8px);
  color: var(--text-secondary);
  font-size: var(--text-s);
  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
   marker for an eye, so the accent colour is never the only carrier (#44 AC6).
   Story 273: the marker is a flex item of the tab, so it counts towards the
   tab's own min-content and the tab's minimum is now marker plus gap plus the
   whole word. It therefore no longer takes room from the label it marks — the
   current tab asks for more width than its neighbours instead of fitting less
   word into the same quarter, which is what made it the worst-broken one. */
.app-nav-link[aria-current="page"]::before { 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) {
  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)); }
}

/* 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 {
  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;
  padding: .55rem;
  gap: .45rem;
}

[data-playing] .game-meta { gap: .35rem; }
[data-playing] .game-meta .eyebrow { margin-bottom: 0; }
[data-playing] .pill { padding: .2rem .45rem; font-size: var(--text-xs); }
[data-playing] .pill strong { margin-left: .3rem; }
/* Story 233: the compact padding above still applies while a game runs, but the
   roll count must not shrink back down to the meta/status floor it just
   stepped up from — it is the one fact this line exists to keep readable. */
[data-playing] .game-turn .pill { font-size: var(--text-m); }

/* 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); }
/* AC8: focus mode tightens the icon buttons without changing their shared
   2.8rem touch floor. */
[data-playing] .game-exits button { padding: 0; font-size: var(--text-s); }
/* 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;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

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

.language-choice select {
  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;
}
