/* ui/shared/base.css — reset + the shared component kit.
   Screens compose these classes; games contribute CONTENT only.
   No game-specific CSS exists anywhere in the project.

   SYSTEM v3 — "Economics Games · Playful" (decisions/shared/
   20260820-0900-design-adoption.md; pinned mockup journal/_design/
   playful.dc.html). The screen is a warm cream app surface with white cards
   floating on soft warm shadows, pill chips and CTAs, one orange accent, and
   Nunito with tabular numerals everywhere a figure appears.

   Three structural rules SURVIVE from v2 unchanged, because they are behavior
   rather than style and the harness pins them:
   1. ZONES, NOT A PAGE. `.app` is a full-height grid of three bands — header,
      scrolling stage, anchored action band. The primary action never moves
      and never scrolls away.
   2. NOTHING MOVES WITH THE TYPED VALUE (§9m): reserved lines, fixed rows,
      whole-chip history.
   3. SIGN IS MONEY'S OWN FACT: your gains are accent-deep, any loss is ink
      with the typographic minus on a quiet field, another player's gain is
      muted ink — so in every column a loss is visibly distinct from a gain. */

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--surface-app);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-md);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}
h1, h2, h3, p, ul { margin: 0; padding: 0; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; border: 0; background: none; padding: 0; }
:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* every figure in the product is tabular — figures must not change width as
   digits change */
.num, .money {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
}
.money--pos { color: var(--accent-deep); }
.money--neg { color: var(--loss); }
.money--zero { color: var(--ink); }
/* ANOTHER PLAYER'S GAIN: never in your colour, and MUTED — so a rival column's
   losses (full ink, signed) stay visibly distinct from its gains. */
.money--other { color: var(--ink-soft); }
/* A FIGURE NEVER BREAKS (decisions §9m): the typographic minus is a legal
   break opportunity, so money is one unbreakable run everywhere. */
.out-money, .hero-money, .till-money, .chip-money, .stat-value, .commit-qty { white-space: nowrap; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* the one label register: micro, heavy, open caps (mockup 9-11px / 800). */
.eyebrow {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--ink-mid);
}

/* ================= THE APP SHELL ================= */
/* Three bands, full height. The action band is bolted to the bottom of the
   viewport at every width. */
.app {
  height: 100vh;
  height: 100dvh;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  /* ONE COLUMN, PINNED TO THE VIEWPORT. With no column template the implicit
     column is `auto`, and a grid item's min-width is its max-content: the
     first game whose money ran to five digits and cents (competition-market,
     "Total −$2710.39" in the till, "($210 − $19 cost) × 86 units" in the
     outcome card) widened the whole shell to ~420px on a 390px phone from
     round 1's results on — "WALLACE.QUE" cut at the edge, the rival's price
     truncated out of the so-far chip. Every game that fit did so because its
     figures were short. minmax(0, 1fr) makes the band wrap or clip inside
     itself instead of pushing the page. */
  grid-template-columns: minmax(0, 1fr);
  overflow: hidden;   /* the gated action bar parks outside the viewport */
  background: var(--surface-app);
}

/* ---------- header (cream chrome: wordmark, game bar, step dots) ---------- */
/* The top padding is the sparks' lane: a clear band above the round gauge
   that the celebration can use without touching a glyph. */
.topbar {
  background: var(--surface-app);
  color: var(--ink);
  padding: 14px var(--sp-4) 6px;
  display: flex; flex-direction: column; gap: 6px;
}
.topbar-row, .gamebar, .stepdots {
  width: 100%; max-width: 560px; margin: 0 auto;
}
@media (min-width: 900px) {
  .topbar--wide .topbar-row, .topbar--wide .gamebar, .topbar--wide .stepdots { max-width: 1080px; }
}
/* row 1: the wordmark, the round gauge, and where to join */
.topbar-row { display: flex; align-items: center; gap: var(--sp-3); min-height: 24px; }
.brandmark {
  flex: none;
  font-size: 16px; font-weight: var(--w-heavy); letter-spacing: -0.01em;
  white-space: nowrap;
}
.brandmark .brand-tld { color: var(--accent); }
.join-label {
  flex: none; margin-left: auto;
  font-size: 10.5px; font-weight: var(--w-bold);
  letter-spacing: 0.1em; text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  color: var(--ink-mid);
  white-space: nowrap;
}

/* row 2: the game bar — a white card naming the game, the seat and the round,
   carrying the help control and the till (and a clock pill ONLY when a real
   timer exists; solo/local play never shows one). */
.gamebar {
  display: flex; align-items: center; gap: var(--sp-2);
  background: var(--surface); border-radius: var(--r-lg);
  padding: 7px 10px 7px 14px;
  box-shadow: var(--shadow-card);
}
.gamebar-main { flex: 1; min-width: 0; }
.topbar-title {
  display: block;
  font-size: 14.5px; font-weight: var(--w-heavy);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* the round counter: the mockup's micro meta line under the title */
.round-pill {
  display: block; margin-top: 1px;
  font-size: 10.5px; font-weight: var(--w-bold);
  letter-spacing: 0.08em; text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  color: var(--ink-mid); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* the last round is named as the last round — the honest stake, expressed */
.topbar[data-final="1"] .round-pill { color: var(--accent-deep); }

/* the clock pill (real server timers only — never rendered in solo play).
   Tint at rest; flips to ink with white text at <=15s, as in the mockup. */
.clockpill {
  flex: none; display: inline-flex; align-items: center; gap: 6px;
  border-radius: var(--r-pill); padding: 6px 12px;
  background: var(--accent-tint); color: var(--accent-deep);
}
.clockpill::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.clockpill-time { font-size: 12.5px; font-weight: var(--w-heavy); font-variant-numeric: tabular-nums; }
.clockpill--low { background: var(--ink); color: var(--on-brand); }

.icon-btn {
  width: 32px; height: 32px; border-radius: var(--r-pill); flex: none;
  background: var(--sunken);
  color: var(--ink-soft);
  display: grid; place-items: center;
  font-size: var(--fs-sm); font-weight: var(--w-heavy);
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.icon-btn:hover { background: var(--accent-tint); color: var(--accent-deep); }
.icon-btn--plain { background: var(--sunken); color: var(--ink-soft); }
.icon-btn--plain:hover { background: var(--accent-tint); color: var(--accent-deep); }

/* THE TILL — the student's running money, a white chit in the game bar.
   TRUTH: it is an aggregate, so it always equals the sum of the rounds the
   student can actually see (index.js holds it back until the newest chip
   lands) and it is WRITTEN, never tweened. */
.till {
  position: relative;
  flex: none;
  display: inline-flex; align-items: baseline; gap: 6px;
  background: var(--accent-tint); border-radius: var(--r-pill);
  padding: 5px 12px 6px;
}
.till-label {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--accent-deep);
  opacity: 0.75;
}
.till-money { font-size: 14px; font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }

/* Segmented per-round gauge: ONE COUNTABLE PIP PER ROUND — completed pips in
   accent, a lost round's pip in ink (the gauge is a record of the session),
   the round in progress a white pip on the track, rounds to come the track's
   own tone. Sits in the header's first row so progress reads at a glance;
   long games (>16 rounds) fall back to a continuous fill. */
.progress {
  flex: 1; min-width: 60px;
  display: flex; gap: 3px; height: 10px;
  background: var(--track);
  border-radius: var(--r-pill);
  padding: 2px;
}
.progress-seg {
  position: relative; flex: 1; border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.55);                      /* still to play */
}
.progress-seg--on { background: var(--accent); }              /* completed round */
.progress-seg--lost { background: var(--ink); }               /* that round LOST */
.progress-seg--now { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--accent-faded); }
.progress-seg--final { box-shadow: 0 0 0 2px var(--accent-faded); }
.progress-seg--fill { transform-origin: left center; animation: seg-fill var(--t-beat) var(--ease); }
@keyframes seg-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.progress-fill {
  flex: none; height: 100%; background: var(--accent); border-radius: var(--r-pill);
  transition: width var(--t-med) var(--ease);
}

/* row 3: the step dots — Decide · Sealed · Results, the mockup's phase rail.
   Active dot glows in accent, a past dot fades to the accent's faded tone,
   a coming dot is the track's tone; connector dashes between them. */
.stepdots {
  display: flex; align-items: center; justify-content: center; gap: 10px;
}
.stepdot { display: inline-flex; align-items: center; gap: 6px; }
.stepdot-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--track);
}
.stepdot-label {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--ink-mid);
}
.stepdot--on .stepdot-dot { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); }
.stepdot--on .stepdot-label { color: var(--accent-deep); }
.stepdot--past .stepdot-dot { background: var(--accent-faded); }
.stepdot-sep { width: 22px; height: 2px; border-radius: 2px; background: #efe2d3; }

/* ---- A ROUND LANDS ----------------------------------------------------
   Celebrated on the two records that carry it: the pip that just filled and
   the till. Both take their SIGN and their FORCE from the engine's own number
   (`--force`); nothing keys off how long the student took (decisions §9). */
.progress-seg--burst {
  animation: seg-fill var(--t-beat) var(--ease), pip-bloom var(--t-land) var(--ease) var(--t-beat) backwards;
  z-index: 1;
}
.progress-seg--burst::after {
  content: ""; position: absolute; inset: -3px; border-radius: var(--r-pill);
  --ring: var(--primary-bright);
  animation: pip-burst var(--t-land) var(--ease) forwards;
  pointer-events: none;
}
.progress-seg--burst[data-round="loss"] { animation-name: seg-fill, pip-dim; }
.progress-seg--burst[data-round="loss"]::after {
  --ring: var(--loss);
  animation-name: pip-thud;
}
@keyframes pip-bloom {
  0% { background: #ffffff; box-shadow: 0 0 calc(6px + 14px * var(--force, 1)) calc(1px + 5px * var(--force, 1)) var(--primary-bright); }
  100% { background: var(--accent); box-shadow: 0 0 0 0 transparent; }
}
@keyframes pip-dim {
  0%, 100% { background: var(--accent); }
  35% { background: var(--loss); }
}
@keyframes pip-burst {
  0% { box-shadow: 0 0 0 0 var(--ring); opacity: calc(0.5 + 0.5 * var(--force, 1)); }
  100% { box-shadow: 0 0 0 calc(3px + 4px * var(--force, 1)) transparent; opacity: 0; }
}
@keyframes pip-thud {
  0%, 100% { box-shadow: 0 0 0 0 var(--ring); opacity: 0; }
  40% { box-shadow: 0 0 0 3px var(--ring); opacity: calc(0.45 + 0.35 * var(--force, 1)); }
}
/* THE SPARKS HAVE A LANE, AND THEY STAY IN IT: each spark is a real element
   positioned as a fraction of ITS OWN pip's width — it cannot reach a
   neighbour — and it travels only UPWARD, into the header's clear top band,
   behind no glyph. */
.spark-field { position: absolute; inset: 0; pointer-events: none; }
.spark {
  position: absolute; top: 0;
  width: 4px; height: 4px; margin-left: -2px;
  border-radius: var(--r-pill); background: var(--accent);
  opacity: 0;
  animation: spark-up var(--t-land) var(--ease-pop) var(--delay, 0ms) forwards;
}
@keyframes spark-up {
  0% { transform: translateY(0) scale(0.4); opacity: calc(0.5 + 0.5 * var(--force, 1)); }
  70% { opacity: calc(0.35 + 0.4 * var(--force, 1)); }
  100% { transform: translateY(calc(-1 * var(--rise, 12px))) scale(1); opacity: 0; }
}
/* the band must not clip the sparks it throws, but the TYPE sits above them */
.progress { overflow: visible; }
.topbar-row { position: relative; z-index: 1; }
.gamebar { position: relative; z-index: 2; }
.till { z-index: 2; }
/* the till takes the same beat: it lifts, and throws one ring in the sign's
   own colour. The FIGURE inside it never moves (decisions 9g/11h). */
.till--land { animation: till-land var(--t-land) var(--ease-pop); }
@keyframes till-land {
  0% { transform: none; }
  35% { transform: translateY(calc(-2px - 2px * var(--force, 1))) scale(calc(1 + 0.05 * var(--force, 1))); }
  100% { transform: none; }
}
.till--land::after {
  content: ""; position: absolute; inset: -2px; border-radius: var(--r-pill);
  --ring: var(--primary-bright);
  animation: till-ring var(--t-land) var(--ease) forwards;
  pointer-events: none;
}
.till--land[data-round="loss"]::after { --ring: var(--loss); }
@keyframes till-ring {
  0% { box-shadow: 0 0 0 0 var(--ring); opacity: calc(0.3 + 0.55 * var(--force, 1)); }
  100% { box-shadow: 0 0 0 calc(8px + 12px * var(--force, 1)) transparent; opacity: 0; }
}

/* ---------- stage (the only scrolling region) ---------- */
.stage {
  overflow-y: auto; overflow-x: hidden;
  padding: 6px 14px 0;
  display: flex; flex-direction: column;
}
/* NO SEAM ABOVE THE FOOTER: the last panel runs to the action bar. */
.stage > .wrap { min-height: 100%; }
.wrap > :last-child { flex: 1 0 auto; }
/* MEASURING THE CONTENT AT ITS NATURAL HEIGHT (see index.js fitCurves): this
   flag is set for one synchronous measurement and cleared before any paint. */
.stage[data-measuring="1"] > .wrap { min-height: 0; }
.stage[data-measuring="1"] > .wrap > :last-child { flex: none; }
/* Wide viewports: the composition is CENTRED on the desktop ground; the empty
   1fr tracks collapse to zero the moment the content is taller than the stage. */
@media (min-width: 900px) {
  .stage {
    background: var(--ground);
    box-shadow: inset 0 1px 0 rgba(51, 36, 28, 0.06);
    padding: var(--sp-5) var(--sp-4);
    display: grid; grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr);
  }
  /* min-height AUTO, not 0 (r20): 0 opts the wrap out of its automatic
     minimum, so on a viewport shorter than the board the stage's auto row
     CLAMPED to the free space and the board's cards leaked ~3px past its
     cream bottom edge (visible at 1280x800 outcome; r19's stretched rail
     masked it). With auto the row honours the board's packed content and the
     stage scrolls, which is what a too-short viewport is supposed to do. */
  .stage > .wrap { grid-row: 2; min-height: auto; }
  .wrap > :last-child { flex: none; }
}
.wrap {
  width: 100%; max-width: 560px; margin: 0 auto;
  display: flex; flex-direction: column; gap: var(--sp-2);
}
@media (min-width: 900px) { .wrap { gap: var(--sp-3); } }
.wrap > * { flex: none; }
/* THE BOARD (r19, proportions reworked r20). Wide viewports compose the SAME
   elements as the phone into ONE object: a cream rounded board on the stone
   ground — the mockup's phone-on-stone feel, scaled up — with the main card
   spanning its left and a rail on its right. Nothing appears or disappears by
   width.

   The standing rules:
   1. one bounded surface: everything the phase shows sits ON the board, so
      there is no white island floating on bare stone;
   2. CONTENT-DRIVEN HEIGHT (r20): the board is exactly as tall as its tallest
      column packs naturally — nothing is flex-grown to fake a level bottom.
      r19 stretched the history card to the main card's bottom line, which at
      scale read as a card that is mostly empty interior; a rail now simply
      ends where its content ends (`align-items: start` here, and every packed
      column is a .card-col below);
   3. the action belongs to the board (index.js parks it on the card's own
      action edge at width), so the anchored band below carries nothing and
      wears the ground's tone. */
@media (min-width: 900px) {
  .wrap--split {
    max-width: var(--board-max);
    background: var(--surface-app);
    border-radius: var(--r-xl);
    box-shadow: var(--shadow-card);
    padding: var(--sp-5);
    display: grid;
    grid-template-columns: minmax(0, 1fr) var(--rail-w);
    gap: var(--sp-5);
    align-items: start;   /* natural heights — no column is stretched */
  }
  .wrap--split > .card, .wrap--split > .reveal-hold { grid-column: 1; }
  /* THE RAIL IS ONE COLUMN (index.js groups history + benchmarks into .rail
     at width): a flex column that PACKS — history card, one gutter, the
     benchmarks, and then it ends. No part of it grows to fill the board's
     height (r20: a stretched card with an empty interior is what content-
     driven height exists to forbid). */
  .wrap--split > .rail {
    grid-column: 2;
    display: flex; flex-direction: column; gap: var(--sp-5);
    min-height: 0;
  }
  .rail > * { flex: none; }
  .rail > .strip { margin-top: 0; }
  /* A PACKED SIDE COLUMN of a board card (r20, shared): index.js groups a
     card's stacked elements into .card-col at width. It packs top-down at the
     card's own gutter rhythm and ends where its content ends — the flex
     wrapper is deliberate (the r19 grid lesson): flex packing is exact and
     identical in all three engines, where grid rows spanning a taller
     neighbour smear the excess into the gaps. */
  .card-col {
    display: flex; flex-direction: column; align-items: stretch;
    gap: var(--sp-4);
    min-width: 0;
  }
  .card-col > * { flex: none; }
  .wrap--split .chips {
    flex-direction: column; overflow: visible; padding: 0;
    scroll-snap-type: none; margin-top: 0;
  }
  .wrap--split .chip {
    flex: none;
    flex-direction: row; align-items: center; justify-content: space-between;
    gap: var(--sp-3); min-width: 0;
  }
  .wrap--split .chip-top { flex: 1; gap: var(--sp-3); }
  .wrap--split .chip-round { min-width: 34px; }
  /* the terminal has no rail (its history is the ledger) but sits on the same
     board, so the debrief is a settled object rather than a centred column */
  .stage[data-state="terminal"] .wrap {
    max-width: 1140px;
    background: var(--surface-app);
    border-radius: var(--r-xl);
    box-shadow: var(--shadow-card);
    padding: var(--sp-5);
  }
  /* the action edge: the board's own home for the primary action (index.js
     moves it here at width). The pill is a COLUMN'S action: it packs where
     the column puts it, right-aligned toward the card's edge, and never runs
     wider than a column (r20 — the systematic cap replaces r19's reliance on
     the grid area happening to be narrow). */
  .card-action { display: flex; flex-direction: column; align-items: flex-end; }
  .card-action > * { width: 100%; max-width: 420px; }
}

/* ---------- action band (anchored, unmissable) ---------- */
.actionbar {
  background: var(--surface-app);
  box-shadow: 0 -1px 0 var(--sunken);
  padding: 6px var(--sp-4) calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 900px) {
  .actionbar { padding: var(--sp-3) var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px)); }
  /* r19: at width the primary action lives ON the board (index.js), so the
     band is empty on the play states and continues the stage's stone ground —
     no seam, no lone pill on a strip. (Instructions still uses it for Start.) */
  .actionbar { background: var(--ground); box-shadow: none; }
}
.actionbar-inner {
  width: 100%; max-width: 560px; margin: 0 auto;
  display: flex; flex-direction: column; gap: var(--sp-2);
}
.action-note {
  font-size: var(--fs-sm); font-weight: var(--w-semi); color: var(--ink-soft);
  text-align: center; min-height: 20px;
}
/* The outcome's action band RISES when the round has finished resolving.
   Keyed on the app's own attribute; space is reserved, never removed. */
.app[data-action-beat="0"] .actionbar--gated,
.app[data-action-beat="1"] .actionbar--gated,
.app[data-action-beat="2"] .actionbar--gated { transform: translateY(110%); }
.app[data-action-beat="3"] .actionbar--gated { animation: bar-up var(--t-beat) var(--ease) backwards; }
@keyframes bar-up { from { transform: translateY(110%); } to { transform: none; } }

/* ================= PANELS ================= */
/* A card is white, radius 16-20, floating on a soft warm shadow — at every
   width (the mockup's card language is not a laptop luxury). */
.card {
  background: var(--surface);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-card);
  padding: var(--sp-3);
  display: flex; flex-direction: column; gap: var(--sp-2);
  animation: rise var(--t-med) var(--ease);
}
@media (min-width: 900px) {
  .card { padding: var(--sp-6); gap: var(--sp-4); }
}
/* On the PHONE the mockup's decide/wait/brief compositions sit DIRECTLY on
   the cream surface — the white cards inside them (the chart, the rule rows,
   the seat rows) are the cards. This is also what gives the plot its width
   back: the r17 resolution guarantee (a $1 difference ≥ 2px wherever the
   plotted range supports it) is bought with plot width, not with a squeezed
   composition. At >=900 the composition is one white card again. */
@media (max-width: 899.98px) {
  .card--decision, .card--wait, .card--instructions {
    background: transparent; box-shadow: none; border-radius: 0;
    padding: 4px 0 0;
  }
}
@keyframes rise { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }

/* ---------- the question ---------- */
.ask {
  font-size: var(--fs-lg); font-weight: var(--w-heavy);
  letter-spacing: var(--track-tight); line-height: 1.25; text-wrap: balance;
}

/* ================= THE CAST ================= */
/* A play screen has two parties and both of them speak. The system draws
   them; a game supplies only names and lines. Drawing (mockup-exact): ink
   works, accent roof, cream lit window, ink eyes with white glints. */
.speaker { display: flex; align-items: flex-start; gap: var(--sp-2); }
.speaker-face { flex: none; display: block; }
.avatar { display: block; width: 42px; height: 42px; }
.speaker--lg { align-items: center; gap: var(--sp-3); }
.speaker--lg .avatar { width: 76px; height: 76px; }
@media (min-width: 900px) { .speaker--lg .avatar { width: 96px; height: 96px; } }
.speaker--lg .bubble { padding: var(--sp-2) var(--sp-4) var(--sp-3); }
.speaker--lg .bubble-line { font-size: var(--fs-md); }
.speaker--lg .bubble::before, .speaker--lg .bubble::after { top: 22px; }
.avatar--rival .av-cap { fill: var(--cast-roof); }
.avatar--rival .av-stack { fill: var(--cast-body); }
.avatar--rival .av-smoke {
  fill: none; stroke: var(--ink-faint); stroke-width: 2; stroke-linecap: round;
}
.avatar--rival .av-head { fill: var(--cast-body); }
.avatar--rival .av-window { fill: var(--cast-window); }
.avatar--rival .av-eye { fill: var(--cast-body); }
.avatar--rival .av-glint { fill: #ffffff; opacity: 0.95; }
.avatar--rival .av-brow {
  stroke: var(--cast-body); stroke-width: 2; stroke-linecap: round; fill: none;
}
.avatar--rival .av-mouth {
  stroke: var(--cast-body); stroke-width: 2; stroke-linecap: round; fill: none;
}
/* the market: deliberately faceless — a board of bars, ink on ink, one bar
   in the accent (the mockup's market mark). */
.avatar--market .av-plate { fill: var(--cast-body); }
.avatar--market .av-bar { fill: var(--cast-window); }
.avatar--market rect.av-bar:last-of-type { fill: var(--accent); }
.avatar--market .av-base { stroke: var(--cast-window); stroke-width: 2; stroke-linecap: round; }

/* the speech bubble: white card, square shoulder toward the speaker
   (mockup border-radius 4/16/16/16), soft shadow, micro name plate. */
.bubble {
  position: relative; flex: 1; min-width: 0;
  background: var(--surface);
  border-radius: 4px var(--r-lg) var(--r-lg) var(--r-lg);
  box-shadow: var(--shadow-card-soft);
  padding: 7px var(--sp-3) 8px;
}
.bubble::before, .bubble::after {
  content: ""; position: absolute; left: -7px; top: 13px;
  width: 0; height: 0;
  border: 7px solid transparent; border-left: 0; border-right-color: rgba(51, 36, 28, 0.07);
}
.bubble::after { left: -5px; border-right-color: var(--surface); }
/* the plate's base register; a PLAIN plate (the market) stays one line and
   ellipsizes if it must */
.bubble-name {
  display: block;
  font-size: 9.5px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps-wide); text-transform: uppercase; color: var(--ink-faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* THE SPLIT PLATE (r19, wrap ruling r20): name span + role span ("Firm B",
   plus the status token where one exists). THE NAME RENDERS IN FULL wherever
   the persona speaks — user's joke name is the character, so it WRAPS to a
   further line rather than truncating (r20 coordinator ruling; supersedes
   r19's one-line ellipsis, and is the one deliberate change to the otherwise
   frozen phone view). Inline flow, so wrapping lands on word boundaries; the
   role+status run is one unbreakable unit glued to the name's last word by
   no-break spaces, so a separator "·" can never start a line and the status
   can never be pushed out of view. Wide: the role takes its own clean line
   under the name, no separator. */
.bubble-name--split {
  display: block; white-space: normal; overflow: visible; text-overflow: clip;
  /* the wrapped plate is all caps (no descenders): a tight leading keeps the
     two-line name from spending the fold budget the full name is allowed to
     cost — firefox renders the taller stack and was pushing the history row
     under the fold at 390x844. (1.2, not tighter: below that the glyph boxes
     of adjacent lines overlap and read as a collision.) */
  line-height: 1.2;
}
.bubble-name--split .bubble-name-text { display: inline; white-space: normal; }
/* THE RUN IS GLUED BY ITS NO-BREAK SPACES, NOT BY `nowrap`, and the difference is a
   viewport. `white-space: nowrap` made the whole role+status run unbreakable, which
   holds the separator in place only while the status is SHORT. The sequenced games are
   the first with a long one — "waiting for your offer", "waiting for your transfer" —
   and gate 5 caught the plate painting to 413px inside a 390px phone, and to 421px
   inside 360px.
   Dropping `nowrap` keeps the stated intent intact: the "·" is written as
   \00a0·\00a0, so it is ALREADY bound to the preceding word by non-break spaces and
   still cannot start a line. What changes is that the status may now wrap at its own
   internal spaces instead of pushing the plate off screen.
   (This is the r20 wrap ruling applied to the run as well as to the name: a plate that
   wraps is the design; a plate that leaves the viewport is not. The critic had already
   filed the 7px clip at 390 as U2 and noted a shorter word would only MASK it.) */
.bubble-name-meta { display: inline; }
.bubble-name-meta::before { content: "\00a0·\00a0"; }
@media (min-width: 900px) {
  .bubble-name-meta { display: block; }
  .bubble-name-meta::before { content: none; }
}
.speaker--rival .bubble-name { color: var(--ink-faint); }
.bubble-status { color: var(--ink-faint); font-weight: var(--w-bold); }
/* same glue, same reason: a bare " · " could start a line once the run wraps */
.bubble-status:not(:empty)::before { content: "\00a0·\00a0"; }
.bubble-status .status-dot { margin-right: 4px; }
.bubble-line {
  font-size: 13px; font-weight: var(--w-bold); color: var(--ink);
  line-height: 1.4; text-wrap: balance;
  margin-top: 2px;
}
.bubble-line b { font-weight: var(--w-heavy); white-space: nowrap; }

/* THE MARKET'S RULE IS READ ONCE, THEN IT GETS OUT OF THE WAY: from round 2
   it collapses to one tappable pill and opens in place on demand. Nothing is
   removed — always in the DOM, always one tap away. */
.terms { display: flex; flex-direction: column; gap: var(--sp-2); }
.terms-peek {
  display: flex; align-items: center; gap: 6px;
  align-self: flex-start; max-width: 100%;
  background: var(--sunken);
  border-radius: var(--r-pill);
  padding: 3px var(--sp-3) 3px 4px;
  min-height: 28px;
  font-size: 10.5px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-soft);
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.terms-peek:hover { background: var(--accent-tint); color: var(--accent-deep); }
.terms-peek-mark { flex: none; display: block; }
.terms-peek-mark .avatar { width: 20px; height: 20px; }
.terms-peek-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.terms-chev {
  flex: none; width: 8px; height: 8px; margin-left: 2px;
  border-right: 2px solid var(--ink-faint); border-bottom: 2px solid var(--ink-faint);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--t-fast) var(--ease);
}
.terms[data-open="1"] .terms-chev { transform: rotate(-135deg) translate(-2px, -2px); }
.terms[data-open="0"] .terms-body { display: none; }
.terms .bubble { background: var(--surface); }
.terms .avatar { width: 36px; height: 36px; }
/* ...but the PEEK's own mark stays chip-sized (this must outrank the rule
   above, or the collapsed chip carries a 36px face and grows 14px) */
.terms .terms-peek-mark .avatar { width: 20px; height: 20px; }

/* ---------- the number control ---------- */
/* The giant quantity: the input IS the mockup's big tabular figure, with the
   unit as a micro-label beside it and a stepper tile on each side. */
.field-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); min-height: 18px; }
.field-label {
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-soft);
}
/* WRAPS, because `nowrap` only ever held while the hint was short. A one-field game's
   hint is "whole dollars $0–$100" and fits; the first COMPOSITE game states two domains
   in one line ("share 0–100% in steps of 10%; payment −$700 to $700") and painted to
   412px inside a 390px phone. Gate 5 caught it, exactly as it caught the rival plate
   earlier today — the same `nowrap`-holds-until-the-text-grows shape, in a second
   place. A hint that wraps is fine; a hint outside the viewport is not, and truncating
   it would hide the domain the student needs. */
.field-hint { font-size: var(--fs-xs); font-weight: var(--w-bold); color: var(--ink-faint); }
.field-hint--alert:empty { display: none; }
.field-hint--alert { color: var(--accent-deep); font-weight: var(--w-heavy); }

.num-field { display: flex; align-items: stretch; gap: var(--sp-3); min-height: var(--tap-xl); }
/* steppers: the mockup's square white tiles on a soft shadow */
.step-btn {
  position: relative; flex: none; width: var(--tap-lg); border-radius: var(--r-md);
  background: var(--surface);
  box-shadow: var(--shadow-ctl);
  transition: transform var(--t-press) var(--ease), box-shadow var(--t-press) var(--ease),
    opacity var(--t-fast) var(--ease);
}
.step-btn::before, .step-btn::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 20px; height: 3.5px; border-radius: var(--r-pill);
  background: var(--ink);
  transform: translate(-50%, -50%);
}
.step-btn--minus::after { display: none; }
.step-btn--plus::after { transform: translate(-50%, -50%) rotate(90deg); }
.step-btn:hover:not(:disabled) { box-shadow: var(--shadow-ctl), inset 0 0 0 2px var(--accent-tint); }
.step-btn:active:not(:disabled) { transform: translateY(1px); }
.step-btn:disabled { opacity: 0.3; cursor: default; transform: none; }

/* the value and its unit are ONE quantity: no box — the figure is the object */
.num-box {
  flex: 1; min-width: 0;
  display: flex; align-items: baseline; justify-content: center; gap: 8px;
  background: transparent;
  border-radius: var(--r-md);
  padding: 0 var(--sp-2);
}
.num-box:focus-within { box-shadow: var(--focus-ring); }
/* right-aligned in a slot as wide as the widest legal value: the ones column
   is the origin and never moves; a second digit appears to its LEFT. */
.num-input {
  flex: none; min-width: 0;
  background: transparent; border: 0; padding: 0;
  text-align: right;
  font-size: var(--fs-2xl); font-weight: var(--w-heavy); line-height: 1.1;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}
/* the slot's width is the widest legal value in `ch`, chosen by ui/inputs/number.js as a
   class — never an inline style, which gate 4 counts as hand-tuning. One rule per width;
   NUM_WIDTH_CLASSES in number.js is the count here, keep them together. */
.num-input.num-w1  { width: 1ch; }
.num-input.num-w2  { width: 2ch; }
.num-input.num-w3  { width: 3ch; }
.num-input.num-w4  { width: 4ch; }
.num-input.num-w5  { width: 5ch; }
.num-input.num-w6  { width: 6ch; }
.num-input.num-w7  { width: 7ch; }
.num-input.num-w8  { width: 8ch; }
.num-input.num-w9  { width: 9ch; }
.num-input.num-w10 { width: 10ch; }
.num-input.num-w11 { width: 11ch; }
.num-input.num-w12 { width: 12ch; }
.num-input.num-w13 { width: 13ch; }
.num-input.num-w14 { width: 14ch; }
.num-input.num-w15 { width: 15ch; }
.num-input.num-w16 { width: 16ch; }
.num-input::placeholder { color: var(--ink-faint); font-weight: var(--w-bold); font-size: var(--fs-lg); }
/* A SELECT IS WORDS, NOT A NUMBER. The setup form dressed its <select> in .num-input,
   so "Fixed all game" rendered at the 34px heavy numeral scale built for value slots —
   the first thing the user saw on the deployed site. A choice control reads at body
   scale, fills its box, and keeps the browser chrome it needs to look pressable. */
.sel-input {
  flex: 1; min-width: 0; width: 100%;
  background: transparent; border: 0; padding: 0 var(--sp-1);
  font: inherit; font-size: var(--fs-md); font-weight: var(--w-bold);
  color: var(--ink); line-height: 1.3;
}
.num-input:focus-visible { box-shadow: none; }
.unit-tag {
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--ink-faint);
  pointer-events: none;
}
/* an out-of-range value says so INSIDE the field, in the unit tag's place */
.unit-tag--alert { display: none; color: var(--accent-deep); text-transform: none; letter-spacing: 0; font-size: var(--fs-sm); }
.num-field--alert .unit-tag { display: none; }
.num-field--alert .unit-tag--alert { display: inline; }
.num-field--alert .num-input { color: var(--accent-deep); }
@media (min-width: 900px) {
  .field-head, .num-field, .qslider { max-width: 400px; }
}

/* THE SLIDER (mockup): cream track, accent fill, white thumb with an accent
   ring. A second hand on the same value — it writes through the exact path
   typing uses, so there is one source of truth for what is committed. */
.qslider {
  position: relative; height: 28px;
  display: flex; align-items: center;
  cursor: pointer;
  touch-action: pan-y;
}
.qslider-track {
  position: absolute; left: 0; right: 0; height: 8px;
  border-radius: var(--r-pill); background: var(--sunken);
}
.qslider-fill {
  position: absolute; left: 0; width: 0; height: 8px;
  border-radius: var(--r-pill); background: var(--accent);
}
.qslider-thumb {
  position: absolute; left: 0; transform: translateX(-50%);
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--surface);
  box-shadow: 0 2px 8px rgba(51, 36, 28, 0.25), inset 0 0 0 3px var(--accent);
}
.qslider--empty .qslider-thumb { box-shadow: 0 2px 8px rgba(51, 36, 28, 0.18), inset 0 0 0 3px var(--track); }
.qslider--empty .qslider-fill { display: none; }
.card--locked .qslider { opacity: 0.45; pointer-events: none; }

/* Board composition for the decision card (r20). One continuous white card,
   two objects: the DECISION COLUMN — a .card-col packing question → market
   chip → the rival's word → stepper/slider → commit, top-down at the card's
   gutter, ending where its content ends — and beside it the HERO HILL as a
   LANDSCAPE figure (the shared chart-slot rule below sizes it), with the
   consequence sentence as the figure's caption directly under it. Nothing in
   the column is spread to fake the hill's height (r20: the user's ruling —
   elements are adjusted for the distance, never merely spaced out). */
@media (min-width: 900px) {
  .card--decision {
    display: grid;
    grid-template-columns: var(--dcol-w) minmax(0, 1fr);
    grid-template-rows: auto auto;
    grid-template-areas:
      "dcol tile"
      "dcol readout";
    column-gap: var(--sp-6);
    row-gap: var(--sp-4);
  }
  .card--decision > .card-col { grid-area: dcol; }
  .card--decision > .out-box { grid-area: tile; align-self: start; }
  /* the consequence sentence reads as the hill's caption, beside the commit */
  .card--decision > .readout { grid-area: readout; align-self: start; padding-top: 0; }
  .card--decision .speaker--decide { align-items: center; gap: var(--sp-3); }
  .card--decision .speaker--decide .avatar { width: 84px; height: 84px; }
  .card--decision .speaker--decide .bubble { padding: var(--sp-2) var(--sp-4) var(--sp-3); }
  .card--decision .speaker--decide .bubble-line { font-size: var(--fs-md); }
  .card--decision .speaker--decide .bubble::before,
  .card--decision .speaker--decide .bubble::after { top: 20px; }
}
.decide-controls { display: flex; flex-direction: column; gap: 6px; }

/* ================= THE PROJECTION PANEL ================= */
/* The white curve card (mockup): the chart, its labelled marks, and the
   consequence SENTENCE that names the price and the profit inline. The tile
   custom properties survive for the acknowledgement pulse. */
.out-box {
  --tile: var(--surface);
  --tile-hi: var(--accent-tint);
  background: var(--tile);
  border-radius: 18px;
  box-shadow: var(--shadow-card);
  padding: var(--sp-3) 10px 6px;
  display: flex; flex-direction: column; gap: 2px;
  transition: background var(--t-quick) var(--ease);
}
.out-box--loss {
  --tile: var(--surface);
  --tile-hi: var(--loss-tint-strong);
}
/* on a RESULTS card the panel is the same white card */
.out-box--neutral {
  --tile: var(--surface);
  --tile-hi: var(--sunken);
}
@media (min-width: 900px) { .out-box { padding: var(--sp-4); gap: var(--sp-2); } }
.out-cond { font-size: 12.5px; font-weight: var(--w-bold); color: var(--ink); line-height: 1.55; }
.out-cond b { font-weight: var(--w-heavy); color: var(--ink); white-space: nowrap; }
.out-cond--soft { color: var(--ink-soft); }

/* the consequence sentence: the condition, the price it implies and the
   profit it pays are ONE sentence (mockup), with the figure inline in the
   money's own colour. The row reserves two lines so a price that grows a
   character can never rewrap the panel below it (§9m). */
.readout {
  display: block;
  min-height: 40px;
  padding: 2px var(--sp-1) 0;
  font-size: 12.5px; font-weight: var(--w-bold); line-height: 1.5;
}
.readout .out-cond { display: inline; }
.readout .out-cond b { color: var(--ink); }
@media (min-width: 900px) { .readout { min-height: 66px; font-size: var(--fs-sm); } }
.out-money {
  display: inline-block;
  font-size: 13.5px; font-weight: var(--w-heavy);
  letter-spacing: 0;
}
.out-money--empty { color: var(--ink-faint); }

/* ================= BUTTONS ================= */
/* Primary: the mockup's 54px accent pill with the warm glow. The press still
   TRAVELS (the commit beat animates through --lip), it just lands on a glow
   instead of a slab. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  border-radius: var(--r-pill);
  font-size: 16px; font-weight: var(--w-heavy); letter-spacing: 0.01em;
  min-height: var(--tap-action);
  padding: 0 var(--sp-5);
  user-select: none;
  transition: transform var(--t-press) var(--ease), box-shadow var(--t-press) var(--ease),
    background var(--t-fast) var(--ease);
}
.btn-primary {
  width: 100%;
  background: var(--accent); color: var(--on-brand);
  box-shadow: var(--shadow-cta);
}
.btn-primary:active:not(:disabled), .btn-primary.btn--press:not(.btn--committed) {
  transform: translateY(var(--lip));
  box-shadow: 0 1px 4px rgba(232, 93, 61, 0.3);
  background: var(--accent-deep);
}
.btn-primary:disabled {
  background: var(--sunken); color: var(--ink-faint);
  box-shadow: none;
  cursor: default; transform: none;
}
/* COMMITTED: the action is spent — the control becomes the confirmation, a
   white ring-pill in the secondary voice, sitting down where it landed. */
.btn--committed, .btn-primary.btn--committed:disabled {
  cursor: default;
  transform: translateY(var(--lip));
  background: var(--surface); color: var(--accent-deep);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.btn--committed .btn-tick, .committed-pill .btn-tick {
  display: inline-grid; place-items: center; flex: none;
  width: 24px; height: 24px; border-radius: var(--r-pill);
  background: var(--accent); color: var(--on-brand);
  font-size: var(--fs-sm); font-weight: var(--w-heavy);
  animation: tick-in var(--t-seal) var(--ease-pop) backwards;
}
@keyframes tick-in { from { transform: scale(0); opacity: 0; } to { transform: none; opacity: 1; } }
/* the same confirmation, carried into the wait (mockup's secondary ring pill) */
.committed-pill {
  width: 100%; min-height: var(--tap-action);
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  border-radius: var(--r-pill);
  background: var(--surface); color: var(--accent-deep);
  box-shadow: inset 0 0 0 2px var(--accent);
  font-size: 15px; font-weight: var(--w-heavy);
}
/* the whole field goes quiet with it, so the card reads as spent, not idle */
.card--locked .num-field, .card--locked .field-head { opacity: 0.45; transition: opacity var(--t-beat) var(--ease); }
.btn-link {
  color: var(--accent-deep); font-size: var(--fs-sm); font-weight: var(--w-heavy);
  padding: var(--sp-1) var(--sp-2); border-radius: var(--r-sm);
  min-height: 28px; display: inline-flex; align-items: center;
}
.btn-link:hover { background: var(--accent-tint); }

.fineprint { font-size: var(--fs-xs); font-weight: var(--w-semi); color: var(--ink-soft); text-align: center; }
.status-dot {
  display: inline-block; width: 8px; height: 8px; border-radius: var(--r-pill);
  background: var(--accent); margin-right: 6px; vertical-align: 1px;
}
/* a status line that changes MEANING must not swap silently */
.status--swap { animation: status-swap var(--t-beat) var(--ease); }
@keyframes status-swap {
  from { opacity: 0; transform: translateY(3px); }
  to { opacity: 1; transform: none; }
}
.status--swap .status-dot { animation: status-dot var(--t-seal) var(--ease); }
@keyframes status-dot {
  0% { box-shadow: 0 0 0 0 var(--primary-glow); }
  60% { box-shadow: 0 0 0 9px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* ================= HISTORY ================= */
/* "So far — you vs rival": a labelled rail of white round cards on the app
   surface (mockup), not a panel of its own. */
.strip {
  display: flex; flex-direction: column; gap: 6px;
  padding: 4px 2px 8px;
}
@media (min-width: 900px) {
  .strip {
    gap: var(--sp-3);
    background: var(--surface);
    padding: var(--sp-4) var(--sp-4) var(--sp-5);
    border-radius: var(--r-xl); box-shadow: var(--shadow-card);
  }
}
.strip-head { display: flex; align-items: center; gap: var(--sp-2); }
.strip-title {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-faint);
}
/* overflow affordance: a CONTROL, never a gradient (a mask cuts figures
   mid-glyph and can shear the sign off a loss — r10). */
.strip-pager { display: none; gap: 4px; margin-right: auto; }
.strip-pager--on { display: inline-flex; }
.strip-page {
  width: 24px; height: 18px; border-radius: var(--r-pill);
  background: var(--sunken); color: var(--ink-soft);
  font-size: var(--fs-sm); font-weight: var(--w-heavy); line-height: 1;
  display: grid; place-items: center;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.strip-page:hover:not(:disabled) { background: var(--accent-tint); color: var(--accent-deep); }
.strip-page:disabled { opacity: 0.28; cursor: default; }
.strip-empty {
  font-size: var(--fs-sm); font-weight: var(--w-semi); color: var(--ink-soft);
  padding: 0 var(--sp-1) var(--sp-1);
  margin-top: var(--chip-peek, 0px);
}
.strip-more { flex: none; margin: -4px -8px -4px auto; }
/* A ROUND IS NEVER HALF-SHOWN: the rail is an exact whole number of columns,
   so parking on the newest round lands chip boundaries on BOTH edges. */
.chips {
  --chip-cols: 2;
  display: flex; gap: var(--sp-2); overflow-x: auto; padding: 2px;
  scrollbar-width: none;
  scroll-snap-type: x mandatory; scroll-padding-left: 2px;
  margin-top: var(--chip-peek, 0px);
}
@media (min-width: 560px) { .chips { --chip-cols: 3; } }
.chips::-webkit-scrollbar { display: none; }
/* whole rounds dim instead of being sliced. De-emphasis, never bleaching. */
.chip--dim { opacity: 0.72; }
.chips .chip { transition: opacity var(--t-beat) var(--ease); }
.chip {
  flex: 0 0 calc((100% - (var(--chip-cols) - 1) * var(--sp-2)) / var(--chip-cols));
  min-width: 0;
  scroll-snap-align: start;
  background: var(--surface); border: 1px solid transparent; border-radius: var(--r-sm);
  box-shadow: var(--shadow-card-soft);
  padding: 5px var(--sp-3);
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-2); text-align: left;
}
.chip-top { flex: 1; }
/* a losing round sits on the quiet tint, so a bad round reads at a glance */
.chip--loss { background: var(--loss-tint); box-shadow: none; border-color: var(--line); }
.chip-top { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.chip-round {
  font-size: 10px; font-weight: var(--w-heavy); letter-spacing: var(--track-caps);
  color: var(--ink-faint); flex: none;
}
.chip-qs {
  font-size: var(--fs-sm); font-weight: var(--w-heavy); white-space: nowrap;
  /* ellipsis, not clip: a chip whose money is wide squeezes this line, and a
     student should see that it was cut rather than read "$15 vs $1" as a fact */
  overflow: hidden; text-overflow: ellipsis;
}
/* the rival's number is muted everywhere it appears */
.chip-qs .rv { color: var(--ink-soft); font-weight: var(--w-bold); }
.chip-money { font-size: 14px; font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }

/* ================= WAITING ================= */
/* Sealed and held (mockup): the chip, the giant sealed number, what the
   commitment stands to pay, the rival's word, and every seat's status. */
.card--wait { align-items: stretch; }
.commit-display { display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); padding: var(--sp-2) 0 0; }
/* the sealed chip breathes — waiting is a calm ambient breath, never a spinner */
.commit-check {
  position: relative;
  display: inline-flex; align-items: center; gap: 6px;
  border-radius: var(--r-pill); padding: 5px 12px;
  background: var(--accent-tint); color: var(--accent-deep);
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps-wide); text-transform: uppercase;
  margin-bottom: var(--sp-2);
}
.commit-qty { font-size: 56px; font-weight: var(--w-heavy); line-height: 1.05; letter-spacing: -0.03em; }
.commit-label {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
}
/* the wait's status IS the rival's status, so the rival states it */
.wait-status { display: block; }
.wait-status .bubble-line { font-size: var(--fs-md); font-weight: var(--w-bold); }
.commit-check--breathe { animation: breathe var(--t-breathe) infinite var(--ease); }
.commit-check--breathe::before, .commit-check--breathe::after {
  content: ""; position: absolute; inset: -6px;
  border-radius: var(--r-pill); border: 2px solid var(--primary-glow);
  animation: breathe-ring var(--t-breathe) infinite var(--ease);
}
.commit-check--breathe::after { animation-delay: calc(var(--t-breathe) / -2); }
@keyframes breathe {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.04); }
}
@keyframes breathe-ring {
  0% { transform: scale(0.92); opacity: 0.15; }
  40% { opacity: 0.85; }
  100% { transform: scale(1.18); opacity: 0; }
}
/* per-seat submit status (mockup): dot + name + Submitted/Deciding rows */
.seatlist { display: flex; flex-direction: column; gap: var(--sp-2); }
.seat-row {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: 10px var(--sp-4);
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
}
.seat-dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--track); }
.seat-row--in .seat-dot { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); }
/* one line per seat (r19): a long firm name ellipsizes rather than growing the
   row — the row's height is part of the waiting screen's rhythm */
.seat-name {
  flex: 1; min-width: 0; font-size: 14px; font-weight: var(--w-heavy); text-align: left;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.seat-status {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-mid);
}
.seat-row--in .seat-status { color: var(--accent-deep); }
/* Board composition for the wait (r20): two packed columns (.card-col) — the
   sealed commitment, what it stands to pay and the confirmation pill on the
   left; the rival's word and the seat list on the right. Each column packs
   top-down and ends where its content ends: no row is flexed open to level
   the two (content-driven height). */
@media (min-width: 900px) {
  .card--wait {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: var(--sp-8);
    align-items: start;
  }
}

/* ================= RESULTS ================= */
/* THE MARKET'S ANSWER — the facts the student did not choose, as white cards
   (mockup). WHOSE fact each is comes from the game def (`who`). */
.stat-row { display: flex; gap: var(--sp-2); }
.stat {
  flex: 1; min-width: 0;
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
  padding: var(--sp-3);
  display: flex; flex-direction: column; gap: 3px;
}
.stat--you { box-shadow: var(--shadow-card-soft), inset 0 0 0 1.5px var(--accent-tint); }
.stat--market {}
.stat--rival {}
.stat-label {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-mid);
}
.stat-value {
  font-size: 19px; font-weight: var(--w-heavy); letter-spacing: var(--track-tight);
  line-height: 1.15; margin-top: auto;
}

/* YOUR ANSWER — the round's figure on a field that takes the sign's tone:
   a gain sits on the accent tint, a loss on the quiet tint. Identical
   choreography either way (no reward for anything the model did not produce). */
.hero {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-1);
  text-align: center;
  border-radius: var(--r-lg);
  padding: var(--sp-4) var(--sp-3);
  background: var(--surface);
  box-shadow: var(--shadow-card-soft);
}
.hero--gain { background: var(--accent-tint); box-shadow: none; }
.hero--loss { background: var(--loss-tint); box-shadow: none; }
/* the kicker is the mockup's settled chip */
.hero-kicker {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps-wide); text-transform: uppercase;
  background: var(--surface); color: var(--accent-deep);
  border-radius: var(--r-pill); padding: 4px 12px;
  box-shadow: var(--shadow-card-soft);
}
.hero--gain .hero-kicker, .hero--loss .hero-kicker { background: var(--surface); }
.hero-figure { display: block; }
.hero-money {
  display: inline-block;
  font-size: var(--fs-hero); font-weight: var(--w-heavy); line-height: 1.05;
  letter-spacing: -0.03em;
}
.hero-sub { font-size: var(--fs-sm); font-weight: var(--w-bold); color: var(--ink-soft); font-variant-numeric: tabular-nums; }

/* the verdict: one line placing the round against the theory benchmark */
.verdict {
  font-size: 12.5px; font-weight: var(--w-bold); line-height: 1.55;
  color: var(--ink-soft); text-wrap: pretty;
  padding: 0 var(--sp-1);
}

/* the class scoreboard (mockup): rank rows, your row on the accent tint */
.board { display: flex; flex-direction: column; gap: var(--sp-2); }
.board-title {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-faint);
  margin-top: var(--sp-1);
}
.board-row {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: 10px var(--sp-4);
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
}
.board-row--you { background: var(--accent-tint); box-shadow: none; }
.board-rank { width: 18px; font-size: 13px; font-weight: var(--w-heavy); color: var(--ink-faint); }
.board-name { flex: 1; min-width: 0; font-size: 14px; font-weight: var(--w-heavy); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.board-row--you .board-name { color: var(--accent-deep); }
.board-q { font-size: 12px; font-weight: var(--w-bold); color: var(--ink-soft); }
.board-pi { font-size: 12.5px; font-weight: var(--w-bold); min-width: 44px; text-align: right; }
.board-total { font-size: 13px; font-weight: var(--w-heavy); min-width: 52px; text-align: right; }
/* Board composition for the round result (r20): the facts, the profit, the
   verdict and the rival's word are one packed column (.card-col) on the left;
   the hindsight hill is a LANDSCAPE figure on the right (the shared chart-slot
   rule sizes it); the continue action and the standings close the bottom row —
   the standings under the chart, where the row is wide enough to read every
   firm. The reveal's temporal order is untouched — beats 1..3 land in the same
   sequence, each into space holdFinalSize has already reserved. */
@media (min-width: 900px) {
  .card--outcome {
    display: grid;
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    grid-template-rows: auto auto;
    grid-template-areas:
      "ocol   lesson"
      "action board";
    column-gap: var(--sp-6);
    row-gap: var(--sp-4);
  }
  .card--outcome > .card-col { grid-area: ocol; }
  .card--outcome > .out-box { grid-area: lesson; align-self: start; }
  .card--outcome > .board { grid-area: board; align-self: start; }
  .card--outcome > .card-action { grid-area: action; align-self: end; }
}

/* ================= INSTRUCTIONS ================= */
.title-xl { font-size: var(--fs-xl); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); line-height: 1.15; }
.lead { font-size: var(--fs-sm); font-weight: var(--w-semi); line-height: 1.6; }
/* the market's terms: a white card of hairline rows (mockup lobby card) */
.rule-box {
  background: var(--surface); border-radius: 18px;
  box-shadow: var(--shadow-card);
  padding: 4px var(--sp-4); display: flex; flex-direction: column;
}
.rule-row {
  display: flex; gap: var(--sp-3); font-size: var(--fs-sm); align-items: baseline;
  padding: 10px 0; border-bottom: 1px solid var(--hairline);
}
.rule-row:last-child { border-bottom: 0; }
.rule-key {
  flex: none; width: 104px; font-weight: var(--w-heavy); color: var(--ink-mid);
  font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
}
.rule-val { font-weight: var(--w-heavy); font-variant-numeric: tabular-nums; }
.example-box {
  background: transparent; border-radius: var(--r-lg);
  padding: var(--sp-1); font-size: 12.5px; font-weight: var(--w-semi);
  line-height: 1.6; color: var(--ink-soft); text-wrap: pretty;
}
.example-box b { color: var(--ink); font-weight: var(--w-heavy); }
.meta-list { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-1); }
.meta-list li {
  list-style: none; display: flex; gap: var(--sp-2); align-items: baseline;
  font-size: var(--fs-xs); font-weight: var(--w-semi); color: var(--ink-mid); line-height: 1.6;
}
.meta-list li::before {
  content: ""; flex: none; width: 6px; height: 6px; border-radius: var(--r-pill);
  background: var(--accent); align-self: flex-start; margin-top: 7px;
}

/* ================= BENCHMARKS (orientation only, never advice) ============ */
.bench { display: flex; flex-direction: column; gap: var(--sp-2); padding: 2px 2px var(--sp-2); }
.bench-title {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-faint);
}
.bench-row { display: flex; gap: var(--sp-2); }
.bench-card {
  flex: 1; min-width: 0;
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
  padding: 10px 11px;
}
.bench-name {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-mid);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bench-q { font-size: 15px; font-weight: var(--w-heavy); font-variant-numeric: tabular-nums; margin-top: 5px; }
.bench-pi { font-size: 12px; font-weight: var(--w-bold); font-variant-numeric: tabular-nums; color: var(--accent-deep); margin-top: 1px; }
.bench-note {
  font-size: 11px; font-weight: var(--w-semi); color: var(--ink-mid);
  text-wrap: pretty;
}
/* on the board (r19) the benchmarks are a white rail card like the history
   above them — anchored to the board's bottom edge, never orphan chips on
   bare ground — and their chips go quiet on the neutral tint */
@media (min-width: 900px) {
  .bench {
    background: var(--surface);
    border-radius: var(--r-xl);
    box-shadow: var(--shadow-card);
    padding: var(--sp-4);
    gap: var(--sp-3);
  }
  .bench-card { background: var(--sunken); box-shadow: none; }
}

/* ================= OVERLAY + SHEET ================= */
.overlay {
  position: fixed; inset: 0; z-index: 50;
  background: var(--scrim);
  display: flex; align-items: flex-end; justify-content: center;
  animation: fade var(--t-fast) var(--ease);
}
@keyframes fade { from { opacity: 0; } }
.sheet {
  background: var(--surface-app);
  width: 100%; max-width: 560px; max-height: 86vh; overflow: auto;
  border-radius: var(--r-xl) var(--r-xl) 0 0;
  padding: var(--sp-5);
  display: flex; flex-direction: column; gap: var(--sp-4);
  animation: sheet-up var(--t-med) var(--ease);
}
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0; } }
@media (min-width: 900px) {
  .overlay { align-items: center; padding: var(--sp-6); }
  .sheet { border-radius: var(--r-xl); box-shadow: var(--shadow-pop); }
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.sheet-title { font-size: var(--fs-lg); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }
.sheet .table-wrap { background: var(--surface); border-radius: var(--r-lg); box-shadow: var(--shadow-card); padding: 4px var(--sp-4); }

/* ================= HISTORY TABLE ================= */
/* mockup: 2px header rule, hairline rows, tabular numerals */
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.table th {
  font-size: 10px; font-weight: var(--w-heavy); color: var(--ink-faint);
  text-transform: uppercase; letter-spacing: 0.08em;
  text-align: right; padding: 9px var(--sp-2);
  border-bottom: 2px solid var(--sunken);
}
.table td { padding: 9px var(--sp-2); border-top: 1px solid var(--hairline); text-align: right; font-weight: var(--w-bold); }
.table tbody tr:first-child td { border-top: 0; }
.table th:first-child, .table td:first-child { text-align: left; }
.table tfoot td { border-top: 2px solid var(--sunken); font-weight: var(--w-heavy); }
/* whose column it is is said by the header's accent */
.table th:last-child { box-shadow: inset 0 -2px 0 var(--ink-faint); }
.table th:nth-last-child(2) { box-shadow: inset 0 -2px 0 var(--accent); }

/* ================= THE DEBRIEF TREND (terminal) ================= */
/* total quantity per round against the three theory totals, with a dash
   legend (mockup analysis card). All values engine facts. */
.trend-card {
  background: var(--surface); border-radius: 18px;
  box-shadow: var(--shadow-card);
  padding: var(--sp-4);
  display: flex; flex-direction: column; gap: var(--sp-2);
}
.trend-caption {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-mid);
}
.trend-plot { display: block; width: 100%; height: auto; overflow: visible; }
.trend-axis { stroke: var(--ink); stroke-width: 1.5; opacity: 0.25; }
.trend-line { fill: none; stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.trend-dot { fill: var(--ink); }
.trend-band { stroke-width: 1.6; opacity: 0.85; fill: none; }
/* Cournot's three anchors, kept because its def names them and `proof` compares its
   rendering at zero tolerance. They are the reason this block needed generalising:
   a benchmark line's colour is a ROLE (a reference, an alternative, a floor), not a
   term from one game's economics. New games name a role instead — see the three
   below — and cournot migrates when a third scalar game makes it free to do. */
.trend-band--collusive { stroke: var(--accent); stroke-dasharray: 3 5; }
.trend-band--nash { stroke: var(--accent); }
.trend-band--competitive { stroke: var(--line-strong); stroke-dasharray: 3 5; }
.trend-band--a { stroke: var(--accent); stroke-dasharray: 3 5; }
.trend-band--b { stroke: var(--accent); }
.trend-band--c { stroke: var(--line-strong); stroke-dasharray: 3 5; }
.trend-legend { display: flex; flex-direction: column; gap: 6px; }
.trend-key {
  display: flex; align-items: center; gap: 9px;
  font-size: 11.5px; font-weight: var(--w-bold); color: var(--ink-soft);
}
.trend-key svg { flex: none; }
.trend-key-label { flex: 1; }
.trend-key-val { font-variant-numeric: tabular-nums; }
.debrief {
  font-size: 12.5px; font-weight: var(--w-bold); line-height: 1.6;
  color: var(--ink-soft); text-wrap: pretty;
  padding: 0 var(--sp-1);
}
/* Board composition for the debrief (r20): two packed columns (.card-col) —
   the closing total over the full ledger on the left; the session stats, the
   trend, the prose and the closing action on the right. Each column packs
   top-down and ends where its content ends (content-driven height replaces
   r19's flexed prose row). Reveal beats unchanged (hero -> stats -> record). */
@media (min-width: 900px) {
  .card--terminal {
    display: grid;
    grid-template-columns: minmax(0, 11fr) minmax(0, 9fr);
    column-gap: var(--sp-6);
    align-items: start;
  }
}

/* ================= PROFIT CURVE ================= */
/* The mockup's chart language: an ink polyline on the white card, the best
   reply and your quantity marked in the accent. The r14 MARK GRAMMAR is a
   standing decision and survives the restyle: every mark is a column that
   stands on $0 and ends at the value it names (the best reply a wide wash
   column, your quantity a slim stem under the accent dot), so a mark can
   never claim across the $0 line. */
.curve-block { display: flex; flex-direction: column; gap: 5px; }
/* NOTE: no vertical padding here, ever — the y-scale gutter is positioned
   against this box, so its height must equal the SVG's exactly. */
.curve-wrap {
  position: relative; padding-right: 38px;
  background: var(--surface); border-radius: var(--r-sm);
  overflow: hidden;
}
.curve { display: block; width: 100%; height: auto; overflow: visible; }
/* THE CHART-SLOT ASPECT RULE (r20, systematic). On the phone a chart keeps its
   width-derived ratio (height:auto above — unchanged). AT WIDTH a chart is a
   LANDSCAPE FIGURE: the slot renders at the shared aspect token with a hard
   height ceiling, and curve.js re-maps its data onto whatever box the slot
   gives it (syncToSlot — the viewBox follows the slot, so the geometry stays
   exact and nothing is letterboxed or distorted). EVERY game's curve slot
   inherits this; no screen sizes a chart, ever. A layout that needs a taller
   plot (the decision hill's 300px resolution floor) buys it with WIDTH. */
@media (min-width: 900px) {
  svg.curve {
    height: auto;
    aspect-ratio: var(--chart-aspect-wide);
    max-height: var(--chart-h-max);
  }
}
/* THE HILL IS A CONTROL: drag along it to set the quantity; vertical gestures
   still scroll the stage (pan-y). */
.curve-block--live .curve-wrap { touch-action: pan-y; cursor: grab; }
.curve-block--live.curve-block--scrub .curve-wrap { cursor: grabbing; }
.curve-scrub { fill: transparent; pointer-events: none; }
.curve-block--live .curve-scrub { pointer-events: all; }
.curve-zone-loss { fill: var(--ink); opacity: 0.035; }
.curve-base {
  fill: none; stroke: var(--line-strong); stroke-width: 1; stroke-dasharray: 3 4;
}
/* the landscape fills retire with the v2 art direction: the mockup's chart is
   the line itself. The elements remain (curve.js draws them); they paint
   nothing. */
.curve-fill { fill: none; }
.curve-fill--loss { fill: none; }
.curve-line {
  fill: none; stroke: var(--ink); stroke-width: 2.6;
  stroke-linecap: round; stroke-linejoin: round;
}
/* THE MORPH IS ASKED FOR ONLY WHERE THE ENGINE CAN GIVE IT (CSS `d`). */
@supports (d: path("M0 0L1 1")) {
  .curve-base, .curve-fill, .curve-line { transition: d var(--t-beat) var(--ease); }
  .curve-block[data-refit="1"] .curve-base,
  .curve-block[data-refit="1"] .curve-fill,
  .curve-block[data-refit="1"] .curve-line { transition: none; }
}
.curve-block[data-refit="1"] .curve-peak-g,
.curve-block[data-refit="1"] .curve-marker-g { transition: none; }
/* below $0 the line is the same ink — the loss region is named by the zero
   line, the y-scale and the floor wash, not by a second stroke colour */
.curve-line--loss { stroke: var(--ink); }

/* ---- ONE GRAMMAR FOR BOTH MARKS (round 14, standing) ------------------- */
.curve-guide { fill: var(--accent); opacity: 0.22; }
.curve-block--met .curve-guide { opacity: 0.38; }
.curve-peak-g { transition: transform var(--t-track) var(--ease); }
/* A POSITION IS A READOUT: the mark never travels; a pulse and a ring say
   "that changed" without asserting anything. */
.curve-marker-g--off, .curve-drop--off { opacity: 0; }
/* A CURVE THAT NAMES NO BEST REPLY SHOWS NO BEST MARK. Distinct from
   `--off` above, which dims a legend that is still meaningful: these hide
   outright, because a mark labelled "best" standing where no best is known is
   a claim the caller did not make. See ui/shared/curve.js `positionAll`. */
.curve-peak-g--off, .curve-leg--hidden { opacity: 0; }
.curve-drop { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; opacity: 0.55; }
.curve-dot--land { animation: dot-land var(--t-quick) var(--ease-pop); transform-box: fill-box; transform-origin: center; }
@keyframes dot-land {
  0% { transform: scale(1.9); }
  60% { transform: scale(0.94); }
  100% { transform: scale(1); }
}
.curve-dot-ring {
  fill: none; stroke: var(--accent); stroke-width: 3; opacity: 0;
  transform-box: fill-box; transform-origin: center;
}
.curve-block--neg .curve-dot-ring { stroke: var(--loss); }
.curve-ring--burst { animation: dot-ring var(--t-beat) var(--ease) forwards; }
@keyframes dot-ring {
  0% { transform: scale(0.7); opacity: 0.75; }
  100% { transform: scale(3.4); opacity: 0; }
}
/* the grab ring: invisible until a finger is actually on the plot */
.curve-handle {
  fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0;
  transition: opacity var(--t-fast) var(--ease);
}
.curve-block--scrub .curve-handle { opacity: 0.45; }
.curve-block--neg .curve-handle { stroke: var(--loss); }
/* each y label is joined to the height it names */
.curve-tick { stroke: var(--line-strong); stroke-width: 1; }
/* the accent marker dot with the white stroke (mockup-exact) */
.curve-dot { fill: var(--accent); stroke: var(--surface); stroke-width: 3; }
/* a losing position is ink wherever it is drawn — plotted mark AND legend
   swatch, from one state on the block */
.curve-block--neg .curve-dot { fill: var(--loss); }
.curve-block--neg .curve-drop { stroke: var(--loss); }
.curve-axis {
  display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2);
  font-size: 10.5px; font-weight: var(--w-bold); color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  padding-right: 38px;
}
/* ONE SLOT, TWO OCCUPANTS, ONE LINE: drag affordance + optimum note overlay */
.curve-axis-mid {
  position: relative; flex: 1; min-width: 0; height: 15px;
}
.curve-axis-mid > * {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.curve-axis-hint {
  color: var(--accent-deep); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  opacity: 0.8;
}
.curve-axis-mid .at-best--on ~ .curve-axis-hint { display: none; }
/* y-scale gutter: matches the SVG box exactly */
.curve-y { position: absolute; inset: 0 0 0 auto; right: 0; width: 38px; }
.curve-y-val {
  position: absolute; right: 4px; transform: translateY(-50%);
  font-size: 10.5px; font-weight: var(--w-bold); color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.curve-y-max { top: 0; }
.curve-y-min { top: 100%; }
.curve-y-zero { color: var(--ink-soft); font-weight: var(--w-heavy); }
/* THE CHART LABELS ITSELF: each mark carries its own pill tag over the mark
   it names, rows fixed (`best` above, `you` below) so the band's height never
   changes with the value being typed (§9m). */
.curve-tags {
  position: relative; height: 38px;
  font-size: 11px; font-weight: var(--w-heavy); line-height: 1.25;
  font-variant-numeric: tabular-nums;
}
.curve-tag {
  position: absolute; left: 0; transform: translateX(-50%);
  display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
  color: var(--ink);
  background: var(--surface); border-radius: var(--r-pill);
  padding: 2px 8px 2px 4px;
  box-shadow: 0 0 0 1px var(--hairline), var(--shadow-card-soft);
}
.curve-tag::after {
  content: ""; position: absolute; left: 50%; bottom: -4px;
  margin-left: calc(-3px + var(--point-dx, 0px));
  border: 3px solid transparent; border-bottom: 0; border-top-color: var(--line-strong);
}
.curve-leg--best { top: 0; }
.curve-leg--you { top: 19px; }
.curve-leg-glyph { width: 13px; height: 15px; flex: none; overflow: visible; }
.curve-leg--off { color: var(--ink-soft); font-weight: var(--w-bold); }
/* the optimum note, stated as a fact when the payoffs tie; its line is
   RESERVED at every value (§9m). */
.at-best {
  visibility: hidden;
  font-size: 11px; font-weight: var(--w-heavy); color: var(--accent-deep);
  letter-spacing: 0.01em;
}
.at-best--on { visibility: visible; }
.at-best--static { visibility: visible; }

/* ================= MOTION BEATS (durations from tokens) ================= */

/* typing: figures snap (9g); the acknowledgement carries no number — the
   card's wash pulses once, reading the panel's OWN tint variables. */
.out-box--tick { animation: tint-pulse var(--t-quick) var(--ease); }
@keyframes tint-pulse {
  0% { background: var(--tile); }
  45% { background: var(--tile-hi); }
  100% { background: var(--tile); }
}
/* ...and the inline figure ticks with it — the SLAB moves, never the value. */
.out-money--tick { animation: figure-tick var(--t-beat) var(--ease-pop); display: inline-block; }
@keyframes figure-tick {
  0% { transform: translateY(3px) scale(0.94); opacity: 0.7; }
  55% { transform: translateY(0) scale(1.05); opacity: 1; }
  100% { transform: none; }
}

/* commit: one seal beat — the button travels and springs back; the card
   throws a single ring. */
.card--seal { animation: seal var(--t-seal) var(--ease); }
@keyframes seal {
  0% { box-shadow: var(--shadow-card); }
  35% { box-shadow: var(--shadow-card), 0 0 0 10px var(--primary-glow); }
  100% { box-shadow: var(--shadow-card), 0 0 0 0 transparent; }
}
.btn--seal { animation: seal-btn var(--t-seal) var(--ease-pop); }
@keyframes seal-btn {
  0% { transform: translateY(var(--lip)); }
  55% { transform: translateY(-2px); }
  100% { transform: translateY(var(--lip)); }
}

/* outcome reveal: a FIGURE'S ENTRANCE animates the figure as a WHOLE —
   opacity, scale, position; never a clip or mask (decisions 13c). */
.hero-money--reveal { animation: figure-in var(--t-figure) var(--ease-pop) backwards; }
@keyframes figure-in {
  0% { opacity: 0; transform: scale(0.8) translateY(6px); }
  60% { opacity: 1; transform: scale(1.05) translateY(0); }
  100% { opacity: 1; transform: none; }
}
.chip--pending { display: none; }
/* the new round arrives fully LEGIBLE in its first frame — a ring marks it */
.chip--enter { animation: chip-in var(--t-beat) var(--ease-pop); }
@keyframes chip-in {
  0% { transform: scale(0.94); box-shadow: 0 0 0 0 var(--primary-glow); }
  60% { transform: scale(1.02); box-shadow: 0 0 0 10px transparent; }
  100% { transform: none; box-shadow: 0 0 0 0 transparent; }
}
/* Staged reveal: a beat that has not arrived occupies NOTHING inside the
   card; nothing on the page moves while it fills — the card's FINAL height is
   measured at mount and held by .reveal-hold, which wears the panel's own
   surface. */
.reveal-hold {
  display: block; background: var(--surface);
  border-radius: var(--r-xl); box-shadow: var(--shadow-card);
}
.reveal-hold > .card { background: transparent; box-shadow: none; }
[data-beat="0"] .reveal-step--1,
[data-beat="0"] .reveal-step--2, [data-beat="1"] .reveal-step--2,
[data-beat="0"] .reveal-step--3, [data-beat="1"] .reveal-step--3, [data-beat="2"] .reveal-step--3 {
  display: none;
}
[data-beat="1"] .reveal-step--1,
[data-beat="2"] .reveal-step--2,
[data-beat="3"] .reveal-step--3 { animation: land var(--t-beat) var(--ease) backwards; }
@keyframes land {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: none; }
}
/* the white cards inside the held reveal keep their own shadows */
.reveal-hold .stat, .reveal-hold .out-box, .reveal-hold .board-row,
.reveal-hold .hero, .reveal-hold .trend-card { animation: none; }

/* terminal: one restrained closing crest behind the total */
.hero-money--crest { position: relative; }
.hero-money--crest::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 140px; height: 140px; margin: -70px 0 0 -70px;
  border-radius: var(--r-pill); border: 3px solid var(--primary-glow);
  animation: crest var(--t-crest) var(--ease) forwards;
  pointer-events: none;
}
@keyframes crest {
  from { transform: scale(0.5); opacity: 0.9; }
  to { transform: scale(1.6); opacity: 0; }
}

/* SHORT PHONES (<=800px tall): the same composition, sized down — nothing
   appears or disappears, the projected figure simply has to be readable at
   rest on a 740px viewport too (standing matrix assertion). LAST in the file
   on purpose: these override same-specificity base rules by order. */
@media (max-width: 899.98px) and (max-height: 800px) {
  /* r20 name-wrap ruling: the rival's full name costs the plate a second line
     (~12px). SHORT phones pay for it here — a few 2px trims above the
     projected figure — so the figure stays readable at rest on a 740px
     viewport (standing matrix assertion) and taller phones change by nothing
     but the wrap itself. */
  .topbar { padding-top: 8px; gap: 3px; }
  .gamebar { padding: 3px 10px 3px 14px; }
  .ask { font-size: 20px; }
  .card--decision, .card--wait, .card--instructions { padding-top: 0; }
  .decide-controls { gap: 2px; }
  .speaker--decide .avatar { width: 36px; height: 36px; }
  .speaker--decide .bubble-line { font-size: 12.5px; }
  .num-field { min-height: 44px; }
  .num-input { font-size: 34px; }
  .qslider { height: 20px; }
  .terms .bubble { padding: 6px 10px 7px; }
  .terms .avatar { width: 30px; height: 30px; }
  .out-box { padding-top: 8px; }
  .readout { min-height: 36px; font-size: 12px; }
}

/* ==================================================================== */
/* ================= THE WIDE PARADIGM (r21): STAGE + LEDGER ========== */
/* decisions/shared/20260821-1000-stage-paradigm.md. At wide viewports the
   play container resolves ONE of two tiers and stamps it on the shell:
     .app[data-layout="stage"]  — >=900 wide AND >=720 tall AND the game
        declares a stage figure: the figure IS the screen. Slim app bar,
        stage slot filling all remaining height, one control strip along the
        bottom (the anchored action band, same three-band shell), context
        panel right, open by default. NO SCROLLING in stage states.
     .app[data-layout="board"]  — the LEDGER fallback (too short, or no
        stage figure): the user's beautified Ledger mockup — flat cream
        surface, tiles delimited by 2px hairline borders, no card shadows,
        a fixed decision column beside a large hill, ruled standings.
   The phone (<900) is untouched by everything in this section: every rule
   is scoped to a [data-layout] the phone never carries. */

/* ---- the shared 58px app bar (both wide tiers) -------------------------
   The SAME elements the phone's three-row header carries, flattened into
   one row via display:contents — nothing appears or disappears by width,
   the boxes just stop stacking. Order is the mockup's reading order. */
.topbar--bar {
  flex-direction: row; align-items: center;
  gap: var(--sp-4);
  height: var(--appbar-h);
  padding: 0 var(--sp-5);
}
.topbar--bar .topbar-row, .topbar--bar .gamebar,
.topbar--bar .gamebar-main, .topbar--bar .stepdots { display: contents; }
.topbar--bar .brandmark { order: 0; font-size: 17px; }
.topbar--bar .topbar-title { order: 1; font-size: 13px; overflow: visible; }
.topbar--bar .round-pill { order: 2; margin-top: 0; font-size: 11px; overflow: visible; }
.topbar--bar .stepdot, .topbar--bar .stepdot-sep { order: 3; }
.topbar--bar .progress { order: 4; flex: 1 1 60px; max-width: 220px; margin-left: auto; }
.topbar--bar .join-label { order: 5; margin-left: 0; }
.topbar--bar .icon-btn { order: 6; }
.topbar--bar .till { order: 7; }
.topbar--bar .clockpill { order: 8; }
.app[data-layout="stage"] .topbar--bar { box-shadow: 0 1px 0 var(--track); position: relative; z-index: 3; }
.app[data-layout="board"] .topbar--bar { border-bottom: 2px solid var(--tile-line); }

/* ---- STAGE: the shell ------------------------------------------------- */
.app[data-layout="stage"] .stage {
  overflow: hidden;                    /* no scrolling in stage states */
  background: var(--ground);
  box-shadow: none;
  display: flex;
  padding: var(--sp-4);
}
.app[data-layout="stage"] .stage > .wrap {
  min-height: 0; max-width: none; background: transparent;
  box-shadow: none; padding: 0; border-radius: 0;
}
.wrap--stage { width: 100%; flex: 1; margin: 0; display: flex; flex-direction: row; gap: var(--sp-4); }
.wrap--stage > * { flex: none; }
.stagemain { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: var(--sp-3); }
.stagehead { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-5); }
.stagehead-main { min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
.stagehead .speaker--decide { align-items: center; gap: var(--sp-3); max-width: 430px; }
.stagehead .speaker--decide .avatar { width: 64px; height: 64px; }

/* THE STAGE SLOT: the figure takes ALL remaining height. The chart keeps
   the measured-aspect system — never preserveAspectRatio stretching: the
   slot renders whatever box the layout gives it and curve.js syncToSlot
   re-maps the viewBox onto that box, so geometry stays exact and the data
   area simply gains pixels (more than the Ledger's, never fewer). */
.stage-fig { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.stage-fig > .out-box {
  --tile: transparent;
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  background: var(--tile); box-shadow: none; padding: 0 var(--sp-2);
}
.stage-fig .curve-block { flex: 1 1 auto; min-height: 0; }
.stage-fig .curve-wrap { flex: 1 1 auto; min-height: 0; background: transparent; }
.app[data-layout="stage"] .stage-fig svg.curve { height: 100%; aspect-ratio: auto; max-height: none; }
.app[data-layout="stage"] .bubble { background: var(--surface-app); }
.app[data-layout="stage"] .bubble::after { border-right-color: var(--surface-app); }

/* stage: waiting — the sealed number IS the stage, centered */
.stagemain--waiting { justify-content: center; align-items: center; gap: var(--sp-3); text-align: center; }
.stagemain--waiting .seatlist { flex-direction: row; gap: var(--sp-3); }
.stagemain--waiting .seat-row { background: var(--surface-app); }
.stagemain--waiting .out-box { --tile: transparent; background: var(--tile); box-shadow: none; align-items: center; max-width: 620px; }
.stagemain--waiting .commit-qty { font-size: 84px; }
.stagemain--waiting .wait-status { max-width: 560px; }

/* stage: results — outcome header row over the hindsight-hill stage.
   READING ORDER KEEPS THE FACTS FIRST (standing r16 rule; the mockup put
   the profit before the facts — the rule wins): facts, then profit, then
   the rival's word. */
.stagemain--outcome .stagehead { align-items: center; }
.stagemain--outcome .stat { background: var(--surface-app); min-width: 120px; }
.stagemain--outcome .hero { flex: none; background: transparent; box-shadow: none; padding: 0; align-items: flex-start; text-align: left; }
.stagemain--outcome > .verdict { flex: none; text-align: center; }

/* stage: terminal — the trend chart is the stage (uniform viewBox scaling,
   centered; never a stretch) */
.stagemain--terminal .stagehead { align-items: center; }
.stagemain--terminal .hero { background: transparent; box-shadow: none; padding: 0; align-items: flex-start; text-align: left; }
.stage-fig--trend .trend-card { flex: 1 1 auto; min-height: 0; background: var(--surface-app); }
.stage-fig--trend .trend-plot { flex: 1 1 auto; min-height: 0; width: 100%; height: 100%; }

/* ---- STAGE: the context panel (open by default, collapsible) ---------- */
.stagepanel {
  width: var(--panel-w); min-height: 0; overflow-y: auto;
  background: var(--surface-app);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-card);
  padding: var(--sp-4);
  display: flex; flex-direction: column; gap: var(--sp-4);
}
.stagepanel-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.stagepanel .strip, .stagepanel .bench { background: transparent; box-shadow: none; padding: 0; }
.stagepanel .chips { flex-direction: column; overflow: visible; padding: 0; scroll-snap-type: none; margin-top: 0; }
.stagepanel .chip {
  flex: none; flex-direction: row; align-items: center;
  justify-content: space-between; gap: var(--sp-3); min-width: 0;
}
.stagepanel .chip-top { flex: 1; gap: var(--sp-3); }
.stagepanel .chip-round { min-width: 34px; }
/* collapsed: the panel hides, the reopen tab stays visible — never both */
.wrap--stage[data-panel="0"] .stagepanel { display: none; }
.wrap--stage[data-panel="1"] .stagetab { display: none; }
.stagetab {
  flex: none; align-self: center;
  writing-mode: vertical-rl;
  background: var(--ink); color: var(--surface-app);
  border-radius: var(--r-md) 0 0 var(--r-md);
  padding: var(--sp-4) 6px;
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps-wide); text-transform: uppercase;
}

/* ---- STAGE: the control strip (the anchored action band's wide form) -- */
.app[data-layout="stage"] .actionbar { background: var(--surface-app); box-shadow: 0 -1px 0 var(--track); }
.app[data-layout="stage"] .actionbar-inner { max-width: none; padding: 0 var(--sp-3); }
.stage-strip { display: flex; flex-direction: column; gap: 4px; }
.stage-controls { display: flex; align-items: center; gap: var(--sp-4); }
.stage-controls .num-field { flex: none; max-width: none; min-width: 170px; }
.stage-controls .qslider { flex: 1 1 auto; max-width: none; }
.stage-controls .readout { flex: none; width: 330px; min-height: 44px; padding: 0; }
.stage-controls .btn { width: auto; min-width: 210px; }
.key-hint { text-align: center; font-size: 11px; font-weight: var(--w-bold); color: var(--ink-faint); }
.app[data-layout="stage"] .actionbar-inner > .btn,
.app[data-layout="stage"] .actionbar-inner > .committed-pill { width: auto; margin: 0 auto; min-width: 320px; }

/* ---- LEDGER (board tier): flat cream, hairline tiles ------------------ */
.app[data-layout="board"] .stage {
  background: var(--surface-app);
  box-shadow: none;
  display: flex; flex-direction: column;
  padding: var(--sp-6) var(--sp-8) var(--sp-5);
}
.app[data-layout="board"] .stage > .wrap { min-height: 0; }
.wrap--ledger {
  max-width: var(--ledger-max); width: 100%; margin: 0 auto;
  background: transparent; box-shadow: none; padding: 0;
  display: flex; flex-direction: column; gap: var(--sp-5);
}
.ledger { display: flex; gap: 40px; align-items: flex-start; }
.ledger-col { width: var(--ledger-col); flex: none; min-width: 0; }
.ledger-fig { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-3); }
.ledger--center {
  flex-direction: column; align-items: center;
  max-width: 640px; margin: 0 auto; text-align: center; gap: var(--sp-4);
}
.ledger--center .seatlist { flex-direction: row; flex-wrap: wrap; justify-content: center; gap: var(--sp-3); }
.ledger--center .card-action { align-items: center; width: 100%; }

/* the Ledger skin: surfaces flatten, tiles carry a 2px hairline border */
.app[data-layout="board"] .card, .app[data-layout="board"] .reveal-hold { background: transparent; box-shadow: none; }
.app[data-layout="board"] .out-box { --tile: transparent; background: var(--tile); box-shadow: none; padding: 0 var(--sp-2); }
.app[data-layout="board"] .curve-wrap { background: transparent; }
.app[data-layout="board"] .bubble { background: var(--hairline); box-shadow: none; }
.app[data-layout="board"] .bubble::after { border-right-color: var(--hairline); }
.app[data-layout="board"] .rule-box { box-shadow: none; border: 2px solid var(--tile-line); background: transparent; }
.app[data-layout="board"] .bench { background: transparent; box-shadow: none; padding: 0; }
.app[data-layout="board"] .bench-card { background: transparent; box-shadow: none; border: 2px solid var(--tile-line); }
.app[data-layout="board"] .stat { background: transparent; box-shadow: none; border: 2px solid var(--tile-line); }
.app[data-layout="board"] .stat--you { border-color: var(--accent-faded); }
.app[data-layout="board"] .seat-row { background: transparent; box-shadow: none; border: 2px solid var(--tile-line); border-radius: var(--r-pill); }
.app[data-layout="board"] .hero { background: transparent; box-shadow: none; padding: 0; align-items: flex-start; text-align: left; }
.app[data-layout="board"] .ledger--center .hero { align-items: center; text-align: center; }
.app[data-layout="board"] .hero-kicker { box-shadow: none; background: var(--accent-tint); }
.app[data-layout="board"] .board-title { border-bottom: 2px solid var(--tile-line); padding-bottom: 7px; }
.app[data-layout="board"] .board-row { background: transparent; box-shadow: none; border-radius: 0; border-bottom: 1px solid var(--hairline); padding: 10px 6px; }
.app[data-layout="board"] .board-row--you { background: var(--accent-tint); border-radius: var(--r-sm); }
.app[data-layout="board"] .trend-card { background: transparent; box-shadow: none; border: 2px solid var(--tile-line); }
.app[data-layout="board"] .step-btn { background: transparent; box-shadow: none; border: 2px solid var(--tile-line); }
.app[data-layout="board"] .actionbar { background: var(--surface-app); box-shadow: none; }
.app[data-layout="board"] .stage[data-state="terminal"] .wrap { max-width: var(--ledger-max); background: transparent; box-shadow: none; padding: 0; }
.app[data-layout="board"] .card-action > * { max-width: 430px; }
/* the decision column reads at the mockup's scale */
.app[data-layout="board"] .ledger-col .num-field { min-height: 64px; }
.app[data-layout="board"] .ledger-col .num-input { font-size: 58px; }

/* THE HISTORY STAYS VISIBLE AT REST (standing rule — the mockup's closed
   drawer is not acceptable): a ruled "so far" list under the figure. */
.app[data-layout="board"] .strip { background: transparent; box-shadow: none; border-radius: 0; padding: var(--sp-3) 0 0; border-top: 2px solid var(--tile-line); }
.app[data-layout="board"] .chips { flex-direction: column; overflow: visible; padding: 0; scroll-snap-type: none; margin-top: var(--chip-peek, 0px); }
.app[data-layout="board"] .chip {
  flex: none; flex-direction: row; align-items: center;
  justify-content: space-between; gap: var(--sp-3); min-width: 0;
  background: transparent; box-shadow: none; border: 2px solid var(--tile-line);
}
.app[data-layout="board"] .chip--loss { background: var(--loss-tint); }
.app[data-layout="board"] .chip-top { flex: 1; gap: var(--sp-3); }
.app[data-layout="board"] .chip-round { min-width: 34px; }

/* THE NAME RENDERS IN FULL (r20 wrap ruling, extended to the r21 contexts):
   wherever the rival's name appears in the panel or the Ledger standings it
   WRAPS to a further line rather than truncating — a cartel joke that can't
   say "cartel" is dead. (The phone's standings keep their committed rule.) */
.stagepanel .board-name, .app[data-layout="board"] .board-name,
.stagemain--waiting .seat-name, .ledger--center .seat-name {
  white-space: normal; overflow: visible; text-overflow: clip; line-height: 1.2;
}

/* ==========================================================================
   THE PAGE KIT — screens that are not a running game
   Added 2026-08-28. decisions/shared/20260828-c8-kit-widening.md.

   WHY THIS EXISTS. Everything above this line was written for the cournot PLAY
   screen, and TOKENS.md says so: "Treat it as the play-screen kit, not a
   general component library… If your screen needs one of the above, do not
   scavenge play-screen classes for it. Repurposing `.till`, `.rule-box`,
   `.hero-money`, `.verdict` or `.seat-row` into a configuration form is how the
   setup screen went wrong." That is not a hypothetical: the setup screen did
   exactly that, the user rejected it on sight, and gate 4 scored it 0 and 14
   against a floor of 21 with 142 and 160 inline styles.

   So the components TOKENS.md lists as NOT CARRIED are built here, once, as
   named parts: content card, status pill, form field group, stepper field,
   tab bar, stat tile, side sheet, shelf header, search field, ink ground.
   Everything resolves to tokens.css; no screen below adds a colour, size,
   radius, shadow or measure of its own, which is CLAUDE.md invariant 5.

   NOTHING ABOVE THIS LINE CHANGED. Every name here is new, so no existing
   consumer's rendering moves and `npm run proof` is untouched by construction —
   TOKENS.md's change protocol: "Adding is growth. Redefining is a break."
   ========================================================================== */

/* [hidden] MUST WIN OVER THIS KIT'S OWN DISPLAY RULES.
   The UA sheet says `[hidden] { display: none }` at the weakest specificity, so
   any class here that sets display:flex or display:grid silently BEATS it and a
   "hidden" node renders. base.css already carries a scar from this exact trap
   (`.terms` vs the hidden attribute, noted beside the old setup form), and the
   error box, the warning box and the solo button are all hidden by default. */
.pane[hidden], .pane-body[hidden], .pane-band[hidden], .notebox[hidden],
.field[hidden], .field-note[hidden], .field-top[hidden], .tabpanel[hidden],
.tabs[hidden], .fieldgrid[hidden], .statgrid[hidden], .stat-tile[hidden],
.pill[hidden], .tagrow[hidden], .gamecard[hidden], .gamegrid[hidden],
.shelf[hidden], .shelfhead[hidden], .searchfield[hidden], .stepper[hidden],
.actionstack[hidden], .sidesheet[hidden], .pagecols[hidden], .pagecol[hidden],
.pagehead[hidden], .inkblock[hidden], .btn[hidden] { display: none; }

/* the catalog's search hides rows through the stylesheet, never inline */
.gamecard[data-hit="0"], .shelf[data-hit="0"] { display: none; }

/* links that are structure rather than prose carry no underline until hover */
.brandlink { color: inherit; text-decoration: none; }
.gamecard { text-decoration: none; }

/* ---------- the page shell ----------
   Setup, join and monitor are NOT in-game screens, so the Stage/Ledger tiers do
   not apply to them (TOKENS.md tier-scope amendment, 2026-08-21): they compose
   in the Playful card language at every width — white cards on the cream field,
   scaled up for the laptop, never flattened. A form is also read from the top,
   so this page flows from the top rather than centring in the stage's grid. */
/* THE APP SHELL'S GRID COLUMN IS `auto`, WHICH MEANS MAX-CONTENT.
   `.app` declares `grid-template-rows` and no columns, so its single implicit
   column sizes to its widest item's max-content — and `.app` is also
   `overflow: hidden`. At 360px the catalog therefore laid out 409px wide and the
   extra 49px was silently CLIPPED: the header's own count line and the right
   edge of every game card were cut off, with `documentElement.scrollWidth`
   still reporting 360 so nothing looked wrong. Found by driving the screen at
   360 and measuring rects, not by looking at it — the clip is invisible.

   `minmax(0, 1fr)` pins the column to the shell's width, so a too-wide row
   shrinks (the round pill already carries `overflow:hidden`, so it is the one
   that gives) instead of pushing the page out from under its own clip.

   SCOPED TO PAGE SCREENS, DELIBERATELY. `.app` is shared with the play screen,
   whose renders are compared at ZERO tolerance by `npm run proof`. A screen opts
   in by adding `app--page`; nothing that does not opt in changes at all. */
.app--page { grid-template-columns: minmax(0, 1fr); }

.stage--page { background: var(--surface-app); }
@media (min-width: 900px) {
  .stage--page {
    background: var(--surface-app); box-shadow: none;
    display: flex; flex-direction: column;
    padding: var(--sp-6) var(--sp-6) var(--sp-14);
  }
  .stage--page > .wrap { grid-row: auto; min-height: 0; }
}
.wrap--page {
  max-width: var(--board-max); width: 100%; margin: 0 auto;
  display: flex; flex-direction: column; gap: var(--sp-5);
}
.wrap--page > * { flex: none; }

/* two columns at width, one on the phone; the aside carries context and the
   main column carries the thing being decided */
.pagecols { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--sp-5); }
.pagecol { flex: 1 1 280px; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-4); }
.pagecol--aside { max-width: 400px; }
.pagecol--main { flex: 1 1 340px; max-width: 560px; }
@media (min-width: 900px) { .pagecols { gap: var(--sp-8); } }

/* the page's own title block */
.pagehead { display: flex; flex-direction: column; gap: var(--sp-2); }
.pagehead-sub { font-size: var(--fs-md); font-weight: var(--w-semi); line-height: 1.55; color: var(--ink-soft); }
.backlink {
  align-self: flex-start;
  color: var(--accent-deep); font-size: var(--fs-sm); font-weight: var(--w-heavy);
  padding: var(--sp-1) 0; min-height: 28px; display: inline-flex; align-items: center;
}
.backlink:hover { text-decoration: underline; }

/* ---------- content card ----------
   TOKENS.md "not carried": game/content card. White, soft warm shadow, the
   mockup's 16px radius — the same card language as the phone, at every width. */
.pane {
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  display: flex; flex-direction: column;
  overflow: hidden;
}
.pane-body { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
@media (min-width: 900px) { .pane-body { padding: var(--sp-5) var(--sp-6) var(--sp-6); } }
/* a band inside the card that stays put while the tabs below it change */
.pane-band {
  padding: var(--sp-4) var(--sp-4) var(--sp-3);
  border-bottom: 1px solid var(--hairline);
  display: flex; flex-direction: column; gap: var(--sp-3);
}
@media (min-width: 900px) { .pane-band { padding: var(--sp-5) var(--sp-6) var(--sp-4); } }
.pane-title { font-size: var(--fs-lg); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }

/* ---------- tab bar ----------
   TOKENS.md "not carried": there was no tab bar. The active tab is named by the
   accent underline and full ink; the resting tab is muted — no box, no fill, so
   the card's own surface stays the only surface. */
.tabs {
  display: flex; gap: var(--sp-1);
  padding: 0 var(--sp-4);
  border-bottom: 1px solid var(--hairline);
}
@media (min-width: 900px) { .tabs { padding: 0 var(--sp-6); } }
.tab {
  appearance: none; background: none; border: 0;
  padding: var(--sp-3) var(--sp-3);
  min-height: var(--tap);
  font: inherit; font-size: var(--fs-sm); font-weight: var(--w-heavy);
  color: var(--ink-faint);
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  cursor: pointer;
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.tab:hover { color: var(--ink-soft); }
.tab--on { color: var(--ink); border-bottom-color: var(--accent); }
.tab:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-sm); }
.tabpanel { display: flex; flex-direction: column; gap: var(--sp-4); }
.tabpanel[data-on="0"] { display: none; }

/* ---------- form field group ----------
   TOKENS.md "not carried": form field group. Label and range sit ABOVE the
   control and the note sits below it — gate 5's rule that bounds are stated
   outside the value slot, so an empty slot can never be read as a value. */
.fieldgrid {
  display: grid; gap: var(--sp-4) var(--sp-5);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
}
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field-top { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.field-name { font-size: var(--fs-xs); font-weight: var(--w-heavy); color: var(--ink-soft); }
.field-range {
  font-size: var(--fs-xs); font-weight: var(--w-bold); color: var(--ink-faint);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
.field-note { font-size: var(--fs-xs); font-weight: var(--w-semi); color: var(--ink-mid); line-height: 1.45; }

/* ---------- stepper field ----------
   TOKENS.md "not carried". Distinct from the play screen's `.num-field`, which
   is the 40-58px decision numeral with detached tiles — a configuration form is
   read at body scale and needs a compact segmented control. Both tap targets
   clear --tap (46px, above the mockup's 44). */
.stepper {
  display: flex; align-items: stretch;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--sunken);
  overflow: hidden;
  min-height: var(--tap);
}
.stepper:focus-within { box-shadow: var(--focus-ring); }
.stepper-btn {
  position: relative;
  appearance: none; background: none; border: 0;
  flex: none; width: var(--tap); min-height: var(--tap);
  cursor: pointer;
  transition: background var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease);
}
.stepper-btn::before, .stepper-btn::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 14px; height: 2.5px; border-radius: var(--r-pill);
  background: var(--ink-soft);
  transform: translate(-50%, -50%);
}
.stepper-btn--dec::after { display: none; }
.stepper-btn--inc::after { transform: translate(-50%, -50%) rotate(90deg); }
.stepper-btn:hover:not(:disabled) { background: var(--accent-tint); }
.stepper-btn:hover:not(:disabled)::before, .stepper-btn:hover:not(:disabled)::after { background: var(--accent-deep); }
.stepper-btn:disabled { opacity: 0.28; cursor: default; }
.stepper-val {
  flex: 1; min-width: 0; width: 100%;
  background: var(--surface);
  border: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line);
  padding: var(--sp-2) var(--sp-1);
  text-align: center;
  font: inherit; font-size: var(--fs-md); font-weight: var(--w-heavy);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.stepper-val:focus-visible { outline: none; }
/* the browser's own spinners duplicate the tiles and shrink the value slot */
.stepper-val::-webkit-outer-spin-button, .stepper-val::-webkit-inner-spin-button {
  appearance: none; margin: 0;
}
.stepper-val[type="number"] { appearance: textfield; -moz-appearance: textfield; }

/* a choice control in the same shell — words, at body scale, never the numeral */
.picker {
  width: 100%; min-height: var(--tap);
  appearance: none;
  background: var(--surface);
  border: 1px solid var(--line); border-radius: var(--r-sm);
  padding: 0 var(--sp-3);
  font: inherit; font-size: var(--fs-md); font-weight: var(--w-bold);
  color: var(--ink);
  cursor: pointer;
}
.picker:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* ---------- status pill ----------
   TOKENS.md "not carried": status pill. Three states, drawn from the FROZEN
   palette rather than a new colour triple — see the decision file. A live fact
   takes the accent tint (the palette's "this is yours / this is on" voice), a
   caution takes the quiet tint the system already uses where a fact owns a
   surface without celebrating, and an inert state is a hairline outline with no
   fill at all. Deliberately NOT `.trend-band--a/b/c`, which are chart strokes. */
.tagrow { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.pill {
  display: inline-flex; align-items: center; gap: 5px;
  border-radius: var(--r-pill);
  padding: 4px 11px;
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: 0.04em;
  background: var(--sunken); color: var(--ink-soft);
  white-space: nowrap;
}
.pill--live { background: var(--accent-tint); color: var(--accent-deep); }
.pill--note { background: var(--loss-tint); color: var(--ink); }
.pill--off { background: transparent; color: var(--ink-faint); box-shadow: inset 0 0 0 1px var(--line); }

/* ---------- stat tile ----------
   TOKENS.md "not carried": stat tile. A labelled figure that is REPORTED, not
   decided — distinct from `.stat`, which is the results screen's white card. */
.statgrid { display: grid; gap: var(--sp-2); grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr)); }
.stat-tile {
  background: var(--sunken); border-radius: var(--r-sm);
  padding: var(--sp-2) var(--sp-3);
  display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.stat-tile-label {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-mid);
}
.stat-tile-val {
  font-size: var(--fs-md); font-weight: var(--w-heavy); letter-spacing: var(--track-tight);
  font-variant-numeric: tabular-nums;
}
.stat-tile-sub { font-size: var(--fs-xs); font-weight: var(--w-bold); color: var(--ink-soft); font-variant-numeric: tabular-nums; }

/* ---------- ink ground ----------
   The one dark surface in the system (tokens: --ground-ink / --on-ink). The
   user's design goes dark in exactly two places: the setup briefing's payoff
   block and the catalog's featured hero. */
.inkblock {
  background: var(--ground-ink); color: var(--on-ink);
  border-radius: var(--r-md);
  padding: var(--sp-3) var(--sp-4);
  font-size: var(--fs-sm); font-weight: var(--w-semi); line-height: 1.65;
  overflow-x: auto;
}
.inkblock-label {
  display: block;
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--on-ink-soft);
  margin-bottom: var(--sp-1);
}
.inkblock-row { display: block; }
.inkblock-row + .inkblock-row { border-top: 1px solid var(--on-ink-line); padding-top: 5px; margin-top: 5px; }
.inkblock-key { color: var(--on-ink-soft); }

/* a quiet note that owns a surface (engine warnings, honest limitations) */
.notebox {
  background: var(--sunken); border-radius: var(--r-sm);
  padding: var(--sp-3);
  display: flex; flex-direction: column; gap: var(--sp-1);
  font-size: var(--fs-sm); font-weight: var(--w-semi); line-height: 1.5; color: var(--ink);
}
.notebox--alert { background: var(--accent-tint); color: var(--accent-deep); font-weight: var(--w-bold); }
.notebox-label {
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-mid);
}
.notebox--alert .notebox-label { color: var(--accent-deep); }
.notebox-line { display: block; }

/* ---------- side sheet ----------
   TOKENS.md "not carried". `.sheet` is the phone's bottom sheet inside the app
   shell; this is the full-height panel the setup mockup opens from the edge.
   At phone width it takes the whole screen, because a 480px panel on a 390px
   viewport is a modal wearing a costume. */
.scrim {
  position: fixed; inset: 0; z-index: 60;
  background: var(--scrim);
  animation: fade var(--t-fast) var(--ease);
}
.sidesheet {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 61;
  width: 480px; max-width: 100%;
  background: var(--surface-app);
  box-shadow: var(--shadow-pop);
  overflow-y: auto;
  padding: var(--sp-5) var(--sp-5) var(--sp-14);
  display: flex; flex-direction: column; gap: var(--sp-5);
  animation: sheet-in var(--t-med) var(--ease);
}
@keyframes sheet-in { from { transform: translateX(24px); opacity: 0; } }
.sidesheet-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.sidesheet-title { font-size: var(--fs-xl); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); line-height: 1.15; }
.sidesheet-close {
  appearance: none; border: 0; cursor: pointer;
  flex: none; width: var(--tap); height: var(--tap);
  border-radius: var(--r-pill);
  background: var(--sunken); color: var(--ink-soft);
  font-size: var(--fs-md); font-weight: var(--w-heavy);
}
.sidesheet-close:hover { background: var(--accent-tint); color: var(--accent-deep); }
.sidesheet-sec { display: flex; flex-direction: column; gap: var(--sp-2); }
.sidesheet-sec-title {
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-deep);
}
.sidesheet-body { font-size: var(--fs-md); font-weight: var(--w-semi); line-height: 1.6; }

/* ---------- shelf header + game card + search field ----------
   TOKENS.md "not carried": shelf header, list row, search field. The catalog
   shipped as a flat text list on the ground colour; the user's design is cards
   with a titled, counted shelf above them. */
.shelfhead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-3); flex-wrap: wrap;
  padding-bottom: var(--sp-2);
  border-bottom: 2px solid var(--tile-line);
}
/* name and tagline are one baseline run that wraps rather than colliding — at
   360px they ran together as "Oligopolyfew sellers, many regrets" */
.shelfhead-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 var(--sp-2); min-width: 0; }
.shelfhead-name { font-size: var(--fs-lg); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }
.shelfhead-sub { font-size: var(--fs-sm); font-weight: var(--w-semi); color: var(--ink-soft); }
.shelfhead-count {
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-faint);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.shelf { display: flex; flex-direction: column; gap: var(--sp-3); }
.gamegrid { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); }
.gamecard {
  display: flex; flex-direction: column; gap: 6px;
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
  padding: var(--sp-3) var(--sp-4);
  color: inherit;
  transition: transform var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.gamecard:hover { transform: translateY(-1px); box-shadow: var(--shadow-card); }
.gamecard-name { font-size: var(--fs-md); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); line-height: 1.25; }
.gamecard-blurb { font-size: var(--fs-sm); font-weight: var(--w-semi); line-height: 1.5; color: var(--ink-soft); }
.gamecard-meta {
  font-size: var(--fs-xs); font-weight: var(--w-bold); color: var(--ink-faint);
  display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; margin-top: auto;
}
.searchfield {
  display: flex; align-items: center; gap: var(--sp-2);
  background: var(--surface); border-radius: var(--r-pill);
  box-shadow: var(--shadow-ctl);
  padding: 0 var(--sp-4);
  min-height: var(--tap); width: 100%; max-width: 420px;
}
.searchfield:focus-within { box-shadow: var(--shadow-ctl), var(--focus-ring); }
.searchfield-input {
  flex: 1; min-width: 0;
  background: transparent; border: 0; padding: 0;
  font: inherit; font-size: var(--fs-md); font-weight: var(--w-bold); color: var(--ink);
}
.searchfield-input:focus-visible { outline: none; }
.searchfield-input::-webkit-search-cancel-button { appearance: none; }

/* ---------- featured hero + sort + join entry ----------
   The three elements `journal/_design/VALIDATION-20260826.md` marks ADOPT for the
   catalog that the 2026-08-28 rebuild did not carry. They were agreed in writing,
   nothing on disk could say they were missing, and the screen shipped without them
   with every gate green — which is why `harness/conform.js` (gate 7) now exists and
   why each one below is a required, PAINTED element in `harness/_conformance.json`.

   The hero is the second consumer of the ink ground added in cycle c8, which named
   this exact use: "the setup briefing's payoff block and the catalog's featured
   hero". No new hue, no new token — a role the palette already carries. */
.feature {
  background: var(--ground-ink); color: var(--on-ink);
  border-radius: var(--r-lg);
  padding: var(--sp-5) var(--sp-4);
  display: flex; flex-direction: column; gap: var(--sp-3);
}
@media (min-width: 900px) {
  .feature { padding: var(--sp-6); flex-direction: row; align-items: center; gap: var(--sp-6); }
  .feature-text { flex: 1; min-width: 0; }
  .feature-side { flex: none; width: 280px; }
}
.feature-text { display: flex; flex-direction: column; gap: var(--sp-2); }
.feature-eyebrow {
  font-size: 11px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--on-ink-soft);
}
.feature-name {
  font-size: var(--fs-xl); font-weight: var(--w-heavy);
  letter-spacing: var(--track-tight); line-height: 1.15;
}
.feature-blurb {
  font-size: var(--fs-md); font-weight: var(--w-semi); line-height: 1.55;
  color: var(--on-ink-soft);
}
.feature-meta {
  display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center;
  font-size: var(--fs-xs); font-weight: var(--w-bold); color: var(--on-ink-soft);
}
/* the CTA sits on ink, so it cannot borrow the cream-field button shadows */
.feature .btn-primary { box-shadow: none; }
.feature-side { display: flex; flex-direction: column; gap: var(--sp-3); }
.feature-note {
  font-size: var(--fs-xs); font-weight: var(--w-semi); line-height: 1.5;
  color: var(--on-ink-soft);
  border-top: 1px solid var(--on-ink-line); padding-top: var(--sp-2);
}

/* ---------- difficulty ----------
   The validation asks for green/amber/red badges. Cycle c8 DECLINED that triple and
   recorded the refusal: a new colour scale is a new art direction, and TOKENS.md says a
   screen needing a colour the system does not carry escalates rather than inventing one.
   That refusal stands, so the slot is filled with a three-segment meter drawn in the
   palette's OWN ink shades plus the word.

   This is better than the triple would have been on this card, not merely cheaper: the
   "Plays today" pill already owns the accent here, so a second coloured chip beside it
   would have made two different scales compete on one line. The meter reads without
   colour vision, and the word carries it for a screen reader. */
.diff { display: inline-flex; align-items: center; gap: 5px; }
.diff-meter { display: inline-flex; align-items: flex-end; gap: 2px; }
.diff-bar { width: 3px; background: var(--ink-faint); opacity: 0.4; border-radius: 1px; }
.diff-bar:nth-child(1) { height: 5px; }
.diff-bar:nth-child(2) { height: 8px; }
.diff-bar:nth-child(3) { height: 11px; }
.diff-bar.on { background: var(--ink-soft); opacity: 1; }
.diff-word { text-transform: capitalize; }

/* the control row: search on the left, sort on the right, wrapping at phone width */
.filterbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
}
.filterbar .searchfield { flex: 1 1 240px; }
.sortfield { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
.sortfield-label {
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-faint);
}
.sortfield .picker { width: auto; min-width: 132px; }

/* A SECOND, INDEPENDENT REASON A SHELF CAN BE HIDDEN.
   `[data-hit="0"]` is the SEARCH's answer; this is the SORT's. They are separate
   attributes on purpose — one handler owning both would have to remember the other's
   state, and the first thing to rot would be a shelf hidden by a search that a sort
   change then revealed. Either rule alone hides the row; both must say show. */
.shelf[data-sortmode="0"] { display: none; }

/* join by code — the student who arrives at the landing page with a code in hand.
   Absent from the mockup (it draws the instructor's path only), kept because the
   catalog IS where that student lands. Vocabulary from `playful.dc.html`'s code state. */
.joinrow {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card-soft);
  padding: var(--sp-3) var(--sp-4);
}
.joinrow-label {
  font-size: var(--fs-sm); font-weight: var(--w-heavy); color: var(--ink-soft);
  flex: 1 1 100%;
}
@media (min-width: 640px) { .joinrow-label { flex: 0 0 auto; } }
.joinrow-input {
  flex: 1 1 140px; min-width: 0;
  background: var(--sunken); border: 1px solid var(--line); border-radius: var(--r-sm);
  min-height: var(--tap); padding: 0 var(--sp-3);
  font: inherit; font-size: var(--fs-md); font-weight: var(--w-heavy);
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink);
}
.joinrow-input:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.joinrow .btn { width: auto; flex: none; padding-inline: var(--sp-5); }

/* ---------- actions ----------
   A secondary action that is a real alternative, not a weaker primary: the
   ring pill the system already uses for a spent commit, reused as a choice. */
.actionstack { display: flex; flex-direction: column; gap: var(--sp-3); }
.btn-ghost {
  width: 100%;
  background: var(--surface); color: var(--accent-deep);
  box-shadow: inset 0 0 0 2px var(--accent-tint-strong);
  text-decoration: none;
}
.btn-ghost:hover { box-shadow: inset 0 0 0 2px var(--accent); }
/* a secondary action that opens REFERENCE rather than committing anything sizes
   to its own label — full width, it out-shouts the primary action beside it */
.btn--inline {
  width: auto; align-self: flex-start;
  min-height: var(--tap); font-size: var(--fs-sm); padding: 0 var(--sp-4);
}
.btn-ghost:disabled { color: var(--ink-faint); box-shadow: inset 0 0 0 2px var(--line); cursor: default; }
/* an anchor wearing .btn must centre its label exactly as a <button> does */
a.btn { text-decoration: none; }
/* AN ANCHOR HAS NO `disabled`. A link that cannot act yet — the solo route
   while the engine is refusing the config — must still LOOK spent and must not
   take a click, or it is a control that lies about being one (gate 5's rule,
   stated for the half of the kit that is links rather than buttons). The screen
   drops the href as well; this is the half a stylesheet can carry. */
.btn[aria-disabled="true"] {
  background: transparent; color: var(--ink-faint);
  box-shadow: inset 0 0 0 2px var(--line);
  cursor: default; pointer-events: none;
}
.hero-code {
  display: block;
  font-size: var(--fs-hero); font-weight: var(--w-heavy); line-height: 1.05;
  letter-spacing: 0.12em;
  font-variant-numeric: tabular-nums;
}

/* ================= THE JOIN HANDOFF (setup, #/code/<CODE>) =================
   Added 2026-08-30 by c17, for the user's ruling
   decisions/setup/20260830-the-join-handoff-qr-short-route-end-session.md.

   NO MOCKUP COVERS THIS. `journal/_design/COVERAGE.md` has no row for the
   instructor's post-Create panel; the nearest vocabulary in the user's own design
   is `playful.dc.html`'s host card (a `Code` micro-label, the code, and a line
   telling the instructor where students join). Everything below is INVENTED and
   declared in journal/setup/20260830-c17-join-handoff.md, per COVERAGE rule 3.

   APPEND-ONLY, NEW SELECTORS ONLY. Not one rule above this line was edited and
   not one existing selector is extended. Every class here is new and appears
   nowhere outside the setup code screen, so a play-screen element cannot match
   one — which is what keeps `npm run proof`'s zero-tolerance play renders where
   they are, by construction rather than by assurance. Delete this block and the
   rest of the kit is byte-identical.

   Every value is a token, with ONE deliberate exception, named where it appears:
   present mode's two measures are viewport-relative, because "readable from
   fifteen metres" is a rule about filling whatever screen it is projected on and
   no fixed pixel token can express it. They are declared once, as named custom
   properties on `.present`, rather than scattered as literals. */

/* the rail: the QR beside the code at width, under it on a phone */
.pagecol--rail { flex: 1 1 260px; max-width: 320px; }

.handoff-fig { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
.qrframe {
  background: var(--surface); border-radius: var(--r-md);
  box-shadow: var(--shadow-card);
  padding: var(--sp-3);
  width: 100%; max-width: 260px;
}
.qrframe img { display: block; width: 100%; height: auto; }
/* the URL an instructor pastes into their own slides — a real workflow no QR
   replaces, so it is TEXT and not only an encoding */
.qrline {
  font-size: var(--fs-sm); font-weight: var(--w-bold); color: var(--ink-soft);
  text-align: center; overflow-wrap: anywhere; line-height: 1.45;
  font-variant-numeric: tabular-nums;
}

/* 1280: same composition, rail narrower, QR smaller — still legible up close */
@media (min-width: 900px) and (max-width: 1365px) {
  .pagecol--rail { max-width: 260px; }
  .qrframe { max-width: 196px; }
}

/* 390: THE CODE COMES FIRST AND THE QR IS BEHIND A TOGGLE. An instructor on a
   phone cannot project, and a QR on a phone only serves the person standing
   beside them — so it must not push the code, which is the thing being read
   aloud, off the first screenful. */
.qrtoggle { display: none; }
@media (max-width: 899px) {
  /* the rail is a stacked band under the code, not a narrow column beside nothing —
     `.pagecol--rail`'s 320px ceiling is a WIDTH rule and reads as a ragged indent once
     the columns have wrapped */
  .pagecol--rail { max-width: none; }
  .qrtoggle { display: inline-flex; }
  .handoff-fig { display: none; }
  .handoff[data-qr="open"] .handoff-fig { display: flex; }
}

/* ---------- present mode ----------
   One keystroke from the panel: the code and the QR fill the screen and nothing
   else. The panel's real job is to be readable from fifteen metres and a 200px QR
   in a sidebar is not; once this exists the rail only has to be LEGIBLE, which is
   what makes the three tiers above easy rather than a compromise. */
.present {
  /* THE ONE EXCEPTION, and the reason for it. A projector may be 1024x768 or
     3840x2160 and the requirement is the same sentence either way: fill it.
     Both are min() of a width- and a height-relative measure so a short, wide
     projector cannot push the QR off the bottom — gate 5's rule, applied by hand
     because gate 5 drives play routes and not this screen. */
  --present-code-size: min(15vw, 20vh);
  --present-qr-size: min(46vh, 62vw);
  /* THE DESTINATION HAS TO SCALE WITH THE CODE, OR THE SLIDE IS HALF A SLIDE.
     These two were `--fs-sm` — a fixed 13.5px — inside a mode where every other
     measure is viewport-relative. Measured at 1920x1080 the code was 216px and the
     line telling a student WHERE to type it was 13.5px: a ratio of 16:1, so a
     student whose camera will not focus sees a 45cm code and cannot read the
     address. The user's ruling prices the short route as "a human fallback an
     instructor can say out loud or put on a slide" — present mode IS the slide, and
     that is exactly where the fallback was set to unreadable. (c17 critic, 2026-09-02.)
     `max(--fs-sm, …)` keeps a floor so a phone never renders these SMALLER than
     they were; the min() pair is the same short-projector guard as above. */
  --present-eyebrow-size: max(var(--fs-sm), min(2.2vw, 3.2vh));
  --present-hint-size: max(var(--fs-sm), min(2.6vw, 3.6vh));

  position: fixed; inset: 0; z-index: 70;
  background: var(--ground-ink); color: var(--on-ink);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--sp-4); padding: var(--sp-5);
  overflow: auto;
}
.present-eyebrow {
  font-size: var(--present-eyebrow-size); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps-wide); text-transform: uppercase;
  color: var(--on-ink-soft); text-align: center;
  max-width: 100%; overflow-wrap: anywhere;
}
.present-code {
  font-size: var(--present-code-size); font-weight: var(--w-heavy);
  letter-spacing: 0.1em; line-height: 1; max-width: 100%;
  font-variant-numeric: tabular-nums; text-align: center;
}
.present-qr {
  background: var(--surface); border-radius: var(--r-lg); padding: var(--sp-3);
  width: var(--present-qr-size); max-width: 100%;
}
.present-qr img { display: block; width: 100%; height: auto; }
.present-hint {
  font-size: var(--present-hint-size); font-weight: var(--w-semi); color: var(--on-ink-soft);
  text-align: center;
  /* It carries a URL, so it must be allowed to break rather than push the overlay
     wider than the projector — the same no-horizontal-overflow rule gate 5 applies
     to the play routes, applied by hand because gate 5 does not drive this screen. */
  max-width: 100%; overflow-wrap: anywhere;
}
/* a way out that does not depend on knowing the keystroke */
.present-close {
  position: absolute; top: var(--sp-4); right: var(--sp-4);
  min-height: var(--tap); min-width: var(--tap);
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: var(--r-pill);
  background: transparent; color: var(--on-ink);
  box-shadow: inset 0 0 0 2px var(--on-ink-line);
  font-size: var(--fs-md); font-weight: var(--w-heavy);
}
.present-close:hover { box-shadow: inset 0 0 0 2px var(--on-ink-soft); }

/* ================= THE OPTION INPUT (promoted from ui/inputs/inputs.css) =================
   The choice cards the decision-input kit ships (ui/inputs/choice.js). They were
   styled in ui/inputs/inputs.css, which the app page and the offline bundle never
   load — TOKENS.md lists that file as the remaining private stylesheet and says its
   classes are "promotion into an existing section rather than an invention". The
   first option-kind game (2026-09-05, the DISCRETE-PAIR family) is the promotion:
   the rules below are inputs.css's, token for token, with one addition — the phone
   lays 2–3 options SIDE BY SIDE (the mockup's card language, and a decision card
   that already holds a rule, a rival and a figure cannot afford a stacked column of
   54px cards). Every value resolves to a token. */
.input-group { display: flex; flex-direction: column; }
.input-alert {
  min-height: var(--sp-5);            /* reserved: an alert never moves the card */
  margin: var(--sp-1) 0 0;
  font-size: var(--fs-sm);
  font-weight: var(--w-bold);
  color: var(--accent-deep);
}
.opt-label { font-weight: var(--w-bold); }
.opt-meta {
  font-size: var(--fs-xs);
  font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--ink-mid);
}
.opt-detail {
  font-size: var(--fs-sm);
  font-weight: var(--w-semi);
  color: var(--ink-soft);
}
.choice-grid {
  display: grid;
  /* half the rail measure is the narrowest a tappable card may be; two fit on
     a phone, four on a board column, and a fifth wraps */
  grid-template-columns: repeat(auto-fit, minmax(min(calc(var(--rail-w) / 2), 100%), 1fr));
  gap: var(--sp-3);
}
.choice-card {
  min-height: var(--tap-action);
  padding: var(--sp-3) var(--sp-4);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: var(--sp-1);
  text-align: left;
  font: inherit;
  font-size: var(--fs-md);
  color: var(--ink);
  background: var(--surface);
  border: var(--lip-sm) solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card-soft);
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.choice-card .opt-label { font-size: var(--fs-lg); font-weight: var(--w-heavy); letter-spacing: var(--track-tight); }
.choice-card:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.choice-card[aria-checked="true"] {
  border-color: var(--accent);
  background: var(--accent-tint);
  box-shadow: none;
}
.choice-card[disabled] { cursor: default; box-shadow: none; }
.choice-card[disabled]:not([aria-checked="true"]) { color: var(--ink-faint); background: var(--sunken); border-color: var(--line); }
/* sealed: the committed card stays visibly the committed card */
.choice-card[disabled][aria-checked="true"] { border-color: var(--accent-faded); }
/* the Ledger skin: flat tiles, hairline borders, as every other tile there */
.app[data-layout="board"] .choice-card { box-shadow: none; border-color: var(--tile-line); }
.app[data-layout="board"] .choice-card[aria-checked="true"] { border-color: var(--accent); }
/* the Stage's control strip: the option group takes the room the slider took */
.stage-controls .input-group { flex: 1 1 auto; min-width: 0; }
.stage-controls .choice-grid { grid-template-columns: repeat(auto-fit, minmax(min(var(--rail-w), 100%), 1fr)); }
.stage-controls .choice-card { min-height: var(--tap-lg); padding: var(--sp-2) var(--sp-4); }
.stage-controls .input-alert { min-height: 0; margin: 0; }

/* ================= THE PAYOFF MATRIX (ui/shared/matrix.js) =================
   The DISCRETE-PAIR family's figure: their move is a column, your move a row,
   and the cell where they meet is the payoff the screen projects. It shares the
   curve's grammar — a `best` mark in wash, a `you` mark in solid accent, each
   naming itself in a tag row above the figure — and the curve's sign convention:
   your gain in accent-deep, your loss in ink, theirs in rival neutral. No mockup
   covers this figure; it is composed from the play kit's own vocabulary. */
.matrix-block { display: flex; flex-direction: column; gap: 5px; }
.matrix-tags {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  min-height: 38px;
  font-size: 11px; font-weight: var(--w-heavy); line-height: 1.25;
  font-variant-numeric: tabular-nums;
}
.matrix-tag {
  display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
  color: var(--ink);
  background: var(--surface); border-radius: var(--r-pill);
  padding: 2px 8px 2px 4px;
  box-shadow: 0 0 0 1px var(--hairline), var(--shadow-card-soft);
}
.matrix-tag--off { color: var(--ink-soft); font-weight: var(--w-bold); }
.matrix-swatch { width: 13px; height: 13px; flex: none; border-radius: var(--r-sm); }
.matrix-swatch--best { background: var(--accent); opacity: 0.22; }
.matrix-block--met .matrix-swatch--best { opacity: 0.38; }
.matrix-swatch--you { background: var(--accent); }
.matrix-block--neg .matrix-swatch--you { background: var(--loss); }
.matrix-swatch--cell { background: var(--sunken); width: 10px; height: 10px; }
.matrix-wrap { overflow-x: auto; }
.matrix {
  width: 100%; border-collapse: separate; border-spacing: 3px;
  table-layout: fixed;
  font-variant-numeric: tabular-nums;
}
.matrix th, .matrix td { padding: var(--sp-2) var(--sp-1); text-align: center; vertical-align: middle; }
.matrix-corner { text-align: left; }
.matrix-axis {
  display: block;
  font-size: 10px; font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--ink-mid);
}
.matrix-axis--you::before { content: "↓ "; }
.matrix-axis--them::before { content: "→ "; }
.matrix-colhead, .matrix-rowhead {
  font-size: var(--fs-xs); font-weight: var(--w-heavy); color: var(--ink-soft);
  letter-spacing: var(--track-caps); text-transform: uppercase;
}
.matrix-rowhead { text-align: left; padding-left: var(--sp-2); }
.matrix-ref-tag { display: none; font-size: 9px; color: var(--accent-deep); text-transform: none; letter-spacing: 0; }
.matrix-col--ref .matrix-ref-tag { display: block; }
.matrix-col--ref { color: var(--accent-deep); }
.matrix-cell {
  background: var(--surface); border-radius: var(--r-sm);
  box-shadow: inset 0 0 0 1px var(--hairline);
  transition: background var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.matrix-mine {
  display: block;
  font-size: var(--fs-md); font-weight: var(--w-heavy); letter-spacing: var(--track-tight);
}
.matrix-theirs { display: block; font-size: 10.5px; font-weight: var(--w-bold); }
/* the marks: their column is the assumption, your row the choice, the best row the wash */
.matrix-cell--ref { box-shadow: inset 0 0 0 1px var(--line); }
.matrix-cell--best { background: var(--accent-tint); }
.matrix-block--met .matrix-cell--best { background: var(--accent-tint-strong); }
.matrix-row--you .matrix-rowhead { color: var(--accent-deep); }
.matrix-cell--you { box-shadow: inset 0 0 0 2px var(--accent); }
.matrix-block--neg .matrix-cell--you { box-shadow: inset 0 0 0 2px var(--loss); }
.matrix-cell--hit { background: var(--accent-tint); box-shadow: inset 0 0 0 2px var(--accent); }
.matrix-block--neg .matrix-cell--hit { background: var(--loss-tint); box-shadow: inset 0 0 0 2px var(--loss); }
.matrix-cell--land { animation: matrix-land var(--t-beat) var(--ease); }
@keyframes matrix-land {
  0% { background: var(--accent-tint-strong); }
  100% { background: var(--accent-tint); }
}
.matrix-block--neg .matrix-cell--land { animation-name: matrix-land-loss; }
@keyframes matrix-land-loss {
  0% { background: var(--loss-tint-strong); }
  100% { background: var(--loss-tint); }
}
/* the grid as a control: a row is a target */
.matrix-block--live .matrix-row { cursor: pointer; }
.matrix-block--live .matrix-row:hover .matrix-cell { box-shadow: inset 0 0 0 1px var(--line-strong); }
.matrix-block--live .matrix-row:hover .matrix-cell--you,
.matrix-block--live .matrix-row:hover .matrix-cell--hit { box-shadow: inset 0 0 0 2px var(--accent); }
.matrix-block--locked .matrix-row { cursor: default; }
.card--locked .matrix-block { opacity: 0.85; }
/* the foot row: the reading key left, the shared note/hint slot in the middle */
.matrix-foot {
  display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2);
  font-size: 10.5px; font-weight: var(--w-bold); color: var(--ink-faint);
}
.matrix-foot-key { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.matrix-foot-mid { position: relative; flex: 1; min-width: 0; height: 15px; }
.matrix-foot-mid > * {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.matrix-hint {
  color: var(--accent-deep); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  opacity: 0.8;
}
.matrix-foot-mid .at-best--on ~ .matrix-hint { display: none; }
/* the wide tiers: the grid is the landscape figure, so its cells grow */
@media (min-width: 900px) {
  .matrix th, .matrix td { padding: var(--sp-3) var(--sp-2); }
  .matrix-mine { font-size: var(--fs-xl); }
  .matrix-theirs { font-size: var(--fs-xs); }
  .matrix-colhead, .matrix-rowhead { font-size: var(--fs-sm); }
}
/* the Stage: the figure fills the stage slot, its cells fill the figure */
.stage-fig .matrix-block { flex: 1 1 auto; min-height: 0; justify-content: center; }
.stage-fig .matrix-wrap { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; justify-content: center; }
.app[data-layout="stage"] .matrix-mine { font-size: var(--fs-2xl); }
.app[data-layout="stage"] .matrix th, .app[data-layout="stage"] .matrix td { padding: var(--sp-5) var(--sp-3); }
.app[data-layout="board"] .matrix-cell { background: var(--surface); }

/* ---- select rows and composite fields, PROMOTED OUT OF ui/inputs/inputs.css ----
   That file is referenced by NOTHING — not ui/app/index.html, not harness/bundle.js —
   so every rule in it is dead. The choice cards were promoted here for exactly that
   reason when the first option-kind game shipped, and integrity.js still pins them
   ("the choice cards are in the stylesheet the app and the bundle load"). The same
   trap then sat waiting for the next two kinds to be used for the first time, and both
   were used today:
     - `.select-row` — ultimatum's offer stage moved to `kind: 'select'` this session,
       so its eleven rows were rendering UNSTYLED. My regression, and the gates passed
       because unstyled rows still fit inside the viewport.
     - `.comp-*` — design-a-contract-principal-agent is the first composite, and with
       no `.comp-field` column its hint painted to 412px inside a 390px phone. Gate 5
       caught that one; nothing would have caught the select rows.
   Copied verbatim so the two files do not drift in wording; inputs.css keeps its copy
   as the kit's own record. */
.select-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.select-row {
  min-height: var(--tap);
  padding: var(--sp-3) var(--sp-4);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  text-align: left;
  font: inherit;
  font-size: var(--fs-md);
  font-weight: var(--w-semi);
  color: var(--ink);
  background: var(--surface);
  border: var(--lip-sm) solid var(--line);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.select-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.select-row[aria-checked="true"] { border-color: var(--accent); background: var(--accent-tint); }
.select-row[disabled] { cursor: default; }
.select-row[disabled]:not([aria-checked="true"]) { color: var(--ink-faint); background: var(--sunken); }
.select-row[disabled][aria-checked="true"] { border-color: var(--accent-faded); }
.select-row .opt-meta { margin-left: auto; flex: none; }

.comp { display: flex; flex-direction: column; gap: var(--sp-4); }
.comp-field { display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0; }
.comp-label {
  font-size: var(--fs-xs);
  font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--ink-soft);
}
@media (min-width: 900px) {
  .comp { flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: var(--sp-5); }
  .comp-field { flex: 1 1 var(--dcol-w); min-width: 0; }
}

/* Reduced motion: every beat above collapses to an instant state change.
   JS-driven stages collapse in ui/shared/motion.js — feature parity is total. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; }
}

/* ============================================================================
   ASK-AI PANEL — moved out of ui/screens/debrief/debrief.css on 2026-09-10.
   TOKENS.md lists that file's contents as "known debt: each is a component the
   shared kit should carry", and this is the clearest case: the panel is not a
   debrief ornament, it is a component the PLAY screen needs, because a finished
   game had no way to reach the debrief at all. A tester could only get there by
   reading the route out of the repo and typing the URL.
   Every declaration below already resolved to shared tokens, so nothing bespoke
   moved with it.
   ========================================================================= */
/* AI panel — a collapsed details block, last in the side column (fix 21) */
.db-ai { display: flex; flex-direction: column; gap: var(--sp-3); }
.db-ai-summary { cursor: pointer; margin: 0; }
.db-ai-summary::marker { color: var(--ink-mid); }
.db-ai:not([open]) { gap: 0; }
.db-ai-log { display: flex; flex-direction: column; gap: var(--sp-2); max-height: calc(var(--chart-h-max) - var(--sp-8)); overflow-y: auto; }
.db-ai-msg { border-radius: var(--r-md); padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm); font-weight: var(--w-bold); }
.db-ai-msg--q { background: var(--accent-tint); color: var(--accent-deep); align-self: flex-end; }
.db-ai-msg--a { background: var(--sunken); color: var(--ink); display: flex; flex-direction: column; gap: var(--sp-1); }
.db-ai-stubtag {
  align-self: flex-start;
  font-size: var(--fs-xs); font-weight: var(--w-heavy);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--ink-mid); border: 2px solid var(--tile-line);
  border-radius: var(--r-pill); padding: 0 var(--sp-2);
}
.db-ai-row { display: flex; gap: var(--sp-2); }
.db-ai-input {
  flex: 1 1 auto; min-width: 0; font: inherit; font-size: var(--fs-sm);
  min-height: var(--tap); padding: 0 var(--sp-3);
  border: 2px solid var(--tile-line); border-radius: var(--r-md);
  background: var(--surface); color: var(--ink);
}
.db-ai-input:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.db-ai-send { flex: none; width: auto; } /* .btn-primary's width:100% is the phone CTA rule; here it shares a row */
