/* 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) */
  --line-inset: oklch(88% 0.02 78); /* softer interior hairline, between list rows on a filled surface */
  --bg-sunken: oklch(96% 0.015 80); /* one step below --bg-raised: pills, sheet rows */
  --radius-pill: 99px;
  --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 */
  /* The verdict green as *text* — `--hit` is a fill and too light to
     read on the parchment, `--hit-ink` is the near-black meant to sit
     on top of that fill. This is the third member of the same family,
     for the one-line confirmations ("Copied", "Syncing") that are
     saying yes rather than colouring a tile. Not a second accent: same
     hue as every solved cell on the board. */
  --ok-text: oklch(42% 0.09 150);

  /* A real red, unlike anything else in the palette — deliberately off
     the terracotta/green family every other token in this file shares,
     since a validation error ("that username is taken") is not a game
     verdict and shouldn't borrow the board's own colour language.
     Still paired with a non-colour channel wherever it's used (the
     alert-triangle icon, a shake), never colour alone. */
  --error: oklch(52% 0.19 25);
  --error-ink: oklch(98% 0.01 25); /* text on a filled --error surface */
  /* The game's own red "!" disc (game_assets.py's UI_GLYPHS), shown
     beside error messages. A §7 client-art carve-out like the
     lock/win/lose/clear marks -- its red already matches the site's
     `--error`, which is kept off the board's terracotta/green language
     on purpose. Set as a `background-image`, not a font glyph: the
     self-hosted faces are a latin subset, so "▲"/"⚠" would silently
     fall back to whatever the device has. */
  --icon-alert: url("data/sprites/ui/icon-alert.webp");

  /* 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-5: 1.25rem; /* the result card's own block inset */
  --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);
  --line-inset: oklch(33% 0.02 55);
  --bg-sunken: oklch(24% 0.02 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);
  --ok-text: oklch(80% 0.11 150);
  --error: oklch(68% 0.19 25);
  --error-ink: oklch(18% 0.03 25);
  --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;
  /* A column, so the footer (its direct child on the picker; main's, on
     a game page) can be pushed to the bottom of a short page instead of
     trailing the content wherever that happens to end — see `footer`'s
     own `margin-top: auto` below. Content taller than the viewport still
     scrolls the ordinary way; nothing here locks or clips it. */
  display: flex;
  flex-direction: column;
}

/* Set by each page's own inline head script (before this file even
   loads) when the saved site language is Japanese -- js/lang.js's
   wireSiteLang() removes the class the moment it has applied that
   language to the page's static strings, so this only ever covers the
   gap between first paint and that module script actually running.
   Without it, a returning JP reader saw every static string in English
   for that whole gap, which on a page whose own script waits on a data
   fetch first (daily.html, trivia.html, endless.html) was long enough
   to be a real, reported flash -- not a same-tick formality. Hiding
   the body rather than something narrower: the strings live all over
   the page, and there is no single container that would cover them all
   without also catching the "Loading…" spinner itself, which needs to
   stay invisible for exactly as long as everything else does. */
html.lang-pending body {
  visibility: hidden;
}

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

/* A game page's footer lives inside `main` (the picker's is a sibling of
   it), so `main` needs to be the flex column its own footer is pushed to
   the bottom of. `flex: 1` claims the body's leftover height; nothing
   here clips or locks it the way the ≤30rem tier below does, so this
   keeps working the same way once that tier's own rules take over. */
.game-page main {
  display: flex;
  flex-direction: column;
  /* `1 1 auto`, not the `1` shorthand: a 0 flex-basis let this clip its
     own content instead of growing with it — opening the how-to panel
     pinned main to the space free at load and let the panel spill past
     it uncounted, so the page never gained the scroll height to reach
     the overflow. `auto` sizes from content first and only grows into
     leftover room after, which is what "taller than the viewport keeps
     working" needs. */
  flex: 1 1 auto;
}

/* 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;
}

/* The same cover, holding a failed load instead of a pending one --
   js/page-loader.js's showLoadError(). */
.page-loader-error {
  max-width: 26rem;
  text-align: center;
  color: var(--ink);
}

.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%);
  }
}

/* Theme toggle + Account, the same pair and the same `.icon-button`
   sizing the game pages' `.site-bar-actions` groups in their header bar
   — index.html used to have one gear button opening a settings sheet
   that held both as a toggle-row and embedded forms; now it has the
   same two direct controls every other page does, and nothing left to
   put in a sheet. Lives inside `.site-header`'s own flex row on a
   desktop-width page — it used to be a separate `.theme-row` centred
   above the header on its own, which read as a stray floating control
   rather than a page-level one: `body` is a flex column for the sticky
   footer, which makes any direct child sized by `margin: 0 auto` alone
   shrink to its own content rather than the 34rem the header's
   border-bottom actually draws, so the row and the header centred
   independently at two different widths and never lined up. One flex
   row fixed that by construction. `margin-left: auto` is what pushes
   the group to the row's own right edge; the ≤30rem override below
   replaces that with fixed positioning instead, for a different reason
   — see there. */
.site-header-actions {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-left: auto;
}

/* ---- 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 the plain `.site-header` above instead:
   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. */
/* Three tracks, not a flex row, so the mode tabs sit on the *page's*
   centre line rather than in the middle of whatever is left over. As a
   flex row with auto margins the tabs were centred between the wordmark
   and the icon buttons, and the wordmark is the wider of the two — at
   1280px that put them 111px left of centre, which is exactly as
   off-centre as it looked. Equal `1fr` cheeks fix the middle track to
   the centre whatever the two sides weigh; `minmax(0, …)` lets a cheek
   shrink under its own content instead of shoving the tabs across, the
   same way the flex row's `min-width: 0` did.

   The tracks are positional, so the phone block — which hides the tabs
   and shows the Home button, three items either way — restates them. */
.site-bar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: stretch;
  gap: var(--sp-3);
  min-height: 3.5rem;
  /* No bottom margin since 2026-08-10: `.issue-strip` sits flush under
     the bar and carries the space down to the playfield. */
  margin: calc(-1 * var(--pad-top)) calc(-1 * var(--pad-x)) 0;
  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);
}

/* Bigger than it looks: `home.webp` is 91×79, so `contain` fits it to
   the *width* of this box and the mark it paints is a ratio shorter.
   At 1.375rem that was 22×19 adrift in a 44px button — the glyph read
   as an afterthought rather than the target. 1.875rem paints ~30×26,
   which is 60px of source on a 2x screen against 91 available, so it is
   still being drawn down rather than blown up (see state.md's note on
   the position badge). The button keeps its 44px hit area either way;
   only the mark inside it grows. */
.site-bar-home img {
  width: 1.875rem;
  height: 1.875rem;
  object-fit: contain;
}

/* `justify-self` so the link is only as wide as the words in it: its
   track is a whole cheek of the bar, and a stretched anchor would make
   half the header a silent link back to the picker. */
.site-bar-mark {
  display: flex;
  align-items: center;
  justify-self: start;
  gap: var(--sp-2);
  min-width: 0;
  color: var(--ink);
  text-decoration: none;
}

.site-bar-text {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
}

/* The site's own mark at the head of the bar, shown from 52rem up. The
   tabs are centred on the *page*, so the wordmark's cheek is only ever
   half the bar less the tabs, and the mark runs out of room there. Below
   30rem the Home button has this slot anyway — a phone needs the way
   back to the picker more than it needs a logo. */
/* Height-bound for the same reason as `.site-mark`. This one also
   spends the bar's fixed left budget — see the note above — so a wider
   mark costs the wordmark room; at 125×102 it draws ~55px. Still under
   the 2x ceiling (3.1875rem) below. The show breakpoint is 52rem rather
   than the board's unrelated 46rem: at this size the wordmark and tab
   labels collide below ~830px, 94px above where 46rem would show it. */
.site-bar-icon {
  display: none;
  flex: none;
  width: auto;
  height: 2.875rem;
}

@media (min-width: 52rem) {
  .site-bar-icon {
    display: block;
  }
}

.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;
}

/* DRAFT (2026-08-10) — the issue line as a band of its own, between the
   bar and the playfield, replacing the 13px subline under the wordmark.

   Why it moved rather than grew: the bar's three tracks put the tabs on
   the page's centre line, so the wordmark's cheek is a fixed budget
   already shared with the mark, and the subline could only ever be as
   loud as the leftovers allowed. Given the full width it needs no
   budget from anyone, and the same three-track trick puts the issue
   number itself on the page's centre line.

   The three read as one line, centred together: pinned to the band's
   two edges they were a screen's width apart on a desktop, which reads
   as three unrelated labels rather than one heading. The number takes
   the display face because it is what names the puzzle — the mode is
   already a lit tab a few pixels above, and the date is context. The
   phone block below spreads them instead, where the width is short
   enough that the edges are still one line and the middle track buys
   the number the room it needs. */
.issue-strip {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: var(--sp-3);
  margin: 0 calc(-1 * var(--pad-x)) clamp(var(--sp-4), 4.5vw, 3rem);
  padding: var(--sp-2) var(--pad-x);
  border-bottom: var(--border);
  background: var(--bg-raised);
}

.issue-mode,
.issue-date {
  font-size: 0.8125rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--muted);
}


/* Tabular so a ticking round or a rolling date never jogs the centre
   track sideways. The "No."/"Round" label stays text; only the digits
   after it are the game's own gold coin-style numeral sprites
   (2026-08-19, a design.md §7 carve-out like the result/lock/clear marks
   — glossy and shadowed on purpose, not flattened to the palette). The
   span carries the real "No. 9" as an aria-label since the digit images
   are decorative (empty alt) and the number would otherwise be
   invisible to a screen reader. */
.issue-number {
  display: inline-flex;
  align-items: flex-end;
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 3.4vw, 1.5rem);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
  white-space: nowrap;
  color: var(--ink);
}

.issue-digit {
  height: clamp(1.375rem, 4vw, 1.75rem);
  width: auto;
  margin-left: 0.1875rem;
}

/* The bar's middle track, so it is centred on the page rather than on
   the space between its neighbours. The current mode is an inset rule in
   --accent, not a filled tab: 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;
}

.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;
}

/* The day's modes as one group; Endless, which has no calendar, stands
   apart across a divider in a menu of its own. */
.site-bar-nav-group {
  display: flex;
  align-items: stretch;
}

.site-bar-nav-divider {
  align-self: center;
  width: 1px;
  height: 1.625rem;
  margin: 0 clamp(0.5rem, 2.2vw, 0.875rem);
  background: var(--line);
}

/* A <details> popover with no scrim -- js/mode-nav.js closes it on
   Escape and outside click. */
.endless-menu {
  position: relative;
  display: flex;
  align-self: center;
}

.endless-menu-trigger {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  height: 2.25rem;
  padding: 0 clamp(0.5rem, 2.2vw, 1rem);
  color: var(--muted);
  cursor: pointer;
  font: 600 var(--step--1) / 1.2 var(--font-ui);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  list-style: none;
}

.endless-menu-trigger::-webkit-details-marker {
  display: none;
}

.endless-menu-trigger:hover,
.endless-menu[open] .endless-menu-trigger,
.endless-menu-trigger[data-current] {
  color: var(--ink);
}

.endless-menu-trigger[data-current] {
  box-shadow: inset 0 -3px 0 var(--accent);
}

.endless-menu-trigger img {
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

/* On an endless page the trigger carries the current mode's own mark
   (quest for Stats, story-press for Trivia) beside the tower, so the
   collapsed nav says which endless run you're in, not just "Endless".
   Smaller than the tower -- it's the qualifier, not the subject -- and
   nudged in tight so the two read as one unit. */
.endless-menu-mode-icon.endless-menu-mode-icon {
  width: 1rem;
  height: 1rem;
  margin-left: calc(-1 * var(--sp-1));
}

.endless-menu-caret {
  width: 0.6rem;
  height: 0.6rem;
  color: var(--muted);
}

.endless-menu-panel {
  position: absolute;
  top: calc(100% + 3px);
  left: 0;
  z-index: 40;
  min-width: 12rem;
  padding: var(--sp-1);
  background: var(--bg-raised);
  border: var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
}

.endless-menu-panel a {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  color: var(--ink);
  text-decoration: none;
  font: 600 var(--step--1) / 1.2 var(--font-ui);
  letter-spacing: normal;
  text-transform: none;
}

.endless-menu-panel a:hover {
  background: var(--bg-sunken);
  color: var(--ink);
}

.endless-menu-panel a[aria-current="page"] {
  box-shadow: inset 0 -3px 0 var(--accent);
}

.endless-menu-panel img {
  width: 1.25rem;
  height: 1.25rem;
  object-fit: contain;
}

.endless-menu-item-name {
  flex: 1;
}

.endless-menu-panel .site-bar-badge {
  margin-left: auto;
}

/* Between the phone breakpoint (where the whole nav goes) and roughly
   where the site mark reappears, the bar runs tight -- drop the Endless
   trigger's label to its tower mark (and, on an endless page, its mode
   mark). `data-i18n-aria` carries the name for a screen reader. */
@media (max-width: 58rem) {
  .endless-menu-trigger > span[data-i18n] {
    display: none;
  }
}

/* Tighter still: drop the day-mode link labels to their icons too. The
   links keep `data-i18n-aria` for a screen reader. */
@media (max-width: 47rem) {
  .site-bar-nav-group > a > span {
    display: none;
  }
}

/* Language, theme and the overflow menu — three icon-sized controls that
   fit one row at every width. Account, How to play, What's new and Send
   feedback live in the menu (`.header-menu` below). */
.site-bar-actions {
  display: flex;
  align-items: center;
  justify-self: end;
  gap: var(--sp-2);
}

/* 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);
}

/* The how-to sprite, sized to sit at the same weight as the Home mark
   beside it. `help.webp` is 53×52, so 1.625rem is 52 device pixels of a
   53-pixel source at 2x — the most this asset has to give, and the
   ceiling on how far this can go. */
.icon-button img {
  width: 1.625rem;
  height: 1.625rem;
  object-fit: contain;
}

/* The current language's short code (applyButton() in js/lang.js). */
.lang-glyph {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.75rem;
  padding: 0.1rem 0.35rem;
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  color: var(--muted);
  font: 700 0.6875rem/1 var(--font-ui);
  letter-spacing: 0.04em;
}

.lang-toggle .lang-glyph {
  min-width: 0;
  padding: 0;
  background: none;
  color: var(--ink);
  font-size: 0.75rem;
}

/* The header overflow menu: a <details> popover behind the game's own
   menu mark, holding Account, How to play, What's new and Send
   feedback. Language and theme keep their own buttons beside it.
   Same no-scrim open/close recipe as `.endless-menu`
   (js/header-menu.js). */
.header-menu {
  position: relative;
  display: flex;
  align-self: center;
}

.header-menu-trigger {
  position: relative;
  list-style: none;
  cursor: pointer;
}

.header-menu-trigger::-webkit-details-marker {
  display: none;
}

.header-menu[open] .header-menu-trigger {
  border-color: var(--ink);
}

/* Something in the menu is unread (a new changelog entry). Cleared once
   the changelog page is opened -- js/whatsnew.js. */
.header-menu-dot {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  width: 0.45rem;
  height: 0.45rem;
  border-radius: 50%;
  background: var(--accent);
}

.header-menu-dot[hidden] {
  display: none;
}

.header-menu-panel {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  z-index: 40;
  min-width: 14rem;
  padding: var(--sp-1);
  background: var(--bg-raised);
  border: var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
}

.header-menu-item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  min-height: 0;
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--ink);
  text-decoration: none;
  font: 600 var(--step--1) / 1.2 var(--font-ui);
  text-align: left;
  cursor: pointer;
}

.header-menu-item:hover,
.header-menu-item:active {
  background: var(--bg-sunken);
  color: var(--ink);
}

.header-menu-item > img {
  flex: none;
  width: 1.25rem;
  height: 1.25rem;
  object-fit: contain;
}

.header-menu-label {
  flex: 1;
}

/* The accent count pill on "What's new". */
.header-menu-badge {
  flex: none;
  min-width: 1.15rem;
  height: 1.15rem;
  padding: 0 0.3rem;
  border-radius: 999px;
  background: var(--accent);
  color: var(--bg-raised);
  font: 700 0.6875rem / 1.15rem var(--font-ui);
  text-align: center;
}

.header-menu-badge[hidden] {
  display: none;
}

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

   Grown with the two sprites either side of it, but not to their size:
   this is a solid shape and they are line art with air in them, so
   matching it to their box would have it out-weigh them. 1.25rem
   against their ~1.65rem of painted mark reads level. */
.theme-glyph {
  position: relative;
  display: block;
  width: 1.25rem;
  height: 1.25rem;
  border: 2px 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: the mark, then the name and its line, with a
   rule under them, the way a catalogue page opens. `center` rather than
   the `baseline` it aligned on until 2026-08-09 — baseline was for two
   text children, and the first child is a picture now. */
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2) var(--sp-4);
  /* `width: 100%` alongside `max-width`, not `max-width` alone: `body`
     being a flex column (see `.theme-toggle`'s comment) means a bare
     `width: auto` here sizes this flex item to its own content instead
     of stretching out to the cap, which left no free space on the row
     for `.theme-toggle`'s `margin-left: auto` to push against. */
  width: 100%;
  max-width: 34rem;
  margin: 0 auto var(--sp-6);
  padding-bottom: var(--sp-2);
  border-bottom: 2px solid var(--ink);
}

/* The picker's copy of the mark in the game pages' header bar, a size up
   because it stands beside a `--step-3` headline rather than inside a
   56px rule. The placeholder is 102px tall, so 2x runs out at 3.1875rem
   — this sits right at that ceiling, as large as it goes before a 2x
   screen starts upscaling it. See the note in index.html. */
/* Height-bound, not boxed. A square box with `contain` fits whichever
   dimension runs out first, so the mark's rendered size depended on
   whether the art happened to be portrait or landscape: the 78×89
   resting mark filled all 2.75rem of height, and swapping in the 125×102
   pressed one made it 18% shorter without a line of CSS changing. Set
   the height, let the width follow, and the mark is as tall as it is
   told to be whatever shape the art is. */
.site-mark {
  flex: none;
  width: auto;
  height: 3.1875rem;
}

/* `flex: 1` (grow/shrink/basis 0%), not the default `0 1 auto` a bare
   flex item gets: with `width`/`flex-basis` auto, a flex container's
   line-wrap decision uses the item's own *unwrapped* preferred width,
   not whatever it would shrink to once siblings are accounted for --
   invisible in English, where the tagline's one-line width (`Guess the
   Princess Connect! Re:Dive character`, ~284px at this font) already
   fits the header's 34rem cap alongside the mark and the three header
   buttons, but the Japanese tagline's one-line width (~322px, this
   being denser CJK text, not a shorter string) doesn't -- so the whole
   `.site-header-actions` group got pushed onto its own line under the
   mark and title instead of sitting beside them, only in Japanese, at
   every width from here up (the header's own `max-width` never grows
   past 34rem, so a wider viewport never rescues it). Basis 0% is what
   stops the item from claiming that unwrapped width in the first
   place: it now takes only the row's actual leftover space and wraps
   its own text inside that, the same fix already scoped to the ≤30rem
   breakpoint below for a different reason (there, `.site-header-actions`
   also switches to `position: fixed`) -- `min-width: 0` alongside it is
   what lets it shrink below the tagline's longest unbreakable run
   rather than being floored there. */
.masthead {
  min-width: 0;
  flex: 1;
}

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

/* Two sections. "Today" is a 2x2 grid of quiet cards; "Any time" holds
   the calendar-free Endless runs as a ruled index list. */
.landing-section {
  max-width: 40rem;
  margin: 0 auto;
}

.landing-section + .landing-section {
  margin-top: var(--sp-6);
}

.landing-section-head {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  margin-bottom: var(--sp-3);
}

.landing-section-label {
  font: 700 0.6875rem/1.2 var(--font-ui);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}

.landing-section-rule {
  flex: 1;
  height: 1px;
  background: var(--line);
}

/* The Endless section head's own tower mark, inline with its label. */
.landing-section-icon {
  flex: none;
  width: 1.5rem;
  height: 1.5rem;
  object-fit: contain;
}

.landing-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4);
}

@media (min-width: 34rem) {
  .landing-grid {
    grid-template-columns: 1fr 1fr;
    /* All four tiles the same size -- the two placeholder cards grow to
       match the real ones' row rather than sitting short beneath them. */
    grid-auto-rows: 1fr;
  }
}

/* A quiet card, not an accent-filled one -- several on screen at once
   would break the one-accent-fill rule (docs/design.md section 7), so
   the accent shows only as a hover/focus border, the same way the mode
   nav's current-page rule uses it. */
.mode-card {
  position: relative;
  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. The image is the game's
   own art for the idea, read as decoration -- the <h2> names the mode,
   so the image is alt="". */
.mode-card-head {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

/* Boxed rather than sized by height: the icons have opposite proportions
   (quest is 125x77, the tower 100x130) and matching heights would make
   the wide one twice the visual weight. `contain` in a square gives them
   the same footprint. */
.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 state -- the reason a returning player gets something for
   landing here. Beats 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);
}

/* The game's own "NEW" starburst badge (game_assets.py's UI_GLYPHS),
   sitting beside the mode's name while the mode is new. Section 7
   client-art carve-out, same family as the CLEAR/WIN/LOSE marks
   (docs/decisions/0033-lock-win-lose-clear-marks-added.md). */
.mode-card-badge-new {
  flex: none;
  height: 2rem;
  width: auto;
  margin-left: var(--sp-1);
}

/* The client's own achievement stamp (game_assets.py's UI_GLYPHS),
   pinned to the card's bottom-right corner. Shown once today's puzzle
   for this mode is finished, win or lose -- it marks "you've done
   today's one," not a verdict. Section 7 client-art carve-out, see
   docs/decisions/0033-lock-win-lose-clear-marks-added.md. */
.mode-card-clear {
  position: absolute;
  right: var(--sp-3);
  bottom: var(--sp-3);
  height: 3.5rem;
  width: auto;
  transform: rotate(-10deg);
}

.mode-card-clear[hidden] {
  display: none;
}

/* The two not-yet-built modes. Inert -- a <div>, not a link -- so the
   pointer cursor and hover border that promise a destination are
   dropped; the fourth ("???") tile is also knocked back so it reads as
   a held slot. */
.mode-card-placeholder {
  cursor: default;
}

.mode-card-placeholder:hover {
  border-color: var(--line);
}

.mode-card .mode-card-placeholder-note {
  color: var(--muted);
  font-weight: 400;
}

.mode-card-mystery {
  opacity: 0.7;
}

/* The fourth tile has no real icon -- a bordered "???" box the size of
   a real mode mark stands in. */
.mode-card-mystery .mode-card-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  border: var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  color: var(--muted);
  font: 700 var(--step-0) / 1 var(--font-ui);
}

/* Endless runs as a ruled index list, not a second grid -- an appendix
   to the day's puzzles above. Each row carries its own side padding (not
   the panel's) so a hover fill runs the full width; `overflow: hidden`
   clips that fill to the panel's rounded corners on the end rows. */
.endless-panel {
  border: var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-raised);
  overflow: hidden;
}

.endless-list {
  display: flex;
  flex-direction: column;
}

.endless-row {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  min-height: 3.75rem;
  padding: var(--sp-3) var(--sp-5);
  border-bottom: 1px solid var(--line);
  color: var(--ink);
  text-decoration: none;
}

.endless-row:last-child {
  border-bottom: 0;
}

.endless-row:hover,
.endless-row:focus-visible {
  background: var(--bg-sunken);
}

.endless-row-icon {
  flex: none;
  width: 2rem;
  height: 2rem;
  object-fit: contain;
}

.endless-row-name {
  min-width: 5.5rem;
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
}

/* Round/streak as a compact stat pair -- a serif number over a small
   uppercase label, the same treatment the result card's streak callout
   uses. Pushed to the right of the row, just left of the CTA. The
   "No run yet" state keeps this box but fills it with a bare muted
   string (its only child is then a text node, which the flex layout
   handles fine). */
.endless-row-state {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  margin-left: auto;
  color: var(--muted);
  font-size: var(--step-0);
}

.endless-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  line-height: 1;
}

.endless-stat + .endless-stat {
  padding-left: var(--sp-4);
  border-left: 1px solid var(--line-inset);
}

.endless-stat-value {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.endless-stat-label {
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}

.endless-row-cta {
  flex: none;
  margin-left: var(--sp-5);
  color: var(--accent);
  font-weight: 600;
  font-size: var(--step-0);
}

/* Trial: the game's arrow sprite (common_icon_arrow, parked in site/img/
   for now) after the picker's forward CTAs and the Endless result
   card's "Next character" button, in place of a trailing "→" the
   self-hosted latin-subset font can't render. Kept off design.md §7's
   flat-glyph rule as a test only -- drop this block and restore the
   " →" in the strings if it doesn't sit right. */
.mode-card-cta::after,
.endless-row-cta::after,
button#next-button.result-primary::after {
  content: "";
  display: inline-block;
  width: 0.62em;
  height: 1.05em;
  margin-left: 0.35em;
  vertical-align: -0.16em;
  background: url("img/arrow.webp") center / contain no-repeat;
}

/* Smaller icon, tighter padding at 30rem and under -- the phone width
   this no longer shares with the 2-up desktop grid. Descriptions run
   however many lines their text needs (no clamp). */
@media (max-width: 30rem) {
  .landing-grid {
    margin-top: var(--sp-3);
  }

  .landing-section + .landing-section {
    margin-top: var(--sp-3);
  }

  .landing-section-head {
    margin-bottom: var(--sp-1);
  }

  .mode-card {
    gap: var(--sp-1);
    padding: var(--sp-3);
  }

  .mode-card-icon {
    width: 2.5rem;
    height: 2.5rem;
  }

  .mode-card h2 {
    font-size: var(--step-1);
  }

  .mode-card-cta {
    padding-top: 1px;
  }

  .endless-row {
    gap: var(--sp-3);
    min-height: 3.25rem;
    padding-left: var(--sp-4);
    padding-right: var(--sp-4);
  }

  .endless-row-icon {
    width: 1.75rem;
    height: 1.75rem;
  }

  .endless-row-name {
    min-width: 4.5rem;
    font-size: var(--step-0);
  }

  .endless-row-state {
    gap: var(--sp-3);
  }

  .endless-stat + .endless-stat {
    padding-left: var(--sp-3);
  }

  .endless-stat-value {
    font-size: var(--step-0);
  }

  .endless-row-cta {
    margin-left: var(--sp-3);
  }
}

/* ---- 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;
  gap: var(--sp-2);
  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);
}

/* Same fix as `.account-form[hidden]` below, for the same reason: the
   rule above sets `display` at the same specificity the `hidden`
   attribute's own UA style does, so a later author rule wins the
   cascade and a hidden button renders anyway. `resend-verify-button`
   is the one that actually surfaced this -- it starts hidden and
   stayed visible without this. */
button[hidden],
.button[hidden],
.button-quiet[hidden] {
  display: none;
}

/* 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;
  flex-wrap: wrap;
  gap: var(--sp-3);
  row-gap: var(--sp-2);
  margin-top: var(--sp-3);
}

/* Pips and the guesses-left count stay together on the left; the roster
   trigger is pushed to the right edge by its own auto margin, and drops
   to its own line if the row can't hold all three. */
.guess-meta-main {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  min-width: 0;
}

/* A quiet control, not an accent one: it sits in the guess-box meta row
   beside the pip count, and the Guess button is the one accent fill on
   this screen (docs/design.md §7). Quiet by weight, not by presence —
   full-strength `--ink` text on a sunken fill behind a hairline border,
   so it reads as a real control rather than disabled text. The mark
   leads the label so the button reads as a browse affordance rather
   than a label with a trailing badge. */
.lexicon-trigger {
  flex: none;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: 2.4rem;
  padding: var(--sp-1) var(--sp-3) var(--sp-1) var(--sp-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  color: var(--ink);
  font: 600 var(--step-0) / 1 var(--font-ui);
  cursor: pointer;
}

.lexicon-trigger:hover {
  border-color: var(--muted);
  background: color-mix(in oklch, var(--bg-sunken) 92%, var(--ink));
}

.lexicon-trigger-icon {
  display: grid;
  place-items: center;
}

.lexicon-trigger-icon img {
  width: 1.6rem;
  height: 1.6rem;
  object-fit: contain;
}

/* 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 colour *and* weight *and* glyph *and* a shake — colour is
   never the only channel, but it's one of them now (2026-08-15; it used
   to deliberately avoid colour altogether, which read as flat next to
   how loud a real mistake feels to make). `--error` is the one red in
   the palette, kept off the board's own terracotta/green language on
   purpose since a typo'd guess isn't a game verdict. */
.hint[data-state="error"] {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  font-weight: 600;
  color: var(--error);
}

.hint[data-state="error"]::before {
  content: "";
  flex: none;
  width: 1em;
  height: 1em;
  background: var(--icon-alert) no-repeat center / contain;
}

/* ---- 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
   — line boxes counted per column every 10px from 320 to 800, not a
   shared breakpoint that would be wrong for one of them. "Character"
   wraps from 46rem down, because that is where the column collapses to
   the portrait's width; "Availability" from 35rem down.

   Element, Position and Release joined them on 2026-08-08, with Attack
   and Rarity at their own narrower widths, after a real phone found the
   header the way the 2026-08-06 note here said it would be found ("add
   a `short` to any of them if that changes"). "Position" was the one
   reported, but fixing it alone would have changed nothing a player
   sees: the head row is as tall as its tallest cell, and Element and
   Release wrap at the same width. Guild and Role never wrap. No column
   title wraps at any width from 320 to 1440 now. */
.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;
  }
}

/* "Availability" is the long one, and it stops fitting twice: below
   35rem, and again in a band from 760px to 1016px. The second one is not
   a contradiction — between those widths the column grows more slowly
   than the header type does, and above 1016 the page has stopped
   widening while the type has stopped growing with it, so the word fits
   again. Found on 2026-08-08 while measuring the other five; it had been
   wrapping on every laptop since the aliases went in on 2026-08-06,
   which is not a width anyone had thought to check. */
@media (max-width: 35rem), (min-width: 47.5rem) and (max-width: 63.5rem) {
  .head-availability .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-availability .head-short {
    display: inline;
  }
}

/* "Element", "Position" and "Release" all stop fitting their column at
   420px and clear it again at 430, so one threshold covers the three
   with a little margin. */
@media (max-width: 27rem) {
  .head-element .head-text,
  .head-position_group .head-text,
  .head-release_year .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-element .head-short,
  .head-position_group .head-short,
  .head-release_year .head-short {
    display: inline;
  }
}

/* "Attack" survives 50px narrower than those three, so it switches
   where it actually has to and not before. */
@media (max-width: 24rem) {
  .head-atk_type .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-atk_type .head-short {
    display: inline;
  }
}

/* "Rarity" holds out until 330px — a 320px screen and nothing else,
   which is exactly where a second header line is worth the most. */
@media (max-width: 21rem) {
  .head-rarity .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-rarity .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;
}

/* Which way the answer lies, drawn rather than typed (2026-08-08).
   It used to be a text glyph, and on the position column that glyph was
   never the board's own typeface: the latin subset of Source Serif 4
   carries U+2191/U+2193 but not U+2190/U+2192, so "←" fell through to
   whatever serif the device had while the "↑" two columns over rendered
   properly — a hairline at 12px on a phone, reported as illegible at
   arm's length. A shape has no such stack to fall down, and is solid at
   any size, which is the other half of the complaint.

   One arrow, four rotations. `currentColor` matters: the tile turn hides
   a face by animating `color: transparent` (see the flip keyframes), and
   a shape filled this way goes with it — the way an <img> did not, which
   is what `.cell-icon` needed its own animation for. */
.arrow {
  --arrow-size: clamp(0.9rem, 2.9vw, 1.15rem);

  display: inline-block;
  flex: none;
  width: var(--arrow-size);
  height: var(--arrow-size);
  background: currentColor;
  clip-path: polygon(
    100% 50%,
    50% 100%,
    50% 66%,
    0 66%,
    0 34%,
    50% 34%,
    50% 0
  );
}

.arrow[data-dir="left"] {
  transform: rotate(180deg);
}

.arrow[data-dir="up"] {
  transform: rotate(-90deg);
}

.arrow[data-dir="down"] {
  transform: rotate(90deg);
}

/* 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;
}

/* Blocks, not one padded box. The portrait used to span down beside the
   spread; since 2026-08-14 it shares a headline row with the naming and
   nothing else, and the spread runs the card's full width underneath —
   which is what lets the bars stand tall enough to read as a chart
   rather than as a sparkline squeezed into a side column.

   `padding: 0` on the card itself, each area carrying its own inset
   instead, so `.result-actions` can run edge to edge as a footer bar on
   its own fill. The card clips to its own radius already (`overflow-y:
   auto` computes the x axis to `auto` too), so that bar needs no bottom
   corners of its own. */
.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"
    "spread spread"
    "stats stats"
    "actions actions";
  column-gap: var(--sp-4);
  row-gap: 0;
  width: min(40rem, 100%);
  max-height: 100%;
  overflow-y: auto;
  padding: 0;
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
}

/* The phone's drag handle, hinting the card is a sheet docked to the
   bottom edge rather than a panel welded there. Nothing drags it — the
   affordance is honest about the direction (down, to "Review board"),
   not about the gesture — so it stays out of the accessibility tree.
   Desktop, where the card floats centred, has nothing to hint at. */
.result-grabber {
  display: none;
}

/* 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. */
/* 96px of the 128px file rather than all of it: the portrait shares one
   headline row with the naming now instead of spanning down beside the
   spread, and at full size it stood taller than the three lines of type
   next to it and set the whole row's height on its own. Drawn down, not
   up — a 128px source at 96px is still sharp on a 2x screen. */
.result-portrait-frame {
  grid-area: portrait;
  align-self: center;
  position: relative;
  flex: none;
  width: 6rem;
  height: 6rem;
  margin: var(--sp-5) 0 var(--sp-4) var(--sp-5);
}

.result-portrait {
  width: 100%;
  height: 100%;
  object-fit: contain;
  border-radius: var(--radius-sm);
  background: var(--bg);
}

/* The client's own WIN/LOSE mark (game_assets.py's UI_GLYPHS), pinned to
   the portrait's corner like a stage-clear stamp — the §7 client-art
   carve-out extended to it, see docs/decisions/0033-lock-win-lose-clear-marks-added.md. Source `renders it
   at its own aspect (`result-win.webp` 102x47, `result-lose.webp`
   127x52), so only height is fixed and width follows from the file. */
.result-outcome-badge {
  position: absolute;
  top: -0.5rem;
  left: -0.625rem;
  height: 2rem;
  width: auto;
  transform: rotate(-8deg);
}

/* One child (`.result-naming-text`), so this reads as a column stack;
   `flex: 1` on that child fills the width. */
.result-naming {
  grid-area: naming;
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  min-width: 0;
  margin: var(--sp-5) var(--sp-5) var(--sp-4) 0;
}

.result-naming-text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  flex: 1;
}

/* The eyebrow — rank word and outcome folded into one line by the
   caller (main.js's showResult() strips the rank word's own trailing
   period and joins the two with " · "; Endless and Trivia have no rank
   word at all and lead with their own mode name instead — see their
   showResult()s). Accent on a win to match the portrait's "WIN" badge,
   muted on a loss — colour only, never a fill, since the Share button
   below is §7's one accent fill and this must not become a second. */
.result-eyebrow {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.result[data-outcome="won"] .result-eyebrow {
  color: var(--accent);
}

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

/* The largest type on the screen after the masthead: the name is the
   payoff now, not the guess count — the eyebrow above already says how
   the game went, so the news left to deliver is *who*. */
.result-name {
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  line-height: 1.02;
}

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

/* How every finished game so far ended, by guess count — the one thing
   the old four-number strip couldn't say. Standing bars on a shared
   baseline, count above and bucket below, at every width; `--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.

   It was nine *rows* of horizontal bars above 30rem until 2026-08-08,
   and the shape was what left the card looking half empty (reported by
   the owner as "a lot of space beneath the icon and to the right of the
   bars"). Both holes came from the same thing: a stack of nine short
   rows is tall and narrow, so it out-ran the portrait beside it while
   its own tracks — capped at 14rem to stop a bar sprawling — left ~240px
   of the card unused to their right. Standing the bars up makes the
   block as wide as the column it's in and about a third as tall.

   Since 2026-08-14 the baseline is a real drawn rule and the bucket
   numbers sit below it, an axis rather than a caption — which is only
   possible because the bars and the labels are now two sibling rows
   (see js/board.js's renderSpread()) instead of one column per bucket:
   a rule drawn per column breaks at every gap. */
.result-spread {
  /* One value, read by both rows — they only line up because their
     tracks are equal-width and their gaps identical, so the two must
     never drift apart. */
  --spread-gap: 0.375rem;
}

.spread-bars {
  display: flex;
  align-items: flex-end;
  gap: var(--spread-gap);
  border-bottom: 1px solid var(--line);
}

.spread-row {
  display: flex;
  flex: 1;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  min-width: 0;
  font-size: 0.71875rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
  color: var(--muted);
}

.spread-count {
  line-height: 1;
}

/* `flex: none` matters: the row runs down the page, so a flex basis of 0
   on that axis would override the height below and collapse every bar to
   its minimum.

   `padding-top` is the headroom the "YOU" tag needs to clear the tallest
   bar. A bar's height is a percentage of this box's *content*, so
   reserving the tag's line here caps all of them short of the top at
   once — the alternative being to scale the proportion itself, which
   would make `--fill` mean something other than "share of the tallest
   bucket" and quietly break check_logic.mjs's own reading of it. */
.spread-track {
  display: flex;
  flex: none;
  align-items: flex-end;
  width: 100%;
  height: 5.75rem;
  padding-top: 1.25rem;
}

.spread-bar {
  position: relative;
  width: 100%;
  height: var(--fill);
  min-height: 3px;
  border-radius: 3px 3px 0 0;
  background: var(--line);
}

.spread-labels {
  display: flex;
  gap: var(--spread-gap);
  margin-top: var(--spread-gap);
}

.spread-label {
  display: flex;
  flex: 1;
  flex-direction: column;
  align-items: center;
  min-width: 0;
}

/* The one number on this card that wasn't in the display serif --
   design.md's own rule is "a genuine display serif reserved for names
   and numbers," and `.stats dd` already follows it a few lines below
   this. */
.spread-label-value {
  font-family: var(--font-display);
  font-size: 0.8125rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--muted);
}

/* Screen-reader only, both of them. "lost" was never on screen; "YOU"
   left the label on 2026-08-14 for `.spread-tag` above its own bar,
   which is where it actually points at something. */
.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;
}

/* 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 count above goes to full-strength ink, the bucket
   number below with it, and the bar wears a tag naming itself. */
.spread-row.is-current {
  color: var(--ink);
}

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

.spread-label.is-current .spread-label-value {
  color: var(--ink);
}

/* The flag on the player's own bar. Sized to the headroom `.spread-track`
   reserves above the tallest bar, and `white-space: nowrap` because the
   column under it is ~50px wide on a desktop and ~36px on a phone — this
   is meant to overhang its neighbours, not wrap inside its own column. */
.spread-tag {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 5px);
  transform: translateX(-50%);
  padding: 1px 6px;
  border-radius: var(--radius-pill);
  background: var(--hit);
  color: var(--hit-ink);
  font: 700 0.5625rem/15px var(--font-ui);
  letter-spacing: 0.1em;
  white-space: nowrap;
}

/* 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.
   Wins by source order over `.is-current .spread-bar` above when a row
   is both (this game was a loss): the ✕ bucket is still `.is-current`
   so its own "YOU" tag shows, but the bar itself must not read as a
   win. */
.spread-row.is-lost .spread-bar {
  background: var(--miss);
  border-bottom: 2px solid var(--muted);
}

/* A loss's own "YOU" tag shouldn't borrow the win-only green above --
   still a filled pill, same shape as a win's, just in the loss's own
   `--miss`/`--ink` pairing instead of a colour that would read as a
   false "solved" signal. */
.spread-row.is-current.is-lost .spread-tag {
  background: var(--miss);
  color: var(--ink);
}

.result .stats {
  grid-area: stats;
  margin: var(--sp-4) var(--sp-5) 0;
  border-radius: 0.625rem;
}

/* The sunken panel that holds the bars, on all three result cards --
   Daily/Trivia (with the You/Everyone switch below) and Endless (a
   plain "This run" heading, no switch: there's no global counter for a
   mode with no calendar day to key it to). Filled and bordered rather
   than the flat rule-separated treatment the rest of the card uses,
   the one deliberate exception docs/design.md §7 already carves out
   for the result dossier itself -- this is a container inside that
   container, not a second one on the page. */
.spread-panel {
  grid-area: spread;
  margin: 0 var(--sp-5);
  padding: 0.875rem;
  background: var(--bg-sunken);
  border: 1px solid var(--line-inset);
  border-radius: var(--radius-md);
}

/* Two rows of type in the left column with the tabs spanning both of
   them on the right, so the switch stays vertically centred against the
   pair rather than against the title alone. Below 30rem the description
   drops to its own full-width row underneath instead — see that tier. */
.spread-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "title tabs"
    "desc tabs";
  align-items: center;
  column-gap: var(--sp-3);
}

.spread-heading {
  grid-area: title;
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.spread-title {
  grid-area: title;
  font-size: 0.71875rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

/* A `<p>` on Daily/Trivia (its own line below `.spread-head`, so its
   presence never changes the tabs' row height — see the markup's own
   comment) but still just a plain inline child of `.spread-heading` on
   Endless, which has no tabs to disturb. `margin` covers both: it's the
   gap from `.spread-head` above in the first case, and adds to
   `.spread-heading`'s own `gap` in the second.

   Deliberately *not* collapsed when empty (no `:empty` rule) even
   though the "you" side never fills it in: on a phone, `.result` docks
   to the bottom edge (`align-items: flex-end`), so any height the panel
   gains or loses on a tab switch moves the *whole card* up or down --
   including the tabs a player just tapped, right out from under their
   thumb. Reserving the line always, blank or not, is what keeps a
   switch from changing the panel's total height at all. */
.spread-desc {
  margin: 2px 0 0;
  min-height: 1.3em;
  font-size: var(--step--1);
  line-height: 1.3;
  color: var(--ink);
}

/* You / Everyone -- the same sliding-knob pill `.auth-tabs` uses, one
   size down. `.is-everyone` (set by js/main.js's and js/trivia-main.js's
   pickView()) drives both the knob and which side is showing; the panel
   underneath is rebuilt on every switch by js/board.js's renderSpread(),
   not shown/hidden, since "you" and "everyone" are different data, not
   the same data in two views. */
.spread-tabs {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  flex: none;
  padding: 3px;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}

.spread-tabs-knob {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  background: var(--ink);
  border-radius: var(--radius-pill);
  transition: transform var(--dur-base) var(--ease-out);
}

.spread-tabs.is-everyone .spread-tabs-knob {
  transform: translateX(100%);
}

.spread-tab {
  position: relative;
  z-index: 1;
  min-height: 2rem;
  padding: 0 var(--sp-3);
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--muted);
  font: 700 0.6875rem/1 var(--font-ui);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
}

.spread-tab[aria-selected="true"] {
  color: var(--bg-raised);
}

/* "Everyone" above 30rem, "All" below it — see the phone tier. Two spans
   rather than one relabelled by script: which word fits is a question
   about the viewport, and the viewport is the stylesheet's to answer. */
.spread-tab-short {
  display: none;
}

/* A loading placeholder for the "Everyone" side while the anonymous
   global counter (docs/planning/done/accounts-plan.md decision 3) hasn't answered yet --
   the "You" side never needs this, it's computed from local storage and
   ready before the panel first paints. Bar heights are fixed inline,
   the way js/board.js's real bars set `--fill` -- these never move, so
   there's no reason to compute them. */
/* Stands exactly where the real bars will: the track's own height, the
   same margin above it, and a bottom margin standing in for the label
   row it has no labels for — so the panel doesn't change height (and,
   on a docked phone card, shove the whole card up) the moment the
   count lands. */
.spread-skeleton {
  display: flex;
  align-items: flex-end;
  gap: 0.375rem;
  height: 5.75rem;
  margin: 0.875rem 0 1.4375rem;
  animation: spread-skeleton-pulse 1.4s ease-in-out infinite;
}

.spread-skeleton[hidden] {
  display: none;
}

.spread-skeleton span {
  flex: 1;
  min-height: 2px;
  border-radius: 3px 3px 0 0;
  background: var(--line-inset);
}

@keyframes spread-skeleton-pulse {
  0%,
  100% {
    opacity: 0.45;
  }
  50% {
    opacity: 0.85;
  }
}

@media (prefers-reduced-motion: reduce) {
  .spread-skeleton {
    animation: none;
    opacity: 0.65;
  }
}

.spread-panel .result-spread {
  margin-top: 0.875rem;
}

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

.spread-foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
  margin: 0.625rem 0 0;
  font-size: 0.71875rem;
  color: var(--muted);
}

/* The right-hand half — "8 games played", "Faster than 86% of them" —
   is the one the eye should land on; the left is the axis saying what
   the bars count. */
.spread-foot > :last-child:not(:only-child) {
  font-weight: 600;
  color: var(--ink);
}

.spread-foot:empty {
  display: none;
}

/* A footer bar on its own fill, running the card's full width rather
   than sitting inside its padding — the actions are the one part of the
   card that is a control surface rather than a report, and the seam is
   what says so. No bottom corners of its own: the card clips to its own
   radius (see `.result-card`). */
.result-actions {
  grid-area: actions;
  margin-top: 1.125rem;
  padding: 0.875rem var(--sp-5);
  border-top: 1px solid var(--line-inset);
  background: var(--bg-sunken);
}

/* A square button carrying a glyph instead of a word — Home and Share
   on all three cards now (2026-08-15). Neither needs a label competing
   with the one accent-filled action (`.result-primary` below), which is
   what used to make Share the loudest thing on a Daily/Trivia card
   instead of the mode's own move forward. 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.5rem;
  height: 1.5rem;
}

/* The Home button's sprite, at the same weight as the drawn Share glyph
   beside it — `home.webp` is 91×79, comfortably above 1.5rem (48 device
   pixels at 2x) so this is still drawn down rather than blown up. */
.icon-action img {
  width: 1.5rem;
  height: 1.5rem;
  object-fit: contain;
}

/* 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. Load-bearing on Endless, whose Share stays
   an unlabelled glyph (`.icon-action`'s own comment); harmless but
   redundant on Daily/Trivia, where the button says "Share result"
   itself now. */
.result-status {
  flex-basis: 100%;
  margin: 0;
  text-align: center;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--ok-text);
}

.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;
  }
}

/* A short horizontal shake, applied by JS (`.shake`, added and removed
   around `animationend`) whenever a form actually rejects a submission
   — not a CSS-only reaction to `data-state="error"`, since the same
   wrong password shown again on a second failed attempt still needs to
   read as a new event, not a state that was already true. */
@keyframes shake {
  10%,
  90% {
    transform: translateX(-1px);
  }
  20%,
  80% {
    transform: translateX(2px);
  }
  30%,
  50%,
  70% {
    transform: translateX(-4px);
  }
  40%,
  60% {
    transform: translateX(4px);
  }
}

.shake {
  animation: shake 400ms var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  .shake {
    animation: none;
  }
}

/* Brings the card back after "Review board" dismissed it. Sits in the
   page's own flow, directly under the board (or the ladder, in Trivia)
   — the same place the last guess landed — rather than floating in a
   screen corner the player has to go hunting for. `align-self` keeps it
   from stretching to `main`'s full flex width like its neighbours.
   Hidden entirely while the card is up. Re-docked to the bottom edge,
   fixed, below 30rem — see that tier for why. */
.result-reopen {
  align-self: center;
  margin-top: var(--sp-2);
}

.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: 0.625rem;
}

/* Share is `.icon-action` everywhere now (2026-08-15) — a bare glyph on
   all three cards, not just Endless. Promoting each mode's own move
   forward to the accent fill (below) means Share is never the loudest
   thing on the card on any of them, which is what a labelled button
   next to a half-width mode link used to get wrong on a phone. */

/* Each mode's own move forward — "Play Endless →", "Next character →",
   "Play today's Stats Quiz →" — is the card's one accent fill (§7),
   a plain `.button` (or bare `<button>`) carrying an icon so Home,
   Share and this one all read as a family. Endless's own icon is a
   drawn shuffle glyph, not a sprite: there is no cross-mode destination
   to borrow an icon from, the same reasoning `.icon-action`'s comment
   gives for drawing Share instead of taking a sprite. */
.result-primary img,
.result-primary svg {
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

/* The countdown, as a label over a value rather than a sentence —
   trailing instead of pinned to the far edge: at the board's full
   63rem, `space-between` left it marooned a screen away from the
   button it belongs with. */
.result-next {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 1px;
  margin-left: auto;
  text-align: right;
}

.result-next-label {
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.result-next-value {
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

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

   Four cells in one bordered strip, each on its own `--bg-raised` tile
   with a 1px `--line-inset` seam between them (the grid's own gap,
   filled by the grid's background rather than a border on every cell —
   one declaration instead of four) — the same divided-panel treatment
   `.account-signed-in-actions`'s neighbours use, not the flat rule this
   replaced.

   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: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  margin: var(--sp-4) 0 0;
  background: var(--line-inset);
  border: 1px solid var(--line-inset);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.stat {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  background: var(--bg-raised);
  min-width: 0;
}

.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, and the number over its label rather
   than all three parts on one line: these were sized to hold a section
   of full page width on their own, and in a quarter of a 640px card
   "Win rate" had nowhere to go but a second line, which stood its cell
   taller than the three beside it. */
.result .stat {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas:
    "icon value"
    "icon label";
  align-items: center;
  gap: 0 var(--sp-2);
  padding: 0.625rem var(--sp-3);
}

.result .stat img {
  grid-area: icon;
  height: 1.5rem;
  max-width: 1.75rem;
}

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

.result .stats dt {
  grid-area: label;
  font-size: 0.71875rem;
}

/* ---- trivia mode ---------------------------------------------------
   Replaces the attribute board: a single ledger card of five numbered
   rungs that fill downward as clues open, instead of eight columns to
   compare. js/trivia-main.js builds the five rows once and updates them
   in place on every render, rather than rebuilding the card the way
   renderBoard() rebuilds the grid — a locked rung transitioning to
   revealed needs a node that already exists to transition *from*. */
.trivia-card[hidden] {
  display: none;
}

.trivia-card {
  display: flex;
  flex-direction: column;
  /* Was 40rem — read as small against `main`'s own 63rem cap, with the
     board (this card's real counterpart in Stats mode) using nearly all
     of it. 58rem leaves a little margin rather than running edge to edge:
     unlike the board's nine columns, a one-column ledger has nothing to
     spread into the last few rem, so full width would just be more
     border-to-text padding, not more content. */
  max-width: 58rem;
  margin: 0 auto var(--sp-4);
  background: var(--bg-raised);
  border: var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.trivia-card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-6);
  border-bottom: 2px solid var(--ink);
}

.trivia-card-title {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 600;
}

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

/* Decorative — the same real-DOM-text-stays-primary pattern as .pips in
   the guess box. `#clue-count`'s text is what a screen reader hears;
   this row of segments is aria-hidden. */
.clue-progress-track {
  display: flex;
  gap: 0.25rem;
}

.clue-progress-seg {
  width: 1.625rem;
  height: 0.3125rem;
  border-radius: 0.15625rem;
  background: var(--line);
}

.clue-progress-seg.is-filled {
  background: var(--ink);
}

.trivia-card-count {
  font: 600 0.75rem/1.2 var(--font-ui);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}

.trivia-card-head-meta {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

/* Same recipe as .spread-tabs (a pill track, a sliding knob, two
   borderless tabs) at a size that fits inline beside "N of 5 clues"
   instead of the spread panel's full-width row. */
.lang-switch {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  flex: none;
  padding: 2px;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}

.lang-switch-knob {
  position: absolute;
  top: 2px;
  bottom: 2px;
  left: 2px;
  width: calc(33.333% - 2px);
  background: var(--ink);
  border-radius: var(--radius-pill);
  transition: transform var(--dur-base) var(--ease-out);
}

/* translateX is relative to the knob's own width (one column), so this
   scales to any number of tabs without a per-column percentage --
   100% = one column over, 200% = two. */
.lang-switch.is-romaji .lang-switch-knob {
  transform: translateX(100%);
}

.lang-switch.is-jp .lang-switch-knob {
  transform: translateX(200%);
}

.lang-switch-tab {
  position: relative;
  z-index: 1;
  min-height: 1.375rem;
  padding: 0 0.5rem;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--muted);
  font: 700 0.625rem/1 var(--font-ui);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
  white-space: nowrap;
}

.lang-switch-tab[aria-selected="true"] {
  color: var(--bg-raised);
}

/* A clue icon on the left, the label/value column in the middle, and
   the number + note stacked on the right — the number no longer needs
   its own column-spanning badge now that nothing else shares the right
   edge with it. */
.rung {
  display: grid;
  grid-template-columns: 1.75rem minmax(0, 1fr) auto;
  column-gap: var(--sp-4);
  align-items: center;
  min-height: 3.25rem;
  padding: 0.625rem var(--sp-6);
  border-bottom: 1px solid var(--line-inset);
  /* Both the tint and the new-rung border are transitioned, not just
     applied — a locked rung is already in the DOM as `.is-next` (see
     renderRungs() in js/trivia-main.js), so the class change on a miss
     animates rather than pops. Cut to nothing under reduced motion, same
     as the board's tile flip.

     `perspective` lives here rather than on the value alone: it needs to
     be set on an ancestor of the rotating element, and this is the
     nearest one that only that element's own flip should affect. Inert
     until `.is-flipping` triggers the animation below. */
  perspective: 900px;
  transition:
    background-color var(--dur-rise) var(--ease-out),
    box-shadow var(--dur-rise) var(--ease-out);
}

.rung:last-child {
  border-bottom: none;
}

.rung.is-locked {
  background: var(--bg-sunken);
}

.rung.is-new {
  box-shadow: inset 3px 0 0 var(--hit);
}

.rung-icon-slot {
  position: relative;
  width: 1.75rem;
  height: 1.75rem;
}

.rung-icon {
  width: 1.75rem;
  height: 1.75rem;
  object-fit: contain;
  transition:
    opacity var(--dur-rise) var(--ease-out),
    filter var(--dur-rise) var(--ease-out);
}

.rung.is-locked .rung-icon {
  opacity: 0.35;
  filter: grayscale(1);
}

/* The client's own lock mark (game_assets.py's UI_GLYPHS), badged on the
   category icon's corner exactly the way the game itself badges a locked
   unit's portrait — the §7 client-art carve-out extended to it, see
   docs/decisions/0033-lock-win-lose-clear-marks-added.md. Hidden rather than absent on a revealed rung, so
   unlocking animates the same opacity/scale a miss already gives the
   icon above, instead of popping. */
.rung-lock {
  position: absolute;
  right: -0.25rem;
  bottom: -0.25rem;
  width: 1rem;
  height: 1rem;
  object-fit: contain;
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity var(--dur-rise) var(--ease-out),
    transform var(--dur-rise) var(--ease-out);
}

.rung.is-locked .rung-lock {
  opacity: 1;
  transform: scale(1);
}

.rung-content {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  min-width: 0;
}

.rung-label-row {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
}

.rung-label {
  font: 600 0.6875rem/1.2 var(--font-ui);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

.rung.is-locked .rung-label {
  color: oklch(62% 0.02 65);
}

/* Names the rung the flip below is about to run on — a border alone
   (the previous treatment) read as a state change but not as a label a
   returning player could name. */
.rung-new-tag {
  font: 700 0.59375rem/1.2 var(--font-ui);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--hit);
}

.rung-new-tag:empty {
  display: none;
}

/* The value's positioning context: `.rung-value-face` carries the actual
   content and its typography, and is the only child during ordinary
   render. A miss that unlocks this rung briefly adds a second child,
   `.rung-value-back` — a clone of the face *before* its content was
   swapped in, holding what the rung looked like locked — so the two can
   cross-fade against each other during the flip below. Absolute
   positioning is what lets `-back` sit exactly over `-face` rather than
   stacking beneath it. */
.rung-value {
  position: relative;
  min-width: 0;
}

.rung-value-face {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.35;
  transition: opacity var(--dur-base) ease;
}

/* An EN/Romaji switch restates the same clue in a different language —
   not a new reveal — so it gets its own quick cross-fade (crossFadeLang()
   in js/trivia-main.js) rather than the flip above, which is reserved
   for "locked" turning into "legible". */
#rung-list.is-relanguaging .rung-value-face {
  opacity: 0;
}

.rung-value-back {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

/* Every rung reads as a sentence or a comma list, not a short badge
   value — Likes ranges up to 80 characters in the real data, as long as
   any other rung — so all five drop to regular weight rather than
   bolding a full line. The quote also carries its own quotation marks
   in the data (see js/trivia-main.js's schema note), not added here.
   `.rung-value-back` picks these up too: it's always a clone of
   `.rung-value-face` (see js/trivia-main.js), carrying that class along
   with it. */
.rung[data-key="tagline"] .rung-value-face,
.rung[data-key="quote"] .rung-value-face,
.rung[data-key="quote2"] .rung-value-face,
.rung[data-key="facts"] .rung-value-face,
.rung[data-key="likes"] .rung-value-face {
  font-weight: 400;
}

.rung[data-key="quote"] .rung-value-face,
.rung[data-key="quote2"] .rung-value-face {
  font-style: italic;
}

/* A locked rung's redaction (docs/planning/done/trivia-mode-plan.md): rather than the
   real value blurred in the DOM, it's rendered as one block per word —
   "the tagline is long" without ever putting real text where dev tools
   could read it, for every locked rung, not just the one about to open.
   `color-mix` against `var(--ink)` rather than a literal light-mode
   alpha keeps the same contrast against the card in both themes. */
.rung-redact-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3125rem;
}

.rung-redact {
  flex: none;
  height: 0.875rem;
  border-radius: 0.1875rem;
  background: color-mix(in oklch, var(--ink) 9%, transparent);
}

.rung.is-locked.is-next .rung-redact {
  background: color-mix(in oklch, var(--ink) 16%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .rung {
    transition: none;
  }

  .rung-value-face {
    transition: none;
  }
}

/* The rung that just unlocked turns its value over, the same "what was
   hidden is now legible" beat as the board's tile flip — `.rung-value`
   is the plane that turns (`perspective` lives on `.rung`, its parent);
   `-face` and `-back` cross-fade at the exact edge-on instant, 50/51%,
   the same split the board's flip/flip-face/flip-back keyframes use and
   for the same reason: the swap is invisible while the plane is on edge.
   Set by createFlipBack() in js/trivia-main.js, which is also what
   removes `-back` and this class once the animation ends — there is
   nothing here to clean itself up. */
.rung.is-flipping .rung-value {
  animation: rung-flip var(--dur-flip) var(--ease-flip) both;
}

.rung.is-flipping .rung-value-face {
  animation: rung-flip-face var(--dur-flip) var(--ease-flip) both;
}

/* `.rung-value-back` is a clone of `.rung-value-face` and carries that
   class too (see the comment above `.rung-value-face`'s tagline/quote
   rule) — so it matches the face rule above as well as this one. Equal
   specificity, so source order decides, which is why this has to come
   after: `-back` needs the fade-*out* keyframes, not the fade-in ones
   its borrowed class would otherwise win with. */
.rung.is-flipping .rung-value-back {
  animation: rung-flip-back var(--dur-flip) var(--ease-flip) both;
}

@keyframes rung-flip {
  0% {
    transform: rotateX(0deg);
  }
  50%,
  51% {
    transform: rotateX(90deg);
  }
  100% {
    transform: rotateX(0deg);
  }
}

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

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

@media (prefers-reduced-motion: reduce) {
  .rung.is-flipping .rung-value,
  .rung.is-flipping .rung-value-face,
  .rung.is-flipping .rung-value-back {
    animation: none;
  }
}

/* Number and note stack on the right, in the column the badge used to
   own alone — reads as a receipt's line-total column rather than a
   left-hand row index. */
.rung-meta {
  grid-column: 3;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.1875rem;
}

.rung-badge {
  color: oklch(74% 0.02 70);
  font: 700 0.6875rem/1 var(--font-ui);
  font-variant-numeric: tabular-nums;
}

.rung.is-revealed .rung-badge {
  color: var(--muted);
}

.rung.is-new .rung-badge {
  color: var(--ink);
}

.rung.is-locked.is-next .rung-badge {
  color: var(--accent);
}

.rung-note {
  flex: none;
  font: 700 0.6875rem/1.2 var(--font-ui);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: oklch(62% 0.02 65);
}

.rung-note:empty {
  display: none;
}

/* The rung about to open on the next miss gets the accent, the same way
   the guess button does — "Last clue" (the ladder's final rung) stays
   the quiet grey, since there's no further miss for it to announce. */
.rung-note.is-urgent {
  color: var(--accent);
}

/* The clue-source annotation, trailing the rung label
   (js/trivia-main.js). Names where a revealed Quote/Intro clue's English
   came from — a fan translation ("EN patch") or one machine-translated
   for this project ("MTL"), English ladder only. Plain faint caption
   text, set off from the label by a hairline divider — no box, and out
   of the meta column, so it neither reads as a boxed tag nor competes
   with .rung-note's "Next hint" status line. */
.rung-source {
  padding-left: var(--sp-2);
  border-left: 1px solid var(--line-inset);
  font: 400 0.625rem/1.2 var(--font-ui);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: oklch(74% 0.02 70);
  white-space: nowrap;
}

.rung-source:empty {
  display: none;
}

/* Portrait mode's reveal stage: the pixelation canvas and its one note
   line. The canvas holds the answer's splash art, drawn by
   js/portrait-main.js — blocky while the game is in play, full-res once
   it ends. It is the page's focus, so it runs close to `main`'s full
   width with the guess box below it, not above. */
.portrait-stage {
  /* An explicit width, not just max-width: `main` on a game page sizes
     to its content (its auto side margins beat the flex stretch), and a
     bare <canvas> only contributes its 300px default — so without this
     the stage, and `main` with it, collapse to ~320px. `100vw - 2 *
     --pad-x` is the body's own usable width, the same expression the
     account panel uses. Capped at 44rem: the exported splash is ~896px
     wide, so this keeps it downscaled (crisp) rather than blown up on a
     desktop. */
  width: min(44rem, calc(100vw - 2 * var(--pad-x)));
  margin: 0 auto var(--sp-6);
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

/* The reveal is mounted like a print: the canvas sits inside a parchment
   mat, held by a hairline frame with a small terracotta bracket at each
   corner. The mat is what makes the edge read as deliberate framing
   rather than a border that only appears mid-tween -- during a redraw
   the canvas can flash its own edge, and now that edge lands on the mat,
   inside the visible frame, instead of at it. */
.portrait-stage-frame {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--bg-raised);
  padding: clamp(0.4rem, 1.6vw, 0.85rem);
}

#portrait-canvas {
  display: block;
  width: 100%;
  height: auto;
  /* The 16:9 lives here now, not on the frame, so the frame can size to
     canvas-plus-mat while the canvas keeps the splash's exact shape (the
     game's `bg_still_unit` illustration). `aspect-ratio` reserves the
     height before the bitmap is set, so the missed row and guess box
     below don't jump on decode. */
  aspect-ratio: 16 / 9;
  border: 1px solid var(--line-inset);
  border-radius: calc(var(--radius-md) - 0.35rem);
  background: var(--bg-sunken);
}

/* Four L-brackets just inside the frame edge -- eight solid-colour
   slices, no gradient or shadow (design.md §7), in a terracotta-leaning
   structural tone so they read as a ledger flourish, not a second
   accent fill competing with the Guess button. */
.portrait-stage-frame::after {
  content: "";
  position: absolute;
  inset: clamp(0.25rem, 1vw, 0.5rem);
  pointer-events: none;
  --bracket: color-mix(in oklch, var(--accent) 42%, var(--line));
  --bl: clamp(0.6rem, 2.2vw, 1rem); /* bracket leg length */
  --bt: 2px; /* bracket thickness */
  background-image:
    linear-gradient(var(--bracket) 0 0), linear-gradient(var(--bracket) 0 0),
    linear-gradient(var(--bracket) 0 0), linear-gradient(var(--bracket) 0 0),
    linear-gradient(var(--bracket) 0 0), linear-gradient(var(--bracket) 0 0),
    linear-gradient(var(--bracket) 0 0), linear-gradient(var(--bracket) 0 0);
  background-repeat: no-repeat;
  background-position: 0 0, 0 0, 100% 0, 100% 0, 0 100%, 0 100%, 100% 100%, 100% 100%;
  background-size:
    var(--bl) var(--bt), var(--bt) var(--bl),
    var(--bl) var(--bt), var(--bt) var(--bl),
    var(--bl) var(--bt), var(--bt) var(--bl),
    var(--bl) var(--bt), var(--bt) var(--bl);
  transition: filter 220ms ease-out;
}

/* A wrong guess lands: the corner brackets brighten toward full accent
   and settle -- the permanent decoration reacting, not a border ring
   appearing and vanishing. Reduced-motion skips the class in JS. */
.portrait-stage.is-sharpening .portrait-stage-frame::after {
  filter: brightness(1.4) saturate(1.7);
}

.missed-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
  max-width: 58rem; /* matches .trivia-card, the only other thing on this page */
  margin: 0 auto var(--sp-6);
}

.missed-row[hidden] {
  display: none;
}

.missed-label {
  font: 600 0.6875rem/1.2 var(--font-ui);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.missed-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}

/* There are no per-column verdicts in this mode, so a wrong guess leaves
   only this behind: a struck name, the same treatment Wordle-style games
   give a used-up word. */
.missed-pill {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0.3125rem 0.625rem 0.3125rem 0.3125rem;
  border: var(--border);
  border-radius: var(--radius-pill);
  background: var(--bg-sunken);
}

.missed-pill img {
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  object-fit: cover;
}

.missed-name {
  font-family: var(--font-display);
  font-size: 0.9375rem;
  text-decoration: line-through;
  color: var(--muted);
}

/* Real DOM text at every width, an aria-hidden short form shown only
   below the phone breakpoint — same head-text/head-short pattern
   headCell() in board.js uses for a column title. */
.meta-short {
  display: none;
}

@media (max-width: 30rem) {
  .trivia-card {
    margin-bottom: var(--sp-3);
  }

  .trivia-card-head {
    padding: 0.5625rem var(--sp-3);
    flex-wrap: wrap;
  }

  /* The lang switch is what pushes this row over — clue-progress alone
     always fit beside the title. Wrapping it to its own full-width line
     keeps both legible instead of squeezing the switch into a sliver. */
  .trivia-card-head-meta {
    width: 100%;
    justify-content: space-between;
  }

  .trivia-card-title {
    font-size: 1.0625rem;
  }

  .clue-progress-seg {
    width: 1rem;
  }

  .rung {
    grid-template-columns: 1.25rem minmax(0, 1fr) auto;
    column-gap: var(--sp-2);
    padding: 0.5625rem var(--sp-3);
    min-height: 0;
  }

  .rung-icon-slot {
    width: 1.25rem;
    height: 1.25rem;
  }

  .rung-icon {
    width: 1.25rem;
    height: 1.25rem;
  }

  .rung-lock {
    right: -0.1875rem;
    bottom: -0.1875rem;
    width: 0.75rem;
    height: 0.75rem;
  }

  .rung-badge {
    font-size: 0.6875rem;
  }

  .rung-label {
    font-size: 0.5625rem;
  }

  .rung-value-face {
    gap: 0.25rem;
    font-size: 1rem;
  }

  .rung-redact {
    height: 0.375rem;
  }

  .meta-full {
    display: none;
  }

  .meta-short {
    display: inline;
  }
}

/* ---- 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%);
}

/* The footer's "send feedback" panel -- a third instance of the same
   <details>/scrim recipe as .how-to above and the account dialog below,
   reusing .sheet-panel/.sheet-title/.sheet-close/.account-form for its
   contents rather than a fourth set of those. */
.feedback-sheet {
  font-size: var(--step--1);
  color: var(--muted);
}

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

details.feedback-sheet[open] {
  position: fixed;
  inset: 0;
  z-index: 30;
}

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

.feedback-sheet .account-form {
  padding: var(--sp-4);
}

/* The game's own support-desk mark (site/data/sprites/menu/support.webp,
   common_menu_icon_support) beside the sheet's title -- same 1.75rem
   slot and .account-head-icon's own object-fit: contain, since this is
   the same "menu mark next to a heading" shape that dialog already
   uses, not a new one. Left glossy and shadowed as extracted, the same
   §7 carve-out every other menu mark on this site gets. */
.feedback-sheet-head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin: 0 0 var(--sp-3);
}

.feedback-sheet-head .sheet-title {
  margin: 0;
}

.feedback-sheet-icon {
  flex: none;
  width: 1.75rem;
  height: 1.75rem;
  object-fit: contain;
}

/* The form's own `gap` (.account-form) already spaces every child
   evenly; .sheet-close's base rule adds a *second* margin-top on top of
   that one gap, which only reads as a wider gap above Close than
   anywhere else in the form. Zeroed here so Send and Close sit the same
   distance apart as every other pair of fields above them. */
.feedback-sheet .sheet-close {
  margin-top: 0;
}

/* Never shown -- a real submitter never sees or reaches this field.
   Off-screen rather than `display: none`/`visibility: hidden`, which
   some bots already know to skip. */
.feedback-honeypot {
  position: absolute;
  left: -9999px;
  top: -9999px;
}

/* The roster browser (item 8 of v1.2, docs/planning/current/
   stats-lexicon-plan.md) -- a fourth instance of the same <details>/
   scrim recipe as .how-to and .feedback-sheet above, reusing
   .sheet-panel/.sheet-title/.sheet-close rather than a fifth set of
   those. Stats only -- daily.html is the only page that wires it.

   Two panes: a fixed rail of buckets on the left, the members of one
   bucket on the right. Below 40rem they stack into one column and
   [data-view] on the panel swaps which is shown. */
.lexicon {
  font-size: var(--step--1);
  color: var(--muted);
}

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

details.lexicon[open] {
  position: fixed;
  inset: 0;
  z-index: 30;
}

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

/* A wider, taller sheet than the how-to/feedback ones: the two panes
   need the width, and each scrolls its own region so the sheet itself
   is a fixed height rather than growing with its contents. Padding
   moves off the panel and onto the pieces inside it. Selector doubled
   up to clear the base `.sheet-panel`, defined later in this file. */
.sheet-panel.lexicon-panel {
  display: flex;
  max-width: 56rem;
  height: 85vh;
  max-height: 85vh;
  padding: 0;
  overflow: hidden;
}

.lexicon-grabber {
  display: none;
}

/* Left pane: heading, the axis control, then the scrolling bucket
   list -- the roster's whole navigation. */
.lexicon-rail {
  flex: none;
  width: 15rem;
  display: flex;
  flex-direction: column;
  min-height: 0;
  border-right: 1px solid var(--line-inset);
  background: var(--bg-sunken);
}

.lexicon-rail-head {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  padding: var(--sp-4) var(--sp-3) 0 var(--sp-4);
}

.lexicon-rail-head .sheet-title {
  margin: 0;
}

@media (min-width: 40.001rem) {
  body:not(.dock) .lexicon-members {
    grid-template-columns: repeat(5, minmax(0, 1fr));
  }
}

.lexicon-total {
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* The × in the rail head belongs to the phone layout, where the rail
   is the whole sheet. On the two-pane layout the main pane carries it. */
.lexicon-rail .lexicon-close {
  display: none;
}

.lexicon-axis {
  flex: none;
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-4);
}

.lexicon-axis-label {
  font: 700 0.625rem / 1.2 var(--font-ui);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lexicon-axis-button {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  width: 100%;
  min-height: 2rem;
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--line-inset);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
  color: var(--ink);
  font: 600 var(--step--1) / 1 var(--font-ui);
  cursor: pointer;
}

.lexicon-axis-caret {
  flex: none;
  width: 0.4rem;
  height: 0.4rem;
  border-right: 1.5px solid var(--muted);
  border-bottom: 1.5px solid var(--muted);
  transform: translateY(-2px) rotate(45deg);
}

/* A dropdown, so it may cast a shadow (docs/design.md §7's one
   exception), the same one the autocomplete list uses. */
.lexicon-axis-menu {
  position: absolute;
  left: var(--sp-4);
  right: var(--sp-4);
  top: calc(100% - var(--sp-2));
  z-index: 3;
  padding: var(--sp-1);
  border: var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-raised);
  box-shadow: var(--shadow-float);
}

.lexicon-axis-option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  width: 100%;
  min-height: 2rem;
  padding: var(--sp-1) var(--sp-2);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink);
  font: 500 var(--step--1) / 1.2 var(--font-ui);
  text-align: left;
  cursor: pointer;
}

.lexicon-axis-option:hover {
  background: var(--bg-sunken);
}

.lexicon-axis-option[aria-selected="true"] {
  background: var(--bg-sunken);
  font-weight: 700;
}

.lexicon-axis-option b {
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* The bucket list. One axis, one bucket at a time -- selecting a row is
   the only thing this does (js/lexicon.js's `activeBucket`); it never
   accumulates a set. */
.lexicon-groups {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0 var(--sp-2) var(--sp-4);
}

.lexicon-group {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  min-height: 2.4rem;
  padding: var(--sp-1) var(--sp-2);
  margin-bottom: 1px;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font: 500 var(--step--1) / 1.2 var(--font-ui);
  text-align: left;
  cursor: pointer;
}

.lexicon-group:hover {
  background: var(--bg-raised);
}

.lexicon-group[aria-selected="true"] {
  background: var(--bg-raised);
  color: var(--ink);
  font-weight: 700;
}

.lexicon-group > img {
  flex: none;
  width: 1.05rem;
  height: 1.05rem;
  object-fit: contain;
}

.lexicon-group-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lexicon-group-count {
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

/* Right pane: the header naming the bucket, then its members. */
.lexicon-main {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.lexicon-main-head {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-4) var(--sp-3);
  border-bottom: 1px solid var(--line-inset);
}

.lexicon-back {
  flex: none;
  display: none;
  place-items: center;
  width: 1.9rem;
  height: 1.9rem;
  min-height: 0;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  cursor: pointer;
}

.lexicon-back > span {
  width: 0.45rem;
  height: 0.45rem;
  border-left: 1.5px solid var(--ink);
  border-bottom: 1.5px solid var(--ink);
  transform: translateX(1px) rotate(45deg);
}

.lexicon-main-icon {
  flex: none;
  width: 1.6rem;
  height: 1.6rem;
  object-fit: contain;
}

.lexicon-main-title {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  color: var(--ink);
}

.lexicon-main-count {
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.lexicon-main-spacer {
  flex: 1;
}

/* Specificity has to clear the base `.sheet-close` (full-width bottom
   button) that also lands on this element. */
.lexicon-main-head .lexicon-close.sheet-close {
  flex: none;
  width: 1.9rem;
  height: 1.9rem;
  min-height: 0;
  margin: 0;
  padding: 0;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--muted);
  font-size: var(--step-1);
  line-height: 1;
  cursor: pointer;
}

.lexicon-main-head .lexicon-close.sheet-close:hover {
  background: var(--bg-sunken);
  color: var(--ink);
}

.lexicon-members-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--sp-5) var(--sp-4) var(--sp-4);
}

.lexicon-members {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--sp-5) var(--sp-4);
}

.lexicon-members.is-rising {
  animation: lexicon-rise 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
}

@keyframes lexicon-rise {
  from {
    opacity: 0;
    transform: translateY(0.375rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .lexicon-members.is-rising {
    animation: none;
  }
}

.lexicon-member {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
  text-align: center;
  min-height: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.lexicon-member:active {
  background: transparent;
}

.lexicon-member-portrait {
  position: relative;
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--radius-md);
  overflow: hidden;
  border: 1px solid var(--line-inset);
  background: var(--bg-sunken);
}

.lexicon-member-portrait > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Guessed characters dim and carry the board's "correct" green bar
   along the bottom edge -- a cross-reference against the board in
   progress, not a second filter (stats-lexicon-plan.md's "rule that
   keeps this from becoming a solver"). */
.lexicon-member.is-guessed {
  opacity: 0.45;
}

.lexicon-member.is-selected {
  opacity: 1;
}

.lexicon-member.is-selected .lexicon-member-portrait {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}

.lexicon-selected-mark {
  position: absolute;
  top: var(--sp-1);
  right: var(--sp-1);
  z-index: 1;
  display: none;
  width: 1.35rem;
  height: 1.35rem;
  border-radius: var(--radius-pill);
  background: var(--accent);
}

.lexicon-member.is-selected .lexicon-selected-mark {
  display: block;
}

.lexicon-selected-mark::after {
  content: "";
  position: absolute;
  left: 0.43rem;
  top: 0.25rem;
  width: 0.3rem;
  height: 0.58rem;
  border-right: 2px solid var(--accent-ink);
  border-bottom: 2px solid var(--accent-ink);
  transform: rotate(45deg);
}

.lexicon-member.is-guessed .lexicon-member-portrait::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--hit);
}

.lexicon-member-name {
  font-size: 0.8125rem;
  line-height: 1.25;
  min-height: 2.5em;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.lexicon-selection {
  flex: none;
  padding: var(--sp-3) var(--sp-4) var(--sp-4);
  border-top: 1px solid var(--line-inset);
  background: var(--bg-raised);
}

.lexicon-submit {
  width: 100%;
}

/* Below 40rem the sheet can't be two panes wide. It becomes one column
   with a grab handle, and [data-view] on the panel shows either the
   bucket list (index) or the grid (page). The grid also goes denser and
   serves the 64px thumb, since smaller tiles are the only lever left
   for less scrolling. */
@media (max-width: 40rem) {
  .sheet-panel.lexicon-panel {
    flex-direction: column;
    height: 85vh;
    max-height: 85vh;
  }

  .lexicon-grabber {
    display: block;
    flex: none;
    width: 2.25rem;
    height: 4px;
    margin: var(--sp-3) auto 0;
    border-radius: var(--radius-pill);
    background: var(--line-inset);
  }

  /* In the one-column layout the rail is a flex item in the panel's
     column, not the stretched-to-height side pane it is on desktop.
     Without `flex: 1` it keeps the base rule's `flex: none` and sizes
     to its content, so a bucket list taller than the sheet (Guild, 27
     rows) overflows the panel's `overflow: hidden` and can't be
     scrolled -- `.lexicon-groups` never overflows its own box because
     nothing bounds the rail's height. */
  .lexicon-rail {
    flex: 1;
    width: auto;
    min-height: 0;
    border-right: 0;
    background: var(--bg-raised);
  }

  .lexicon-rail-head {
    padding: var(--sp-3) var(--sp-4) 0;
  }

  .lexicon-rail .lexicon-close {
    display: grid;
    place-items: center;
    width: 1.9rem;
    height: 1.9rem;
    min-height: 0;
    margin: 0 0 0 auto;
    padding: 0;
    border: 0;
    border-radius: var(--radius-pill);
    background: transparent;
    color: var(--muted);
    font-size: var(--step-1);
    line-height: 1;
    cursor: pointer;
  }

  .lexicon-groups {
    padding-bottom: var(--sp-5);
  }

  /* On a phone the rail is the whole first screen: rows are a plain
     list that drills into the grid, so they carry a chevron and no
     selected state (there is no second pane showing a selection). */
  .lexicon-group {
    min-height: 3rem;
    padding: var(--sp-2) var(--sp-2);
    margin-bottom: 0;
    border-radius: 0;
    border-bottom: 1px solid var(--line-inset);
    color: var(--ink);
    font-size: var(--step--1);
  }

  .lexicon-group[aria-selected="true"] {
    background: transparent;
    font-weight: 500;
  }

  .lexicon-group:active {
    background: var(--bg-sunken);
  }

  .lexicon-group::after {
    content: "";
    flex: none;
    width: 0.4rem;
    height: 0.4rem;
    margin-left: var(--sp-1);
    border-top: 1.5px solid var(--muted);
    border-right: 1.5px solid var(--muted);
    transform: rotate(45deg);
  }

  .lexicon-panel[data-view="index"] .lexicon-main {
    display: none;
  }

  .lexicon-panel[data-view="page"] .lexicon-rail {
    display: none;
  }

  .lexicon-panel[data-view="page"] .lexicon-back {
    display: grid;
  }

  .lexicon-members-scroll {
    padding: var(--sp-4);
  }

  .lexicon-members {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--sp-4) var(--sp-3);
  }

  .lexicon-member-name {
    font-size: 0.75rem;
  }
}

/* The account dialog, shared by all seven game/picker pages -- a compact
   panel docked under the header menu (design pass 2026-08-19, against
   Claude Design's "Account Modal" mockup, variants 1b/2a), replacing
   the centred decision-to-make dialog this used to be. The zero-width
   `.account-control` keeps the sheet at the menu edge while its trigger
   lives inside the adjacent menu panel; below 30rem the measured trigger
   position is published by account.js instead. */
.account-sheet summary {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.account-control {
  position: relative;
  display: inline-flex;
}

/* The account trigger now lives in the overflow menu. Keep the sheet's
   zero-width anchor at the action row's right edge, where its desktop
   panel continues to align with the menu that opened it. */
.site-bar-actions > .account-control,
.site-header-actions > .account-control {
  order: 1;
  width: 0;
  margin-left: calc(-1 * var(--sp-2));
}

/* A lighter scrim than the sheets that block the whole page (how-to,
   the migration overlay) -- this is a dropdown off one control, not a
   decision the rest of the page needs to step aside for, so 30% reads
   as "something is open" without dimming the board underneath it into
   illegibility. */
details.account-sheet[open]::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 29;
  background: oklch(20% 0.02 55 / 30%);
}

.account-dialog {
  position: absolute;
  top: calc(100% + var(--sp-2));
  right: 0;
  z-index: 30;
  width: 22rem;
  max-width: calc(100vw - 2 * var(--pad-x));
  max-height: 85vh;
  overflow-y: auto;
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-float);
}

/* The caret pointing back at the menu trigger -- roughly centred over
   it rather than measured exactly, since the button's own position in
   the row isn't this rule's to know. A diamond clipped to its own
   top-left corner, the way a browser's own dropdown callouts draw one.
   `::after`, not `::before` a second time -- `<details>` already has
   the scrim on that pseudo-element above, and `.account-dialog::before`
   would get clipped by the dialog's own `overflow-y: auto` (for a
   signup form tall enough to need it) the moment it pokes above the
   box. `<details>` itself has no overflow and isn't positioned, so this
   still resolves against `.account-control` exactly like the dialog's
   own `top`/`right` do, just restated 5px short of the dialog's own top
   edge so the diamond's bottom half tucks under it. */
details.account-sheet[open]::after {
  content: "";
  position: absolute;
  top: calc(100% + var(--sp-2) - 0.3125rem);
  right: 1rem;
  width: 0.625rem;
  height: 0.625rem;
  z-index: 30;
  background: var(--bg-sunken);
  border-left: 1px solid var(--ink);
  border-top: 1px solid var(--ink);
  transform: rotate(45deg);
}

/* Below the header bar, edge to edge, rather than docked to the button:
   a caret pointing at one 44px icon among several reads fine on a
   desktop-width row, but on a phone the button *is* the corner of the
   screen, and a 22rem dropdown anchored to it would run off the left
   edge. `position: fixed` rather than `absolute` off `.account-control`
   is what lets `left`/`right` measure from the viewport instead of that
   small anchor. `--account-panel-top` is published fresh on every open
   by js/account.js's own click handler, the header's real measured
   bottom edge -- `.site-bar` and `.site-header` aren't the same height,
   and only one of them exists on a given page, so a flat guess would be
   off on whichever one it wasn't tuned against. The calc is only the
   fallback for the (never expected) case of no `<header>` at all. */
@media (max-width: 30rem) {
  .account-dialog {
    position: fixed;
    top: var(--account-panel-top, calc(var(--pad-top) + 3.5rem + var(--sp-2)));
    right: var(--pad-x);
    left: var(--pad-x);
    width: auto;
    max-width: none;
  }

  /* Still points at the menu row that opened it rather than disappearing -- fixed
     to the viewport, straddling the panel's own top edge the same way
     the desktop caret straddles the dialog's, but positioned by
     `--account-caret-x` (js/account.js, published alongside
     `--account-panel-top`) instead of resolving against
     `.account-control`, which the panel no longer anchors to here. */
  details.account-sheet[open]::after {
    position: fixed;
    top: calc(var(--account-panel-top, calc(var(--pad-top) + 3.5rem + var(--sp-2))) - 0.3125rem);
    left: var(--account-caret-x, 50%);
    right: auto;
    transform: translateX(-50%) rotate(45deg);
  }
}

.account-head {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  background: var(--bg-sunken);
  border-bottom: 1px solid var(--line-inset);
  border-radius: var(--radius-md) var(--radius-md) 0 0;
}

/* Same reason `.account-form[hidden]` exists: the explicit `display`
   above has equal specificity to the `[hidden]` UA rule and wins on
   source order alone without this -- js/account.js's renderAccountState()
   hides the signed-out head while `#account-signed-in` shows its own. */
.account-head[hidden] {
  display: none;
}

.account-head-icon,
.account-back {
  flex: none;
}

.account-head-icon {
  width: 1.75rem;
  height: 1.75rem;
  object-fit: contain;
}

/* Swaps in for the profile icon while the signup form is showing --
   the one piece of navigation this panel has instead of the old
   Log in/Sign up tabs. `.wireForms()`'s switchForm() toggles which of
   the two is `hidden`. */
.account-back {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--muted);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
}

.account-back:hover {
  color: var(--ink);
}

/* Same reason `.account-form[hidden]` exists above: the explicit
   `display: inline-flex` two rules up has equal specificity to the
   `[hidden]` UA rule and wins on source order alone without this. */
.account-back[hidden] {
  display: none;
}

.account-head-title {
  flex: 1;
  min-width: 0;
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  line-height: 1.1;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.account-head .account-close {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  min-height: 0;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
}

.account-close:hover {
  color: var(--ink);
}

/* The Log in / Sign up switch -- a sliding knob under whichever label is
   current, the same pill radius the site already spends on
   `.missed-pill`. Driven by `.is-signup` on the track (js/account.js's
   switchForm()) rather than each button painting its own fill, so only
   one thing ever needs repainting. */
.auth-tabs {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  padding: 3px;
  margin-bottom: var(--sp-3);
  background: var(--bg-sunken);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}

.auth-tabs-knob {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-pill);
  transition: transform var(--dur-base) var(--ease-out);
}

.auth-tabs.is-signup .auth-tabs-knob {
  transform: translateX(100%);
}

.auth-tab {
  position: relative;
  z-index: 1;
  min-height: 2.75rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--muted);
  font: 700 0.75rem/1 var(--font-ui);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
}

.auth-tab[aria-selected="true"] {
  color: var(--ink);
}

/* Errors are colour, weight, the alert glyph and a shake together --
   the same `--icon-alert` mark `.hint[data-state="error"]` shows,
   restated here as a bordered block rather than a bare line: a
   form-level failure ("that username is taken") needs to read as its
   own thing above two fields that are themselves fine. */
.account-form-error {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin: 0 0 var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--error);
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--error);
}

.account-form-error::before {
  content: "";
  flex: none;
  width: 1.125rem;
  height: 1.125rem;
  background: var(--icon-alert) no-repeat center / contain;
}

.account-form-error[hidden] {
  display: none;
}

.account-form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

/* Without this, the class rule above (display: flex, same 0-1-0
   specificity as the `[hidden]` UA rule but declared later) wins the
   cascade and a hidden form renders anyway — caught visually, not by
   any functional check, since both forms being present just looked
   like an unusually long single form rather than a crash. */
.account-form[hidden] {
  display: none;
}

/* The dialog carries no padding of its own (`.account-head`,
   `.account-switch-row` and the signed-in rows below each set their
   own inset instead), so the forms set theirs here -- scoped rather
   than added to the shared `.account-form` rule above, since
   account.html's own copy of these forms sits in the page's reading
   column and needs none of it. */
.account-dialog .account-form {
  padding: var(--sp-4);
}

.account-dialog .account-form-error {
  margin: var(--sp-4) var(--sp-4) 0;
}

.account-field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

/* `display: flex` above outranks the browser's own `[hidden]` rule, so
   an account with no password to state kept showing the field asking
   for it. */
.account-field[hidden] {
  display: none;
}

.account-field label {
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--ink);
}

/* The asterisk is the non-colour channel -- required-ness never rests
   on --error alone, the same "colour is never the only signal" rule
   every other verdict on this site follows. */
.required-mark {
  color: var(--error);
}

.account-field-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
}

.field-optional {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.account-field input,
.account-field textarea,
.account-field select {
  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-0) / 1.2 var(--font-ui);
}

.account-field textarea {
  resize: vertical;
}

.feedback-browser-data {
  align-items: flex-start;
}

.feedback-browser-data small {
  display: block;
  margin-top: var(--sp-1);
  color: var(--muted);
  line-height: 1.4;
}

/* Sunk a shade below the dialog's own `--bg-raised` panel, rather than
   matching it flat -- account.html's page background is a different
   context, so this is scoped to the docked sheet rather than changed
   on the shared rule above. */
.account-dialog .account-field input {
  background: var(--bg-sunken);
}

/* A wrong username/password marks the password field itself, not just
   the banner above the form -- js/account.js's setHint() sets this on
   the first password input in the form that just failed, and clears it
   on the next keystroke. */
.account-field input[aria-invalid="true"] {
  border-color: var(--error);
}

/* Between the login form's password field and its submit button, and
   the login form's own footer link -- the docked sheet's version of
   `.account-checkbox-field` + `.account-forgot`, side by side instead
   of stacked, since the panel has the width for it as one row. */
.account-field-row-inline {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}

.account-forgot-inline {
  font-size: var(--step--1);
  color: var(--muted);
}

.account-forgot-inline:hover {
  color: var(--ink);
}

/* "No account yet? Sign up →" -- the docked sheet's only way from Log
   in to Sign up now that there's no tab pair; the reverse trip is the
   header's own back arrow (`.account-back`). Hidden on the signup form
   itself, the way the old tabs never needed to be. */
/* The second way in, under the form: a rule with "or" set into it, then
   a quiet full-width link. Quiet rather than filled because the form's
   own submit is this screen's one accent, and no Discord blurple —
   design.md §7 allows exactly one accent and this is somebody else's. */
.account-alt {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: 0 var(--sp-4) var(--sp-4);
}

.account-alt[hidden] {
  display: none;
}

.account-alt-label {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  font-size: var(--step--1);
  color: var(--muted);
}

.account-alt-label::before,
.account-alt-label::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--line-inset);
}

.account-alt-link {
  display: block;
  text-align: center;
  text-decoration: none;
}

/* account.html's own copy sits in the page's reading column, which
   supplies its own inset -- the same reason .account-form's padding is
   scoped to the dialog. */
.account-page .account-alt {
  padding: 0;
  margin-top: var(--sp-4);
}

.account-switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  background: var(--bg-sunken);
  border-top: 1px solid var(--line-inset);
  font-size: var(--step--1);
  color: var(--muted);
}

.account-switch-row[hidden] {
  display: none;
}

.account-switch-link {
  padding: 0;
  border: 0;
  background: transparent;
  font: 700 var(--step--1) / 1 var(--font-ui);
  color: var(--accent);
  cursor: pointer;
}

.account-switch-link:hover {
  text-decoration: underline;
}

/* A length hint tied to the signup field's own 12-character minimum
   (js/account.js's wirePasswordMeter()) -- muted until the requirement
   is met, then the board's own --hit green, the same "this is now
   correct" language every guess uses. Never --accent: the submit
   button below is already this screen's one accent fill. */
.password-meter {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-top: 1px;
}

.password-meter[hidden] {
  display: none;
}

.password-meter-track {
  flex: 1;
  display: flex;
  height: 4px;
  border-radius: var(--radius-pill);
  background: var(--line-inset);
  overflow: hidden;
}

.password-meter-fill {
  height: 100%;
  border-radius: var(--radius-pill);
  background: var(--muted);
  transition: width var(--dur-base) ease;
}

.password-meter-fill.is-met {
  background: var(--hit);
}

.password-meter-label {
  flex: none;
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.account-submit {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  width: 100%;
  margin-top: var(--sp-1);
}

.button-spinner {
  display: none;
  flex: none;
  width: 1rem;
  height: 1rem;
  border: 2px solid var(--accent-ink);
  border-top-color: transparent;
  border-radius: 50%;
  opacity: 0.7;
}

.account-submit.is-busy .button-spinner {
  display: inline-block;
  animation: spinner-turn 700ms linear infinite;
}

/* The spinner is drawn in the colour of the text it sits beside, and on
   a quiet button that isn't `--accent-ink` -- account.html's own forms
   take the quiet treatment (see `.account-page` below: none of its
   actions is the one accent fill design.md §7 allows per screen), which
   left an invisible spinner turning on a pale surface. */
.button-quiet .button-spinner {
  border-color: var(--ink);
  border-top-color: transparent;
}

@keyframes spinner-turn {
  to {
    transform: rotate(360deg);
  }
}

/* The settling beat between a successful submit and the dialog closing
   (js/account.js's showSuccess()) -- see that function's own comment on
   why this exists instead of an instant cut. The bar's width is set
   inline, one of the only places in this file besides js/board.js's
   spread bars that a proportion isn't something the stylesheet can
   know. */
.account-success {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-6) var(--sp-4) var(--sp-4);
  text-align: center;
}

.account-success[hidden] {
  display: none;
}

.account-success-check {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: var(--hit);
  color: var(--hit-ink);
}

.account-success-check svg {
  width: 1.5rem;
  height: 1.5rem;
}

.account-success-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  line-height: 1.1;
  color: var(--ink);
}

.account-success-note {
  margin: 0;
  max-width: 30ch;
  font-size: var(--step--1);
  color: var(--muted);
}

.account-success-bar {
  display: flex;
  width: 7rem;
  height: 3px;
  margin-top: var(--sp-1);
  border-radius: var(--radius-pill);
  background: var(--line-inset);
  overflow: hidden;
}

.account-success-fill {
  width: 0%;
  height: 100%;
  background: var(--accent);
  transition: width 900ms linear;
}

/* Reopening the dialog while already signed in: the avatar swaps into
   the same `.account-head` band the signed-out states use, so the
   panel keeps one head shape across every state instead of a fourth
   one just for this. The avatar is the one place on the page an
   initial stands in for a portrait -- there is no account picture --
   and its accent fill never coincides with the submit button's, which
   only shows while signed out. */
.account-head-signed-in .account-avatar-text {
  flex: 1;
  min-width: 0;
}

.account-session-label {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

.account-avatar {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-sm);
  background: var(--accent);
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 700;
}

.account-avatar-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.account-username {
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  color: var(--ink);
}

/* "Manage account" and "Log out" -- a plain list, not two boxed
   buttons: neither is this screen's one accent fill (design.md §7), so
   there's nothing for a button shape to earn here that a row with a
   hover background doesn't already say. Closing happens via the head's
   × , the scrim, or Escape now, so there's no "Done" button either. */
.account-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  width: 100%;
  min-height: 2.75rem;
  padding: var(--sp-3) var(--sp-4);
  border: 0;
  border-top: 1px solid var(--line-inset);
  background: transparent;
  font: 600 var(--step-0) / 1.2 var(--font-ui);
  color: var(--ink);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.account-row:hover {
  background: var(--bg-sunken);
}

.account-row-arrow {
  color: var(--muted);
}

/* ---- account.html and reset.html -----------------------------------
   Neither is a mode, so neither appears in the header's Daily/Trivia/
   Endless group: they carry the bar in the shape the phone layout
   already uses -- a Home button where the tabs would be. This is the
   same three-track grid the 30rem block sets, hoisted out of that block
   by a class so it applies at every width on these two pages only; the
   `.site-bar-home` rule below outranks the base `display: none` by
   specificity rather than by order, so it holds above 30rem too. */
.site-bar-plain {
  grid-template-columns: auto minmax(0, 1fr) auto;
}

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

/* A reading column, not the board's 63rem -- nothing here is a grid of
   eight tracks, and the forms below would be absurd at full width.

   `width: 100%` is load-bearing, not belt-and-braces: `body` is a flex
   column and `main`'s own `margin: 0 auto` is an auto *cross-axis*
   margin, which in flexbox cancels the default stretch and sizes the
   item to its content instead. Every game page hides that (a board is
   as wide as the page anyway); here it made a short panel -- the "you're
   back in" one on reset.html -- shrink to the width of its longest
   sentence. */
.account-page {
  width: 100%;
  max-width: 34rem;
  padding-top: var(--sp-6);
}

/* account.html only (reset.html keeps the plain reading column above):
   wide enough for the signed-in sidebar layout below. The signed-out
   login form and the success beat opt back into the narrow measure
   themselves, since neither has a second column to spend the width on. */
.account-page-account {
  max-width: 58rem;
}

#account-signed-out,
#account-success {
  max-width: 34rem;
  margin-inline: auto;
}

/* Rules and whitespace, not boxes (design.md §7: "the result dossier is
   the only filled, bordered container"). Each block is separated from
   the one above by a hairline, and the only filled surfaces on the page
   are the ones that already exist elsewhere: the avatar row, the four
   `.stats` cells, and the inputs. */
/* `:not([hidden])` on the *preceding* panel: reset.html's three panels
   are alternatives rather than a stack, so without it the one on screen
   drew a separator against a sibling that isn't there. */
.account-panel:not([hidden]) + .account-panel {
  margin-top: var(--sp-6);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--line);
}

/* Identity + jump nav beside the record, rather than above it -- who you
   are and the one irreversible action stay on screen while "Your
   record" through "Sessions" scroll past underneath. Stacks below
   40rem, where there's no room to spend on a second column. */
/* Account page only, where the sidebar's jump links are the site's only
   in-page anchors. Below 40rem that nav is a sticky strip directly above
   the section it moves to, and an instant jump there reads as the page
   having been swapped rather than travelled through. Not site-wide: a
   game page's own scroll corrections (js/viewport.js's keyboard lock)
   have to snap. The class sits on <html> because `scroll-behavior`
   reaches the viewport only from the root element -- and it is not
   `.account-page`, which is the reading column on <main> below. */
.smooth-anchors {
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  .smooth-anchors {
    scroll-behavior: auto;
  }
}

.account-layout {
  display: grid;
  grid-template-columns: 15rem minmax(0, 1fr);
  gap: var(--sp-6);
  align-items: start;
}

@media (max-width: 40rem) {
  .account-layout {
    grid-template-columns: 1fr;
  }
}

/* Sticks to the viewport while the (much taller) main column scrolls
   past -- `align-items: start` on `.account-layout` is load-bearing
   here, not just visual: without it the sidebar would stretch to fill
   the grid row, leaving `position: sticky` no room to move within. On
   the stacked mobile layout the sidebar's own row is only as tall as
   the sidebar itself, so sticking is naturally a no-op there -- nothing
   extra needed to turn it off. */
.account-sidebar {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
  position: sticky;
  top: var(--sp-6);
}

.account-identity-card {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-3);
  background: var(--bg-sunken);
  border: 1px solid var(--line-inset);
  border-radius: var(--radius-sm);
}

.account-identity-head {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

.account-identity-rule {
  height: 1px;
  background: var(--line-inset);
}

.account-identity-email {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
}

.account-identity-email-value {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--step--1);
  color: var(--muted);
}

/* A plain jump list, not a router -- `.is-current` only ever tracks the
   last link clicked (js/account-page.js's wireSidebarNav()), since
   nothing here watches scroll position. */
.account-nav {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.account-nav-link {
  display: flex;
  align-items: center;
  min-height: 2.5rem;
  padding: 0 var(--sp-3);
  border-left: 2px solid transparent;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  color: var(--muted);
  font-size: var(--step--1);
  font-weight: 600;
  text-decoration: none;
}

.account-nav-link:hover {
  color: var(--ink);
}

.account-nav-link.is-current {
  border-left-color: var(--accent);
  background: var(--bg-raised);
  color: var(--ink);
  font-weight: 700;
}

.account-sidebar-foot {
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
}

.account-sidebar-foot .account-danger-button {
  width: 100%;
}

.account-main {
  min-width: 0;
}

/* The desktop sidebar's trick above -- pin it, let the taller column
   scroll past -- has no room to work here: a stacked sidebar's own grid
   row is only as tall as the sidebar, so its `position: sticky` is
   already a no-op below this width. Mobile gets its own arrangement
   instead of just losing the pin: the identity card stays first, the
   jump nav becomes a sticky tab strip so a section is always one tap
   away without scrolling back up, and "Delete account" -- which sat
   directly under the nav, making it the fourth thing on screen above a
   player's own stats -- drops to the end of the page. `display:
   contents` turns `.account-sidebar` into a pass-through so its three
   children can be placed independently by the grid areas below rather
   than travelling together as one block. This has to come after the
   base rules above: a later same-specificity rule beats an earlier one
   regardless of which is behind a media query, so declaring this block
   before them would have had no effect on their properties. */
@media (max-width: 40rem) {
  .account-layout {
    grid-template-areas:
      "identity"
      "nav"
      "main"
      "foot";
  }

  .account-sidebar {
    display: contents;
  }

  .account-identity-card {
    grid-area: identity;
  }

  .account-main {
    grid-area: main;
  }

  .account-sidebar-foot {
    grid-area: foot;
  }

  .account-nav {
    grid-area: nav;
    position: sticky;
    top: 0;
    z-index: 20;
    flex-direction: row;
    gap: 0;
    background: var(--bg-raised);
    border-bottom: 1px solid var(--line);
  }

  .account-nav-link {
    flex: 1 1 0;
    justify-content: center;
    min-height: 2.75rem;
    padding: 0 var(--sp-1);
    border-left: none;
    border-radius: 0;
    text-align: center;
    font-size: 0.72rem;
  }

  .account-nav-link.is-current {
    background: none;
    box-shadow: inset 0 -3px 0 var(--accent);
  }

  /* Without this, jumping to a section scrolls its heading to the very
     top of the viewport, which is exactly where the sticky nav sits --
     the heading lands underneath it rather than below it. */
  .account-panel {
    scroll-margin-top: 3.25rem;
  }
}

.account-record-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-bottom: var(--sp-3);
}

.account-record-head > :first-child {
  margin: 0;
}

.account-record-summary {
  flex: none;
  font-size: var(--step--1);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The email/password forms' input-and-action row -- a field beside the
   button that acts on it, instead of the button trailing underneath.
   `align-items: start` keeps the button level with the input rather than
   stretched down to match the field's own note line beneath it. */
.account-field-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--sp-3);
}

.account-field-row .account-submit {
  width: auto;
  margin-top: 0;
}

.account-inline-action {
  margin-top: var(--sp-2);
}

.account-field-row-note {
  align-items: center;
}

.account-field-row-note .account-field-note {
  margin: 0;
}

.account-actions-row-tight > * {
  flex: none;
}

@media (max-width: 30rem) {
  .account-field-row {
    grid-template-columns: 1fr;
  }

  .account-field-row .account-submit {
    width: 100%;
  }

  .account-field-row-note {
    align-items: stretch;
  }

  .account-actions-row-tight > * {
    flex: 1 1 auto;
  }
}

.account-field-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-3) var(--sp-4);
}

@media (max-width: 30rem) {
  .account-field-pair {
    grid-template-columns: 1fr;
  }
}

/* The guess/clue distribution is the tall part of a mode's record;
   collapsed until asked for, so three modes fit without a wall of
   charts under a page whose whole point is a glance at the numbers. */
.account-mode-toggle {
  justify-self: end;
  min-height: 2.125rem;
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--muted);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
  color: var(--ink);
  font: 600 0.78125rem/1.2 var(--font-ui);
  cursor: pointer;
}

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

.account-mode-detail[hidden] {
  display: none;
}

.account-page-title {
  margin: 0 0 var(--sp-2);
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  line-height: 1.1;
}

.account-section-title {
  margin: 0 0 var(--sp-3);
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
}

.account-intro,
.account-field-note {
  margin: 0 0 var(--sp-3);
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--muted);
}

/* Directly under the field it explains, so it reads as part of it. */
.account-field-note {
  margin: var(--sp-1) 0 0;
}

.account-field-note a,
.account-forgot a {
  color: var(--ink);
}

/* Under the login form's submit button, on all five surfaces that carry
   that form. A plain link, deliberately: reset.html with no token *is*
   the request form, so this is one anchor rather than a second form
   embedded in four pages with no templating to share it. */
.account-forgot {
  margin: 0;
  font-size: var(--step--1);
  text-align: center;
}

/* Between the login form's password field and its submit button, on the
   same five surfaces `.account-forgot` is. A native checkbox, not a
   drawn one -- `accent-color` alone gets it onto the parchment palette
   without a custom SVG or the shadow/gradient a styled box usually
   needs. */
.account-checkbox-field {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--step--1);
  color: var(--muted);
}

.account-checkbox-field input {
  width: 1rem;
  height: 1rem;
  accent-color: var(--accent);
}

/* The commitment docs/planning/done/accounts-plan.md made and never shipped: what is held,
   by whom, and that none of it goes anywhere. Quiet by design -- it is a
   promise, not a notice to click through. */
.account-privacy {
  margin: var(--sp-3) 0 0;
  font-size: 0.71875rem;
  line-height: 1.5;
  color: var(--muted);
  text-align: center;
}

.account-member-since {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Whether the address on file has been confirmed, said in words beside
   the label rather than as a coloured dot -- the same "never colour
   alone" rule the board's verdicts follow. "Unconfirmed" is muted, not
   red: it is a state to fix at leisure, not an error (see
   docs/planning/done/account-finalization-plan.md §4 on why verification gates nothing). */
.account-email-state {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.account-email-state[data-state="verified"] {
  color: var(--ok-text);
}

.account-block {
  margin-top: var(--sp-4);
}

.account-actions-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-1);
}

.account-actions-row > * {
  flex: 1 1 12rem;
  justify-content: center;
  text-align: center;
}

/* Every form on this page answers in the same place and the same voice.
   Reserves its line so a panel doesn't jump when an answer lands. */
.account-status {
  margin: var(--sp-2) 0 0;
  min-height: 1.2em;
  font-size: var(--step--1);
  color: var(--ok-text);
}

.account-status.is-error {
  color: var(--error);
  font-weight: 600;
}

/* The same alert mark the form errors carry. `inline-block` rather than
   a flex child: `.account-status` is a plain <p> whose alignment varies
   by where it sits (left in the account dialog, centred in the feedback
   sheet), and an inline mark rides along with the text either way. */
.account-status.is-error::before {
  content: "";
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-right: 0.35em;
  vertical-align: -0.15em;
  background: var(--icon-alert) no-repeat center / contain;
}

/* Only ever shown when arriving from a confirmation link. */
.account-notice {
  margin: 0 0 var(--sp-4);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--line);
  border-left: 3px solid var(--ok-text);
  border-radius: var(--radius-sm);
  background: var(--bg-sunken);
  font-size: var(--step--1);
}

.account-notice.is-error {
  border-left-color: var(--error);
}

.account-notice[hidden] {
  display: none;
}

/* One row per mode, all three sharing a single bordered card rather than
   three separate ones -- a 1px gap over a `--line-inset` background is
   what draws the hairline between rows, the same trick a bordered table
   would use, without an actual `<table>`. Each row itself is the four
   numbers a result card's `.stats` shows plus a name and an icon, but
   bare -- no per-stat icon, no cell backgrounds -- because a whole row
   of three is already the "card" `.stats` is elsewhere the only one of. */
.account-mode-list {
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: var(--line-inset);
  border: 1px solid var(--line-inset);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.account-mode {
  background: var(--bg-raised);
}

.account-mode-row {
  display: grid;
  grid-template-columns: auto minmax(7rem, 1fr) repeat(4, minmax(0, 4.5rem)) auto;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
}

/* A mode with nothing played yet has no numbers and no toggle -- just
   the icon, the name, and the one thing worth saying. */
.account-mode-row-empty {
  grid-template-columns: auto minmax(7rem, 1fr) auto;
}

.account-mode-icon {
  flex: none;
  height: 1.625rem;
  width: auto;
  max-width: 2.125rem;
  object-fit: contain;
}

.account-mode-row-empty .account-mode-icon {
  opacity: 0.55;
}

/* An endless run's row: the tower with the base mode's own mark on its
   corner -- smaller, the qualifier not the subject -- the same pairing
   the header's Endless nav and the import dialog use, here as a corner
   badge rather than side by side. */
.account-mode-icon-endless {
  position: relative;
  display: inline-flex;
  max-width: none;
}

.account-mode-icon-endless > img:first-child {
  height: 1.625rem;
  width: auto;
  object-fit: contain;
}

.account-mode-subicon {
  position: absolute;
  right: -0.3rem;
  bottom: -0.3rem;
  width: 0.9375rem;
  height: 0.9375rem;
  object-fit: contain;
  background: var(--bg-raised);
  border-radius: 999px;
  padding: 1px;
}

.account-mode-name {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  color: var(--ink);
}

.account-mode-row-empty .account-mode-name {
  color: var(--muted);
}

.account-mode-empty {
  font-size: var(--step--1);
  color: var(--muted);
  text-align: right;
}

/* `display: contents` on desktop -- its four `.account-mode-stat`
   children become direct items of `.account-mode-row`'s own grid, one
   per `repeat(4, minmax(0, 4.5rem))` track, with no wrapper box of their
   own to draw. The 40rem breakpoint below turns this back into a real
   grid, once the row itself has already stacked. */
.account-mode-stats {
  display: contents;
}

/* Icon over number over label, not icon beside them -- a column this
   narrow (`minmax(0, 4.5rem)`, the mockup's own width) has no width to
   spare beside the icon without "Win rate"/"Avg clues" wrapping, but
   plenty of height to stack into. The full cell width stays available
   to the label this way, at every size -- there's no separate mobile
   override below because this already is the mobile treatment. */
.account-mode-stat {
  display: grid;
  grid-template-areas:
    "icon"
    "value"
    "label";
  justify-items: center;
  gap: 1px;
  text-align: center;
}

.account-mode-stat-icon {
  grid-area: icon;
  height: 1.375rem;
  width: auto;
  max-width: 1.625rem;
  object-fit: contain;
  margin-bottom: 2px;
}

.account-mode-stat-value {
  grid-area: value;
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.account-mode-stat-label {
  grid-area: label;
  margin-top: 3px;
  font-size: 0.71875rem;
  color: var(--muted);
}

.account-mode-detail {
  padding: 0 var(--sp-4) var(--sp-4);
}

@media (max-width: 40rem) {
  .account-mode-row {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas:
      "icon name toggle"
      "stats stats stats";
    row-gap: var(--sp-3);
  }

  .account-mode-row-empty {
    grid-template-areas: "icon name empty";
  }

  .account-mode-icon {
    grid-area: icon;
  }

  .account-mode-name {
    grid-area: name;
  }

  .account-mode-empty {
    grid-area: empty;
  }

  .account-mode-toggle {
    grid-area: toggle;
  }

  .account-mode-stats {
    grid-area: stats;
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: var(--sp-2);
  }
}

/* Deleting is the one irreversible thing here, so it carries the
   palette's real red -- the same `--error` the form errors use, as an
   outline and a word rather than a filled button, since §7's one accent
   fill on this page belongs to the submit buttons. */
.account-danger-button {
  border-color: var(--error);
  color: var(--error);
}

.account-danger-button:hover {
  background: var(--bg-sunken);
  color: var(--error);
}

/* Inside the confirmation overlay only, where the destructive action is
   the primary one and there is no accent fill on screen to compete with
   it -- `--error-ink` exists for exactly this surface. */
.account-danger-fill,
.account-danger-fill:active {
  background: var(--error);
  border-color: var(--error);
  color: var(--error-ink);
}

/* The delete confirmation (js/account-page.js) -- the same centred
   overlay recipe as the migration prompt above, reused rather than
   reinvented, which is the bar design.md §7's scrim note actually sets.
   Grouped selectors instead of a copy so the two can never drift. */
.confirm-overlay {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4);
  background: oklch(20% 0.02 55 / 55%);
}

.confirm-card {
  width: 100%;
  max-width: 26rem;
  max-height: 85vh;
  overflow-y: auto;
  padding: var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  color: var(--muted);
  font-size: var(--step--1);
}

.confirm-card p {
  margin: 0 0 var(--sp-3);
  line-height: 1.5;
}

.confirm-lede {
  color: var(--ink);
}

.confirm-gate {
  padding-top: var(--sp-3);
  border-top: 1px solid var(--line-inset);
}

/* Disabled until the typed word matches -- a plain grey rather than a
   dimmed red, so the button doesn't half-read as "delete, but weaker." */
.account-danger-fill:disabled {
  background: var(--line);
  border-color: var(--line);
  color: var(--muted);
  cursor: not-allowed;
}

.confirm-actions {
  display: flex;
  gap: var(--sp-2);
  margin-top: var(--sp-4);
}

.confirm-actions > * {
  flex: 1;
}

.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);
}

/* Phase 3's one-time import prompt (js/migration.js) -- a JS-constructed
   overlay rather than a <details> sheet like the ones above, since it
   has no permanent trigger to open it. Centred rather than docked to a
   screen edge: unlike the how-to/account sheets, this one
   only ever appears once, right after a deliberate login/signup action,
   so it reads as a decision to make rather than a panel to browse. Same
   scrim colour as every other overlay on the page (`details.how-to[open]
   ::before` etc.) for one consistent "something is blocking the page"
   language, not a new one for a feature that happens to be newer. */
.migration-overlay {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4);
  background: oklch(20% 0.02 55 / 55%);
}

.migration-card {
  width: 100%;
  max-width: 26rem;
  max-height: 85vh;
  overflow-y: auto;
  padding: var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  color: var(--muted);
  font-size: var(--step--1);
}

.migration-eyebrow {
  display: block;
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
}

.migration-intro {
  margin: var(--sp-1) 0 var(--sp-3);
}

.migration-list {
  margin: 0 0 var(--sp-3);
}

.migration-row {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) 0;
  border-top: 1px solid var(--line-inset);
}

.migration-row:first-child {
  border-top: none;
}

.migration-row-icon {
  flex: none;
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

/* An endless run's row: the tower with the base mode's own mark tucked
   beside it, the same pairing the header's Endless nav uses. The wrapper
   drops the fixed width so two marks fit. */
.migration-row-icon-endless {
  width: auto;
  display: inline-flex;
  align-items: center;
}

.migration-row-icon-endless img {
  width: 1.375rem;
  height: 1.375rem;
  object-fit: contain;
}

.migration-row-subicon.migration-row-subicon {
  width: 1rem;
  height: 1rem;
  margin-left: calc(-1 * var(--sp-1));
}

.migration-row dt {
  flex: 1;
  font-weight: 600;
  color: var(--ink);
}

.migration-row dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

.migration-actions {
  display: flex;
  gap: var(--sp-2);
}

.migration-actions > * {
  flex: 1;
}

/* Reserves a line before it has text, so the button row doesn't shift
   up-and-down when "Importing…" appears after a click. */
.migration-status {
  margin: var(--sp-2) 0 0;
  min-height: 1.2em;
}

/* The one-time "What's new" popup (js/whatsnew.js) -- same centred
   scrim, card surface and one-shot nature as the migration dialog
   above, so it borrows the same shape rather than inventing a second
   overlay language. */
.whatsnew-overlay {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4);
  background: oklch(20% 0.02 55 / 55%);
}

.whatsnew-card {
  width: 100%;
  max-width: 26rem;
  max-height: 85vh;
  overflow-y: auto;
  padding: var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  color: var(--muted);
  font-size: var(--step--1);
}

.whatsnew-eyebrow {
  display: block;
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
}

.whatsnew-list {
  margin: var(--sp-1) 0 var(--sp-3);
  padding-left: 1.1rem;
}

.whatsnew-list li {
  margin: var(--sp-1) 0;
}

.whatsnew-link {
  display: inline-block;
  margin-bottom: var(--sp-3);
  color: var(--accent);
  font-weight: 600;
}

.whatsnew-actions > .button {
  width: 100%;
}

/* ---- changelog page --------------------------------------------------
   A reading measure, not the board's ~63rem. One <section> per release,
   each a heading with the version and a muted date beside it over a
   plain bullet list. */
.changelog-page {
  max-width: 40rem;
  margin: 0 auto;
  padding: var(--sp-8) 0;
}

.changelog-page > h1 {
  font-family: var(--font-display);
  font-size: var(--step-2);
  margin: 0 0 var(--sp-6);
}

.changelog-entry {
  margin-bottom: var(--sp-6);
}

.changelog-version {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  font-family: var(--font-display);
  font-size: var(--step-1);
  margin: 0 0 var(--sp-2);
}

.changelog-date {
  font-family: var(--font-ui);
  font-size: var(--step--1);
  font-weight: 400;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.changelog-entry ul {
  margin: 0;
  padding-left: 1.1rem;
  color: var(--muted);
}

.changelog-entry li {
  margin: var(--sp-1) 0;
}

/* 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;
  /* `auto` on top pins it to the bottom of the flex column above (body
     or, on a game page, main) whenever there's slack; `padding-top`
     guarantees the same breathing room from the content above it even
     when there's none, i.e. once a page is tall enough to scroll. */
  margin: auto auto 0;
  padding-top: var(--sp-8);
  text-align: center;
  font-size: var(--step--1);
  color: var(--muted);
}

/* A quiet byline under the disclaimer, a touch dimmer so the two lines
   read as a stack rather than compete. */
.footer-credit {
  margin-top: var(--sp-2);
  color: var(--muted);
  opacity: 0.8;
}

/* The privacy-policy link, on every page's footer so the policy is
   reachable from anywhere. Kept in the footer's own muted colour rather
   than the accent so it reads as fine print, not a call to action. */
.footer-links {
  margin-top: var(--sp-2);
}

.footer-links a {
  color: inherit;
}

/* Standalone legal pages (privacy.html). One centred prose column,
   display face for headings, generous line height for reading. */
.legal-page {
  max-width: 40rem;
  margin: 0 auto;
  padding: var(--sp-8) var(--sp-4);
}

.legal-page h1 {
  font-family: var(--font-display);
  font-size: var(--step-2);
  margin: 0 0 var(--sp-1);
}

.legal-page .legal-updated {
  font-size: var(--step--1);
  color: var(--muted);
  margin: 0 0 var(--sp-6);
}

.legal-page h2 {
  font-family: var(--font-display);
  font-size: var(--step-1);
  margin: var(--sp-6) 0 var(--sp-2);
}

.legal-page p,
.legal-page li {
  line-height: 1.65;
}

.legal-page ul {
  margin: var(--sp-2) 0;
  padding-left: 1.1rem;
}

.legal-page li {
  margin: var(--sp-1) 0;
}

/* ---- 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;
    /* `--kb-inset` is how much of the page an on-screen keyboard is
       covering, published by js/viewport.js and zero without one.
       Subtracting it here is what makes the locked column *be* the
       visible area rather than merely fit the screen the keyboard is
       sitting on: `main` then ends where the keyboard begins, so a board
       that runs past it genuinely overflows and can be scrolled to. Left
       at a flat 100dvh, the content still fits its box, nothing is
       scrollable, and a guess row behind the keyboard is unreachable —
       which is what a real phone reported on 2026-08-08. Zero on
       Chrome/Android, where the viewport meta has already done this. */
    height: calc(100dvh - var(--kb-inset, 0px));
    /* Beats the base `body { min-height: 100vh }`, which silently won
       every argument this rule tried to have. Worth knowing that it was
       never only about the keyboard: `vh` is the *large* viewport, so on
       any phone showing its browser toolbars the locked column was
       already being held taller than the screen it was locked to. */
    min-height: 0;
    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;
  }

  .site-header {
    max-width: none;
    /* Overrides the base `flex-wrap: wrap`: the mark and the masthead
       must not be the thing that gives when the row is tight, or the
       corner-clearing padding below (which only affects the masthead's
       own preferred width, not the header's available space) ends up
       forcing them onto two lines instead of just wrapping the
       tagline's text — see the 2026-08-13 log entry for the two
       approaches that did that before this one. `.masthead`'s own
       `flex: 1; min-width: 0` (base rule, above) is what lets it
       actually shrink to make room instead of overflowing. */
    flex-wrap: nowrap;
  }

  /* Room for the now-fixed `.site-header-actions` (below) to sit over,
     not beside: out of flow, the group no longer pushes the tagline over
     on its own, so without this the text ran under their corner instead
     of wrapping clear of it. `.masthead`'s own `flex: 1` (base rule) is
     what makes this safe — its width now comes from the row's remaining
     space rather than its own content, so padding one child no longer
     feeds back into how wide the flex item itself wants to be. Three
     2.75rem buttons (language, theme, menu) plus the two gaps between
     them, their own `--pad-x` inset from the edge, and a small gap to
     the text. Was sized for two buttons until the language toggle
     joined the group here — a long, space-sparse Japanese tagline
     is what actually reaches this corner, since English wraps at a
     word boundary well clear of it first. */
  /* The fixed action group (below) is three icon buttons — language,
     theme, menu. Both the tagline and the wordmark reserve that
     corner; the wordmark also steps down a size, "priconnedle" being
     one unbreakable word that won't fit beside the buttons at its full
     display size on a phone. */
  .tagline,
  .site-header h1 {
    padding-right: 9.75rem;
  }

  .site-header h1 {
    font-size: var(--step-1);
  }

  /* Pulled out of the header's flex row and pinned to the viewport
     corner instead, as one group: inline, it was the reason the mark
     and title wrapped to two lines on a narrow phone — the row had to
     fit mark, title *and* two 2.75rem buttons on one line, and lost.
     Fixed positioning removes the pair from that row's layout entirely,
     so the row goes back to sizing around just the mark and title.
     `top`/`right` match `body`'s own `--pad-x`/`--pad-top` gutter so it
     sits where the header's own edge would be, not floating free of it;
     z-index only has to clear the world-map wash (-1) and ordinary page
     content, and stays under the sheet's own 30 so the scrim still
     covers it once open, same as the game pages' header buttons do. */
  .site-header-actions {
    position: fixed;
    top: var(--pad-top);
    right: var(--pad-x);
    z-index: 5;
    margin-left: 0;
  }

  /* The mode tabs go; the way back to the picker becomes a real target
     at the head of the bar. Three items either way, so the bar's tracks
     only change shape: Home and the icon buttons are sized by content
     and the wordmark takes what is left, there being nothing to centre
     once the tabs are gone. */
  .site-bar {
    grid-template-columns: auto minmax(0, 1fr) auto;
  }

  .site-bar-nav {
    display: none;
  }

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

  /* The strip is the one thing naming the mode once the tabs are gone,
     so it stays — but a locked phone page spends every pixel it gives
     up on board rows, so it gives up what it can: the labels drop a
     step and the band's own padding halves. ~30px all in.

     Spread to the edges here rather than centred as a group. A phone is
     narrow enough that the two ends are still read as one line, and the
     three tracks give the number the middle of the screen — where the
     centred group would leave it fighting the date for room. */
  .issue-strip {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    padding-top: var(--sp-1);
    padding-bottom: var(--sp-1);
  }

  .issue-date {
    justify-self: end;
  }

  .issue-mode,
  .issue-date {
    font-size: 0.75rem;
  }

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

     A flex column, so the footer can sit at the bottom of the region
     when the board is short and travel with the content when it isn't
     (see `footer` below). Children opt out of flexing: this container's
     height is fixed by the locked page, so a shrinkable child would be
     squashed to fit rather than scrolled to. */
  .game-page main {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    max-width: none;
    overflow-y: auto;
    /* The docked bar's own measured height (js/viewport.js) plus a
       row's breathing room. The fallback is the flat 6.5rem this was
       until 2026-08-08, which was 13px shy of the bar at 360px.
       Padding reserves the bar's footprint at the end of the content;
       scroll-padding reserves it for *scrolling*, which is a separate
       thing the box model doesn't cover — `scrollIntoView` aligns to the
       scrollport's edge and ignores padding, so without it the guess row
       brought into view comes to rest exactly underneath the bar,
       visible to the layout and invisible to the player. */
    padding-bottom: calc(
      var(--bar-h, 6.5rem) + var(--sp-4) + env(safe-area-inset-bottom)
    );
    scroll-padding-bottom: calc(
      var(--bar-h, 6.5rem) + var(--sp-4) + env(safe-area-inset-bottom)
    );
  }

  .game-page main > * {
    flex: 0 0 auto;
  }

  /* The base footer rule's margin-top: auto already pins this to the
     bottom of the scrolling region rather than trailing the last guess
     row a screen too early (2026-08-08 — reported as "the footer is too
     large and sits beneath the latest guess row"); this tier only tightens
     its own gap and shrinks the type. Two lines of 11px, since a phone is
     not where anyone reads the colophon. */
  .game-page footer {
    padding-top: var(--sp-6);
    font-size: 0.6875rem;
    line-height: 1.4;
  }

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

     Fixed positioning is against the *layout* viewport, which a keyboard
     on iOS doesn't shrink — so without `--kb-inset` the bar is pinned to
     an edge the keyboard is sitting on top of, and the browser scrolls
     the whole visual viewport, header and all, to bring the input into
     view. `max()` rather than a sum: the home-indicator inset is only
     worth clearing when there is no keyboard already clearing it. */
  .guess-box {
    position: fixed;
    left: var(--sp-2);
    right: var(--sp-2);
    bottom: max(var(--kb-inset, 0px), 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 bar was 116px of an 844px phone with nothing typed in it, and
     the board pays for it twice — `main` reserves the same footprint
     above. Three trims, none of them to the 44px hit targets §7 asks
     for, take it to ~89px:

     the field drops to body size, --step-1 having been chosen for a box
     centred at the top of a desktop page (16px is the floor here
     regardless: below it, iOS zooms the page when the field is
     focused); the pip row moves a step closer to the input; and the
     hint stops holding a line open for a message that isn't there.

     That last one is only right because the hint is *only* ever an
     error — "no character by that name", "you already guessed her" — so
     empty is the state it is in for a whole clean game. The bar grows
     under an error and settles back on the next good guess;
     js/viewport.js measures it with a ResizeObserver, so the board's
     reserved space follows both ways. */
  #guess-input {
    font-size: var(--step-0);
  }

  .guess-meta {
    margin-top: var(--sp-2);
  }

  .hint:empty {
    margin-top: 0;
    min-height: 0;
  }

  /* 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: 88dvh;
    /* 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. The grabber sits above
       the lot — see `.result-grabber`. */
    grid-template-areas:
      "grabber grabber"
      "portrait naming"
      "spread spread"
      "stats stats"
      "actions actions";
    column-gap: var(--sp-3);
    border-width: 1px 0 0;
    border-radius: 0.875rem 0.875rem 0 0;
  }

  .result-grabber {
    grid-area: grabber;
    display: block;
    width: 2.5rem;
    height: 4px;
    margin: var(--sp-2) auto 0;
    border-radius: var(--radius-pill);
    background: var(--line-inset);
  }

  .result-portrait-frame {
    width: 4.5rem;
    height: 4.5rem;
    margin: var(--sp-3) 0 0.875rem var(--sp-4);
  }

  .result-outcome-badge {
    height: 1.625rem;
    top: -0.4375rem;
    left: -0.5rem;
  }

  .result-naming {
    gap: var(--sp-3);
    margin: var(--sp-3) var(--sp-4) 0.875rem 0;
  }

  .result-naming-text {
    gap: 2px;
  }

  .result-eyebrow {
    font-size: 0.6875rem;
  }

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

  /* Sizes only, since 2026-08-08 — the standing-bar shape is the base
     rule now, at every width. A phone column is ~36px wide against a
     desktop's ~50, so the type comes down a step and the bars lose a
     little height; the shape they draw is the same one. The count above
     each bar is not optional at this size either: a bar alone can't
     tell none from one, both being the same baseline tick. */
  .result-spread {
    --spread-gap: var(--sp-1);
  }

  .spread-row {
    font-size: 0.625rem;
    gap: 4px;
  }

  .spread-label-value {
    font-size: 0.75rem;
  }

  .spread-track {
    height: 5.25rem;
    padding-top: 1.125rem;
  }

  .spread-tag {
    padding: 1px 5px;
    font: 700 0.5rem/13px var(--font-ui);
  }

  .spread-panel {
    margin: 0 var(--sp-3);
    padding: var(--sp-3);
  }

  /* The description drops out of the head row to a full-width line of
     its own: at this width it has no room beside the tabs, and the tabs
     have none to give. */
  .spread-head {
    grid-template-areas:
      "title tabs"
      "desc desc";
  }

  .spread-tab {
    min-height: 1.875rem;
    padding: 0 var(--sp-3);
    font-size: 0.625rem;
  }

  /* "Everyone" doesn't fit a phone's half of a pill that also has to
     hold "You"; the switch itself is what says what the two sides are. */
  .spread-tab-long {
    display: none;
  }

  .spread-tab-short {
    display: inline;
  }

  .spread-skeleton {
    height: 5.25rem;
    margin: 0.75rem 0 1.375rem;
    gap: var(--sp-1);
  }

  .spread-panel .result-spread {
    margin-top: 0.75rem;
  }

  /* The card has no width to spare for an icon *beside* every number at
     this size, so the icon moves to its own row above the number rather
     than dropping outright (2026-08-15 — it was `display: none` before,
     which read as a regression once the desktop card had icons and the
     phone card didn't). Value-over-label stays centred underneath it,
     the way the mobile mock stacks it, so "Win rate" still has nowhere
     to go but stay one line — only the icon's position changed. */
  .result .stats {
    margin: var(--sp-3);
  }

  .result .stat {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "icon"
      "value"
      "label";
    justify-items: center;
    gap: 1px;
    padding: var(--sp-2) 0.375rem;
    text-align: center;
  }

  .result .stat img {
    grid-area: icon;
    height: 1.375rem;
    max-width: 1.625rem;
    margin-bottom: 1px;
  }

  .result .stats dd {
    font-size: 1.0625rem;
  }

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

  /* A stack, not a wrapped row: the page's one payoff action gets the
     full width at the top of the thumb's reach, the two secondary ones
     split a row under it, and the countdown reads as one line rather
     than a label stacked over a value. */
  .result-actions {
    gap: var(--sp-2);
    margin-top: 0;
    padding: var(--sp-3) var(--sp-3) calc(var(--sp-4) + env(safe-area-inset-bottom));
  }

  /* A whole row to itself, which is what pushes the other actions onto
     the row below — the row still wraps the ordinary way, it just has
     nothing left to fit beside a 100% basis. This is now every mode's
     own move forward ("Play Endless", "Next character", "Play today's
     Stats Quiz"), not Share (2026-08-15) — Share was the biggest thing
     on a Daily/Trivia card at this width, which had the hierarchy
     backwards: the mode's own payoff is the one accent fill, same as
     Endless already had it, and Share and Home are both quiet icons
     that share the row below regardless of mode. */
  .result-primary {
    flex: 0 0 100%;
    min-height: 3rem;
  }

  /* Everything that isn't the full-width primary shares one row: Review
     board plus whichever of Home/Share aren't already `.icon-action`
     squares. `nowrap` so a label breaks onto the next *row* rather than
     onto a second line inside its own button. */
  .result-actions > button:not(.result-primary):not(.icon-action),
  .result-actions > .button-quiet:not(.icon-action) {
    flex: 1 1 8rem;
    white-space: nowrap;
  }

  /* Excluded above rather than overridden after it: `:not(.result-primary)`
     raised that selector's specificity past a bare `.result-actions >
     .icon-action`, which used to be enough to win and stopped being so
     — and a square 44px button stretched to a third of the row is not a
     subtle failure. */
  .result-actions > .icon-action {
    flex: none;
  }

  .result-actions .result-next {
    flex-basis: 100%;
    flex-direction: row;
    align-items: baseline;
    justify-content: center;
    gap: var(--sp-2);
    margin-left: 0;
    text-align: center;
  }

  .result-status {
    flex-basis: 100%;
    text-align: center;
  }

  /* Below 30rem, back to docked-and-fixed: a thumb-reachable bar in the
     same place the card itself docks to on a phone, not a button buried
     in the scroll the in-flow placement above would otherwise leave it
     in. */
  .result-reopen {
    position: fixed;
    z-index: 24;
    align-self: auto;
    margin-top: 0;
    left: var(--sp-2);
    right: var(--sp-2);
    bottom: calc(var(--sp-2) + env(safe-area-inset-bottom));
    box-shadow: var(--shadow-float);
  }
}

/* The 22rem tier that dropped the date lived here until 2026-08-10 and
   is gone with the subline it served. It existed because the line shared
   a cheek of the bar with the mark and the wordmark — `100vw - 176px` of
   room, which "Daily · No. 6 · 2026-08-07" ran out of at 334px. The
   strip has the whole width, where the same three tokens measure ~250px
   against 320, so there is nothing left to drop. */
