/* Mobile-first and fluid. Most players arrive from a Discord link on a
   phone, so the board is built to fit whatever viewport it lands in: its
   eight attribute columns are `1fr` tracks, not fixed widths, and every
   size that depends on them (row height, portrait, type, gaps) is a
   clamp() that scales with the viewport between a phone floor and the
   63rem desktop ceiling. Nothing scrolls sideways at any width — the
   three breakpoints below only change *what a cell says* (name, full
   value, or short code) and where the guess bar docks, never whether the
   board fits. (The character column was pinned as a safety net until
   2026-08-06; the sticky never engaged and its backing was visible, so
   it went — see the note above `.cell.character`.)

   Implements docs/design.md ("Guild Ledger"). The rules in §7 of that
   document are load-bearing — read it before adding a shadow, a
   gradient, a second accent colour or a new animation. */

/* Self-hosted, so the first painted frame already has the real
   typeface. Google Fonts forced a choice between two visible glitches:
   a render-blocking third-party stylesheet (nothing paints until two
   extra origins answer, and module scripts can't run either) or a
   non-blocking one (the page paints in the fallback stack and visibly
   re-flows when the font lands, on every load, cache or not). Same
   origin plus <link rel="preload"> in each page's <head> avoids both.
   Variable, latin subset only — Japanese names fall back to the OS's
   CJK fonts either way, as they always have. Files came from Google's
   own css2 endpoint; re-download them, don't try to generate them. */
@font-face {
  font-family: "Source Serif 4";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: url("fonts/source-serif-4-latin-var.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("fonts/manrope-latin-var.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

:root {
  /* colour — light (default). Warm: a parchment page with a lifted
     paper-white surface on it, warm ink, and a terracotta accent. Every
     pair below is measured, not estimated. */
  --bg: oklch(94% 0.018 80); /* warm parchment, the page itself */
  --bg-raised: oklch(98% 0.012 80); /* dossier, cells, dropdown */
  --ink: oklch(26% 0.02 55); /* body text */
  --muted: oklch(50% 0.03 60); /* secondary text */
  --line: oklch(84% 0.025 75); /* hairline borders (structural, not text) */
  --accent: oklch(58% 0.135 40); /* terracotta, the one accent */
  --accent-ink: oklch(98% 0.01 80); /* text on --accent */

  /* verdict colours — saturated, each paired with a non-colour channel. */
  --hit: oklch(62% 0.15 148); /* exact match */
  --hit-ink: oklch(20% 0.05 150);
  --partial: oklch(78% 0.135 85); /* partial match */
  --partial-ink: oklch(28% 0.06 70);
  --miss: oklch(87% 0.018 75); /* no match, dark enough to read against the page without a border */

  /* type — Source Serif 4 (display) and Manrope (UI), self-hosted from
     site/fonts/ (see the @font-face rules above). Each stack keeps its
     OS fallback and a matching CJK companion for Japanese names. */
  --font-ui: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", "Hiragino Kaku Gothic ProN", "Yu Gothic",
    "Noto Sans CJK JP", Arial, sans-serif;
  --font-display: "Source Serif 4", Georgia, "Iowan Old Style",
    "Times New Roman", "Hiragino Mincho ProN", "Yu Mincho",
    "Noto Serif CJK JP", serif;
  --step--1: 0.8rem;
  --step-0: 1rem;
  --step-1: 1.25rem;
  --step-2: 1.5625rem;
  --step-3: 1.953rem;

  /* spacing (4px base) */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-6: 1.5rem;
  --sp-8: 2rem;

  /* radii — restrained on purpose, nothing pill-shaped */
  --radius-sm: 0.5rem; /* inputs, buttons, board cells, cards */
  --radius-md: 0.75rem; /* dropdown, result card */

  --border: 1px solid var(--line);

  /* the only shadow in the system: real elevation for a floating layer */
  --shadow-float: 0 4px 14px oklch(30% 0.03 60 / 12%);

  --dur-base: 180ms;
  --dur-rise: 300ms; /* the result card and its scrim arriving */
  --dur-flip: 620ms;
  --flip-stagger: 150ms; /* mirrored in main.js — see REVEAL_MS */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-flip: cubic-bezier(0.45, 0, 0.35, 1);

  /* Board metrics. Every one of these is fluid: the upper bound is the
     desktop value and is reached at a 63rem (1008px) viewport, where the
     page stops widening; the lower bound is what a 360px phone gets.
     There is no `--cell` any more — the eight attribute columns divide
     whatever is left of the board after the character column, so the
     board is exactly as wide as the page at every size. */
  --char-col: clamp(7.5rem, 15vw, 9.5rem);
  --char-img: clamp(2.2rem, 6vw, 2.7rem);
  --row-h: clamp(2.75rem, 8.25vw, 5.2rem);
  --gap: clamp(0.15rem, 0.635vw, 0.4rem);
  --cell-pad: clamp(1px, 0.4vw, 4px);
  --cell-font: clamp(0.6rem, 1.49vw, 0.94rem);
  --head-font: clamp(0.5rem, 1.35vw, 0.85rem);
  /* Sized to sit above the value without crowding it: a tile is
     --row-h tall, and this plus a line of --cell-font plus the gap has
     to fit inside that at every width the two are shown together. The
     narrow-screen override is in the 40rem block, where the mark is
     alone in the tile and can afford to be bigger. */
  --cell-icon: clamp(1.05rem, 2.6vw, 1.75rem);
  /* Strength of the world-map wash behind the page — see `body::before`.
     Trial value; the dark theme overrides it. */
  --map-opacity: 0.2;

  /* The page gutter, as tokens rather than literals, because the header
     bar has to cancel them exactly to run edge to edge — see `.site-bar`.
     Two places reading one value beats two places agreeing by accident.
     `--pad-top` is overridden on `.game-page` at the phone breakpoint. */
  --pad-x: clamp(0.5rem, 1.6vw, 1rem);
  --pad-top: var(--sp-4);
}

/* Dark is opt-in, not OS-driven: light is the default for every visitor,
   and this only applies once the toggle button (js/main.js) sets
   data-theme on the root, restored from localStorage on repeat visits. */
:root[data-theme="dark"] {
  /* Warm dusk, not soot: a dark clay slate that keeps the verdict
     fills as saturated as they are in daylight. */
  --bg: oklch(21% 0.018 55);
  --bg-raised: oklch(27% 0.02 55);
  --ink: oklch(93% 0.014 75);
  --muted: oklch(70% 0.025 65);
  --line: oklch(38% 0.025 55);
  --accent: oklch(72% 0.13 45);
  --accent-ink: oklch(16% 0.02 45);
  --hit: oklch(66% 0.15 148);
  --hit-ink: oklch(18% 0.05 150);
  --partial: oklch(78% 0.13 85);
  --partial-ink: oklch(26% 0.06 70);
  --miss: oklch(37% 0.018 55);
  --shadow-float: 0 4px 16px oklch(0% 0 0 / 45%);
  /* Lower than daylight: the map is a bright painting, and at the light
     theme's strength it lifts the whole slate and greys it out. */
  --map-opacity: 0.08;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  /* Side padding shrinks with the viewport: on a 360px phone the 1rem
     desktop gutter is two thirds of an attribute column. */
  padding: var(--pad-top) var(--pad-x) var(--sp-8);
  background: var(--bg);
  color: var(--ink);
  font: var(--step-0) / 1.5 var(--font-ui);
  min-height: 100vh;
}

/* The world map as a wash behind the page. Tried and kept, 2026-08-06
   (owner's call, after looking at it in a real browser). To pull it out,
   delete this rule and the two `--map-opacity` declarations — nothing
   else refers to it.

   Fixed rather than scrolling, so it reads as a surface the page sits on
   instead of a long image going past. `cover`, not `contain`: the map is
   square and the viewport almost never is, so `contain` leaves the
   image's own edges standing on the page as two hard vertical seams
   against bare parchment. Covering crops the continent — on a phone,
   down to a strip that is texture rather than geography — which is the
   better trade for something meant to sit behind the text.

   Opacity is a token per theme, since it needs different strength on
   parchment than on slate, and is the first thing to reach for if this
   fights the board. Sits behind everything and takes no clicks. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: url("data/sprites/scenes/world-map.webp") center / cover
    no-repeat;
  opacity: var(--map-opacity);
  pointer-events: none;
}

/* Wide enough that the eight attribute columns fit without scrolling on
   a desktop; the reading-width sections below set their own narrower
   measures. */
main {
  max-width: 63rem; /* the board's natural width is ~62.3rem — see docs/design.md §4 */
  margin: 0 auto;
  position: relative; /* anchors .page-loader while characters.json loads */
}

/* Covers the guess box / board / result stack until js/main.js (or
   endless-main.js) finishes its first fetch and render — those sections
   exist in the markup immediately but sit empty until then, which read
   as a broken page rather than a loading one. Removed via the `hidden`
   attribute once state is ready; see docs/design.md §6. */
.page-loader {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  min-height: 16rem;
  background: var(--bg);
  color: var(--muted);
  font-size: var(--step--1);
}

.page-loader[hidden] {
  display: none;
}

.loader-bar {
  width: 8rem;
  height: 3px;
  background: var(--line);
  overflow: hidden;
  position: relative;
}

.loader-bar::after {
  content: "";
  position: absolute;
  inset: 0;
  width: 40%;
  background: var(--accent);
  animation: loader-sweep 1.1s var(--ease-out) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .loader-bar::after {
    animation: none;
    width: 100%;
  }
}

@keyframes loader-sweep {
  0% {
    transform: translateX(-100%);
  }
  100% {
    transform: translateX(350%);
  }
}

/* The one control on the page that isn't --accent: a persistent toggle
   would compete with the accent's "single actionable thing" job if it
   were filled the same way the Guess button is, so it stays a quiet
   outline button instead. */
.theme-row {
  display: flex;
  justify-content: flex-end;
  gap: var(--sp-2);
  max-width: 34rem;
  margin: 0 auto var(--sp-2);
}

.theme-toggle {
  display: inline-flex;
  align-items: center;
  min-height: 2.25rem;
  padding: var(--sp-1) var(--sp-3);
  border: var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
  color: var(--muted);
  font: 600 var(--step--1) / 1.2 var(--font-ui);
  text-decoration: none;
}

.theme-toggle:hover {
  color: var(--ink);
  border-color: var(--ink);
}

/* ---- the game pages' header bar ------------------------------------
   One bar in place of the three stacked rows the game pages used to
   open with (a theme row, a Daily/Endless nav, and a masthead). It runs
   edge to edge by cancelling the page gutter with its own negative
   margins — hence `--pad-x`/`--pad-top` being tokens: the two rules have
   to agree exactly or the bar sits inset by a hairline.

   index.html and 404.html keep `.theme-row` + `.site-header` above:
   neither is a game, neither has a mode to switch or an issue number to
   carry, and the picker *is* the mode list. */
/* The space under the bar is a clamp, not `--sp-4`, because the masthead
   this replaced was putting ~150px between the top of the page and the
   guess box and a flat 16px read as jammed against the rule. It scales
   with the viewport like the board metrics do — a narrow screen is
   usually one with less vertical room to spend, and at the phone
   breakpoint the guess box has left the top of the page entirely, so the
   floor is what a docked layout wants anyway. */
.site-bar {
  display: flex;
  align-items: stretch;
  gap: var(--sp-3);
  min-height: 3.5rem;
  margin: calc(-1 * var(--pad-top)) calc(-1 * var(--pad-x))
    clamp(var(--sp-4), 4.5vw, 3rem);
  padding: 0 var(--pad-x);
  background: var(--bg-raised);
  border-bottom: 2px solid var(--ink);
}

/* Phone only: with the mode tabs gone there is nothing else pointing
   back at the picker, so the way out gets a real target of its own
   rather than relying on the wordmark being a link. Above the phone
   breakpoint the two tabs make it redundant. */
.site-bar-home {
  display: none;
  flex: none;
  align-self: center;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  border: var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg);
}

.site-bar-home img {
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

.site-bar-mark {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
  color: var(--ink);
  text-decoration: none;
}

.site-bar-title {
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 3.4vw, 1.375rem);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.01em;
}

/* Which mode this is, in words. Redundant beside the tabs, and the only
   thing naming the mode once they're gone — so it appears exactly where
   they don't. Static markup rather than a JS-written string: it never
   changes, and a width is not something a script should be deciding. */
.site-bar-mode {
  display: none;
}

/* The issue number, which used to be its own `.day-label` row. Kept
   tabular so a ticking round or a date doesn't jitter the wordmark. */
.site-bar-sub {
  font-size: clamp(0.6rem, 1.9vw, 0.66rem);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--muted);
}

/* Centred between the wordmark and the icon buttons. The current mode is
   an inset rule in --accent rather than a filled tab, same reasoning as
   the nav it replaces: a border is not a fill, so §7's one accent fill
   stays with the Guess/Share button. */
.site-bar-nav {
  display: flex;
  align-items: stretch;
  margin: 0 auto;
}

.site-bar-nav a {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0 clamp(0.5rem, 2.2vw, 1.375rem);
  color: var(--muted);
  text-decoration: none;
  font: 600 var(--step--1) / 1.2 var(--font-ui);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.site-bar-nav a:hover {
  color: var(--ink);
}

.site-bar-nav a[aria-current="page"] {
  color: var(--ink);
  font-weight: 700;
  box-shadow: inset 0 -3px 0 var(--accent);
}

.site-bar-nav img {
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

/* The other mode's live state — the round an Endless run is sitting on,
   so the tab is worth glancing at rather than being pure navigation.
   Written by an inline script during parsing, like the issue number. */
.site-bar-badge {
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  background: var(--miss);
  color: var(--ink);
  font-size: 0.625rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
}

.site-bar-badge[hidden] {
  display: none;
}

.site-bar-actions {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-left: auto;
}

/* Square icon buttons, quiet like the text toggle they replace: a
   persistent control shouldn't compete with the one accent-filled action
   on screen. 2.75rem keeps §7's 44px hit area at every width. */
.icon-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  min-height: 0;
  padding: 0;
  border: var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--ink);
}

.icon-button:hover {
  border-color: var(--ink);
  background: var(--bg);
}

.icon-button img {
  width: 1.25rem;
  height: 1.25rem;
  object-fit: contain;
}

/* A half-filled circle for the theme toggle, built from a bordered ring
   and a solid half rather than the two-stop linear-gradient the mockup
   used — §7 bans gradients outright and this needs no exemption to say
   the same thing. Inherits `color`, so it flips with the theme. */
.theme-glyph {
  position: relative;
  display: block;
  width: 0.95rem;
  height: 0.95rem;
  border: 1.5px solid currentColor;
  border-radius: 50%;
  overflow: hidden;
}

.theme-glyph::before {
  content: "";
  position: absolute;
  inset: 0 50% 0 0;
  background: currentColor;
}

/* A masthead, not a hero: name and issue number on one baseline with a
   rule under them, the way a catalogue page opens. */
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2) var(--sp-4);
  max-width: 34rem;
  margin: 0 auto var(--sp-6);
  padding-bottom: var(--sp-2);
  border-bottom: 2px solid var(--ink);
}

.site-header h1 {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.01em;
  color: var(--ink);
}

.site-header h1 a {
  color: inherit;
  text-decoration: none;
}

.tagline {
  margin: var(--sp-1) 0 0;
  color: var(--muted);
  font-size: var(--step--1);
}

/* ---- mode picker (index.html landing page) ------------------------- */

.mode-picker {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4);
  max-width: 40rem;
  margin: var(--sp-6) auto 0;
}

@media (min-width: 34rem) {
  .mode-picker {
    grid-template-columns: 1fr 1fr;
  }
}

/* A quiet card, not an accent-filled one — two of these on screen at
   once would break the one-accent-fill rule (docs/design.md §7), so the
   accent only shows up as a border on hover/focus, the same way the
   mode-nav's current-page rule uses it. */
.mode-card {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  padding: var(--sp-4);
  border: var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-raised);
  color: var(--ink);
  text-decoration: none;
}

.mode-card:hover,
.mode-card:focus-visible {
  border-color: var(--accent);
}

/* The mode's mark and its name on one baseline. Quest for Daily, Luna
   Tower for Endless — both are the game's own art for those ideas, and
   both are read as decoration: the `<h2>` beside each one is what
   names the mode, so the images are `alt=""`. */
.mode-card-head {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

/* Boxed rather than sized by height: the two icons have opposite
   proportions (quest is 125x77, the tower 100x130) and matching their
   heights would make the wide one twice the visual weight of the tall
   one. `contain` inside a square gives them the same footprint.

   3.5rem rather than something smaller because the tower is the game's
   own mode-list icon and carries that much detail — at 44px it
   collapsed into a blue smudge. */
.mode-card-icon {
  flex: none;
  width: 3.5rem;
  height: 3.5rem;
  object-fit: contain;
}

.mode-card h2 {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
}

.mode-card p {
  margin: 0;
  color: var(--muted);
  font-size: var(--step--1);
}

/* Today's/this run's state — the reason a returning player gets
   something for landing here instead of it being a pure detour.
   Specificity has to beat the plain `.mode-card p` rule above. */
.mode-card .mode-card-status {
  color: var(--ink);
  font-weight: 600;
}

.mode-card .mode-card-countdown {
  font-variant-numeric: tabular-nums;
}

.mode-card-cta {
  margin-top: auto;
  padding-top: var(--sp-2);
  color: var(--accent);
  font-weight: 600;
  font-size: var(--step--1);
}

/* ---- guess box ---------------------------------------------------- */

.guess-box {
  position: relative;
  max-width: 30rem; /* holds its own against the wider board below it */
  margin: 0 auto var(--sp-6);
}

.input-row {
  display: flex;
  gap: var(--sp-2);
}

#guess-input {
  flex: 1;
  min-width: 0;
  min-height: 2.75rem;
  padding: var(--sp-2) var(--sp-3);
  border: var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
  color: var(--ink);
  font: var(--step-1) / 1.2 var(--font-ui);
}

/* `.button-quiet` is in this selector, not just in its own rule below,
   because it has to work on an `<a>` as well as a `<button>` — "Play
   Endless" is a link. It used to rely on `.button` being present
   alongside it, which the link did not have, so it matched none of this
   and rendered as bare muted text next to a filled Share button: no
   padding, no height, no radius, and a `border-color` with no
   border-style to show it. */
button,
.button,
.button-quiet {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 2.75rem;
  padding: var(--sp-2) var(--sp-4);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--accent);
  color: var(--accent-ink);
  font: 600 var(--step-0) / 1.2 var(--font-ui);
  text-decoration: none;
  cursor: pointer;
}

button:active,
.button:active {
  background: color-mix(in oklch, var(--accent) 90%, black);
}

/* The secondary half of a pair, for the action sitting next to a primary
   --accent-filled one — only one filled accent may be visible per screen
   (docs/design.md §7), so Play Endless beside Share, and Share beside
   Next Character, take this instead.

   "Quiet" by weight, not by presence: full-strength `--ink` text and a
   `--muted` border, so it reads as the other half of a pair of buttons
   rather than as something disabled. It was `--muted` on `--line`, which
   next to a solid terracotta button looked like an afterthought. */
.button-quiet {
  background: var(--bg-raised);
  color: var(--ink);
  border-color: var(--muted);
}

.button-quiet:hover {
  border-color: var(--ink);
  background: color-mix(in oklch, var(--bg-raised) 92%, var(--ink));
}

.button-quiet:active {
  background: var(--bg-raised);
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.suggestions {
  position: absolute;
  z-index: 10;
  left: 0;
  right: 0;
  margin: var(--sp-1) 0 0;
  padding: var(--sp-1);
  list-style: none;
  background: var(--bg-raised);
  border: var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
  max-height: 20rem;
  overflow-y: auto;
}

.suggestions li {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  min-height: 2.75rem;
  padding: var(--sp-2) var(--sp-3);
  border-left: 2px solid transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
}

/* The highlighted option carries a second, non-colour channel so it is
   still identifiable under a colour filter or forced-colours mode. */
.suggestions li.highlighted,
.suggestions li:hover {
  background: var(--accent);
  border-left-color: var(--accent-ink);
  color: var(--accent-ink);
}

.suggestions img {
  width: 2rem;
  height: 2rem;
  border-radius: var(--radius-sm);
  /* A filled box while the portrait is still in flight, so a slow icon
     reads as "loading" rather than a blank flash next to its name. */
  background: var(--line);
}

.guess-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-top: var(--sp-3);
}

/* Eight pips spend left to right as guesses are used — the count at a
   glance, without reading a sentence. */
.pips {
  display: flex;
  gap: var(--sp-1);
}

.pip {
  width: 0.7rem;
  height: 0.7rem;
  border: 1px solid var(--muted);
  border-radius: 1px;
}

.pip.spent {
  background: var(--ink);
  border-color: var(--ink);
}

.guesses-left,
.hint {
  margin: 0;
  font-size: var(--step--1);
  color: var(--muted);
  min-height: 1.2em;
}

.hint {
  margin-top: var(--sp-2);
  text-align: center;
  color: var(--ink);
}

/* Errors are weight + glyph, never colour alone. */
.hint[data-state="error"] {
  font-weight: 600;
}

.hint[data-state="error"]::before {
  content: "▲ ";
}

/* ---- board -------------------------------------------------------- */

/* A scroll container only in the technical sense: the grid below is
   exactly as wide as this box at every viewport, so nothing ever
   overflows — measured every 7px from 300 to 1493 on 2026-08-06. It
   stays `auto` (rather than `visible`) so that if a future column ever
   does push the board past the page, the failure mode is a board that
   scrolls rather than a page that does. */
.board-wrapper {
  overflow-x: auto;
  padding-bottom: var(--sp-2);
}

/* Except while a row is turning. A tile rotated in perspective projects
   wider than its own track — not "a couple of pixels" as this comment
   used to guess, but 5px at 390 and 22px at 1024 — and against `auto`
   that is enough to flash a horizontal scrollbar on every single guess,
   which is what it did. `clip` refuses to scroll rather than clipping
   anything away: the margin is wider than the worst projection measured,
   so the tile still paints in full, it just no longer counts as
   somewhere the box could be scrolled to. The class is on for the
   length of the turn only (see setRevealing() in js/board.js), so the
   scroll-instead-of-page failure mode above is intact at rest, which is
   the only time it could matter. Browsers without overflow-clip-margin
   (Safari < 16.4) clip at the box edge instead, shaving a sliver off a
   tile mid-turn — no scrollbar either way. */
.board-wrapper.revealing {
  overflow-x: clip;
  overflow-clip-margin: 1.5rem;
}

/* The character column is sized; the eight attribute columns split what
   is left. `minmax(0, 1fr)` rather than `1fr` so a long unbroken value
   can never widen its own track and push the board off-screen. */
.board-head,
.row {
  display: grid;
  grid-template-columns: var(--char-col) repeat(8, minmax(0, 1fr));
  gap: var(--gap);
  margin-bottom: var(--gap);
  width: 100%;
}

/* Gives the tile turn real depth rather than a flat vertical squash. */
.row {
  perspective: 700px;
}

/* Headers stay full words at every width — abbreviating them was cut
   once already (docs/design.md §4) — so they shrink and wrap instead. */
/* Column titles carry a short alias for the widths where the full word
   would wrap inside its own column (see headCell() in js/board.js). Each
   switches at the width it actually needs, measured rather than guessed:
   "Character" wraps from 46rem down, because that is where the column
   collapses to the portrait's width, and "Availability" from 35rem down.
   Every other title fits on one line at every width the board is used
   at, so none of them gets one. */
.head-short {
  display: none;
}

@media (max-width: 46rem) {
  .head-character .head-text {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .head-character .head-short {
    display: inline;
  }
}

@media (max-width: 35rem) {
  .head-limited .head-text {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .head-limited .head-short {
    display: inline;
  }
}

.head {
  /* So a visually-hidden `.head-text` stays anchored inside its own
     column rather than escaping to the nearest positioned ancestor. */
  position: relative;
  padding: var(--cell-pad);
  text-align: center;
  font-size: var(--head-font);
  line-height: 1.15;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  overflow-wrap: break-word;
  hyphens: auto;
}

/* Every cell carries the same 2px frame whatever its verdict, so the
   coloured area is identical across the row — a thinner border on the
   misses made the greens look inset and smaller than their neighbours.
   Fill, edge and text are indirected through variables so the flip
   animation can reference a cell's own verdict colours. */
.cell {
  --fill: var(--bg-raised);
  --edge: var(--line);
  --fg: var(--ink);

  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--cell-pad);
  height: var(--row-h); /* fixed: one long name must not deepen the row */
  padding: var(--cell-pad);
  border: 2px solid var(--edge);
  border-radius: var(--radius-sm);
  background: var(--fill);
  color: var(--fg);
  overflow: hidden;
  text-align: center;
  /* The same serif as the masthead and the stat numbers. The board is
     the thing you look at longest, and a plain UI sans made it read as
     a spreadsheet rather than as part of the catalogue. */
  font-family: var(--font-display);
  font-size: var(--cell-font);
  line-height: 1.2;
  overflow-wrap: anywhere;
}

/* The character column used to be `position: sticky` with an opaque
   `--bg` fill, from when the board scrolled sideways and the name had to
   stay in view. Removed 2026-08-06: since the fluid pass the grid is
   exactly as wide as its container at every viewport, so the sticky
   never engaged — but its backing did paint, always. That was invisible
   for as long as the page behind it was flat `--bg` too, and stopped
   being invisible the moment anything sat behind the board: the header
   cell wore a solid parchment rectangle none of its neighbours had, and
   a strip of the same colour bridged the character box to the guild
   tile beside it. If horizontal scroll ever comes back, this comes back
   with it — and `.board-wrapper` keeps `overflow-x: auto` so the
   failure mode stays "the board scrolls", not "the page does". */

/* Portrait beside the name rather than above it: the name gets the full
   cell height to wrap into, which is what keeps rows uniform. */
.cell.character {
  flex-direction: row;
  justify-content: flex-start;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  background: var(--bg-raised);
  /* Rounded on all four sides like every other tile. It was square on
     the right while the column was pinned, to read as a straight
     boundary against the columns scrolling past it; with nothing
     scrolling and no strip bridging the gap, that flat edge only made
     the box look joined to the guild tile next to it. */
  border-radius: var(--radius-sm);
  text-align: left;
  overflow: hidden;
}

.cell.character img {
  width: var(--char-img);
  height: var(--char-img);
  flex-shrink: 0;
  border-radius: var(--radius-sm);
}

.char-name {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* Two thirds of the roster are "Name (Variant)". Splitting them lets the
   name stay legible while the costume drops to a quieter second line,
   instead of one long string wrapping the row out of alignment. */
.char-base {
  font-weight: 600;
  font-size: var(--cell-font);
  line-height: 1.15;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.char-variant {
  margin-top: 0.1rem;
  font-family: var(--font-ui); /* the sans keeps the costume subordinate */
  font-size: clamp(0.55rem, 1.17vw, 0.74rem);
  line-height: 1.15;
  color: var(--muted);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The two `::after` strips that used to paint over the grid gap beside
   the pinned column went with it on 2026-08-06 — they existed so cells
   scrolling underneath could not show through the gap, and nothing
   scrolls underneath any more. They are the "same weird background"
   that joined the character box to the guild tile once the page behind
   the board stopped being flat. The 2026-08-03 `left: calc(100% + 2px)`
   fix documented in docs/design.md §4 went with them; if a future
   change reintroduces the strips, reintroduce that offset too — a
   padding-box containing block means a bare `left: 100%` paints over
   the cell's own 2px border and reads as a cut-off edge. */

/* A verdict tile's border matches its own fill, so the colour runs edge
   to edge and every tile reads the same size. A darker frame — even at
   an identical 2px — visibly shrank the green: the eye measures the
   coloured area, not the box. Verdicts are told apart by hue, by
   lightness (hit darkest, miss lightest) and, for the partial only,
   by a mark. */
.cell.hit {
  --fill: var(--hit);
  --edge: var(--hit);
  --fg: var(--hit-ink);
}

.cell.partial {
  --fill: var(--partial);
  --edge: var(--partial);
  --fg: var(--partial-ink);
}

.cell.miss {
  --fill: var(--miss);
  --edge: var(--miss);
  --fg: var(--ink);
}

.cell.partial::after {
  content: "≈";
  position: absolute;
  top: 1px;
  right: 3px;
  font-size: var(--step--1);
  line-height: 1;
  opacity: 0.85;
}

/* The game's own mark for a value, drawn above the word it belongs to.
   Purely additive: it is never the only thing carrying a verdict (fill,
   border style and corner glyph still do that, so the board stays
   playable in greyscale) and never the only thing carrying the value —
   `.cell-text` is present at every width for a screen reader, and
   visible above the 40rem breakpoint.

   These are the one place docs/design.md §7's no-gradient, no-shadow
   rules do not hold: the sprites are the client's own glossy, outlined
   artwork and cannot be flattened without redrawing them. Deliberate,
   owner's call 2026-08-06 — see §7's carve-out. */
.cell-icon {
  flex: none;
  height: var(--cell-icon);
  width: auto;
  max-width: 100%;
  object-fit: contain;
}

.arrow {
  font-size: clamp(0.75rem, 1.83vw, 1.15rem);
  font-weight: 700;
  line-height: 1;
}

/* Words for something drawn as a glyph — what each arrow means. Unlike
   `.cell-text`, which is only hidden below the code breakpoint, this is
   hidden at every width: the arrow is always decorative, and "left
   arrow" is not a clue. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The narrow-screen counterpart to .cell-text (see js/board.js) — a
   decorative shorthand, never the accessible name, so it's hidden
   outside the breakpoint that actually needs it and always
   aria-hidden regardless of which one is visible. Larger than the full
   text it replaces: three or four characters can afford the size the
   value they stand in for could not. The floor is set by measurement,
   not by taste: `.cell` breaks anywhere, so a code one pixel wider
   than its tile splits down the middle ("PER/M", "202/5"). At this
   size the only codes that still take two lines are the genuinely
   multi-value ones (`HUM/BST/ELF`), which break at the slash. */
.cell-code {
  display: none;
  font-size: clamp(0.5rem, 2.5vw, 0.95rem);
}

/* Each attribute tile turns over to show its verdict, one after the
   next along the row. The colour swaps at the halfway point, while the
   tile is edge-on and the change is invisible — which is why the
   keyframes need the cell's own --fill/--edge/--fg rather than literal
   colours. The character cell is the guess, not a verdict, so it does
   not turn. */
.row.revealing .cell:not(.character) {
  animation: flip var(--dur-flip) var(--ease-flip) both;
}

@keyframes flip {
  0% {
    transform: rotateX(0deg);
    background-color: var(--bg-raised);
    border-color: var(--line);
    color: transparent;
  }
  50% {
    transform: rotateX(90deg);
    background-color: var(--bg-raised);
    border-color: var(--line);
    color: transparent;
  }
  51% {
    transform: rotateX(90deg);
    background-color: var(--fill);
    border-color: var(--edge);
    color: var(--fg);
  }
  100% {
    transform: rotateX(0deg);
    background-color: var(--fill);
    border-color: var(--edge);
    color: var(--fg);
  }
}

/* The keyframes above hide a tile's face for the first half of its turn
   by going `color: transparent` — which works for every text child, and
   for nothing else. An <img> does not draw itself in `color`, so before
   this rule the mark was legible through the whole flip while the value
   underneath it was not: the answer arrived a beat early, on the one
   column a player most wants to read. Same shape of animation, driven
   off opacity instead, switching at the same 50/51% edge-on moment.

   `animation-delay: inherit` is what keeps the two in step: the per
   column stagger is set on `.cell` by the nth-child rules below, and
   the shorthand on the line above would otherwise reset this child to
   0ms and flash every mark in at once. It has to come after the
   shorthand. */
.row.revealing .cell:not(.character) .cell-icon {
  animation: flip-face var(--dur-flip) var(--ease-flip) both;
  animation-delay: inherit;
}

@keyframes flip-face {
  0%,
  50% {
    opacity: 0;
  }
  51%,
  100% {
    opacity: 1;
  }
}

/* The back of the card, shown for the half of the turn the tile is face
   down — the game's own gem, the way a deck has a patterned back. Only
   the row that is turning gets one: every other row is already face up,
   and a tile that never flips has no back to see.

   Same 50/51% switch and the same inherited stagger as the face above,
   so back and face trade places at the edge-on moment. Base opacity is 0
   rather than 1 so that with `prefers-reduced-motion` — where the
   animation is removed entirely — the gem simply never appears, instead
   of being painted permanently over a settled board. */
.row.revealing .cell:not(.character)::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("data/sprites/menu/jewel.webp");
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto var(--cell-icon);
  opacity: 0;
  pointer-events: none;
  animation: flip-back var(--dur-flip) var(--ease-flip) both;
  animation-delay: inherit;
}

@keyframes flip-back {
  0%,
  50% {
    opacity: 1;
  }
  51%,
  100% {
    opacity: 0;
  }
}

.row.revealing .cell:nth-child(2) {
  animation-delay: 0ms;
}
.row.revealing .cell:nth-child(3) {
  animation-delay: calc(1 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(4) {
  animation-delay: calc(2 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(5) {
  animation-delay: calc(3 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(6) {
  animation-delay: calc(4 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(7) {
  animation-delay: calc(5 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(8) {
  animation-delay: calc(6 * var(--flip-stagger));
}
.row.revealing .cell:nth-child(9) {
  animation-delay: calc(7 * var(--flip-stagger));
}

@media (prefers-reduced-motion: reduce) {
  .row.revealing .cell:not(.character),
  .row.revealing .cell:not(.character) .cell-icon,
  .row.revealing .cell:not(.character)::before {
    animation: none;
  }
}

.empty-note {
  max-width: 30rem;
  margin: var(--sp-2) auto;
  text-align: center;
  color: var(--muted);
  font-size: var(--step--1);
}

/* ---- result ------------------------------------------------------- */

/* The dossier. This is the payoff screen, so it is the one heavily
   built container on the page — everything else gets rules instead of
   boxes so this reads as the destination. */
/* --- the end of a game ----------------------------------------------
   De-boxed 2026-08-06. This was a `1px solid var(--ink)` dossier at
   `max-width: 32rem`, centred under a full-width board — built when it
   held the portrait, the name and an eight-row ledger and was meant to
   read as the destination. Once the answer moved onto the board there
   was a heavy container around an outcome line, two buttons and four
   numbers, narrower than the thing it followed. It is now flat sections
   at the board's own width, separated by rules like everything else on
   the page. docs/design.md §7's "the result dossier is the only filled,
   bordered container" is satisfied vacuously: there is no such container
   any more. */

/* A floating card again (2026-08-07), which is a deliberate reversal of
   the 2026-08-06 de-boxing — read that note above before assuming this
   is drift. What changed is *where* it sits: the panel that was cut had
   grown under the board and made the page taller than the game. This one
   floats over the board and adds no page height at all, which is the
   whole point of the rework. §7's "the result dossier is the only
   filled, bordered container" is back to being satisfied literally: this
   is that container, and there is no second one.
   Dismissed with "Review board" and brought back by `.result-reopen`. */
.result {
  position: fixed;
  inset: 0;
  z-index: 25;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Asymmetric on purpose: the top clears the header bar, so "centred"
     means centred in the play area rather than in the viewport — the
     card was pinned 5.5rem from the top and read as hanging off the
     bar rather than sitting on the board. */
  padding: calc(3.5rem + var(--sp-4)) var(--pad-x) var(--sp-6);
}

.result[hidden] {
  display: none;
}

/* The scrim. Same value as the "how to play" sheet's, and the same
   §7 exception covers it: a full-viewport dimming layer behind a modal
   layer is a different thing from a second card on the page. It is what
   makes the card read as the thing to deal with rather than as a panel
   that happens to overlap the board — and clicking it dismisses, like
   the sheet's. Behind the card but inside `.result`'s own stacking
   context, so it dims the page without dimming the card. */
.result::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: oklch(20% 0.02 55 / 55%);
  animation: scrim-in var(--dur-rise) var(--ease-out) both;
}

.result-card {
  animation: result-rise var(--dur-rise) var(--ease-out) both;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas:
    "portrait naming"
    "portrait spread"
    "stats stats"
    "actions actions";
  gap: var(--sp-3) var(--sp-4);
  width: min(41rem, 100%);
  max-height: 100%;
  overflow-y: auto;
  padding: var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
}

/* The character's own square portrait — the 128px `icon`, not the 64px
   thumbnail the board rows use. These files had no consumer at all after
   the 2026-08-06 rework (docs/state.md notes it); this is the one place
   that shows one near native size again. `contain` on a plain fill, so
   nothing is ever cropped or stretched to fit the square. */
.result-portrait {
  grid-area: portrait;
  flex: none;
  width: 8rem;
  height: 8rem;
  object-fit: contain;
  border-radius: var(--radius-sm);
  background: var(--bg);
}

.result-naming {
  grid-area: naming;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* The eyebrow. Accent as a colour, never a fill — the Share button below
   is §7's one accent fill and this must not become a second. */
.result-rank {
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
}

.result-rank:empty {
  display: none;
}

/* The largest type on the screen after the masthead: this is the news. */
.result-headline {
  margin-top: 2px;
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  line-height: 1.05;
}

.result-name {
  margin-top: var(--sp-1);
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  line-height: 1.1;
}

.result-name .answer-variant {
  font-size: var(--step-0);
}

/* How every finished game so far ended, by guess count — the one thing
   the old four-number strip couldn't say. Horizontal here, and a column
   of vertical bars on a phone (see the 30rem block); one DOM either way.
   `--fill` is set per row by js/board.js and is the only inline style on
   the page, because a proportion isn't something a stylesheet can know. */
.result-spread {
  grid-area: spread;
  display: flex;
  flex-direction: column;
  gap: 3px;
  align-self: start;
}

.spread-row {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--step--1);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.spread-label {
  width: 1ch;
  text-align: right;
}

.spread-track {
  display: flex;
  flex: 1;
  max-width: 14rem;
}

.spread-bar {
  width: var(--fill);
  min-width: 4px;
  height: 10px;
  background: var(--line);
}

/* The game just finished, in its own bucket. Verdict green, matching the
   board it belongs to — and, like every verdict on the board, it isn't
   colour alone: the row's own type goes to full-strength ink and bold. */
.spread-row.is-current {
  font-weight: 700;
  color: var(--ink);
}

.spread-row.is-current .spread-bar {
  background: var(--hit);
}

/* Losses. Not a red bar: a loss is an outcome, not an error, and the
   board's palette has no red in it. A miss fill plus a rule under it,
   the same fill-plus-second-channel pairing every verdict tile uses. */
.spread-row.is-lost .spread-bar {
  background: var(--miss);
  border-bottom: 2px solid var(--muted);
}

.result .stats {
  grid-area: stats;
  margin: 0;
}

.result-actions {
  grid-area: actions;
  margin-top: 0;
}

/* A square button carrying a glyph instead of a word — Share, which is
   a utility beside two worded actions rather than a third of them. It
   keeps whatever fill it is given (accent on Daily, quiet on Endless,
   where "Next character" is the one accent fill) and only changes the
   shape. 2.75rem holds §7's 44px hit area with no text to stretch it. */
.icon-action {
  flex: none;
  width: 2.75rem;
  height: 2.75rem;
  min-height: 0;
  padding: 0;
}

/* Drawn rather than taken from the game's menu set, which has no share
   mark anywhere in it — and a sprite that means something else would be
   worse than a plain glyph on a button with no label. `currentColor`, so
   it inverts with whichever fill the button is carrying. */
.icon-action svg {
  width: 1.25rem;
  height: 1.25rem;
}

/* Confirms the copy. It has to live here rather than in `#hint`, where
   the share message used to go: that element is inside `.guess-box`,
   which is `hidden` the moment a game ends, so the confirmation was
   never visible to anyone. Harmless while the button said "Share
   result"; not harmless once it is a glyph with no label. */
.result-status {
  flex-basis: 100%;
  margin: 0;
  font-size: var(--step--1);
  color: var(--muted);
}

.result-status:empty {
  display: none;
}

/* The card arrives rather than appearing: a short rise and fade, so the
   end of a game reads as one continuous beat with the tile turn that
   preceded it. Motion 4 in docs/design.md §6. It still starts only once
   the last tile has landed — this changes how it arrives, not when. */
@keyframes result-rise {
  from {
    opacity: 0;
    transform: translateY(1.25rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes scrim-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Reduced motion gets the card and the scrim, both at rest. Unlike the
   page loader, there is nothing here that has to keep moving to mean
   anything — the card says what it says standing still. */
@media (prefers-reduced-motion: reduce) {
  .result::before,
  .result-card {
    animation: none;
  }
}

/* Brings the card back after "Review board" dismissed it. Docked in the
   same corner the card floats from, so the way back is where the thing
   went. Hidden entirely while the card is up. */
.result-reopen {
  position: fixed;
  z-index: 24;
  right: var(--pad-x);
  bottom: var(--sp-4);
  box-shadow: var(--shadow-float);
}

.result-reopen[hidden] {
  display: none;
}

/* Names the character, and carries the outcome at the end of the same
   line. No banner: on a win the row above is green in all eight
   columns, which celebrates harder than a coloured strip did. */
.answer-caption {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-1) var(--sp-2);
  margin: 0 0 var(--sp-2);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--ink);
  font-weight: 400;
}

.answer-label {
  font-family: var(--font-ui);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.answer-name {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  line-height: 1.1;
}

/* The costume, kept subordinate to the character. Colour only, no fill,
   so this does not spend §7's one accent fill. */
.answer-variant {
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  color: var(--accent);
}

/* The rank word, the outcome headline and the win's naming line all
   moved into `.result-card` on 2026-08-07 — the card is where the end of
   a game is announced now, and a caption saying it again directly under
   it was the same news twice. What stays here is the loss reveal only:
   the board still has to spell out a character the player never guessed,
   because below 46rem it shows no names at all. */

/* The answer's own one-row table on a loss. Same grid template and same
   container width as the board above, so the columns line up; `.row`
   carries the template, this only needs to not get in its way. */
.answer-board .row {
  margin-bottom: 0;
}

.result-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-3);
  margin-top: var(--sp-4);
}

/* Trailing rather than pinned to the far edge: at the board's full
   63rem, `space-between` left the countdown marooned a screen away from
   the button it belongs with. */
.countdown {
  margin-left: auto;
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* A stat strip: the game's own mark, the number, then the label. Not the
   four `--step-2` columns the old dossier had — those were sized for a
   container that no longer exists — but not the bare caption line that
   replaced them either. With an icon carrying each one there is room to
   let the numbers have some weight without competing with the answer
   row above.

   The marks are decorative (`alt=""`): every stat says what it is in
   words right beside it. None of the four is a thing the client has an
   icon for, so the pairings are a judgement call — see the controllers,
   where they are named. */
.stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-6);
  margin: var(--sp-4) 0 0;
  padding-top: var(--sp-3);
  border-top: var(--border);
}

.stat {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.stat img {
  flex: none;
  height: 2rem;
  width: auto;
  max-width: 2.5rem;
  object-fit: contain;
}

/* `order` gets the number in front of its label while the markup keeps
   `<dt>` before `<dd>`, which is what a definition list requires. */
.stat img {
  order: 1;
}

.stats dd {
  order: 2;
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.stats dt {
  order: 3;
  font-size: var(--step-0);
  color: var(--muted);
}

/* One notch down inside the card: these were sized to hold a section of
   full page width on their own, and here they sit under a headline that
   has to stay the loudest thing in the box. */
.result .stat img {
  height: 1.75rem;
  max-width: 2.125rem;
}

.result .stats dd {
  font-size: var(--step-1);
}

.result .stats dt {
  font-size: var(--step--1);
}

/* ---- not found (404.html) ------------------------------------------ */

/* Un-boxed like everything else that isn't a board tile — a rule and a
   column of text, not a card. The page carries no accent fill of its
   own; the one accent on it is the "All modes" button, which is the
   thing to do next. */
.not-found {
  max-width: 34rem;
  margin: var(--sp-8) auto 0;
  padding-top: var(--sp-4);
  border-top: var(--border);
}

/* Display type, not an accent colour: the number is a label, not a
   warning, and §7 allows exactly one accent per screen — spent above. */
.not-found-code {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-3);
  line-height: 1;
  color: var(--muted);
}

.not-found h2 {
  margin: var(--sp-2) 0 var(--sp-3);
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
}

.not-found p:not(.not-found-code) {
  margin: 0;
  color: var(--muted);
}

.not-found-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  margin-top: var(--sp-6);
}

/* ---- how to play / footer ----------------------------------------- */

/* A sheet at every width now (2026-08-07), not just on a phone. The
   trigger moved into the header bar as an icon button, and a button at
   the top of the page that opens a disclosure at the bottom of it is a
   worse thing than a sheet. It is still the same native <details>: the
   <summary> stays real and focusable, just visually hidden, so keyboard
   and screen-reader behaviour is the platform's rather than invented.

   This is the second half of §7's "one filled, bordered container"
   exception, alongside the result card — a full-viewport scrim behind a
   modal sheet is a different thing from a second panel on the page. */
.how-to {
  font-size: var(--step--1);
  color: var(--muted);
}

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

.how-to ul {
  padding-left: 1.1rem;
}

/* Open: a fixed panel over a scrim, closed by the Close button, the
   scrim itself, or Escape (native <details> keyboard handling). Docked
   to the bottom edge at every width — it is opened from a header button
   but read with a thumb or a glance, and bottom is where both are. */
details.how-to[open] {
  position: fixed;
  inset: 0;
  z-index: 30;
}

details.how-to[open]::before {
  content: "";
  position: fixed;
  inset: 0;
  background: oklch(20% 0.02 55 / 55%);
}

.sheet-panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  max-width: 34rem; /* a reading measure on a desktop, full width on a phone */
  margin-inline: auto;
  max-height: 80vh;
  overflow-y: auto;
  padding: var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-bottom: 0;
  border-radius: var(--radius-md) var(--radius-md) 0 0;
  color: var(--muted);
  font-size: var(--step--1);
}

/* The <summary> is the panel's accessible name but is visually hidden,
   so without this the sheet opens as an untitled modal. */
.sheet-title {
  margin: 0 0 var(--sp-3);
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  color: var(--ink);
}

.sheet-close {
  display: inline-flex;
  width: 100%;
  margin-top: var(--sp-3);
}

/* The code legend tables only explain something that exists below the
   40rem tier, where cells stop showing whole words. */
.mobile-legend {
  display: none;
}

.mobile-legend dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.15rem var(--sp-2);
  margin: var(--sp-2) 0 var(--sp-4);
}

.mobile-legend dt {
  font-weight: 600;
  color: var(--ink);
}

.mobile-legend dd {
  margin: 0;
}

/* The legend for the columns that show a mark rather than a code. Sized
   a little above the board's own so a player comparing the two can tell
   the eight role badges apart while holding the sheet open. */
.mobile-legend dt img {
  display: block;
  height: 1.5rem;
  width: auto;
}

/* The legend mirrors the board's redundant channel, border style and
   all — the two must stay in step. */
.swatch {
  display: inline-block;
  width: 0.9rem;
  height: 0.9rem;
  border: var(--border);
  border-radius: 0.2rem;
  vertical-align: -2px;
  background: var(--miss);
}

.swatch.hit {
  background: var(--hit);
  border: 2px solid var(--hit);
}

.swatch.partial {
  background: var(--partial);
  border: 2px dashed var(--partial-ink);
}

footer {
  max-width: 34rem;
  margin: var(--sp-8) auto 0;
  text-align: center;
  font-size: var(--step--1);
  color: var(--muted);
}

/* ---- adaptive tiers ------------------------------------------------
   Three thresholds, each doing exactly one thing, so a board that has
   already been made to fit by the fluid tracks above only ever changes
   what it *says* as the screen narrows. Nothing here restores
   horizontal scrolling at any width. */

/* --- 46rem: the character column drops the name ---------------------
   The portrait is the reference a row is read against; the name beside
   it needs ~4rem of its own before it is worth the two attribute
   columns it costs. Below that the column shrinks to the portrait, and
   the full name stays reachable through the row's title attribute and
   the result card. */
@media (max-width: 46rem) {
  :root {
    --char-col: calc(var(--char-img) + var(--sp-2));
  }

  .char-name {
    display: none;
  }

  .cell.character {
    justify-content: center;
    padding: var(--cell-pad);
  }
}

/* --- 40rem: attribute values become codes ---------------------------
   Below this the columns are narrower than a wrapped word reads well
   in, so each cell swaps its value for the short code js/board.js
   already renders alongside it. `.cell-text` is visually hidden rather
   than removed, so it stays in the accessibility tree and remains what
   a screen reader announces; `.cell-code` is aria-hidden at every
   width. The code tables in "how to play" appear at the same point,
   which is the only place the codes are explained. */
@media (max-width: 40rem) {
  .cell-text {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .cell-code {
    display: block;
    line-height: 1.1;
  }

  /* Where the game has a mark for the value, the mark replaces the code
     instead of stacking above it. Two reasons, both about this size: a
     tile here is ~44px tall and cannot hold an icon, a code and an
     arrow legibly; and a 21px badge is easier to tell apart at arm's
     length than 8px letters, which is the open worry about `ATK`
     vs `BRK` vs `BST` in docs/state.md. The four columns with no mark
     keep their codes, so every column is at least consistent with
     itself across widths. `.cell-text` is untouched and still what a
     screen reader announces. */
  .cell.has-icon .cell-code {
    display: none;
  }

  :root {
    --cell-icon: clamp(1.35rem, 4.6vw, 2.1rem);
  }

  /* The side padding is worth more as letters here: a code is centred
     in a tile that already has a 2px frame holding it off its
     neighbour, and on a 320px screen those two pixels a side are the
     difference between "PERM" and "PER/M". Vertical padding stays. */
  .cell {
    padding-left: 0;
    padding-right: 0;
  }

  .mobile-legend {
    display: block;
  }
}

/* --- 30rem: phone chrome --------------------------------------------
   Ergonomics, not fit: the guess bar docks to the bottom of the viewport
   where a thumb is, the board scrolls vertically in the space above it,
   and when the game ends the result card takes that same slot.

   The Daily/Endless tab bar that used to sit under the guess bar is gone
   (2026-08-07): the header carries a Home button here instead, so the
   picker is the one place modes are listed and a third mode would cost
   the phone layout nothing. The board gets that 3.75rem back. */
@media (max-width: 30rem) {
  /* Locking the viewport is only right where something is docked to it:
     the two game pages carry `.game-page` for exactly that. The mode
     picker has nothing docked, so it keeps ordinary page scrolling —
     locked, it would strand its own footer at the bottom and reserve a
     bar's worth of empty space for a bar that isn't there. */
  .game-page {
    display: flex;
    flex-direction: column;
    height: 100dvh;
    overflow: hidden;
    /* Vertical space is the scarce one here: the page can't scroll, so
       the top gutter gives back what the bottom no longer needs. The
       header bar reads this to know how far to reach back up. */
    --pad-top: var(--sp-2);
    padding-bottom: 0;
  }

  .theme-row,
  .site-header {
    max-width: none;
  }

  /* The mode tabs go; the way back to the picker becomes a real target
     at the head of the bar. */
  .site-bar-nav {
    display: none;
  }

  .site-bar-home {
    display: inline-flex;
  }

  .site-bar-mode {
    display: inline;
  }

  /* The only place a phone says which round a run is on — the tab that
     carries it as a badge on a desktop isn't here. At the shared 0.6rem
     it was 9.6px of muted uppercase and effectively unreadable, so it
     takes full-strength ink and a size that is actually a size. */
  .site-bar-sub {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--ink);
  }

  /* main becomes the single scrolling region for board + (closed)
     how-to, sized to whatever's left after the header — the fixed guess
     bar below isn't part of this flow, so padding-bottom keeps its
     footprint from covering the last row. */
  .game-page main {
    flex: 1;
    min-height: 0;
    max-width: none;
    overflow-y: auto;
    padding-bottom: calc(6.5rem + env(safe-area-inset-bottom));
  }

  /* Once the game is over the guess bar is hidden and the card takes its
     place — docked, not in the flow, so this only reserves room inside
     `main`'s own scroll region and never makes the page taller. Without
     it the last thing on the board (a loss reveal, most of the time)
     sits under the card with no way to scroll clear of it. `--result-h`
     is the card's measured height, set by js/board.js's setResultOpen()
     and zero while it's dismissed.

     A margin, not padding: `main` is a flex item in a locked 100dvh
     column, so a margin makes the scroll region itself end where the
     card begins. Padding would instead hang the same distance off the
     bottom of the content and give a short board a screen of empty
     space to scroll through — the phantom scroll fixed on 2026-08-06,
     reintroduced. */
  .game-page.game-over main {
    margin-bottom: var(--result-h, 0px);
    padding-bottom: env(safe-area-inset-bottom);
  }

  /* `.result` used to need this too, back when it was a 32rem centred
     box; it is full width at every size now. */
  .empty-note {
    max-width: none;
  }

  /* Guess bar: pinned to the bottom edge instead of the top of the page.
     DOM order is unchanged (still right after the header, before the
     board) so tab order doesn't diverge from what's already there —
     this only changes where it paints. */
  .guess-box {
    position: fixed;
    left: var(--sp-2);
    right: var(--sp-2);
    bottom: env(safe-area-inset-bottom);
    z-index: 20;
    max-width: none;
    margin: 0;
    padding: var(--sp-2);
    background: var(--bg-raised);
    border: var(--border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-float);
  }

  /* The list now sits above an input near the bottom of the screen, so
     it has to open upward — same panel, same --shadow-float, just
     flipped onto the opposite edge. */
  .suggestions {
    top: auto;
    bottom: 100%;
    margin: 0 0 var(--sp-1);
  }

  /* The card takes the slot the guess bar vacates: docked to the bottom
     edge rather than centred, so the reveal lands under the thumb that
     just made the last guess — and a card centred on a screen this size
     would cover the board outright rather than sit on it. The scrim and
     the rise are unchanged; only where it comes to rest differs. */
  .result {
    align-items: flex-end;
    padding: 0;
  }

  .result-card {
    width: 100%;
    max-height: 78dvh;
    gap: var(--sp-3);
    /* One column of areas: the portrait and the naming still share a
       row, but the spread goes full width underneath, where a phone can
       give it eight bars instead of eight rows. */
    grid-template-areas:
      "portrait naming"
      "spread spread"
      "stats stats"
      "actions actions";
    padding: var(--sp-4) var(--sp-3) calc(var(--sp-4) + env(safe-area-inset-bottom));
    border-width: 1px 0 0;
    border-radius: var(--radius-md) var(--radius-md) 0 0;
  }

  .result-portrait {
    width: 5.5rem;
    height: 5.5rem;
  }

  .result-headline {
    font-size: var(--step-1);
  }

  .result-name {
    font-size: var(--step-0);
  }

  /* The same nine buckets stood up: bars grow from a shared baseline
     with the guess count under each, so the shape of a player's history
     is readable in a strip a third the height the rows would need. */
  .result-spread {
    flex-direction: row;
    align-items: flex-end;
    gap: 4px;
  }

  .spread-row {
    flex: 1;
    flex-direction: column;
    gap: var(--sp-1);
  }

  .spread-label {
    width: auto;
    order: 2;
    font-size: 0.625rem;
  }

  /* `flex: none` matters: the base rule's `flex: 1` is a basis of 0 on
     whichever axis the row runs along, and the row runs down the page
     here — so the height below would be overridden and every bar would
     collapse to its 2px minimum. */
  .spread-track {
    order: 1;
    flex: none;
    align-items: flex-end;
    width: 100%;
    max-width: none;
    height: 2.25rem;
  }

  .spread-bar {
    width: 100%;
    min-width: 0;
    height: var(--fill);
    min-height: 2px;
  }

  /* The count goes above its own bar rather than beside it — nine
     columns this narrow have no room for a number at the end of a row,
     and without one the bars alone can't tell none from one, since both
     are the same baseline tick. (It was visually hidden here until
     2026-08-07; a player looking at a lopsided history saw eight flat
     bars and no way to read them.) Only the qualifier stays hidden. */
  .spread-count {
    order: 0;
    font-size: 0.625rem;
    line-height: 1.2;
  }

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

  .result .stats {
    justify-content: space-between;
    gap: var(--sp-2);
  }

  .result .stat {
    gap: var(--sp-1);
  }

  .result .stat img {
    height: 1.375rem;
    max-width: 1.625rem;
  }

  .result .stats dd {
    font-size: var(--step-0);
  }

  .result .stats dt {
    font-size: 0.6875rem;
  }

  /* Thumb-sized halves, with a basis wide enough that a third action
     (Endless has one) wraps onto its own line instead of all three
     squeezing to 110px and clipping "Next character". The countdown
     takes a line of its own rather than trailing off the end of a row. */
  /* `nowrap` so a label breaks onto the next *row* rather than onto a
     second line inside its own button — "Next character →" was wrapping
     at 149px and standing 56px tall beside two 44px neighbours. */
  .result-actions > button,
  .result-actions > .button-quiet {
    flex: 1 1 8rem;
    white-space: nowrap;
  }

  /* Beats the rule above, which is more specific than `.icon-action`'s
     own `flex: none` and would otherwise stretch a square button into a
     third of the row. */
  .result-actions > .icon-action {
    flex: none;
  }

  .result-actions .countdown,
  .result-status {
    flex-basis: 100%;
    margin-left: 0;
    text-align: center;
  }

  .result-reopen {
    left: var(--sp-2);
    right: var(--sp-2);
    bottom: calc(var(--sp-2) + env(safe-area-inset-bottom));
  }
}

/* --- 22rem: the issue line drops its date -----------------------------
   Not a fourth tier — a single string that stops fitting, switched at
   the width it actually stops fitting at, the same way the column-title
   aliases are. The wordmark's slot is the viewport minus the Home
   button, the two icon buttons and the gutters, so it is `100vw - 176px`
   on a phone; "Daily · No. 6 · 2026-08-07" needs 158px of that, which
   runs out at 334px. Endless's "Endless · Round 12" is 130px and never
   comes close, so only Daily has anything to drop — and the date is the
   token a player needs least, the issue number being what identifies
   the puzzle. 22rem leaves a margin over the 334px the measurement
   gives. */
@media (max-width: 22rem) {
  .site-bar-date {
    display: none;
  }
}
