/* ---------- Reset & base ---------- */

*, *::before, *::after {
  box-sizing: border-box;
}

html, body {
  height: 100%;
  /* This app never scrolls as a page — each .screen below is its own
     bounded, individually-scrollable region instead (see .screen's own
     max-height/overflow-y). Locking it here is the outer backstop: even
     if some future change makes a screen taller than intended, the
     *page* still can't grow/scroll, so the bug stays contained to one
     screen's internal scrollbar instead of shifting the whole app. */
  overflow: hidden;
}

body {
  margin: 0;
  font-family: var(--font-sans);
  background: var(--color-bg);
  color: var(--color-text);
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}

img {
  max-width: 100%;
  display: block;
}

button {
  font: inherit;
  /* Every button in this app is a control, never a place to select and
     copy text — prevents the accidental "double-tap selects the label"
     text-highlight some browsers show on repeated taps, without
     touching readable content elsewhere (paragraphs, stat values, the
     share textarea) which stays normally selectable. */
  user-select: none;
  -webkit-user-select: none;
  /* Removes the ~300ms tap-delay/double-tap-to-zoom some touch browsers
     apply while they wait to see if a second tap is coming — every
     button here is a single-purpose control, never part of a
     pinch-zoomable content area, so that wait is pure latency. */
  touch-action: manipulation;
}

/* ================================================================
   DESIGN TOKENS
   ================================================================
   Every color/shadow a component needs is expressed as a custom
   property here, never hard-coded in a component rule below. A
   component says "give me the accent color" (`var(--color-accent)`),
   not "give me #2b6cb0" — which color that resolves to depends on
   `data-theme` and `data-mode` on <html>, set by js/theme.js. This is
   what lets every component render correctly in all 4 theme packs ×
   2 concrete color modes without a single per-theme override anywhere
   outside this token block.

   Structure:
   - :root defines the Light pack / Light mode palette as the ultimate
     safe fallback (used if data-theme/data-mode are ever missing or
     invalid — see js/theme.js for the matching validation).
   - [data-mode="dark"] (no theme requirement) is the Light pack's dark
     variant, doubling as a generic dark fallback.
   - Each [data-theme="X"][data-mode="Y"] block overrides only the
     tokens that change for that combination.
   - "system" mode is never written to the DOM — js/theme.js resolves
     it to a concrete "dark"/"light" data-mode value up front and keeps
     it in sync with the OS via a matchMedia listener. That keeps every
     rule below dealing with exactly two states instead of three, and
     keeps this file the single source of truth for what each state
     looks like (no parallel copy inside an `@media (prefers-color-
     scheme)` block to keep in sync).
   ================================================================ */

:root {
  /* Layout/surface — a soft warm-neutral trio (Phase 14h) rather than
     flat white-on-white: --color-bg (the outer page) is the most muted
     of the three, --color-panel (the #app card, dialogs) is the
     brightest/cleanest since it's the primary content surface, and
     --color-surface (buttons, tiles, stat cards — controls that sit
     *inside* the panel) sits a step more muted than panel so it reads
     as a distinct, gently recessed layer instead of blending into it.
     All three share the same warm hue family (a soft cream, not
     yellow-beige) so the app reads as one calm, cohesive surface rather
     than mismatched grays. */
  --color-bg: #f6f4ef;
  --color-panel: #fcfaf8;
  --color-surface: #edeae6;
  --color-border: #c2b9ad;

  /* Text — a warm near-black rather than flat #1a1a1a, and a warm mid-
     gray secondary. Both comfortably clear WCAG AA's 4.5:1 against all
     three surfaces above (13:1+ for primary text, 5.2-6.1:1 for
     secondary — see the Phase 14h contrast audit). */
  --color-text: #2d261f;
  --color-text-secondary: #685e55;

  /* Accent + focus — intentionally UNCHANGED from the previous palette.
     The brand blue already clears 4.5:1 against every new surface above
     (4.52-5.42:1 — verified, not assumed) without any adjustment, and
     the brief specifically asks to keep one consistent accent rather
     than retune it alongside the neutrals. */
  --color-accent: #2b6cb0;
  --color-accent-contrast: #ffffff;
  --color-focus: #1a56db;

  /* Status — never the only cue; always paired with an icon/label
     (see .status-chip in the Settings dialog for the pattern).
     --color-success darkened slightly from the previous palette
     (#1f7a3d -> #1a7039): against the new --color-surface specifically
     it measured 4.48:1, a hair under WCAG AA's 4.5:1 — this clears it
     with real margin (5.1-5.9:1) instead of skating the line. */
  --color-success: #1a7039;
  --color-warning: #8a5a00;
  --color-error: #b3261e;

  /* Sudoku board tokens (used starting Phase 4). Selection/relation/
     match tints below are deliberately soft — Phase 4 must pair each
     with a structural cue (a ring/border, not color alone) so the
     board reads correctly for color-blind and low-vision players.
     Unchanged in Phase 14h: still clear 4.5:1+ against the new
     --color-panel (board cell background) with no adjustment needed. */
  --color-cell-selected: #cfe3f7;
  --color-cell-related: #e3e8ec;
  /* Phase 14k: softened from a fairly saturated #fde68a (it read as the
     loudest color on the whole board — louder than selection itself,
     the opposite of the intended hierarchy) to a muted warm cream more
     in proportion with this pack's other very-light neutral surfaces.
     Every other theme pack's own --color-cell-match was already this
     soft (compare Cyber/Woodgrain/Paper below) — this brings the Light
     pack in line rather than introducing a new design language. */
  --color-cell-match: #f3e8c9;
  --color-clue-fixed: #111827;
  --color-entry-player: #1e5a92;
  --color-notes: #6b7280;

  /* Warmed and softened slightly from the previous cool navy-black
     (rgba(15,23,42,0.14)) to sit better against the warm neutral
     surfaces above — shadows are still neutral-dark (they represent an
     absence of light, not a tinted surface) but a touch of warmth and
     a little less opacity reads calmer than a cool, harder-edged one. */
  --shadow-color: rgba(40, 32, 20, 0.12);

  /* Elevation scale, built from --shadow-color above so every tier still
     recolors correctly per theme/mode. Consolidated from four box-shadow
     values that were each hand-written at their one or two call sites
     (buttons/chips, the update banner, the settings dialog, and the
     desktop #app card) — same visual output, now named and reused
     instead of retyped. --shadow-lg deliberately layers on top of
     --shadow-md rather than repeating its numbers, so a future change to
     the "close" shadow only has to happen once. */
  --shadow-sm: 0 2px 6px var(--shadow-color);
  --shadow-md: 0 4px 12px var(--shadow-color);
  --shadow-lg: var(--shadow-md), 0 24px 48px var(--shadow-color);
  --shadow-panel: 0 1px 3px var(--shadow-color), 0 12px 32px var(--shadow-color);

  /* Spacing / shape / type — theme-independent */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --space-5: 2.5rem;

  --radius-sm: 0.35rem;
  --radius-md: 0.5rem;
  --radius-lg: 1rem;
  --radius-pill: 999px;

  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* Type scale — every distinct font-size this app uses, named in
     ascending order (same numbering convention as --space-N above)
     rather than t-shirt sizes, because there are more steps here than a
     sm/md/lg scale comfortably covers without collisions. Each number
     maps 1:1 to a value already in use somewhere in this file; nothing
     was rounded or merged, so applying these tokens changes zero
     rendered output. --font-size-6 (0.95rem) and --font-size-8
     (1.05rem) in particular sit oddly close to their neighbors — real
     inconsistencies worth reconciling in a future intentional type-scale
     pass, not silently "fixed" here (see the design-token audit notes). */
  --font-size-1: 0.65rem; /* micro labels, e.g. .game-meta-label */
  --font-size-2: 0.7rem; /* stat/eyebrow labels (dt elements) */
  --font-size-3: 0.8rem; /* status chips, dates, compact meta */
  --font-size-4: 0.85rem; /* secondary hint/detail text */
  --font-size-5: 0.9rem; /* difficulty filter, update banner */
  --font-size-6: 0.95rem; /* game meta values */
  --font-size-7: 1rem; /* settings subheadings */
  --font-size-8: 1.05rem; /* start prompt, primary menu buttons */
  --font-size-9: 1.15rem; /* number pad, high score value */
  --font-size-10: 1.25rem; /* stat values, preview digits */
  --font-size-11: 1.35rem; /* compact brand heading */
  --font-size-12: 1.5rem; /* pause overlay message */
  --font-size-13: 2rem; /* brand heading */
  --font-size-14: 2.5rem; /* brand heading, desktop */

  --font-weight-semibold: 600;
  --font-weight-bold: 700;
  --font-weight-extrabold: 800;

  --letter-spacing-tight: -0.02em; /* large headings (.brand) */
  --letter-spacing-snug: 0.01em; /* button labels */
  --letter-spacing-wide: 0.05em; /* small uppercase eyebrow labels */

  /* The one pervasive border weight used for chrome — dialogs, cards,
     inputs, buttons. Deliberately separate from the board's own grid
     line widths below, which are wider for specific DPI-legibility
     reasons (see .board's comment) and would be the wrong value if a
     future change to this token ever touched them by accident. */
  --border-width-thin: 1px;
  --focus-ring-width: 3px;
  /* General-purpose "state ring" width (an inset box-shadow, not a
     border) — originally declared only inside .board's own block since
     the board was its first use, but genuinely general enough to be
     global: js/ui/high-scores-screen.js's "you just achieved this"
     highlight reuses it outside the board entirely. Kept as its own
     token rather than reusing --focus-ring-width, on purpose — a future
     change to the focus ring must never accidentally resize every one
     of these rings too. */
  --cell-ring-width: 2px;

  --touch-target-min: 44px;

  /* Shared "pressed" affordance for every clickable control app-wide —
     a small, calm scale-down plus a 1px downward nudge, reused by every
     button/tile/tab (see "Shared interactive control states" below)
     instead of each one hand-rolling its own numbers. Deliberately
     restrained: no bounce, no overshoot. */
  --press-scale: 0.98;
  --press-translate-y: 1px;

  /* Motion: durations */
  --duration-fast: 0.2s; /* button/control hover, color, and layout transitions */
  --duration-board: 0.15s; /* board cell state changes (selection/relation/match/conflict) — quicker than --duration-fast so rapid arrow-key or tap navigation across many cells never feels sluggish */
  --duration-shake: 0.3s; /* one-shot invalid-entry shake — a couple of quick, decaying oscillations, brief enough to never block the next keypress */
  --duration-dialog: 0.2s; /* dialog/overlay entrance-exit fade + scale (150-220ms requested range) */
  --duration-moderate: 0.5s; /* one-shot cues, e.g. the completion celebration */
  --duration-slow: 0.8s; /* the intro-to-menu fade */
  --duration-ambient: 26s; /* slow background drift (Cyber theme) */

  /* Motion: easing. Named for role rather than just aliasing the raw
     keyword, so a future change to "what standard UI motion feels like"
     has one place to happen instead of a find-and-replace across every
     transition declaration. */
  --ease-standard: ease; /* the default for UI transitions */
  --ease-out: ease-out; /* entrances / one-shot reveals */
  --ease-in-out: ease-in-out; /* continuous back-and-forth motion */
  --ease-linear: linear; /* constant-rate motion, e.g. looping drifts */
}

/* ---- Light pack, dark mode (also the generic dark-mode fallback) ---- */

/* Light pack, dark mode (Phase 14h): deep graphite rather than
   near-black-with-no-hue, three clearly elevated layers (bg < panel <
   surface, each verified distinct via contrast math not just eyeballed),
   and comfortable off-white text instead of a harsh #f0f0f0. Accent/
   focus intentionally unchanged, same reasoning as the light palette
   above — already clears 4.5:1+ against every new surface. */
[data-mode="dark"] {
  --color-bg: #161618;
  --color-panel: #1f1f23;
  --color-surface: #2a2a2f;
  --color-border: #51515c;

  --color-text: #edebe8;
  --color-text-secondary: #aeaaa2;

  --color-accent: #5b9bd5;
  --color-accent-contrast: #06202f;
  --color-focus: #7db4e8;

  --color-success: #3ddc84;
  --color-warning: #f0b429;
  --color-error: #f28b82;

  --color-cell-selected: #26415a;
  /* Kept in sync with --color-surface above (same value, not a
     coincidence) — "related" cells read as a neutral surface-toned
     highlight in both the old and new palette. */
  --color-cell-related: #2a2a2f;
  --color-cell-match: #4a3b12;
  --color-clue-fixed: #f5f5f5;
  --color-entry-player: #7db4e8;
  --color-notes: #9aa0a6;

  /* Slightly softened from the previous 0.55 opacity — a deep graphite
     background already reads as "dark," so shadows don't need to work
     as hard to show elevation, and a touch less opacity feels calmer. */
  --shadow-color: rgba(0, 0, 0, 0.5);
}

/* ---- Cyber: dark technical interface, restrained neon accents ---- */

[data-theme="cyber"][data-mode="light"] {
  --color-bg: #eef3f5;
  --color-panel: #ffffff;
  --color-surface: #ffffff;
  --color-border: #c8d3d7;

  --color-text: #0b1720;
  --color-text-secondary: #45565f;

  --color-accent: #066a85;
  --color-accent-contrast: #ffffff;
  --color-focus: #0e7490;

  --color-success: #067a53;
  --color-warning: #92620a;
  --color-error: #b3261e;

  --color-cell-selected: #d6eef2;
  --color-cell-related: #e3ecee;
  --color-cell-match: #f3e3ee;
  --color-clue-fixed: #0b1720;
  --color-entry-player: #0b5d75;
  --color-notes: #6b7d84;

  --shadow-color: rgba(11, 23, 32, 0.14);
}

[data-theme="cyber"][data-mode="dark"] {
  --color-bg: #0a0e14;
  --color-panel: #121a24;
  --color-surface: #1a2430;
  --color-border: #24313d;

  --color-text: #e6f1f5;
  --color-text-secondary: #8fa3ad;

  --color-accent: #22d3ee;
  --color-accent-contrast: #04141a;
  --color-focus: #67e8f9;

  --color-success: #34d399;
  --color-warning: #fbbf24;
  --color-error: #f87171;

  --color-cell-selected: #113a44;
  --color-cell-related: #16202a;
  --color-cell-match: #341530;
  --color-clue-fixed: #dff6fb;
  --color-entry-player: #67e8f9;
  --color-notes: #8199a3;

  --shadow-color: rgba(0, 0, 0, 0.6);
}

/* Subtle ambient drift, Cyber theme only — two soft glows slowly easing
   between corners, restrained enough to read as "this interface is
   alive" rather than a distraction. Declared only inside the
   no-preference query so it never even exists for a reduced-motion
   visitor (on top of the blanket override in the Motion section, which
   would otherwise just freeze it mid-frame rather than never starting
   it). No hue/brightness flashing, no strobing — a slow, continuous
   ease between two fixed points. */
@media (prefers-reduced-motion: no-preference) {
  [data-theme="cyber"][data-mode="dark"] body {
    background-image: radial-gradient(circle at 15% 15%, rgba(34, 211, 238, 0.09), transparent 45%),
      radial-gradient(circle at 85% 85%, rgba(34, 211, 238, 0.07), transparent 45%);
    background-repeat: no-repeat;
    animation: cyber-drift var(--duration-ambient) var(--ease-in-out) infinite alternate;
  }

  [data-theme="cyber"][data-mode="light"] body {
    background-image: radial-gradient(circle at 15% 15%, rgba(6, 106, 133, 0.06), transparent 45%),
      radial-gradient(circle at 85% 85%, rgba(6, 106, 133, 0.05), transparent 45%);
    background-repeat: no-repeat;
    animation: cyber-drift var(--duration-ambient) var(--ease-in-out) infinite alternate;
  }
}

@keyframes cyber-drift {
  from {
    background-position: 0% 0%, 100% 100%;
  }
  to {
    background-position: 12% 8%, 88% 92%;
  }
}

/* ---- Woodgrain: warm natural browns (CSS gradients, no images) ---- */

[data-theme="woodgrain"][data-mode="light"] {
  --color-bg: linear-gradient(160deg, #e9d9c2 0%, #dfc7a1 50%, #e3cfae 100%);
  --color-panel: linear-gradient(180deg, #f3e6d2, #ead9bd);
  --color-surface: linear-gradient(160deg, #fbf2e3, #f0e0c4);
  --color-border: #b98f5e;

  --color-text: #3b2412;
  --color-text-secondary: #6b4c30;

  --color-accent: #6b3f16;
  --color-accent-contrast: #fdf6ec;
  --color-focus: #6b3f16;

  --color-success: #3f6b28;
  --color-warning: #8a5205;
  --color-error: #8f2c1a;

  --color-cell-selected: #f0dcb8;
  --color-cell-related: #eddec0;
  --color-cell-match: #e0b975;
  --color-clue-fixed: #2a1a0d;
  --color-entry-player: #6b3f16;
  --color-notes: #7a6142;

  --shadow-color: rgba(59, 36, 18, 0.22);
}

[data-theme="woodgrain"][data-mode="dark"] {
  --color-bg: linear-gradient(160deg, #2b1c10 0%, #201409 60%, #241708 100%);
  --color-panel: linear-gradient(180deg, #34220f, #281a0b);
  --color-surface: linear-gradient(160deg, #3d2814, #2c1c0e);
  --color-border: #5a3f22;

  --color-text: #f1e3cd;
  --color-text-secondary: #c9ac85;

  --color-accent: #d99a4e;
  --color-accent-contrast: #241407;
  --color-focus: #e7b56d;

  --color-success: #7fbf5a;
  --color-warning: #e0a63c;
  --color-error: #e2695a;

  --color-cell-selected: #4a3419;
  --color-cell-related: #3a2814;
  --color-cell-match: #5a4014;
  --color-clue-fixed: #fbf1df;
  --color-entry-player: #e7b56d;
  --color-notes: #a58f6c;

  --shadow-color: rgba(0, 0, 0, 0.55);
}

/* ---- Paper: off-white tactile paper (CSS gradients, no images) ---- */

[data-theme="paper"][data-mode="light"] {
  --color-bg: linear-gradient(160deg, #faf7f0 0%, #f3ecdd 100%);
  --color-panel: linear-gradient(180deg, #fffdf8, #f7f1e4);
  --color-surface: linear-gradient(160deg, #fffefb, #f3ecdd);
  --color-border: #d8cfba;

  --color-text: #2e2a22;
  --color-text-secondary: #6b6355;

  --color-accent: #a1462f;
  --color-accent-contrast: #fffaf0;
  --color-focus: #8a3320;

  --color-success: #2e6b3a;
  --color-warning: #8a5a08;
  --color-error: #8f2c1a;

  --color-cell-selected: #f0e6d0;
  --color-cell-related: #f7f1e4;
  --color-cell-match: #e8d9b0;
  --color-clue-fixed: #201c16;
  --color-entry-player: #8a3320;
  --color-notes: #736b58;

  --shadow-color: rgba(46, 42, 34, 0.16);
}

[data-theme="paper"][data-mode="dark"] {
  --color-bg: linear-gradient(160deg, #2a2823 0%, #201e1a 100%);
  --color-panel: linear-gradient(180deg, #34322c, #282622);
  --color-surface: linear-gradient(160deg, #3a3830, #2c2a25);
  --color-border: #4a473d;

  --color-text: #efe9db;
  --color-text-secondary: #b7ae9b;

  --color-accent: #d97b5c;
  --color-accent-contrast: #241a14;
  --color-focus: #e79274;

  --color-success: #7fbf5a;
  --color-warning: #e0a63c;
  /* Lightened from #e2695a — that value only reached 3.91:1 against
     --color-panel, below WCAG AA's 4.5:1 for normal text (caught by
     Phase 11's contrast audit). #ec8878 keeps the same warm coral hue
     family as this theme's --color-accent/--color-entry-player while
     reaching 5.10:1. */
  --color-error: #ec8878;

  --color-cell-selected: #453f31;
  --color-cell-related: #34322b;
  --color-cell-match: #4d4530;
  --color-clue-fixed: #f7f1e2;
  --color-entry-player: #f0a688;
  --color-notes: #9c9484;

  --shadow-color: rgba(0, 0, 0, 0.5);
}

/* ---------- Motion ---------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---------- Focus states ---------- */

:focus-visible {
  outline: var(--focus-ring-width) solid var(--color-focus);
  outline-offset: 2px;
}

/* ---------- Shared interactive control states ----------
   One consistent state system reused by every clickable control in the
   app — menu buttons, dialog buttons, the number pad, toolbar buttons,
   difficulty tabs, option tiles, and the skip button — so a press
   anywhere feels like the same calm, tactile material responding,
   rather than several unrelated button systems that happen to sit near
   each other. Board cells (.cell) are handled separately below: they
   sit edge-to-edge in a seamless grid, so they only ever get the
   brightness half of this treatment, never the scale/translate — that
   would open a visible gap against their neighbors instead of reading
   as "pressed."

   Timing/scale come from the shared --duration-fast (200ms, inside the
   requested 120-220ms range) and --press-scale/--press-translate-y
   tokens declared with the rest of the design tokens above —
   deliberately restrained: no bounce, no ripple, no glow, just a
   small, quick, physical-feeling response. Declared once here instead
   of each component repeating its own transition list. */
.menu-nav button,
.btn-primary,
.btn-secondary,
.number-btn,
.difficulty-filter-btn,
.option-tile,
.skip-btn {
  transition-property: background-color, color, border-color, box-shadow, transform;
  transition-duration: var(--duration-fast);
  transition-timing-function: var(--ease-standard);
}

/* Hover is gated behind (hover: hover) and (pointer: fine) — without
   that, touch browsers apply :hover on tap and it can stay "stuck"
   until the next unrelated tap, which reads as a visual glitch rather
   than a hover cue that was never really possible on a touchscreen. */
@media (hover: hover) and (pointer: fine) {
  .menu-nav button:not(:disabled):hover,
  .btn-primary:not(:disabled):hover,
  .btn-secondary:not(:disabled):hover,
  .number-btn:not(:disabled):hover,
  .difficulty-filter-btn:not(:disabled):hover,
  .option-tile:hover,
  .skip-btn:hover,
  .cell:not(:disabled):hover {
    filter: brightness(0.96);
  }
}

/* :active isn't gated the same way as hover: a brief press-down cue is
   useful feedback on both a mouse click and a touch tap, and never gets
   "stuck" the way :hover can. box-shadow: none reads as the control
   settling flush against its surface while held (the requested "subtle
   shadow reduction while pressed") and is a harmless no-op on controls
   that had no shadow to begin with. */
.menu-nav button:not(:disabled):active,
.btn-primary:not(:disabled):active,
.btn-secondary:not(:disabled):active,
.number-btn:not(:disabled):active,
.difficulty-filter-btn:not(:disabled):active,
.option-tile:active,
.skip-btn:active {
  transform: translateY(var(--press-translate-y)) scale(var(--press-scale));
  filter: brightness(0.94);
  box-shadow: none;
}

.cell:not(:disabled):active {
  filter: brightness(0.92);
}

/* Disabled: identical treatment across every button type — previously
   duplicated between .menu-nav button and .btn-primary/.btn-secondary
   with the same values, now declared once. */
.menu-nav button:disabled,
.btn-primary:disabled,
.btn-secondary:disabled {
  background: var(--color-border);
  color: var(--color-text-secondary);
  border-color: var(--color-border);
  box-shadow: none;
  cursor: not-allowed;
}

/* ---------- App frame ---------- */

#app {
  min-height: 100dvh;
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

/* ---------- Screens ---------- */

.screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  min-height: 100dvh;
  max-height: 100dvh;
  /* The one sanctioned place content can ever scroll: if a screen's
     content is genuinely too tall for the viewport (e.g. a long high
     scores list on a short device), it scrolls within itself — the
     outer page (locked via html/body's overflow: hidden above) never
     does. */
  overflow-y: auto;
  padding-top: max(var(--space-4), env(safe-area-inset-top));
  padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
  padding-left: max(var(--space-3), env(safe-area-inset-left));
  padding-right: max(var(--space-3), env(safe-area-inset-right));
  text-align: center;
  transition: background-color var(--duration-fast) var(--ease-standard);
}

.screen[hidden] {
  display: none;
}

/* #screen-game[hidden] specifically: the >=1024px and short-landscape-
   phone grid layouts below both set `#screen-game { display: grid }`
   on an ID selector, which is *more* specific than `.screen[hidden]`
   above (one class+attribute vs. one ID) and would otherwise win even
   while hidden — silently laying the game screen out at full height
   (~720px) behind whatever screen is actually showing, inflating page
   height and causing exactly the page-level scrolling this rule exists
   to prevent. This ID+attribute selector has higher specificity than
   a bare `#screen-game`, so it reliably wins regardless of which other
   #screen-game rule appears later in the file. */
#screen-game[hidden] {
  display: none;
}

.brand {
  /* Fluid title: interpolates from the --font-size-13 token at a 360px
     viewport up to --font-size-14 at 960px, then clamps flat outside
     that range — so it stays anchored to the existing type scale
     instead of introducing an arbitrary one-off size. */
  font-size: clamp(var(--font-size-13), 1.7rem + 1.3vw, var(--font-size-14));
  font-weight: var(--font-weight-extrabold);
  line-height: 1.1;
  letter-spacing: var(--letter-spacing-tight);
  margin: 0;
}

.brand span {
  /* --color-entry-player, not --color-accent: an a11y contrast pass
     found the title's accent-colored text falls to 4.23:1 (below WCAG
     AA's 4.5:1) against --color-panel in Paper/dark specifically (the
     desktop #app card background). --color-entry-player is the same
     accent hue family per theme (identical in Woodgrain/light, close
     everywhere else) but is the token already purpose-built and
     audited for colored text on panel/surface — same visual identity,
     comfortable contrast margin in all 8 theme/mode combinations. */
  color: var(--color-entry-player);
}

.brand--compact {
  font-size: clamp(var(--font-size-11), 1.2rem + 0.5vw, var(--font-size-12));
}

/* A small accent-colored rule under every screen's title (Start, Menu,
   Statistics, High Scores all share .brand) — a cheap, reusable way to
   give each screen a consistent branded "header" moment instead of a
   heading floating with nothing to visually anchor it. */
.brand::after {
  content: '';
  display: block;
  width: 3rem;
  height: 3px;
  margin: var(--space-2) auto 0;
  background: var(--color-accent);
  border-radius: var(--radius-sm);
}

/* Standard screen-reader-only pattern (present in the accessibility
   tree, removed from visual/layout flow) — used for the Intro and Game
   screens' own <h1> (an a11y audit found every screen needs one, but
   Game's header is already a tightly-tuned layout with no room to
   spare, and Intro is a brief, self-contained moment neither screen
   needs a second visible "Sudoku" title for). */
.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;
}

.start-logo {
  /* Sized as a supporting brand mark, not a dominant graphic — the
     "Sudoku by Inspire" title (.brand) is the primary hierarchy
     element, so the logo above it stays smaller than the title's own
     footprint at every viewport width. */
  width: clamp(6rem, 22vw, 9rem);
  margin: 0 auto var(--space-2);
}

.start-prompt {
  color: var(--color-text-secondary);
  font-size: clamp(var(--font-size-7), 0.85rem + 0.4vw, var(--font-size-9));
  line-height: 1.4;
  margin: 0;
}

/* ---------- Intro screen ---------- */

#screen-intro {
  position: relative;
}

.intro-video {
  max-width: 100%;
  max-height: 80dvh;
  border-radius: var(--radius-md);
}

.skip-btn {
  position: absolute;
  top: max(var(--space-3), env(safe-area-inset-top));
  right: max(var(--space-3), env(safe-area-inset-right));
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
  /* Deliberately NOT theme tokens: this sits on top of arbitrary video
     frames, not this app's own background, so it needs guaranteed
     contrast against whatever color happens to be playing underneath
     it rather than whatever a given theme pack's surface/text/border
     tokens resolve to. */
  background: rgba(0, 0, 0, 0.7);
  color: #ffffff;
  border: var(--border-width-thin) solid rgba(255, 255, 255, 0.7);
  border-radius: var(--radius-md);
  font-weight: var(--font-weight-semibold);
  cursor: pointer;
}

/* ---------- Main menu ---------- */

.menu-nav {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  width: 100%;
  /* Fluid width instead of a hard mobile/desktop breakpoint jump — keeps
     controls from ever growing button-bar-wide on desktop while still
     using the available width on narrow phones. */
  max-width: clamp(18rem, 60vw, 22rem);
}

.menu-nav button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
  background: var(--color-accent);
  color: var(--color-accent-contrast);
  border: none;
  border-radius: var(--radius-md);
  font-size: var(--font-size-8);
  font-weight: var(--font-weight-semibold);
  letter-spacing: var(--letter-spacing-snug);
  cursor: pointer;
  box-shadow: var(--shadow-sm);
}

/* Decorative only (each button already has a real text label the
   screen reader announces), fixed to a compact size regardless of the
   button's own font-size so the icon reads as a consistent glyph. */
.menu-icon {
  width: 1.2em;
  height: 1.2em;
  flex-shrink: 0;
}

/* New Game keeps the solid accent fill above — it's the one primary
   action on this screen. Continue Game/Statistics/High Scores/Settings
   reuse the outlined .btn-secondary look already established for dialog
   buttons, so the menu reads as "one clear next step, four supporting
   ones" instead of five identical bars with no hierarchy between them. */
.menu-nav .btn-secondary {
  background: var(--color-surface);
  color: var(--color-text);
  border: var(--border-width-thin) solid var(--color-border);
  box-shadow: none;
}

.menu-footer {
  margin-top: var(--space-4);
}

.menu-logo {
  max-width: 9rem;
  margin: 0 auto;
  opacity: 0.9;
}

/* Extra breathing room between the title, the nav list, and the footer —
   with only three direct children, the default .screen gap (var(--space-3))
   left them clumped tightly together in the middle of a much larger card,
   which read as unfinished rather than deliberately centered. */
#screen-menu {
  gap: var(--space-5);
  /* position:relative is the containing block for .menu-sudoku-bg
     below, so it's positioned/clipped relative to the menu screen's
     own box (and, on desktop, stays safely inside #app's rounded card)
     rather than the whole document. z-index:0 is just as load-bearing
     as the position — without an explicit z-index here, this element
     never establishes its own stacking context, so the background's
     z-index:-1 has no local floor to stop at and escapes upward past
     every ancestor (#app, body, ...) looking for one, landing behind
     the very first opaque background it finds — which made the whole
     pattern invisible until this was added (verified by reproducing it
     in isolation before adding this fix). */
  position: relative;
  z-index: 0;
}

/* ---------- Main menu decorative background ----------
   A subtle, non-interactive Sudoku-digit pattern drifting behind the
   menu content. It lives entirely inside #screen-menu (see index.html)
   so it's shown/hidden by the exact same `.screen[hidden] { display:
   none }` rule that already governs every other screen transition —
   no separate JS mount/unmount logic needed, and it's structurally
   impossible for it to leak onto the game screen or any other screen,
   since its whole subtree stops rendering the instant #screen-menu
   itself is hidden.

   Built as a CSS `mask-image` on a single element rather than a DOM
   grid of individual digit elements, for two reasons: it stays at one
   extra element regardless of how much screen area it covers (the
   "limit rendered elements" mobile-performance requirement), and
   `mask-repeat` tiles the pattern natively and pixel-perfectly, which
   is what makes the drift animation loop seamlessly (see the keyframes
   below) without any manual per-tile bookkeeping. The mask only
   defines *shape* (which pixels are digits vs. blank) — the actual
   color comes from this element's own `background-color`
   (--color-text-secondary, the same "ink" token already used for the
   real board's grid lines), so it automatically matches whichever
   theme is active with no per-theme image variants needed. */
.menu-sudoku-bg {
  /* Tunable knobs for this effect — see the follow-up summary for what
     each one controls. */
  --sbg-tile: 420px; /* size of one repeat of the pattern: spacing */
  --sbg-opacity: 0.06; /* 0.04-0.08 requested; 0.06 sits in the middle */
  --sbg-duration: 32s; /* 25-40s requested; slow, ambient drift */

  position: absolute;
  inset: 0;
  z-index: -1; /* behind #screen-menu's own (normal-flow) children */
  overflow: hidden;
  pointer-events: none;
  user-select: none;
  /* Soft edge fade: digits fade in/out near the screen edges instead of
     cutting off abruptly at a hard rectangular boundary. */
  -webkit-mask-image: radial-gradient(ellipse at center, #000 55%, transparent 92%);
  mask-image: radial-gradient(ellipse at center, #000 55%, transparent 92%);
}

.menu-sudoku-bg__pattern {
  position: absolute;
  /* Oversized by exactly one tile in every direction: the translate
     animation below never moves more than one tile-width/height, so
     this guarantees the visible area stays fully covered throughout —
     sizing this to a plain 100% would reveal a blank edge the moment
     the pattern starts sliding. */
  top: calc(-1 * var(--sbg-tile));
  left: calc(-1 * var(--sbg-tile));
  width: calc(100% + 2 * var(--sbg-tile));
  height: calc(100% + 2 * var(--sbg-tile));
  background-color: var(--color-text-secondary);
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
  -webkit-mask-size: var(--sbg-tile) var(--sbg-tile);
  mask-size: var(--sbg-tile) var(--sbg-tile);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22576%22%20height%3D%22576%22%20viewBox%3D%220%200%20576%20576%22%3E%3Cg%20font-family%3D%22system-ui%2Csans-serif%22%20font-weight%3D%22700%22%20font-size%3D%2236%22%20text-anchor%3D%22middle%22%20dominant-baseline%3D%22central%22%20fill%3D%22%23000%22%3E%3Ctext%20x%3D%2232%22%20y%3D%2232%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%2232%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%2232%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%2232%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%2232%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%2232%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%2296%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%2296%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%2296%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%2296%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%2296%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22160%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22160%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22160%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22160%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22160%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22160%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22224%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22224%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22224%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22224%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22224%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22288%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22288%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22288%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22288%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22288%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22288%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22352%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22352%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22352%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22352%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22352%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22416%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22416%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22416%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22416%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22416%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22416%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22416%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22480%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22480%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22480%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22480%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22480%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22544%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22544%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22544%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22544%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22544%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22544%22%3E7%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22576%22%20height%3D%22576%22%20viewBox%3D%220%200%20576%20576%22%3E%3Cg%20font-family%3D%22system-ui%2Csans-serif%22%20font-weight%3D%22700%22%20font-size%3D%2236%22%20text-anchor%3D%22middle%22%20dominant-baseline%3D%22central%22%20fill%3D%22%23000%22%3E%3Ctext%20x%3D%2232%22%20y%3D%2232%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%2232%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%2232%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%2232%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%2232%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%2232%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%2296%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%2296%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%2296%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%2296%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%2296%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22160%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22160%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22160%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22160%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22160%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22160%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22224%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22224%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22224%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22224%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22224%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22288%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22288%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22288%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22288%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22288%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22288%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22352%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22352%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22352%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22352%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22352%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22416%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22416%22%3E4%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22416%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22416%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22416%22%3E8%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22416%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22416%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%2296%22%20y%3D%22480%22%3E7%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22480%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22288%22%20y%3D%22480%22%3E1%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22480%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22544%22%20y%3D%22480%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%2232%22%20y%3D%22544%22%3E9%3C%2Ftext%3E%3Ctext%20x%3D%22160%22%20y%3D%22544%22%3E2%3C%2Ftext%3E%3Ctext%20x%3D%22224%22%20y%3D%22544%22%3E3%3C%2Ftext%3E%3Ctext%20x%3D%22352%22%20y%3D%22544%22%3E5%3C%2Ftext%3E%3Ctext%20x%3D%22416%22%20y%3D%22544%22%3E6%3C%2Ftext%3E%3Ctext%20x%3D%22480%22%20y%3D%22544%22%3E7%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E");
  opacity: var(--sbg-opacity);
  animation: menu-sudoku-drift var(--sbg-duration) var(--ease-linear) infinite;
}

/* Diagonally down-and-left. Translating by *exactly* one tile period in
   each axis is what makes the loop seamless: because the mask repeats
   infinitely, the pattern at translate(-1 tile, +1 tile) is pixel-
   identical to the pattern at translate(0, 0) — the animation "loops"
   without any special-casing, the two endpoints just happen to render
   the same. */
@keyframes menu-sudoku-drift {
  from {
    transform: translate(0, 0);
  }
  to {
    transform: translate(calc(-1 * var(--sbg-tile)), var(--sbg-tile));
  }
}

/* Static rather than animated under reduced motion — still a faint
   Sudoku-flavored texture, just not moving. */
@media (prefers-reduced-motion: reduce) {
  .menu-sudoku-bg__pattern {
    animation: none;
  }
}

/* ---------- Game screen ---------- */

.screen-game {
  justify-content: flex-start;
  gap: var(--space-3);
}

/* Explicit grid instead of a wrapping flex row: with the Menu button,
   four meta stats, and the Settings gear all fighting for one line,
   flex-wrap's natural cascade produces a different — and at a few
   widths (~390-430px) visually broken, with Settings orphaned alone at
   the left of its own row instead of staying paired top-right —
   arrangement depending on exactly how many pixels are available.
   Below 480px this pins down one predictable 2-row layout (Menu +
   Settings sharing row one, stats centered on row two); the
   min-width:480px override further down restores the single-row
   look once there's reliably enough room for it. */
.game-header {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas:
    "back settings"
    "meta meta";
  align-items: center;
  gap: var(--space-2);
  width: min(100%, 32rem);
  margin: 0 auto;
}

#btn-game-back {
  grid-area: back;
  justify-self: start;
}

#btn-game-settings {
  grid-area: settings;
  justify-self: end;
}

.game-back-btn {
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
}

.game-meta {
  grid-area: meta;
  display: flex;
  justify-content: center;
  gap: var(--space-3);
}

@media (min-width: 480px) {
  /* Confirmed via measurement that Menu + all four stats + Settings
     naturally fit on one line from ~440px up — 480px keeps a small
     safety margin above that. */
  .game-header {
    grid-template-columns: auto 1fr auto;
    grid-template-areas: "back meta settings";
  }
}

.game-meta-item {
  font-size: var(--font-size-6);
  color: var(--color-text);
}

.game-meta-label {
  display: block;
  font-size: var(--font-size-1);
  text-transform: uppercase;
  letter-spacing: var(--letter-spacing-wide);
  color: var(--color-text-secondary);
}

.game-status {
  color: var(--color-text-secondary);
  max-width: 26rem;
  min-height: 1.2em;
}

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

.board {
  /* Board-specific line-width tokens, scoped here (not in the global
     :root block) rather than folded into --border-width-thin/
     --focus-ring-width: these two exist for the specific, documented
     DPI-legibility reason below, not because they happen to share a
     number with some other 2px/3px value elsewhere in the app — keeping
     them separately named means a future change to, say, the focus
     ring's width can never accidentally resize the board grid too.
     Declared on .board and consumed by its .cell descendants below
     (custom properties inherit), so both stay in sync from one place.
     --cell-ring-width, used just below, is declared globally on :root
     instead — see its own comment there for why. */
  --board-gap-width: 2px;
  --board-line-width: 3px;

  display: grid;
  grid-template-columns: repeat(9, 1fr);
  grid-template-rows: repeat(9, 1fr);
  width: min(100%, 32rem);
  aspect-ratio: 1 / 1;
  /* Without this, a flex item with non-visible overflow (this one has
     `overflow: hidden` below, needed to clip the grid to its rounded
     corners) gets its automatic flex-shrink minimum floored at 0
     instead of its aspect-ratio-derived size — so on a short viewport
     where .screen-game's flex column doesn't have enough height, the
     board would silently get squashed into a short rectangle instead
     of staying square and letting .screen's own overflow-y: auto
     scroll the rest into view (confirmed: reproduces identically with
     no other change, i.e. pre-existing, not introduced by this pass —
     fixed here since "scales cleanly on narrow screens" is explicitly
     in scope). */
  flex-shrink: 0;
  margin: 0 auto;
  /* 2px, not 1px: a 1px CSS gap only ever renders as a true single
     device pixel on a >=2x-DPI screen. On an ordinary 1x desktop
     monitor the browser has to anti-alias that hairline across
     sub-pixel coverage, which visibly washes it out — confirmed by
     comparing rendered board screenshots at deviceScaleFactor 1 vs 2:
     crisp at 2x, faint at 1x, even though the *color* contrast math
     (still WCAG-passing) never changed. 2px is wide enough to render
     as a solid line at 1x DPI too. */
  gap: var(--board-gap-width);
  /* Was --color-border, which is deliberately subtle for chrome like
     button/dialog outlines — measured at only 1.3-2.4:1 contrast
     against --color-panel across themes here, well under WCAG's 3:1
     minimum for meaningful non-text UI boundaries, and the cell grid
     was essentially invisible in most themes as a result (confirmed
     visually in every theme except Light). --color-text-secondary is
     already verified elsewhere (see the Phase 11 contrast audit) to
     clear 4.5:1 against --color-panel in all 8 theme/mode combinations,
     so reusing it here guarantees a genuinely visible grid everywhere
     without hand-tuning a new color per theme. */
  background: var(--color-text-secondary);
  /* 3px to stay visibly heavier than the 2px ordinary gap above — the
     3x3 box boundaries need to keep reading as "the bold lines" now
     that the ordinary gap isn't a razor-thin 1px anymore. */
  border: var(--board-line-width) solid var(--color-text);
  border-radius: var(--radius-sm);
  overflow: hidden;
  transition: opacity var(--duration-fast) var(--ease-standard);
}

.board.is-empty {
  opacity: 0.35;
}

.cell {
  all: unset;
  box-sizing: border-box;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 0;
  background: var(--color-panel);
  cursor: pointer;
  touch-action: manipulation;
  /* Covers every cell-state change below (selection/relation/match/
     conflict) with one calm, quick transition — background-color and
     box-shadow are separate properties so a selection ring (box-shadow)
     and a tint change (background-color) never have to fight over one
     shared value. Purely visual: neither property affects layout, so
     rapid selection changes across the grid never shift anything. */
  transition: background-color var(--duration-board) var(--ease-standard), box-shadow var(--duration-board) var(--ease-standard);
}

.cell:disabled {
  cursor: default;
}

.cell:focus-visible {
  outline: var(--focus-ring-width) solid var(--color-focus);
  outline-offset: -2px;
  z-index: 1;
}

/* Strong 3x3 box boundaries — a visibly heavier rule than the 2px grid
   gap between ordinary cells, using the same high-contrast token as
   text so it reads clearly in every theme. */
.cell.grid-line-left {
  border-left: var(--board-line-width) solid var(--color-text);
}

.cell.grid-line-top {
  border-top: var(--board-line-width) solid var(--color-text);
}

.cell-value {
  font-size: clamp(0.9rem, 4vw, 1.4rem);
  font-weight: var(--font-weight-semibold);
  /* Player entries read as "handwritten in" (italic) vs. the upright
     printed clues below — a second, non-color channel for telling
     fixed and entered digits apart, on top of the weight/color
     difference, so the distinction survives grayscale or a color-
     vision deficiency. */
  font-style: italic;
  color: var(--color-entry-player);
  transition: color var(--duration-board) var(--ease-standard);
}

.cell-value.is-error {
  color: var(--color-error);
  /* A non-color cue alongside the color change — a colorblind player
     can still see the wavy underline (the classic "spell-check" cue)
     even if the red-vs-black color shift itself is hard to distinguish. */
  text-decoration: underline;
  text-decoration-style: wavy;
  text-decoration-color: currentColor;
}

/* One-shot "settle in" feedback the moment a digit actually appears or
   changes in a cell (js/ui/board-view.js retriggers this class only on
   a real value change, never on an unrelated re-render). Grows up to
   full size rather than overshooting past it — a clean settle, not a
   bounce — and only ever animates opacity/transform, neither of which
   affects layout, so it can never introduce a layout shift or steal a
   frame from the next keypress. Neutralized automatically by the
   prefers-reduced-motion override far above (all animation-durations
   forced to ~0). */
.cell-value.is-value-enter {
  animation: cell-value-enter var(--duration-board) var(--ease-out);
}

@keyframes cell-value-enter {
  from {
    transform: scale(0.8);
    opacity: 0.4;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}

.cell.is-fixed .cell-value {
  color: var(--color-clue-fixed);
  /* Bumped from --font-weight-bold to --font-weight-extrabold and back
     to upright (overriding the italic above) — widens the weight gap
     against player entries and keeps clues reading as "printed," so
     fixed vs. entered is never dependent on the color pairing alone. */
  font-weight: var(--font-weight-extrabold);
  font-style: normal;
}

.cell-notes {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  width: 100%;
  height: 100%;
  padding: 2px;
}

/* Same class of bug as #screen-game[hidden] (see that rule's comment):
   an explicit `display: grid` above beats the browser's default
   `[hidden] { display: none }` UA rule for equal-specificity ties in
   an author stylesheet, so a "hidden" notes grid was staying laid out
   at full cell size — a same-width invisible flex sibling next to
   .cell-value that silently pushed the visible digit off-center
   (confirmed: ~15px left of true center). */
.cell-notes[hidden] {
  display: none;
}

.cell-value[hidden] {
  display: none;
}

.cell-note-digit {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(0.5rem, 1.6vw, 0.65rem);
  font-weight: var(--font-weight-semibold);
  color: var(--color-notes);
  line-height: 1;
}

/* Priority when more than one applies: related < match < selected —
   declared in that order so later rules win on the shared `background`
   property. Selected additionally gets an accent ring (a second,
   structural channel — not just a stronger tint) so it unmistakably
   outranks the flat related/match tints instead of competing with them
   on saturation alone; related and match stay ring-free so they read
   as subordinate/subtle exactly as requested. Conflicts use box-shadow
   (a different property from background) so that signal never has to
   compete with the fill colors for the cascade, and — being declared
   last — its error-colored ring wins over the selection ring on a cell
   that happens to be both selected and conflicting, while the selected
   tint underneath still shows through. */
.cell.is-related {
  background: var(--color-cell-related);
}

.cell.is-match {
  background: var(--color-cell-match);
}

.cell.is-selected {
  background: var(--color-cell-selected);
  box-shadow: inset 0 0 0 var(--cell-ring-width) var(--color-accent);
}

.cell.is-conflict {
  box-shadow: inset 0 0 0 var(--cell-ring-width) var(--color-error);
}

/* One-shot shake the moment a cell first becomes wrong (immediate error
   checking on) — js/ui/board-view.js only retriggers this on the
   false→true transition, never on renders where the cell simply *stays*
   wrong, so it can't turn into repeated flashing while the player
   works out the fix. Small, quickly-decaying amplitude (max 3px) and
   translateX only, never affecting layout — deliberately restrained,
   not a big shove. */
.cell.is-shake {
  animation: cell-shake var(--duration-shake) var(--ease-standard);
}

@keyframes cell-shake {
  0%,
  100% {
    transform: translateX(0);
  }
  20% {
    transform: translateX(-3px);
  }
  40% {
    transform: translateX(3px);
  }
  60% {
    transform: translateX(-2px);
  }
  80% {
    transform: translateX(2px);
  }
}

/* ---------- Board toolbar & number pad ---------- */

.board-toolbar {
  display: flex;
  justify-content: center;
  gap: var(--space-2);
  width: min(100%, 32rem);
  margin: 0 auto;
}

.toolbar-btn {
  flex: 1;
}

/* Shared "selected" look for the two attribute-driven toggle/tab
   controls in the app (the Notes toggle button and the difficulty
   filter tabs) — same accent-fill treatment either way, so a selected
   state reads consistently regardless of which control it's on. */
#btn-notes-toggle[aria-pressed="true"],
.difficulty-filter-btn[aria-selected="true"] {
  background: var(--color-accent);
  color: var(--color-accent-contrast);
  border-color: var(--color-accent);
}

.number-pad {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: var(--space-2);
  width: min(100%, 32rem);
  margin: 0 auto;
}

.number-btn {
  min-height: var(--touch-target-min);
  min-width: var(--touch-target-min);
  background: var(--color-surface);
  color: var(--color-text);
  border: var(--border-width-thin) solid var(--color-border);
  border-radius: var(--radius-md);
  font-size: var(--font-size-9);
  font-weight: var(--font-weight-semibold);
  cursor: pointer;
}

/* Highlights the number-pad button matching the selected cell's
   current value — the same "which digit am I looking at" signal the
   board's own matching-number cells already give, now also visible on
   the pad itself. A ring (like the board's own selected-cell ring),
   not a fill, so it reads as "this is informational" rather than "this
   button is pressed/active." Already covered by .number-btn's shared
   box-shadow transition above, so this fades in/out at the same 200ms
   as every other button-state change instead of snapping. */
.number-btn.is-current-value {
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 var(--cell-ring-width) var(--color-accent);
}

/* A digit whose 9 correct instances are all already on the board (see
   js/ui/board-view.js's render() for why this is provably "nothing left
   to place," not just a guess) — --color-success rather than the
   app-wide disabled treatment (--color-border/--color-text-secondary,
   used by .btn-primary/.btn-secondary/.menu-nav button above) so it
   reads as "done," not "broken" or "unavailable." Verified against
   --color-surface (this button's own background) at >=4.5:1 across all
   8 theme/mode combinations, checking both stops of every gradient
   surface — tightest is Woodgrain/light at 4.83:1. Lower opacity is the
   secondary, non-color cue (never rely on color alone) — legible enough
   to still read the digit, quiet enough not to compete with the board. */
.number-btn.is-complete {
  color: var(--color-success);
  border-color: var(--color-success);
  opacity: 0.65;
  cursor: not-allowed;
}

/* Notes mode: every number button gets a dashed, accent-tinted border
   instead of its normal solid one — "pencil marks," not "ink" — so a
   press's *consequence* (candidate note vs. final answer) is clear
   before the player taps, not just after. Also already covered by the
   shared border-color/color transition on .number-btn.
   Border uses --color-accent (a 3:1 non-text ring, already verified
   against --color-surface in every theme); the digit *text* uses
   --color-entry-player instead — a different, already-audited token
   built specifically for colored text on this exact surface (it's
   what a player's entered digits use on the board itself). An a11y
   pass caught that --color-accent's own text-contrast against
   --color-surface's darkest gradient stop fails WCAG AA (3.88:1) in
   Paper/dark specifically; --color-entry-player clears 4.5:1 with
   margin in all 8 theme/mode combinations. */
.number-pad.is-notes-mode .number-btn {
  border-style: dashed;
  border-color: var(--color-accent);
  color: var(--color-entry-player);
}

/* A complete digit stays complete-looking even while notes mode is on
   (higher specificity than the notes-mode rule above, which would
   otherwise win and paint it as an available pencil-mark target) — a
   pencil mark for a digit that already has all 9 correct instances
   placed is exactly as impossible as a real entry would be, so it
   shouldn't visually invite one either. */
.number-pad.is-notes-mode .number-btn.is-complete {
  border-style: solid;
  border-color: var(--color-success);
  color: var(--color-success);
}

/* ---------- Pause overlay ----------
   Not a native <dialog> (pausing has to work the instant the game
   detects "hidden" or Escape, without waiting on the modal machinery),
   but standardized to look and move like one: same panel/radius/
   shadow surface as .settings-dialog, same fade+scale entrance/exit,
   same backdrop opacity — "polish every dialog/overlay/pause screen
   the same way" applies here too, not just to the <dialog> elements. */
.pause-overlay {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: max(var(--space-3), env(safe-area-inset-top)) max(var(--space-3), env(safe-area-inset-right))
    max(var(--space-3), env(safe-area-inset-bottom)) max(var(--space-3), env(safe-area-inset-left));
  /* Same opacity as .settings-dialog::backdrop — one "soften the
     interface behind a modal surface" treatment everywhere. */
  background: rgba(0, 0, 0, 0.5);
  opacity: 1;
  transition: opacity var(--duration-dialog) var(--ease-standard), overlay var(--duration-dialog) allow-discrete, display var(--duration-dialog) allow-discrete;
}

.pause-overlay[hidden] {
  display: none;
  opacity: 0;
}

@starting-style {
  .pause-overlay:not([hidden]) {
    opacity: 0;
  }
}

.pause-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  width: min(20rem, 92vw);
  padding: var(--space-4);
  background: var(--color-panel);
  color: var(--color-text);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  transform: scale(0.96);
  transition: transform var(--duration-dialog) var(--ease-standard);
}

.pause-overlay:not([hidden]) .pause-card {
  transform: scale(1);
}

@starting-style {
  .pause-overlay:not([hidden]) .pause-card {
    transform: scale(0.96);
  }
}

.pause-card p {
  font-size: var(--font-size-12);
  font-weight: var(--font-weight-bold);
  margin: 0;
}

/* ---------- Shared buttons (Settings dialog) ---------- */

.btn-primary,
.btn-secondary {
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  cursor: pointer;
  font-weight: var(--font-weight-semibold);
}

.btn-primary {
  background: var(--color-accent);
  color: var(--color-accent-contrast);
  border: var(--border-width-thin) solid var(--color-accent);
  /* The one consistently elevated, filled button style in the app —
     matches the menu's "New Game" button (also .btn-primary's visual
     sibling) so every primary call-to-action reads the same, and gives
     the shared :active rule's shadow reduction something to reduce. */
  box-shadow: var(--shadow-sm);
}

.btn-secondary {
  background: var(--color-surface);
  color: var(--color-text);
  border: var(--border-width-thin) solid var(--color-border);
}

/* ---------- Settings dialog ----------
   Shared by all six <dialog> elements in the app (Settings, New Game's
   difficulty picker, the Hint confirm, Puzzle Solved, Replace
   Unfinished Game?, and Clear Data?) — one surface/border/radius/
   shadow/spacing system and one entrance/exit transition covers every
   one of them from this single rule. */

.settings-dialog {
  width: min(30rem, 92vw);
  max-height: min(85dvh, 44rem);
  overflow-y: auto;
  border: none;
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  margin: auto;
  background: var(--color-panel);
  color: var(--color-text);
  box-shadow: var(--shadow-lg);
  /* Closed/exiting state: faded and very slightly shrunk — a small
     transform, not a dramatic pop, and never a bounce (a plain ease,
     no overshoot in the timing function or the keyframe values). The
     `overlay`/`display` transitions (only meaningful with
     `allow-discrete`) are what let a native <dialog> animate on both
     open *and* close at all — without them the browser would just
     snap `display: none` on at the instant `close()` is called,
     skipping the opacity/transform transition entirely. No JS timing
     code needed anywhere for this: it's the browser's own close()
     that gets held open for one transition's length before the dialog
     actually leaves the top layer. */
  opacity: 0;
  transform: scale(0.96);
  transition: opacity var(--duration-dialog) var(--ease-standard), transform var(--duration-dialog) var(--ease-standard), overlay var(--duration-dialog) allow-discrete, display var(--duration-dialog) allow-discrete;
}

.settings-dialog[open] {
  opacity: 1;
  transform: scale(1);
}

/* The one-time "first frame" a dialog is shown from — without this, a
   newly-opened <dialog> has no previous rendered frame to transition
   *from* (it was `display: none` a moment ago), so the browser would
   just jump straight to the [open] state instead of easing into it. */
@starting-style {
  .settings-dialog[open] {
    opacity: 0;
    transform: scale(0.96);
  }
}

.settings-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
  /* Backdrop only fades (no transform — scaling a full-viewport box
     would visibly reveal the page at its shrunken edges, not read as
     "subtle"); kept as its own transition rather than piggybacking on
     the dialog's, since ::backdrop is a separate box the browser
     manages independently. */
  opacity: 0;
  transition: opacity var(--duration-dialog) var(--ease-standard), overlay var(--duration-dialog) allow-discrete, display var(--duration-dialog) allow-discrete;
}

.settings-dialog[open]::backdrop {
  opacity: 1;
}

@starting-style {
  .settings-dialog[open]::backdrop {
    opacity: 0;
  }
}

.settings-form h2 {
  margin-top: 0;
}

.settings-group {
  border: 0;
  padding: 0;
  margin: 0 0 var(--space-4);
}

.settings-group legend {
  font-weight: var(--font-weight-bold);
  padding: 0;
  margin-bottom: var(--space-2);
}

.settings-group .option-tile + .option-tile {
  margin-top: var(--space-2);
}

.settings-hint {
  color: var(--color-text-secondary);
  font-size: var(--font-size-4);
  min-height: 1.2em;
}

.settings-subheading {
  font-size: var(--font-size-7);
  margin: 0 0 var(--space-2);
}

/* Stacked, full-width buttons on mobile (readable at any label length —
   "Reset Appearance"/"Clear Data"/"Close" were previously squeezed into
   one row, wrapping unevenly on narrow screens) and a right-aligned row
   again once there's room, at the same >=768px breakpoint the rest of
   the app's desktop layout switches on (see the media query below).
   Column order matches DOM order (never reversed) so visual order,
   reading order, and keyboard tab order all agree. */
.settings-actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-top: var(--space-4);
}

/* ---- Volume sliders (Settings dialog) ---- */

.settings-slider {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-2) var(--space-3);
}

.settings-slider label {
  font-size: var(--font-size-4);
  color: var(--color-text-secondary);
}

.settings-slider input[type="range"] {
  width: 100%;
  min-height: var(--touch-target-min);
  accent-color: var(--color-accent);
}

/* ---- Option tiles (theme/mode radio choices) ---- */

.option-tile {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
  border: var(--border-width-thin) solid var(--color-border);
  border-radius: var(--radius-md);
  cursor: pointer;
  background: var(--color-panel);
  transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}

.option-tile input[type="radio"],
.option-tile input[type="checkbox"] {
  width: 1.1rem;
  height: 1.1rem;
  accent-color: var(--color-accent);
  flex-shrink: 0;
}

/* Progressive enhancement only — the native radio dot + accent-color
   above already communicates selection without :has() support. */
.option-tile:has(input:checked) {
  border-color: var(--color-accent);
  background: var(--color-surface);
  font-weight: var(--font-weight-semibold);
}

/* The actually-focusable element here is the small 1.1rem radio/
   checkbox, not the tile — the global :focus-visible rule already
   rings that input on its own, but this makes the keyboard focus cue
   land on the whole tile (same visual language as every other
   control's focus ring), which is easier to spot while tabbing through
   a list of tiles. Progressive enhancement again: without :has()
   support the input's own ring still shows. */
.option-tile:has(input:focus-visible) {
  outline: var(--focus-ring-width) solid var(--color-focus);
  outline-offset: 2px;
}

/* ---------- Appearance preview ---------- */

.preview-swatches {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
}

.preview-swatch {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-2);
  border-radius: var(--radius-sm);
  background: var(--color-bg);
  border: var(--border-width-thin) solid var(--color-border);
}

.preview-swatch--selected {
  background: var(--color-cell-selected);
}

.preview-swatch--related {
  background: var(--color-cell-related);
}

.preview-swatch--match {
  background: var(--color-cell-match);
}

.preview-digit {
  font-size: var(--font-size-10);
  font-weight: var(--font-weight-semibold);
  color: var(--color-entry-player);
}

.preview-digit--fixed {
  color: var(--color-clue-fixed);
  font-weight: var(--font-weight-bold);
}

.preview-notes {
  font-size: var(--font-size-2);
  letter-spacing: var(--letter-spacing-wide);
  color: var(--color-notes);
}

.preview-swatch small {
  font-size: var(--font-size-2);
  color: var(--color-text-secondary);
}

.preview-status {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

/* Status is never color-only: each chip pairs a color with an icon
   glyph, a text label, and its own outline shape. */
.status-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--font-size-3);
  font-weight: var(--font-weight-semibold);
  border: var(--border-width-thin) solid currentColor;
}

.status-chip--success {
  color: var(--color-success);
}

.status-chip--warning {
  color: var(--color-warning);
}

.status-chip--error {
  color: var(--color-error);
}

/* ---------- Completion dialog ---------- */

/* A small, one-shot "celebration" cue on the dialog's own heading — no
   confetti particles or extra DOM, just a brief scale+fade-in on the
   text that's already there. Declared only inside the no-preference
   media query (rather than relying solely on the global reduced-motion
   override further up) so a reduced-motion visitor's browser never even
   computes the animation, not just skips visibly running it. */
@media (prefers-reduced-motion: no-preference) {
  #completion-title.celebrate {
    animation: completion-celebrate var(--duration-moderate) var(--ease-out);
  }
}

@keyframes completion-celebrate {
  from {
    opacity: 0;
    transform: scale(0.85);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

/* Sits between the heading and the stats grid, whichever of the two
   treatments js/ui/completion-dialog.js applies (.status-chip--success
   for a top-3 finish, .settings-hint for 4th-10th) — both are already
   fully styled elsewhere, this only adds the spacing this particular
   spot needs. [hidden] (no game/no placement) needs nothing here at
   all: the attribute alone removes it from layout. */
.completion-rank-banner {
  margin: 0 0 var(--space-3);
}

.completion-stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-3);
  margin: 0 0 var(--space-2);
}

.completion-stat dt {
  font-size: var(--font-size-2);
  text-transform: uppercase;
  letter-spacing: var(--letter-spacing-wide);
  color: var(--color-text-secondary);
}

.completion-stat dd {
  margin: 0;
  font-size: var(--font-size-10);
  font-weight: var(--font-weight-bold);
}

/* Score is always the last stat in the DOM (see index.html) — spanning
   it across both columns and setting it off with a divider reads as
   "the total," separate from the difficulty/time/mistakes/hints
   breakdown above it. Calm, not confetti: one accent-colored, slightly
   larger number (the same "this is a score" treatment High Scores'
   own list already uses), no extra motion beyond the existing
   title-celebration animation just above. */
.completion-stat:last-child {
  grid-column: 1 / -1;
  padding-top: var(--space-2);
  border-top: var(--border-width-thin) solid var(--color-border);
}

.completion-stat:last-child dd {
  font-size: var(--font-size-12);
  color: var(--color-accent);
}

.share-label {
  display: block;
  font-weight: var(--font-weight-semibold);
  margin: var(--space-3) 0 var(--space-2);
}

.share-textarea {
  width: 100%;
  resize: vertical;
  font-family: inherit;
  font-size: var(--font-size-4);
  padding: var(--space-2);
  border-radius: var(--radius-md);
  border: var(--border-width-thin) solid var(--color-border);
  background: var(--color-bg);
  color: var(--color-text);
  margin-bottom: var(--space-2);
}

/* ---------- Statistics & High Scores screens ---------- */

.difficulty-filter {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-2);
  width: min(100%, 32rem);
  margin: 0 auto;
}

.difficulty-filter-btn {
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-2);
  font-size: var(--font-size-5);
  background: var(--color-surface);
  color: var(--color-text);
  border: var(--border-width-thin) solid var(--color-border);
  border-radius: var(--radius-md);
  cursor: pointer;
  font-weight: var(--font-weight-semibold);
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-3);
  width: min(100%, 32rem);
  margin: 0 auto;
  text-align: left;
}

.stats-item {
  padding: var(--space-2) var(--space-3);
  background: var(--color-surface);
  border: var(--border-width-thin) solid var(--color-border);
  border-radius: var(--radius-md);
}

.stats-item dt {
  font-size: var(--font-size-2);
  text-transform: uppercase;
  letter-spacing: var(--letter-spacing-wide);
  color: var(--color-text-secondary);
}

.stats-item dd {
  margin: 0;
  font-size: var(--font-size-10);
  font-weight: var(--font-weight-bold);
}

.highscores-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: min(100%, 32rem);
  margin: 0 auto;
  padding: 0;
  list-style: none;
  text-align: left;
}

.highscore-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--color-surface);
  border: var(--border-width-thin) solid var(--color-border);
  border-radius: var(--radius-md);
}

/* A just-achieved run (js/ui/high-scores-screen.js highlights whichever
   entry the most recent completion produced, for exactly one screen
   visit) gets the same inset-ring treatment already used for the
   board's own selection/current-value states — --color-accent as a
   non-text 3:1 use, never as text itself (see .highscore-score's own
   comment just below for why that distinction matters in this row). */
.highscore-row--new {
  box-shadow: inset 0 0 0 var(--cell-ring-width) var(--color-accent);
}

/* Fixed badge shape for every row's rank — a circle sized for 1-2
   digits — so top-3 "medal" rows (below) don't shift size/alignment
   relative to the plain 4th-10th ones. */
.highscore-rank {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.25rem;
  height: 2.25rem;
  border-radius: var(--radius-pill);
  font-weight: var(--font-weight-bold);
  color: var(--color-text-secondary);
}

/* Three "medal" tiers for the top 3 ranks, deliberately built from
   pairings this app has already proven safe elsewhere rather than new
   gold/silver/bronze colors that would each need their own per-theme
   contrast audit:
   - Rank 1: the exact fill .btn-primary already uses everywhere
     (--color-accent behind --color-accent-contrast) — the strongest of
     the three.
   - Rank 2: --color-accent as a border only (safe as a 3:1 non-text
     use) with --color-entry-player for the number itself — the same
     accent-flavored-but-actually-safe-on-surface text token
     .highscore-score uses just below, for the same reason: plain
     --color-accent text fails 4.5:1 against --color-surface in
     Paper/dark.
   - Rank 3: a plain neutral border, the subtlest of the three.
   The "#1"/"#2"/"#3" text is what actually carries the rank — this is
   additional emphasis, not the only way the information is conveyed. */
.highscore-row--rank-1 .highscore-rank {
  background: var(--color-accent);
  color: var(--color-accent-contrast);
}

.highscore-row--rank-2 .highscore-rank {
  border: var(--border-width-thin) solid var(--color-accent);
  color: var(--color-entry-player);
}

.highscore-row--rank-3 .highscore-rank {
  border: var(--border-width-thin) solid var(--color-border);
  color: var(--color-text);
}

/* Plain visible text, not color-only — pairs with .highscore-row--new's
   ring rather than replacing it, so the "just achieved" signal doesn't
   rely on color alone either. Reuses --color-success, the app's
   established "this succeeded" token (see Phase 16b's contrast
   verification against this exact --color-surface background). */
.highscore-new-badge {
  font-size: var(--font-size-3);
  font-weight: var(--font-weight-bold);
  color: var(--color-success);
  text-transform: uppercase;
  letter-spacing: var(--letter-spacing-wide);
}

.highscore-score {
  font-size: var(--font-size-9);
  font-weight: var(--font-weight-bold);
  /* --color-entry-player, not --color-accent — same a11y contrast fix
     as .brand span above: fails 4.5:1 against --color-surface (this
     row's own background) in Paper/dark (3.88:1) with --color-accent. */
  color: var(--color-entry-player);
}

.highscore-detail {
  font-size: var(--font-size-4);
  color: var(--color-text-secondary);
}

.highscore-date {
  margin-left: auto;
  font-size: var(--font-size-3);
  color: var(--color-text-secondary);
}

/* ---------- Intro-to-menu fade ---------- */

.menu-fade-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: #000;
  opacity: 0;
  pointer-events: none;
}

.menu-fade-overlay[hidden] {
  display: none;
}

.menu-fade-overlay.is-visible {
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .menu-fade-overlay {
    transition: opacity var(--duration-slow) var(--ease-standard);
  }
}

/* ---------- Connection status + update banner ---------- */

.connection-status {
  position: fixed;
  top: max(var(--space-2), env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  margin: 0;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-md);
  /* Same pattern as .status-chip--warning: colored text + colored
     border on a neutral panel background, not colored-fill-behind-white-
     text — several theme packs' --color-warning is a light amber meant
     to be read as foreground text, and would fail contrast as a solid
     fill under white text. */
  background: var(--color-panel);
  color: var(--color-warning);
  border: var(--border-width-thin) solid var(--color-warning);
  font-size: var(--font-size-4);
  font-weight: var(--font-weight-semibold);
  width: max-content;
  max-width: min(92vw, 26rem);
  box-shadow: var(--shadow-sm);
}

.connection-status[hidden] {
  display: none;
}

.update-banner {
  position: fixed;
  left: 50%;
  bottom: max(var(--space-3), env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 30;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  background: var(--color-panel);
  color: var(--color-text);
  border: var(--border-width-thin) solid var(--color-border);
  box-shadow: var(--shadow-md);
  max-width: min(90vw, 24rem);
}

.update-banner[hidden] {
  display: none;
}

.update-banner p {
  margin: 0;
  font-size: var(--font-size-5);
}

/* ---------- Desktop ---------- */

@media (min-width: 768px) {
  .difficulty-filter {
    grid-template-columns: repeat(4, 1fr);
  }

  .settings-actions {
    flex-direction: row;
    justify-content: flex-end;
  }

  /* Centers #app as a "floating card" via flexbox rather than the more
     obvious `margin: auto` — a block element's vertical auto-margins
     can collapse through an unconstrained parent (confirmed here: it
     was inflating documentElement.scrollHeight ~40px past the actual
     viewport, a real if invisible cause of unwanted page scroll).
     Flex-item margins don't collapse the same way, which is the actual
     reason for this approach, not just centering. */
  body {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  #app {
    width: calc(100% - var(--space-5) * 2);
    max-width: 60rem;
    height: calc(100dvh - var(--space-5) * 2);
    margin: 0;
    background: var(--color-panel);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-panel);
    overflow-y: auto;
    overflow-x: hidden;
    transition: background-color var(--duration-fast) var(--ease-standard);
  }

  .screen {
    min-height: 100%;
    max-height: 100%;
  }
}

/* ---------- Wide desktop: board + controls side panel ----------
   At >=1024px there's enough spare horizontal room that stacking the
   toolbar and number pad below a centered board just wastes it, and
   pushes the controls a long mouse-travel distance from the board.
   Rearranged as a two-column grid instead — board on the left spanning
   both control rows, toolbar/number pad forming a control "side panel"
   on the right, in the same top-to-bottom DOM/reading order they
   already had (so focus order doesn't jump around relative to what's
   visually below what). Scoped to #screen-game specifically, NOT the
   shared .screen-game class — Statistics/High Scores reuse that class
   for its base flex layout but have a completely different set of
   children, and would break under this grid's named areas. */
@media (min-width: 1024px) {
  #screen-game {
    display: grid;
    grid-template-columns: minmax(20rem, 32rem) minmax(14rem, 18rem);
    grid-template-areas:
      "header  header"
      "status  status"
      "board   toolbar"
      "board   numpad";
    column-gap: var(--space-4);
    row-gap: var(--space-3);
    align-items: start;
    justify-content: center;
    width: 100%;
  }

  #screen-game .game-header {
    grid-area: header;
    width: 100%;
    margin: 0;
  }

  #screen-game .game-status {
    grid-area: status;
    width: 100%;
    max-width: none;
    text-align: left;
  }

  #screen-game #board {
    grid-area: board;
    width: 100%;
    margin: 0;
  }

  #screen-game .board-toolbar {
    grid-area: toolbar;
    flex-direction: column;
    width: 100%;
    margin: 0;
  }

  #screen-game #number-pad {
    grid-area: numpad;
    grid-template-columns: repeat(3, 1fr);
    width: 100%;
    margin: 0;
  }
}

/* ---------- Short landscape phones: same side-by-side idea, smaller ----------
   A phone in landscape is wide but short — stacking the number pad
   below a full-width board can push it past the visible viewport
   entirely, forcing a scroll to reach it mid-game. The two-column
   layout above solves exactly this, just sized down: the board caps to
   a share of viewport *height* (the scarce dimension here) instead of
   width. Gated on orientation+height together so it only ever matches
   genuinely short landscape viewports, never a landscape tablet (which
   has plenty of height to spare and keeps the normal desktop/mobile
   single-column flow up to the 1024px rule above). */
@media (orientation: landscape) and (max-height: 500px) {
  #screen-game {
    display: grid;
    grid-template-columns: min(55vh, 20rem) minmax(12rem, 16rem);
    grid-template-areas:
      "header  header"
      "status  status"
      "board   toolbar"
      "board   numpad";
    column-gap: var(--space-3);
    row-gap: var(--space-2);
    align-items: start;
    justify-content: center;
    padding-top: var(--space-2);
    padding-bottom: var(--space-2);
  }

  #screen-game .game-header {
    grid-area: header;
    width: 100%;
    margin: 0;
  }

  #screen-game .game-status {
    grid-area: status;
    width: 100%;
    max-width: none;
    min-height: 0;
    font-size: var(--font-size-3);
  }

  #screen-game #board {
    grid-area: board;
    width: 100%;
    margin: 0;
  }

  /* Left as a row here (unlike the >=1024px panel above) — height is
     the scarce dimension at this breakpoint, and three stacked buttons
     would cost roughly 3x the vertical space of one row of three. */
  #screen-game .board-toolbar {
    grid-area: toolbar;
    width: 100%;
    margin: 0;
  }

  #screen-game #number-pad {
    grid-area: numpad;
    grid-template-columns: repeat(3, 1fr);
    width: 100%;
    margin: 0;
  }
}
