/* ==========================================================================
   Yahtzee shared UI — skins (spec §10) + chess.com-style layout (spec §9.1).
   Loaded by every host via _content/Yahtzee.Components/css/yahtzee.css.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Skins: CSS custom properties per data-theme (spec §10.2).
   Green derives from the §10.3 reference values; tokens that failed the §11
   contrast requirements (4.5:1 text, 3:1 UI boundaries — enforced by
   SkinContrastTests) are adjusted:
   - accent-text is dark, not white (white on #7fa650 is only 2.8:1), and the
     hover accent goes lighter, not darker, to keep that dark text readable;
   - text-secondary and score-available were a hair under 4.5:1;
   - control-border marks interactive component boundaries (dice, inputs,
     open score cells) at ≥3:1 against the surface; plain border stays
     decorative.
   The three bracket-* tokens colour a category row's edges by the state of its
   score — scored, scored zero, or still open (§9.3 "clear visual lock on used
   categories"). They are state indicators, so each clears 3:1 against both the
   card surface and the row's own surface-alt fill (§11 1.4.11, SkinContrastTests).
   Colour is never the only cue (§11 1.4.1): the cell itself shows the number, a
   "0", or the visually-hidden "Not scored yet".
   On the light-surfaced skins these are dark marks on a pale background, and a
   3 px edge that merely clears the minimum reads as washed out next to the bright
   brackets the dark skins get — testers said as much. So light and pink take
   deeper, more saturated values at ≥4.5:1 against both backgrounds, spaced apart
   in lightness so the three states also separate from each other (§10.2).
   The matching state-fill-* tokens tint the whole row behind the bracket, because
   edges alone still weren't telling the states apart at a glance. Each fill is a
   much lower-chroma member of its bracket's hue family, sitting away from the
   bracket on the light axis — deeper than it in the dark skins, paler in the light
   ones, where a fill darker than the bracket would put the skin's dark body text
   below 4.5:1. Every fill holds 4.5:1 for that text and 3:1 against its own bracket
   (§11 1.4.3/1.4.11, SkinContrastTests). Open stays a near-neutral: it is the
   resting state of most of the card, so it must not compete with the two decided
   ones.
   -------------------------------------------------------------------------- */

:root,
[data-theme="green"] {
  --color-bg:               #2b2b2b;
  --color-surface:          #312e2b;
  --color-surface-alt:      #3d3a37;
  --color-border:           #4a4744;
  --color-control-border:   #8d867c;
  --color-text-primary:     #f0d9b5;
  --color-text-secondary:   #b5a595;
  --color-accent:           #7fa650;
  --color-accent-hover:     #8db564;
  --color-accent-text:      #1a2310;
  --color-dice-face:        #f0d9b5;
  --color-dice-dot:         #312e2b;
  --color-dice-held-border: #7fa650;
  --color-score-locked:     #3d3a37;
  --color-score-available:  #465e2e;
  --color-bracket-scored:   #7fa650;
  --color-bracket-zero:     #e08d7a;
  --color-score-zero-slash: #ff4a3d;
  --color-bracket-open:     #9a9289;
  --color-state-fill-scored: #3d4a2b;
  --color-state-fill-zero:   #5f423a;
  --color-state-fill-open:   #3a3734;
  --color-player-active:    #7fa650;
  --color-dice-pattern:     #7fa650;
  --dice-pattern-alpha:     0.13;
}

/* Light: clean white background, neutral grey panels, blue accent (§10.1). */
[data-theme="light"] {
  --color-bg:               #f2f3f5;
  --color-surface:          #ffffff;
  --color-surface-alt:      #e9ebee;
  --color-border:           #c9ced4;
  --color-control-border:   #7d8894;
  --color-text-primary:     #1c2126;
  --color-text-secondary:   #5a6470;
  --color-accent:           #0b5cad;
  --color-accent-hover:     #094a8c;
  --color-accent-text:      #ffffff;
  --color-dice-face:        #ffffff;
  --color-dice-dot:         #1c2126;
  --color-dice-held-border: #0b5cad;
  --color-score-locked:     #e9ebee;
  --color-score-available:  #d3e4f6;
  --color-bracket-scored:   #0b6b38;
  --color-bracket-zero:     #a3160a;
  --color-score-zero-slash: #e01b0e;
  --color-bracket-open:     #616b78;
  --color-state-fill-scored: #b6d3c3;
  --color-state-fill-zero:   #e8bdb7;
  --color-state-fill-open:   #dbdfe4;
  --color-player-active:    #0b5cad;
  --color-dice-pattern:     #0b5cad;
  --dice-pattern-alpha:     0.08;
}

/* Dark: near-black background, dark grey panels, teal accent (§10.1). */
[data-theme="dark"] {
  --color-bg:               #101214;
  --color-surface:          #1b1e21;
  --color-surface-alt:      #262a2e;
  --color-border:           #3a4046;
  --color-control-border:   #767f87;
  --color-text-primary:     #e6e8ea;
  --color-text-secondary:   #a3adb8;
  --color-accent:           #2bbfa8;
  --color-accent-hover:     #45d3bd;
  --color-accent-text:      #0b2822;
  --color-dice-face:        #e6e8ea;
  --color-dice-dot:         #101214;
  --color-dice-held-border: #2bbfa8;
  --color-score-locked:     #262a2e;
  --color-score-available:  #155448;
  --color-bracket-scored:   #4fbf7a;
  --color-bracket-zero:     #f0918a;
  --color-score-zero-slash: #ff4a3d;
  --color-bracket-open:     #79838c;
  --color-state-fill-scored: #1f422f;
  --color-state-fill-zero:   #503534;
  --color-state-fill-open:   #2c3034;
  --color-player-active:    #2bbfa8;
  --color-dice-pattern:     #2bbfa8;
  --dice-pattern-alpha:     0.13;
}

/* Pink: soft blush background, cream panels, rose accent (§10.1). */
[data-theme="pink"] {
  --color-bg:               #f7e6ea;
  --color-surface:          #fdf7ef;
  --color-surface-alt:      #f4e7db;
  --color-border:           #d9bfc6;
  --color-control-border:   #9c7280;
  --color-text-primary:     #432a33;
  --color-text-secondary:   #79555f;
  --color-accent:           #b0355a;
  --color-accent-hover:     #93294a;
  --color-accent-text:      #ffffff;
  --color-dice-face:        #fdf7ef;
  --color-dice-dot:         #432a33;
  --color-dice-held-border: #b0355a;
  --color-score-locked:     #f4e7db;
  --color-score-available:  #f3cdd8;
  --color-bracket-scored:   #0f6338;
  --color-bracket-zero:     #8a2c0a;
  --color-score-zero-slash: #e01b0e;
  --color-bracket-open:     #82596a;
  --color-state-fill-scored: #bcd4c1;
  --color-state-fill-zero:   #e7c0ad;
  --color-state-fill-open:   #e3d7d4;
  --color-player-active:    #b0355a;
  --color-dice-pattern:     #b0355a;
  --dice-pattern-alpha:     0.09;
}

/* Red: the green skin's chrome — same charcoal panels and cream text — with the
   accent swapped to a warm cast-red at the same lightness (#10.1, issue #15).
   The accent has to be *light*, not deep: accent-text is dark here as it is in
   green, and any red deep enough to carry white text lands below 3:1 against
   this near-black surface. #e0705f matches green's #7fa650 in luminance, so the
   pair of skins reads as one design in two colours.
   Two of green's tokens deliberately do not follow the accent:
   - bracket-scored stays green. "Scored" is green in all four other skins, and a
     red scored bracket next to the red zero bracket would put the two opposite
     states in the same hue.
   - bracket-zero goes amber instead of green's salmon, which is close enough to
     this accent to be mistaken for it. Amber is still the warm-warning end of
     the scale and clears every §11 bar (SkinContrastTests), and colour is never
     the only cue — the cell shows a 0 struck through with score-zero-slash. */
[data-theme="red"] {
  --color-bg:               #2b2b2b;
  --color-surface:          #312e2b;
  --color-surface-alt:      #3d3a37;
  --color-border:           #4a4744;
  --color-control-border:   #8d867c;
  --color-text-primary:     #f0d9b5;
  --color-text-secondary:   #b5a595;
  --color-accent:           #e0705f;
  --color-accent-hover:     #e98878;
  --color-accent-text:      #2a0f0b;
  --color-dice-face:        #f0d9b5;
  --color-dice-dot:         #312e2b;
  --color-dice-held-border: #e0705f;
  --color-score-locked:     #3d3a37;
  --color-score-available:  #6e2f27;
  --color-bracket-scored:   #7fa650;
  --color-bracket-zero:     #e0a24f;
  --color-score-zero-slash: #ff4a3d;
  --color-bracket-open:     #9a9289;
  --color-state-fill-scored: #3d4a2b;
  --color-state-fill-zero:   #5a4526;
  --color-state-fill-open:   #3a3734;
  --color-player-active:    #e0705f;
  --color-dice-pattern:     #e0705f;
  --dice-pattern-alpha:     0.13;
}

/* --------------------------------------------------------------------------
   Base
   -------------------------------------------------------------------------- */

* {
  box-sizing: border-box;
  /* 150 ms transition when the skin switches (§10.4). */
  transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  font-size: 16px;
  line-height: 1.45;
  background-color: var(--color-bg);
  color: var(--color-text-primary);
}

h1, h2, h3 {
  margin: 0 0 0.5rem;
  line-height: 1.2;
}

button {
  font: inherit;
  color: inherit;
  cursor: pointer;
}

/* Visible focus ring in every skin (§11 2.4.7, 2.4.11). */
:focus-visible {
  outline: 3px solid var(--color-dice-held-border);
  outline-offset: 2px;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Focus-trap sentinels (GameOverModal): focusable but invisible and never shown. */
.focus-sentinel {
  position: fixed;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  outline: none;
}

.btn-accent,
.btn-secondary {
  border-radius: 8px;
  padding: 0.55rem 1.1rem;
  font-weight: 600;
  min-height: 44px; /* touch target (§11 2.5.8) */
  min-width: 44px;
}

.btn-accent {
  background-color: var(--color-accent);
  color: var(--color-accent-text);
  border: 1px solid transparent;
}

.btn-accent:hover {
  background-color: var(--color-accent-hover);
}

.btn-secondary {
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border);
}

.btn-secondary:hover {
  border-color: var(--color-text-secondary);
}

[aria-disabled="true"],
:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

/* --------------------------------------------------------------------------
   App shell & top navigation (§9.1)
   -------------------------------------------------------------------------- */

.app-shell {
  min-height: 100vh;
  /* Own stacking context so the dice backdrop below can sit at z-index -1: above this
     element's own background, beneath every screen. Without isolation a negative index
     would drop it behind the shell entirely and it would never be seen. */
  position: relative;
  isolation: isolate;
  /* Android draws the app edge-to-edge, so without this the foot of the shell — the
     last row of the setup menu, the scorecard's total — lays out underneath the system
     navigation bar. The padding sits inside the 100vh (border-box), so reserving it
     shrinks the content rather than adding a scrollbar. Zero everywhere else. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  display: flex;
  flex-direction: column;
  background-color: var(--color-bg);
  color: var(--color-text-primary);
}

/* --------------------------------------------------------------------------
   Dice backdrop

   A faint lattice of dice behind every screen, tilted 45 degrees and drifting
   slowly and endlessly in one direction picked at random per app start
   (yahtzee-background.js sets --dice-drift-*; with no JS the layer is simply
   static). The dice are DieGlyph's own geometry — same rounded face, same pip
   grid — so the wallpaper is recognisably this app's die rather than a generic
   texture.

   The tile is a mask, not a background-image, so one SVG serves all four skins
   and takes its colour from --color-dice-pattern; --dice-pattern-alpha then
   holds it at "discernible but never competing with the panels on top", which
   is a different number on the light-surfaced skins than the dark ones.

   The layer is fixed and oversized (220%, offset -60%) because rotating a
   viewport-sized box leaves its corners bare. Purely decorative and behind
   opaque panels, so it changes no contrast pair (§11 1.4.3); the drift is
   killed by the prefers-reduced-motion rule at the end of this file (§11 2.3.3).
   -------------------------------------------------------------------------- */

/* Tile edge, in one place: the mask repeats on it and yahtzee-background.js reads it
   back off :root so the drift it picks is always a whole number of tiles. */
:root {
  --dice-tile-size: 168px;
}

.app-shell::before {
  content: "";
  position: fixed;
  top: -60%;
  left: -60%;
  width: 220%;
  height: 220%;
  z-index: -1;
  pointer-events: none;
  background-color: var(--color-dice-pattern);
  opacity: var(--dice-pattern-alpha);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168' fill='%23000'%3E%3Cg transform='translate(40 38) rotate(-12) scale(1.9) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='16' cy='8' r='1.7'/%3E%3Ccircle cx='12' cy='12' r='1.7'/%3E%3Ccircle cx='8' cy='16' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(118 30) rotate(25) scale(1.5) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(128 108) rotate(-8) scale(2.1) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='7' r='1.7'/%3E%3Ccircle cx='8' cy='12' r='1.7'/%3E%3Ccircle cx='8' cy='17' r='1.7'/%3E%3Ccircle cx='16' cy='7' r='1.7'/%3E%3Ccircle cx='16' cy='12' r='1.7'/%3E%3Ccircle cx='16' cy='17' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(46 116) rotate(18) scale(1.6) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='12' cy='12' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168' fill='%23000'%3E%3Cg transform='translate(40 38) rotate(-12) scale(1.9) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='16' cy='8' r='1.7'/%3E%3Ccircle cx='12' cy='12' r='1.7'/%3E%3Ccircle cx='8' cy='16' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(118 30) rotate(25) scale(1.5) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(128 108) rotate(-8) scale(2.1) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='7' r='1.7'/%3E%3Ccircle cx='8' cy='12' r='1.7'/%3E%3Ccircle cx='8' cy='17' r='1.7'/%3E%3Ccircle cx='16' cy='7' r='1.7'/%3E%3Ccircle cx='16' cy='12' r='1.7'/%3E%3Ccircle cx='16' cy='17' r='1.7'/%3E%3C/g%3E%3Cg transform='translate(46 116) rotate(18) scale(1.6) translate(-12 -12)'%3E%3Crect x='3' y='3' width='18' height='18' rx='3.5' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.7'/%3E%3Ccircle cx='12' cy='12' r='1.7'/%3E%3Ccircle cx='16' cy='16' r='1.7'/%3E%3C/g%3E%3C/svg%3E");
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
  -webkit-mask-size: var(--dice-tile-size) var(--dice-tile-size);
  mask-size: var(--dice-tile-size) var(--dice-tile-size);
  transform: rotate(45deg);
  animation: dice-drift var(--dice-drift-duration, 120s) linear infinite;
  will-change: transform;
}

/* Motion turned off in settings (§11 2.2.2). yahtzee-background.js stamps the attribute
   at load, before the drift has a frame to run in. The dice themselves stay: it is the
   movement the switch is about, not the wallpaper. */
[data-background-motion="off"] .app-shell::before {
  animation: none;
}

/* The drift distance is a whole number of tiles (yahtzee-background.js), so the layer
   arrives back at a position indistinguishable from where it started and the loop is
   seamless. Defaulting both to 0px keeps a JS-less host from jumping at the wrap. */
@keyframes dice-drift {
  from { transform: rotate(45deg) translate(0, 0); }
  to   { transform: rotate(45deg) translate(var(--dice-drift-x, 0px), var(--dice-drift-y, 0px)); }
}

/* Blazor's unhandled-error bar. Both hosts' index.html declare it and the framework
   reveals it by setting an inline `display: block`, so something has to hide it the rest
   of the time — the web host had the template's app.css rule for that, the MAUI host
   loads no app.css at all, and an unstyled copy therefore sat permanently at the foot of
   every screen on Android, reading "An unhandled error has occurred" when nothing had,
   and adding its own height to the document. Owning it here means the one stylesheet
   both hosts load covers both, in the skin's colours rather than the template's
   lightyellow (§10.2, and text-primary on surface-alt holds 4.5:1 in all four — §11
   1.4.3). It clears the system navigation bar for the same reason .app-shell does. */
#blazor-error-ui {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1000;
  padding: 0.7rem 2.5rem 0.7rem 1.25rem;
  padding-bottom: calc(0.7rem + env(safe-area-inset-bottom, 0px));
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
  border-top: 3px solid var(--color-score-zero-slash);
  box-shadow: 0 -2px 10px rgb(0 0 0 / 0.35);
}

#blazor-error-ui .reload {
  color: inherit;
  margin-left: 0.35rem;
}

#blazor-error-ui .dismiss {
  cursor: pointer;
  position: absolute;
  right: 0.75rem;
  top: 0.5rem;
}

/* Loading splash (SplashScreen, and the identical static markup in each host's
   index.html): the logo centred on the skin's background while the app boots. The
   hosts stamp the cached data-theme before first paint, so this is already in the
   user's theme — no flash of the default skin. */
.splash-screen {
  flex: 1;
  position: relative; /* anchors .splash-version to the corner */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  min-height: 100vh;
  padding: 1rem;
  text-align: center;
}

.splash-mark {
  width: 5.5rem;
  height: 5.5rem;
  animation: splash-pulse 1.8s ease-in-out infinite;
}

.splash-name {
  margin: 0.75rem 0 0;
  font-size: 1.75rem;
  font-weight: 700;
}

.splash-status {
  margin: 0;
  color: var(--color-text-secondary);
}

/* Build version, pinned to the bottom-right corner rather than sitting in the centred
   column, so it never shifts the logo. Android/iOS draw the WebView edge-to-edge, so it
   also has to clear the system navigation bar and the home indicator. */
.splash-version {
  position: absolute;
  right: max(0.75rem, env(safe-area-inset-right));
  bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
  margin: 0;
  font-size: 0.8rem;
  color: var(--color-text-secondary);
}

/* Killed wholesale by the prefers-reduced-motion rule at the end of this file. */
@keyframes splash-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}

/* One row at every width. It used to wrap — that was how it reflowed at 320 px (§11
   1.4.10) while it also carried the round and the current player — and the price was the
   settings cog dropping onto a second line as soon as a game was on, furthest from the
   corner it was put in. With the game info gone the bar holds only the brand and the two
   controls, which fit a 320 px screen on one line; the brand's wordmark is allowed to
   truncate (see .nav-brand-name) so a wide sign-in button can never push the cog off
   again. Nothing here can overflow, so nothing needs to wrap. */
.top-nav {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  /* Edge-to-edge Android/iOS render the WebView under the status bar (viewport-fit=cover
     in index.html); keep the brand clear of the clock/battery/signal icons. */
  padding-top: max(0.5rem, env(safe-area-inset-top));
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
}

/* The brand is the way back to the menu (#20), so it's a <button> wherever there is
   somewhere to go; reset the native chrome to keep the original wordmark look. On the
   menu itself it renders as a <span> carrying the same class, which is why nothing here
   assumes a button — .nav-brand-static below only undoes the affordances. */
.nav-brand {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.15rem 0.25rem;
  margin: -0.15rem -0.25rem; /* keep the visual position while adding a hit area */
  font-size: 1.25rem;
  font-weight: 700;
  font-family: inherit;
  color: inherit;
  background: none;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
  /* The brand is what gives way when the row runs out of width. A shrink factor this far
     above the default is how "the wordmark goes first" is said in flexbox, which has no way
     to order concessions: weighted by base size, 100 collapses the wordmark to nothing
     before the controls beside it have given up anything worth seeing. min-width: 0 is what
     permits the shrink at all. */
  flex-shrink: 100;
  min-width: 0;
}

.nav-brand:hover .nav-brand-name {
  text-decoration: underline;
}

/* The wordmark is the one thing on the bar that may give way, and the only thing that can
   afford to: the die is right beside it saying the same thing. The die stays (.brand-mark
   is flex: none), so a very narrow screen loses letters from "Yahtzee" rather than losing
   the logo or pushing the settings cog off the row. */
.nav-brand-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Home, on the home screen: the logo is just the logo. No pointer, no hover — promising
   a destination that is already on screen is worse than promising nothing. */
.nav-brand-static {
  cursor: default;
}

.nav-brand-static:hover .nav-brand-name {
  text-decoration: none;
}

/* Settings (§9.1), at the far right of the nav — the last thing in the row, past the
   sign-in controls, which is where a cog is looked for. Sized to the §11 2.5.8 44x44
   minimum rather than to the glyph, which is smaller than a thumb. */
.nav-settings {
  display: grid;
  place-items: center;
  flex: none;
  width: 44px;
  height: 44px;
  padding: 0;
  color: inherit;
  background: none;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
}

.nav-settings:hover {
  background-color: var(--color-surface-alt);
}

.settings-icon {
  display: inline-flex;
  flex: none;
  color: var(--color-accent);
}

.settings-icon-svg {
  width: 100%;
  height: 100%;
  display: block;
}

.nav-settings-icon {
  width: 1.5rem;
  height: 1.5rem;
}

/* The logo (BrandMark → DieGlyph): the scorecard's five-pip die, drawn in
   currentColor so it re-tints with the skin's accent on every theme switch. */
.brand-mark {
  display: inline-flex;
  flex: none;
  color: var(--color-accent);
}

.brand-mark-svg {
  width: 100%;
  height: 100%;
  display: block;
}

.nav-brand-mark {
  width: 1.6rem;
  height: 1.6rem;
}

/* Sign-in controls plus the settings cog, at the right-hand end. It may shrink, but only
   once the brand has given up everything it has (see .nav-brand's shrink factor above) —
   and when it does, the sign-in group wraps internally rather than the header wrapping,
   so the cog stays on the brand's row whatever happens. That matters when both OAuth
   providers are configured (§20.10): two full-width sign-in labels do not fit a 320 px
   screen beside anything at all. */
.nav-controls {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  min-width: 0;
}

.sign-in-buttons {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem;
  min-width: 0; /* shrink, and so wrap, instead of pushing the cog off the row */
}

.sign-in-button {
  font-size: 0.85rem;
  padding: 0.45rem 0.8rem;
  /* "Sign in with Google" stays on one line: with the button free to wrap its own label,
     the row can settle a pixel short and break the label rather than take that pixel off
     the wordmark, which is the thing that is there to be sacrificed. Two providers still
     fit a 320 px screen, because the group above wraps between the buttons instead. */
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   Turn banner (§9.1): full-width contextual bar in the accent color
   -------------------------------------------------------------------------- */

.turn-banner {
  width: 100%;
  padding: 0.65rem 1rem;
  text-align: center;
  font-size: 1.1rem;
  font-weight: 700;
}

.turn-banner-active {
  background-color: var(--color-accent);
  color: var(--color-accent-text);
}

.turn-banner-waiting {
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
  border-bottom: 3px solid var(--color-accent);
}

/* --------------------------------------------------------------------------
   Network warning (§23.2)

   A standing marker in the top right of the screen, above the game and below the
   modals, shown only while the connection is actually holding play up. Fixed
   rather than in flow so it never reshuffles the board it is warning about, and
   clear of the status bar on the edge-to-edge mobile hosts.

   The colours are the same two the rest of the app uses for "look at this" and
   "this is wrong", so the marker needs no palette of its own: both sit on
   --color-surface, which every skin already holds at 4.5:1 for text and 3:1 for
   the control borders (§11 1.4.3 / 1.4.11).
   -------------------------------------------------------------------------- */

.net-warning {
  position: fixed;
  top: max(0.5rem, env(safe-area-inset-top));
  right: 0.5rem;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
}

/* 44px clears the §11 2.5.8 target minimum on its own — this is the one control on
   the screen a player reaches for while everything else is unresponsive. */
.net-warning-button {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  padding: 0;
  border-radius: 50%;
  border: 2px solid var(--color-control-border);
  background-color: var(--color-surface);
  color: var(--color-text-primary);
  cursor: pointer;
  box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
}

/* Actually disconnected, rather than merely slow: a heavier ring, alongside the bar
   the icon itself grows — never colour alone (§11 1.4.1). */
.net-warning-lost {
  border-color: var(--color-accent);
  border-width: 3px;
}

.net-warning-mark {
  width: 24px;
  height: 24px;
}

/* A slow pulse, so the marker is noticed arriving over a board the player is
   already looking at. Killed by the prefers-reduced-motion rule at the end of this
   file (§11 2.3.3), which is why nothing here depends on it. */
.net-warning-button {
  animation: net-warning-pulse 2.4s ease-in-out infinite;
}

@keyframes net-warning-pulse {
  0%, 100% { box-shadow: 0 2px 6px rgb(0 0 0 / 0.3); }
  50% { box-shadow: 0 2px 6px rgb(0 0 0 / 0.3), 0 0 0 6px rgb(0 0 0 / 0.06); }
}

.net-warning-panel {
  width: min(19rem, calc(100vw - 1.5rem));
  padding: 0.9rem 1rem;
  border-radius: 12px;
  border: 1px solid var(--color-border);
  background-color: var(--color-surface);
  color: var(--color-text-primary);
  box-shadow: 0 6px 18px rgb(0 0 0 / 0.35);
  text-align: left;
}

.net-warning-title {
  margin: 0 0 0.35rem;
  font-size: 1rem;
  font-weight: 700;
}

.net-warning-text {
  margin: 0 0 0.6rem;
  font-size: 0.9rem;
}

.net-warning-advice {
  margin: 0 0 0.75rem;
  padding-left: 1.1rem;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

.net-warning-advice li + li {
  margin-top: 0.3rem;
}

.net-warning-dismiss {
  width: 100%;
}

/* --------------------------------------------------------------------------
   Main two-panel layout (§9.1): game area left/center, sidebar right
   -------------------------------------------------------------------------- */

.game-main {
  flex: 1;
  width: 100%;
  max-width: 1280px;
  margin: 0 auto;
  padding: 1rem;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 400px;
  gap: 1rem;
  align-items: start;
}

.game-panel {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.game-sidebar {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

/* --------------------------------------------------------------------------
   Score flourish (§9.1, #16): the category's glyph and its points, swept over
   the dice as the score is recorded, on every player's screen.

   The stage is the positioning context, and it is the tray itself rather than
   the panel: the flourish is anchored over the dice, and the panel also holds
   the roster above them and the roll controls below.

   Decorative and non-interactive — aria-hidden in the markup, pointer-events
   none here, so the dice underneath stay tappable while it runs. It is drawn
   from --color-accent on the panel's own surface, opaque rather than tinted,
   which is what keeps it legible over both a printed tray and a 3D one whatever
   the skin. That accent/surface pair is held at 3:1 by SkinContrastTests, which
   is the right bar here: the points are 2rem bold, large text under §11 1.4.3.
   The category label above them is text-secondary, already held at 4.5:1.

   opacity: 0 is the resting state, not a keyframe: the reduced-motion rule at
   the end of this file cancels every animation with `animation: none`, and a
   flourish whose only hiding place was the end of its own keyframes would then
   stay on screen for the rest of the game (§11 2.3.3). Off is the honest
   reading of "reduce motion" for a purely decorative sweep — the score is on
   the card and in the live region either way.
   -------------------------------------------------------------------------- */

.dice-stage {
  position: relative;
}

.score-flourish {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.55rem 1.1rem;
  border: 2px solid var(--color-accent);
  border-radius: 0.9rem;
  background: var(--color-surface);
  box-shadow: 0 0.4rem 1.4rem rgb(0 0 0 / 45%);
  color: var(--color-accent);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -50%);
}

.score-flourish-icon {
  display: flex;
  font-size: 2rem;
}

.score-flourish-icon .cat-svg {
  width: 1.5em;
  height: 1.5em;
}

.score-flourish-body {
  display: flex;
  flex-direction: column;
  line-height: 1.15;
}

.score-flourish-category {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--color-text-secondary);
}

.score-flourish-points {
  font-size: 2rem;
  font-weight: 700;
}

/* All three run well inside the 2 s #16 allows, and all three end at opacity 0
   with fill-mode forwards, so the flourish takes itself off screen without a
   timer in C# having to agree with this file about when it is over. */
.score-flourish-fade {
  animation: score-flourish-fade 1.7s ease-out forwards;
}

.score-flourish-scroll {
  animation: score-flourish-scroll 1.7s linear forwards;
}

.score-flourish-grow {
  animation: score-flourish-grow 1.7s ease-out forwards;
}

@keyframes score-flourish-fade {
  0% { opacity: 0; transform: translate(-50%, -40%); }
  15% { opacity: 1; transform: translate(-50%, -50%); }
  70% { opacity: 1; transform: translate(-50%, -50%); }
  100% { opacity: 0; transform: translate(-50%, -60%); }
}

/* Left to right and off the far side (#16). The travel is in viewport widths so
   it clears the panel on a phone and a desktop alike. */
@keyframes score-flourish-scroll {
  0% { opacity: 0; transform: translate(-50%, -50%) translateX(-60vw); }
  15% { opacity: 1; }
  85% { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -50%) translateX(60vw); }
}

@keyframes score-flourish-grow {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
  20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(2.1); }
}

/* 768–1023 px: stacked — dice tray on top, scorecard below (§9.1). */
@media (max-width: 1023px) {
  .game-main {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --------------------------------------------------------------------------
   Dice tray & dice (§9.2)
   -------------------------------------------------------------------------- */

.dice-tray {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem;
  justify-content: center;
  padding: 2rem 1.25rem;
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 14px;
}

.die {
  position: relative;
  width: clamp(52px, 11vw, 76px);
  aspect-ratio: 1;
  padding: 0;
  background-color: var(--color-dice-face);
  /* Interactive boundary at ≥3:1 vs the tray (§11 1.4.11) — the face fill alone
     can match the surface in the light and pink skins. */
  border: 3px solid var(--color-control-border);
  border-radius: 14%;
  box-shadow: 0 2px 4px rgb(0 0 0 / 0.25);
}

/* Held: border + lock icon, never color alone (§11 1.4.1). */
.die-held {
  border-color: var(--color-dice-held-border);
  box-shadow: 0 0 0 2px var(--color-dice-held-border), 0 2px 4px rgb(0 0 0 / 0.25);
  transform: translateY(-4px);
}

.die-lock {
  position: absolute;
  top: -10px;
  right: -10px;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  background-color: var(--color-dice-held-border);
  color: var(--color-accent-text);
  border-radius: 50%;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.35);
}

/* Pip grid: 3×3, positions driven by data-value. */
.die-face {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  width: 100%;
  height: 100%;
  padding: 15%;
}

.pip {
  width: 72%;
  aspect-ratio: 1;
  border-radius: 50%;
  background-color: var(--color-dice-dot);
  align-self: center;
  justify-self: center;
}

.die-face[data-value="1"] .pip:nth-child(1) { grid-area: 2 / 2; }

.die-face[data-value="2"] .pip:nth-child(1) { grid-area: 1 / 1; }
.die-face[data-value="2"] .pip:nth-child(2) { grid-area: 3 / 3; }

.die-face[data-value="3"] .pip:nth-child(1) { grid-area: 1 / 1; }
.die-face[data-value="3"] .pip:nth-child(2) { grid-area: 2 / 2; }
.die-face[data-value="3"] .pip:nth-child(3) { grid-area: 3 / 3; }

.die-face[data-value="4"] .pip:nth-child(1) { grid-area: 1 / 1; }
.die-face[data-value="4"] .pip:nth-child(2) { grid-area: 1 / 3; }
.die-face[data-value="4"] .pip:nth-child(3) { grid-area: 3 / 1; }
.die-face[data-value="4"] .pip:nth-child(4) { grid-area: 3 / 3; }

.die-face[data-value="5"] .pip:nth-child(1) { grid-area: 1 / 1; }
.die-face[data-value="5"] .pip:nth-child(2) { grid-area: 1 / 3; }
.die-face[data-value="5"] .pip:nth-child(3) { grid-area: 2 / 2; }
.die-face[data-value="5"] .pip:nth-child(4) { grid-area: 3 / 1; }
.die-face[data-value="5"] .pip:nth-child(5) { grid-area: 3 / 3; }

.die-face[data-value="6"] .pip:nth-child(1) { grid-area: 1 / 1; }
.die-face[data-value="6"] .pip:nth-child(2) { grid-area: 2 / 1; }
.die-face[data-value="6"] .pip:nth-child(3) { grid-area: 3 / 1; }
.die-face[data-value="6"] .pip:nth-child(4) { grid-area: 1 / 3; }
.die-face[data-value="6"] .pip:nth-child(5) { grid-area: 2 / 3; }
.die-face[data-value="6"] .pip:nth-child(6) { grid-area: 3 / 3; }

/* Unrolled die (value 0): empty face with a subtle dashed border. */
.die-face[data-value="0"] {
  border-radius: inherit;
}

.die[aria-label*="not rolled"] {
  border-style: dashed;
  box-shadow: none;
}

/* Roll animation, skipped for users who prefer reduced motion. */
.die-rolling {
  animation: die-roll 500ms ease;
}

@keyframes die-roll {
  0%   { transform: rotate(0deg) translateY(0); }
  25%  { transform: rotate(-18deg) translateY(-12px); }
  50%  { transform: rotate(14deg) translateY(-16px); }
  75%  { transform: rotate(-7deg) translateY(-6px); }
  100% { transform: rotate(0deg) translateY(0); }
}

/* --- 3D dice (spec §9.4) ---------------------------------------------------
   One canvas stretched over the whole tray, with the five .die buttons still on top of it
   as the live controls; yahtzee-dice3d.js draws each die into the viewport its button
   occupies. The canvas is decoration (aria-hidden) and never takes a pointer event, so
   holding a die is the same click on the same button it always was.

   .dice-tray-3d is applied only once the renderer has actually started, so a device without
   WebGL2 or a model that fails to load keeps the pip faces below rather than hiding them
   behind an empty canvas. */
.dice-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* With the model drawn behind it, the button's own face would only sit in front of the die,
   so it goes: the die is the visible control, and at 5-6:1 against the tray it carries the
   §11 1.4.11 boundary the border used to. The border stays in the box model at the same
   width so nothing reflows when the setting is switched, and the lock badge, the lifted
   position and the focus ring are all untouched. (The held ring is not — see below.) */
.dice-tray-3d .die {
  background-color: transparent;
  border-color: transparent;
  box-shadow: none;
}

.dice-tray-3d .die-face {
  visibility: hidden;
}

/* Before the turn's first roll there is nothing to draw — the renderer skips a die with no
   value — so the empty dashed slot the 2D tray shows is what stays on screen. */
.dice-tray-3d .die[aria-label*="not rolled"] {
  border-color: var(--color-control-border);
}

/* Held, in 3D: the lock badge and the lift, and no box (#11). Ringing a rendered die with
   a flat rectangle reads as a sprite in a frame — the badge says the same thing without
   contradicting the model. §11 1.4.1 is still satisfied without color alone: the badge is a
   shape, and the renderer takes each die's position from this button's box, so the lift
   inherited from .die-held raises the die itself and not just the overlay. */
.dice-tray-3d .die-held {
  border-color: transparent;
  box-shadow: none;
}

/* The 2D rock-and-hop belongs to the pip face; the 3D die does its own tumbling. */
.dice-tray-3d .die-rolling {
  animation: none;
}

/* --------------------------------------------------------------------------
   Roll controls
   -------------------------------------------------------------------------- */

.roll-controls {
  display: flex;
  justify-content: center;
}

/* Reviewing a finished game (§9.3) on a host with no back gesture to leave by: the
   way out stands exactly where the Roll button did, sized to match, so dismissing the
   game-over dialog doesn't reshape the panel underneath it. */
.review-done {
  padding: 0.75rem 2rem;
  min-height: 48px;
  font-size: 1.05rem;
}

.roll-button {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.75rem 2rem;
  min-height: 48px;
  font-size: 1.1rem;
  font-weight: 700;
  border: 1px solid transparent;
  border-radius: 10px;
  background-color: var(--color-accent);
  color: var(--color-accent-text);
}

.roll-button:hover:not([aria-disabled="true"]) {
  background-color: var(--color-accent-hover);
}

.roll-counter {
  font-size: 0.9rem;
  font-weight: 600;
  padding: 0.15rem 0.5rem;
  border-radius: 6px;
  /* Solid chip: a translucent overlay can't guarantee 4.5:1 in every skin (§11 1.4.3). */
  background-color: var(--color-surface);
  color: var(--color-text-primary);
}

/* Column layout so the buttons always share one row beneath the prompt: with a
   single wrapping flex row, a long category name pushed Cancel onto its own
   line at some text lengths but not others. */
.score-confirm {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  background-color: var(--color-surface);
  border: 1px solid var(--color-accent);
  border-radius: 10px;
}

.score-confirm-text {
  text-align: center;
}

.score-confirm-actions {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
}

.game-error {
  padding: 0.6rem 1rem;
  border-radius: 8px;
  border: 1px solid var(--color-accent);
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
  font-weight: 600;
  text-align: center;
}

/* Narrow phones (e.g. Galaxy S24 portrait, ~360-412 CSS px): the desktop dice
   and confirm-button sizing don't fit five dice plus both buttons on one row,
   so they wrap awkwardly (an orphaned die, a stray button). Shrink both a
   little rather than letting them overflow onto extra lines. */
@media (max-width: 480px) {
  .dice-tray {
    gap: 0.5rem;
    padding: 1.25rem 0.5rem;
  }

  .die {
    width: clamp(36px, 14vw, 48px);
  }

  .score-confirm {
    gap: 0.5rem;
    padding: 0.6rem 0.75rem;
  }

  .score-confirm-actions {
    gap: 0.5rem;
  }

  .btn-accent,
  .btn-secondary {
    padding: 0.45rem 0.8rem;
    font-size: 0.9rem;
    min-height: 40px;
    min-width: 40px;
  }
}

/* --------------------------------------------------------------------------
   Player list (§9.3): sits at the top of the game panel, above the dice tray
   -------------------------------------------------------------------------- */

.player-list {
  list-style: none;
  margin: 0;
  padding: 0.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 12px;
}

.player-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  border-radius: 8px;
  border-left: 4px solid transparent;
}

.player-row-active {
  border-left-color: var(--color-player-active);
  background-color: var(--color-surface-alt);
  font-weight: 700;
}

.player-turn-tag {
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-accent-text);
  background-color: var(--color-player-active);
  border-radius: 999px;
  padding: 0.1rem 0.5rem;
}

.player-score {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}

/* Compact roster (§9.1 "Compact player list", the default): one column per player —
   name on the first row, running total on the second — so the whole roster fits on a
   single row of the panel however many players there are. Equal-width columns that may
   shrink to nothing (minmax(0, 1fr)) are what let a long name ellipsis rather than push
   the row wide. Setting `display: grid` on the <ul> drops its implicit list semantics in
   some engines, so PlayerList.razor states role="list"/"listitem" explicitly. */
.player-list-compact {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 0.25rem;
}

.player-column {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  min-width: 0; /* grid item must be allowed to shrink for the name to truncate */
  padding: 0.35rem 0.15rem;
  border-radius: 8px;
}

/* One line, truncated with an ellipsis — the full name stays in the DOM and in the
   title attribute, so nothing is hidden from assistive technology (§11 1.4.1). */
.player-column-name {
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 0.85rem;
  line-height: 1.2;
}

/* The score wears the same bracket idiom as a scorecard category row. Every column
   reserves the gutter so the numbers stay aligned; only the active player's brackets
   are painted, which is a shape difference, not colour alone (§11 1.4.1). */
.player-column-score {
  min-width: 2.5rem;
  padding: 0.1rem 0.35rem;
  border-left: 3px solid transparent;
  border-right: 3px solid transparent;
  border-radius: 8px;
  text-align: center;
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}

.player-column-active .player-column-name {
  font-weight: 700;
}

.player-column-active .player-column-score {
  border-left-color: var(--color-player-active);
  border-right-color: var(--color-player-active);
  background-color: var(--color-surface-alt);
}

/* --------------------------------------------------------------------------
   Scorecard (§9.2, §11 1.3.1): card-based semantic table, packed two-up

   The whole card must fit on one mobile screen (§9.1), so the category rows are
   laid out two-per-line via CSS grid. That overrides the table elements'
   `display`, which strips their implicit ARIA table roles — Scorecard.razor puts
   the roles back explicitly (role="table"/"rowgroup"/"row"/"columnheader"/
   "rowheader"/"cell"), so the semantic table (§11 1.3.1) is preserved. Logically
   it stays a two-column table (Category, Score) with one category per row; the
   two-up packing is purely visual.
   -------------------------------------------------------------------------- */

.scorecard-card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 12px;
  overflow: hidden;
}

.scorecard-card-current {
  border-color: var(--color-player-active);
}

/* Stacked (default) drops the visible header strip and Upper/Lower labels but keeps
   them in the DOM for AT (§11 1.3.1) — same effect as .visually-hidden. The linear
   layout below restores them. */
.scorecard-colhead,
.scorecard-section-row {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.scorecard {
  display: block;
  width: 100%;
  font-size: 0.9rem;
}

/* A strip across the top of the card. Flex rather than block so the pace (#28) can sit at
   its far end rather than trailing the name: `margin-inline-start: auto` on the pace pushes
   it right in both writing directions, and the name may shrink and wrap rather than shoulder
   it off the card. */
.scorecard caption {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 0.55rem 0.75rem;
  font-weight: 700;
  text-align: left;
  background-color: var(--color-surface-alt);
  border-bottom: 1px solid var(--color-border);
}

.scorecard-caption-name {
  min-width: 0;
}

.scorecard-caption-pace {
  margin-inline-start: auto;
  flex: none;
  font-weight: 400;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}

/* The category rows and the summary/total rows both pack two-up. */
.scorecard tbody {
  display: grid;
  /* minmax(0, …) lets the two tracks shrink below their content's intrinsic width
     so a long category name wraps inside the pill instead of forcing the card
     wider than the viewport (reflow at 320px, §11 1.4.10). */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 0.4rem;
  padding: 0.5rem;
}

.scorecard-summary-row,
.scorecard-total-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-width: 0; /* let a long label shrink/wrap rather than overflow its cell */
  padding: 0.15rem 0.35rem;
}

/* Force the first row of each summary pair onto a fresh grid row so the pairing
   holds no matter how many category rows precede it. */
.scorecard-pair-start {
  grid-column: 1;
}

.scorecard-summary-row th,
.scorecard-summary-row td {
  color: var(--color-text-secondary);
  font-weight: 600;
}

/* Stack the label so the bracketed hint drops onto its own line below the label
   text instead of wrapping mid-phrase across two rows. */
.scorecard-summary-row th {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
}

.scorecard-total-row th,
.scorecard-total-row td {
  font-weight: 800;
  font-size: 1rem;
}

.scorecard-summary-row td,
.scorecard-total-row td {
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.scorecard-hint {
  display: block;
  white-space: nowrap; /* keep the whole bracketed phrase on one line */
  font-weight: 400;
  color: var(--color-text-secondary);
  font-size: 0.72rem;
}

/* A single category: rounded pill holding icon + name on the left, score cell on
   the right. Tinted left/right edges echo the mock-up's bracketed look, and both
   they and the pill's own fill tell the three score states apart at a glance —
   see --bracket / --state-fill below. */
.scorecard-category-row {
  /* Overridden by the state class below; the open pair is the resting state. */
  --bracket: var(--color-bracket-open);
  --state-fill: var(--color-state-fill-open);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.35rem;
  min-width: 0; /* grid item may shrink so its label wraps rather than overflowing */
  min-height: 40px; /* comfortable touch target (§11 2.5.8) */
  padding: 0.2rem 0.35rem 0.2rem 0.5rem;
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--bracket);
  border-right: 3px solid var(--bracket);
  border-radius: 10px;
  background-color: var(--state-fill);
}

/* Score state (Scorecard.razor puts one of these on every category row): the edge
   colour and the fill behind it together distinguish scored / scored-zero / still
   open. Held in two local properties so both layouts read them from a single place. */
.score-state-open {
  --bracket: var(--color-bracket-open);
  --state-fill: var(--color-state-fill-open);
}

.score-state-zero {
  --bracket: var(--color-bracket-zero);
  --state-fill: var(--color-state-fill-zero);
}

.score-state-scored {
  --bracket: var(--color-bracket-scored);
  --state-fill: var(--color-state-fill-scored);
}

.category-label {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  min-width: 0; /* let a long name shrink/wrap instead of overflowing */
  font-weight: 600;
  text-align: left;
}

.category-name {
  min-width: 0;
  line-height: 1.1;
}

.category-icon {
  display: inline-flex;
  flex: none;
  color: var(--color-accent);
}

.cat-svg {
  width: 1.15rem;
  height: 1.15rem;
  display: block;
}

/* --------------------------------------------------------------------------
   Linear scorecard layout (settings §9.1): the original single-column table —
   one category per row, with the visible header strip and Upper/Lower labels.
   Selected in settings; applied via data-scorecard-layout="linear" on <html>.
   These rules override the stacked defaults above; the markup is identical, so
   this is a purely visual switch. Gated on the attribute so the shipped stacked
   layout is untouched when it's absent.
   -------------------------------------------------------------------------- */

[data-scorecard-layout="linear"] .scorecard {
  display: table;
  border-collapse: collapse;
  font-size: 0.95rem;
}

/* Restore the header strip and section labels hidden by the stacked defaults. */
[data-scorecard-layout="linear"] .scorecard-colhead,
[data-scorecard-layout="linear"] .scorecard-section-row {
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip-path: none;
  white-space: normal;
}

[data-scorecard-layout="linear"] .scorecard-colhead {
  display: table-header-group;
}

[data-scorecard-layout="linear"] .scorecard tbody {
  display: table-row-group;
  /* undo the two-up grid packing */
  grid-template-columns: none;
  gap: 0;
  padding: 0;
}

[data-scorecard-layout="linear"] .scorecard-category-row,
[data-scorecard-layout="linear"] .scorecard-summary-row,
[data-scorecard-layout="linear"] .scorecard-total-row,
[data-scorecard-layout="linear"] .scorecard-section-row,
[data-scorecard-layout="linear"] .scorecard-colhead tr {
  display: table-row;
}

/* Drop the pill treatment — in the table a category is an ordinary row. */
[data-scorecard-layout="linear"] .scorecard-category-row {
  border: 0;
  border-radius: 0;
  background-color: transparent;
  min-height: 0;
  padding: 0;
}

[data-scorecard-layout="linear"] .scorecard th,
[data-scorecard-layout="linear"] .scorecard td {
  display: table-cell;
  padding: 0.3rem 0.75rem;
  border-bottom: 1px solid var(--color-border);
  text-align: left;
  vertical-align: middle;
}

/* Every score sits in the middle of the score column — the cells here and the pill
   in the stacked layout centre their number the same way. */
[data-scorecard-layout="linear"] .scorecard td {
  width: 6rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

[data-scorecard-layout="linear"] .scorecard th.score-col {
  text-align: center;
}

/* The state colours survive the layout switch: with the pill gone, the outer edges
   of the row's own cells carry the bracket and those cells carry the fill, so a
   scanned column still reads scored / zero / open. Every row reserves the gutter —
   including the summary and section rows, which have no state — so the columns stay
   aligned down the card. */
[data-scorecard-layout="linear"] .scorecard .scorecard-category-row > * {
  background-color: var(--state-fill);
}

[data-scorecard-layout="linear"] .scorecard tr > :first-child {
  border-left: 3px solid transparent;
}

[data-scorecard-layout="linear"] .scorecard tr > :last-child {
  border-right: 3px solid transparent;
}

[data-scorecard-layout="linear"] .scorecard .scorecard-category-row > :first-child {
  border-left-color: var(--bracket);
}

[data-scorecard-layout="linear"] .scorecard .scorecard-category-row > :last-child {
  border-right-color: var(--bracket);
}

/* The category th is a plain cell here (not the stacked flex pill), so the icon and
   name flow inline — give the name a little breathing room from its glyph. */
[data-scorecard-layout="linear"] .category-name {
  margin-left: 0.3rem;
}

/* Summary th uses column-flex in stacked to stack its hint; in a table cell that
   would collapse the width, so keep it inline-flowing. */
[data-scorecard-layout="linear"] .scorecard-summary-row th {
  display: table-cell;
}

[data-scorecard-layout="linear"] .scorecard-section-row th {
  background-color: var(--color-surface-alt);
  text-transform: uppercase;
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  color: var(--color-text-secondary);
}

[data-scorecard-layout="linear"] .scorecard-total-row th,
[data-scorecard-layout="linear"] .scorecard-total-row td {
  font-size: 1.05rem;
  background-color: var(--color-surface-alt);
}

/* No border under the final row (matches the original card's clean bottom edge). */
[data-scorecard-layout="linear"] .scorecard-total-row th,
[data-scorecard-layout="linear"] .scorecard-total-row td {
  border-bottom: none;
}

/* Score cells (§15.5): locked value, or an interactive preview button. Compact
   here since two share a line. */
.score-cell {
  display: inline-flex;
  align-items: center;
  justify-content: center; /* the number sits in the middle of its box, not against an edge */
  min-width: 2.4rem;
  min-height: 26px; /* target size minimum (§11 2.5.8) */
  padding: 0.15rem 0.45rem;
  border-radius: 6px;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.score-cell-locked {
  background-color: var(--color-score-locked);
  font-weight: 700;
}

/* A scratched category (§9.3 "clear visual lock on used categories"): rather than a
   0, the cell is struck through corner to corner the way a paper card is scored out.
   The mark gets its own vivid --color-score-zero-slash rather than the row's
   bracket-zero: the bracket only tints an edge, while this has to read as a deliberate
   strike from across the card, and it clears 3:1 against the locked cell in every skin
   (§11 1.4.11, SkinContrastTests). The "0" itself stays in the DOM for AT, so the mark
   is never the only carrier of the value (§11 1.4.1). */
.score-cell-zero {
  position: relative;
  /* Belt and braces: whatever an engine makes of the absolutely positioned svg below,
     the mark can never paint outside the cell it belongs to. */
  overflow: hidden;
  color: var(--color-score-zero-slash);
}

/* Taken out of flow so the mark spans the whole cell without sizing it: the cell keeps
   the same box as every other score cell (min-width/min-height above) and the slash is
   painted across it. The size is stated as 100% of that box rather than left to the
   offsets — an svg is a replaced element, and a replaced box with `width: auto` falls
   back to its 300x150 default size on engines that don't resolve it from the inset
   (which is exactly what Android's WebView did: the line ran outside the cell). */
.score-zero-slash {
  position: absolute;
  display: block;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* Not yet scorable (before a roll, or another player's card): show an empty inset
   box so the row still reads as a score slot, matching the locked/available boxes. */
.score-cell-open {
  border: 1px solid var(--color-border);
  background-color: var(--color-surface);
}

.score-cell-available {
  border: 1px dashed var(--color-control-border);
  background-color: transparent;
}

/* 0.75 is the lowest opacity that keeps the composited preview at 4.5:1 in all
   four skins (§11 1.4.3) — verified by SkinContrastTests. */
.score-cell-available .score-preview {
  opacity: 0.75;
  font-weight: 600;
}

.score-cell-available:hover,
.score-cell-available:focus-visible,
.score-cell-previewing {
  background-color: var(--color-score-available);
  border-style: solid;
}

.score-cell-available:hover .score-preview,
.score-cell-previewing .score-preview {
  opacity: 1;
}

.score-cell-selected {
  border: 2px solid var(--color-accent);
  background-color: var(--color-score-available);
}

.score-cell-selected .score-preview {
  opacity: 1;
  font-weight: 800;
}

/* --------------------------------------------------------------------------
   Scorecard deck (§9.1): every player's card in one swipeable strip

   The cards sit side by side and the strip snaps, so a swipe — or a trackpad
   gesture, a drag, or the arrow keys once the strip has focus — moves exactly
   one card at a time. GameScreen scrolls the strip to the player whose turn it
   is; the pager dots below say which card is showing and move to any of them.
   -------------------------------------------------------------------------- */

.scorecard-deck {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  min-width: 0;
}

.scorecard-track {
  display: flex;
  gap: 1rem;
  /* The strip must take the sidebar's width and scroll its cards, never stretch to
     hold them all — without this its intrinsic width is every card side by side and
     the page itself scrolls sideways, which §11 1.4.10 forbids at 320 px. */
  width: 100%;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* A horizontal swipe belongs to the strip; it must not turn into a page
     back-gesture or bounce the page sideways. */
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  /* The dots are the visible affordance — the scrollbar would only crowd the card. */
  scrollbar-width: none;
  /* Room for the focus ring the strip's own scrolling would otherwise clip
     (3 px outline at a 2 px offset), taken back off the layout so the strip
     still lines up with the panel beside it. */
  padding: 5px;
  margin: -5px;
}

.scorecard-track::-webkit-scrollbar {
  display: none;
}

.scorecard-slide {
  flex: 0 0 100%;
  min-width: 0;
  /* Containing block for the card's absolutely positioned bits — the visually-hidden
     header rows and score text. Without it they resolve against the page, escape the
     strip's clipping and drag the document sideways at 320 px (§11 1.4.10). */
  position: relative;
  scroll-snap-align: center;
  /* One card per swipe — never skip past a player. */
  scroll-snap-stop: always;
}

.scorecard-dots {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.25rem;
}

/* 28 px clears the §11 2.5.8 24 px minimum target on its own, so the dots may sit
   close together without shrinking each other's target. */
.scorecard-dot {
  width: 28px;
  height: 28px;
  padding: 0;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
}

.scorecard-dot::before {
  content: "";
  width: 10px;
  height: 10px;
  border-radius: 50%;
  /* ≥3:1 against the page for a non-text control (§11 1.4.11). */
  border: 1px solid var(--color-control-border);
  background-color: transparent;
}

/* The current card's dot is filled, not merely tinted — the fill is a shape change,
   so it does not lean on colour alone (§11 1.4.1). */
.scorecard-dot-current::before {
  background-color: var(--color-player-active);
  border-color: var(--color-player-active);
  transform: scale(1.3);
}

/* --------------------------------------------------------------------------
   Skin switcher (§10.4)
   -------------------------------------------------------------------------- */

.skin-switcher {
  display: flex;
  gap: 0.4rem;
}

.skin-option {
  width: 36px;
  height: 36px;
  padding: 0;
  display: grid;
  place-items: center;
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 50%;
}

.skin-swatch {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 1px solid rgb(0 0 0 / 0.25);
}

/* Ring indicator on the active skin (§10.4). */
.skin-option-active {
  box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-text-primary);
}

/* --------------------------------------------------------------------------
   Settings dialog (§9.1): theme + scorecard-layout controls
   -------------------------------------------------------------------------- */

.settings-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}

/* Title plus the build version, stacked, so the Close button stays on the header's
   baseline and the version reads as a subtitle rather than another control. */
.settings-heading {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.settings-version {
  margin: 0;
  font-size: 0.8rem;
  color: var(--color-text-secondary);
}

.settings-section {
  padding: 1rem 0;
  border-top: 1px solid var(--color-border);
}

.settings-section-title {
  font-size: 1rem;
  font-weight: 700;
  margin-bottom: 0.15rem;
}

.settings-section-hint {
  margin-bottom: 0.75rem;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

/* A plain form field inside a settings section — the Player name box. The label sits above
   the control on its own line so a long label never squeezes the input on a narrow phone
   (§11 1.4.10 reflow), and the input takes the section's full width. */
.setting-field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.setting-field label {
  font-weight: 600;
  font-size: 0.9rem;
}

.setting-field .setup-player-input {
  width: 100%;
}

/* The live confirmation under the field. It is a status region, so it must hold its line
   whether or not it has anything to say — otherwise saving a name nudges everything below
   it down the dialog. */
.setting-field-hint {
  margin: 0.4rem 0 0;
  min-height: 1.3em;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

/* Scorecard-layout chooser: full-width option cards, ringed when active. The same option
   cards serve the computer-difficulty chooser on the setup screen. */
.layout-switcher,
.difficulty-switcher,
.score-confirm-switcher,
.score-flourish-switcher {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.layout-option {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
  width: 100%;
  padding: 0.7rem 0.85rem;
  text-align: left;
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: 10px;
  cursor: pointer;
}

.layout-option-active {
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 1px var(--color-accent);
}

.layout-option-name {
  font-weight: 700;
}

.layout-option-desc {
  font-size: 0.82rem;
  color: var(--color-text-secondary);
}

/* 3D-die chooser (spec §9.4): the die on a turntable between two arrows, its name, a dot per
   die and the way out. The stage is a fixed square so swapping dice never reflows the dialog
   around the reader's thumb. */
.dice-carousel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.9rem;
}

.dice-carousel-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  justify-content: center;
}

.dice-carousel-arrow {
  display: grid;
  place-items: center;
  /* §11 2.5.8: 44x44 target, and the one an arrow-tapping player will use repeatedly. */
  width: 44px;
  height: 44px;
  color: var(--color-text-primary);
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: 50%;
  cursor: pointer;
}

.dice-carousel-stage {
  position: relative;
  display: grid;
  place-items: center;
  width: clamp(120px, 40vw, 168px);
  aspect-ratio: 1;
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: 14px;
  /* The swipe is horizontal, so let the browser keep vertical scrolling of the dialog. */
  touch-action: pan-y;
  cursor: grab;
}

.dice-carousel-canvas {
  width: 100%;
  height: 100%;
}

.dice-carousel-loading {
  position: absolute;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

.dice-carousel-name {
  font-weight: 700;
}

.dice-carousel-desc,
.dice-carousel-hint {
  font-size: 0.82rem;
  color: var(--color-text-secondary);
  text-align: center;
}

.dice-carousel-dots {
  display: flex;
  gap: 0.35rem;
}

.dice-carousel-dot {
  width: 7px;
  height: 7px;
  background-color: var(--color-border);
  border-radius: 50%;
}

.dice-carousel-dot-active {
  background-color: var(--color-accent);
}

.dice-carousel-done {
  margin-top: 0.35rem;
  padding: 0.5rem 1.75rem;
  min-height: 44px;
}

/* Boolean settings row: checkbox + its label on one line, description underneath. */
.setting-toggle {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 0.15rem 0.6rem;
}

.setting-toggle-input {
  width: 1.15rem;
  height: 1.15rem;
  margin: 0;
  accent-color: var(--color-accent);
  cursor: pointer;
}

.setting-toggle-label {
  font-weight: 700;
  cursor: pointer;
}

.setting-toggle-desc {
  grid-column: 2;
  font-size: 0.82rem;
  color: var(--color-text-secondary);
}

/* A setting that hangs off the one above it — "Pace against the bonus" under "Running
   totals" (#28). Indented and ruled down its leading edge so the dependency is visible and
   not only implied by the order, and it appears at all only while the parent is off. */
.setting-toggle-nested {
  margin-top: 0.6rem;
  margin-inline-start: 0.6rem;
  padding-inline-start: 0.8rem;
  border-inline-start: 2px solid var(--color-border);
}

/* Slider setting: label, the range itself, its value in words, then the description.
   Used by "Shake to roll" (#21), where the right number is the player's hand and their phone,
   not something that could be chosen for them. */
.setting-slider {
  display: grid;
  gap: 0.3rem;
  margin-top: 0.75rem;
}

.setting-slider-label {
  font-weight: 700;
  cursor: pointer;
}

.setting-slider-input {
  width: 100%;
  /* 44px of target, per §11 2.5.8 — a range input is dragged, and a thin one is a miss. */
  min-height: 44px;
  accent-color: var(--color-accent);
  cursor: pointer;
}

.setting-slider-value {
  font-weight: 700;
  color: var(--color-text-primary);
}

/* Live shake meter (#21): the movement being measured right now, against the mark the slider
   has set. Its own colours rather than the accent alone, so "past the mark" reads as a state. */
.shake-meter {
  display: grid;
  gap: 0.35rem;
  margin-top: 0.5rem;
}

.shake-meter-bar {
  position: relative;
  height: 12px;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  background-color: var(--color-surface-alt);
  overflow: hidden;
}

.shake-meter-fill {
  height: 100%;
  background-color: var(--color-accent);
  /* Short, and cancelled with every other animation under reduced motion (§11 2.3.3) — it is
     smoothing a live reading, not decoration. */
  transition: width 80ms linear;
}

.shake-meter-mark {
  position: absolute;
  top: -2px;
  bottom: -2px;
  width: 2px;
  background-color: var(--color-text-primary);
}

.shake-meter-status {
  font-size: 0.82rem;
  color: var(--color-text-secondary);
  min-height: 2.4em;
}

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

/* --------------------------------------------------------------------------
   Modals (§9.1): centered dialog with dimmed backdrop
   -------------------------------------------------------------------------- */

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  /* minmax(0, ...) rather than the implicit auto track (#18): an auto track's floor is the
     min-content width of what is in it, so one unbreakable string inside a dialog could
     stretch the track past the viewport, taking the dialog with it and scrolling the page
     sideways behind the backdrop. Pinned to the space available, a dialog can never be
     wider than the screen whatever it is asked to hold. */
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  padding: 1rem;
  background-color: rgb(0 0 0 / 0.55);
  /* Nothing inside a dialog can extend the page (#24). #18 stopped the *track* growing and
     #24 is the belt to that pair of braces: whatever a dialog is one day asked to hold, it
     is clipped to the screen here rather than making the document wider than the viewport.
     A page that has been made wider stays that way after the dialog has gone — every later
     dialog then lays out against the wider page and spills off the side of the screen,
     which is what was reported. Nothing is ever hidden by this: a dialog is capped at 90vh
     and scrolls its own content (.modal), so there is nothing outside these bounds to
     reveal. */
  overflow: hidden;
}

.modal {
  width: 100%;
  max-width: 440px;
  max-height: 90vh;
  overflow-y: auto;
  padding: 1.5rem;
  border-radius: 14px;
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  box-shadow: 0 12px 40px rgb(0 0 0 / 0.45);
  /* ...and the other half of #18: with the box pinned to the screen, anything inside it
     that cannot wrap scrolls *within* the dialog instead — overflow-y: auto computes
     overflow-x to auto, so the sideways scroll simply moves indoors. A player name is up
     to 24 characters and need contain no spaces, which is enough to push a heading or a
     score row past the edge on a phone. `anywhere`, not `break-word`: only `anywhere`
     lowers the min-content contribution, which is what lets a flex or grid item holding
     the name actually shrink rather than merely wrap once it has already overflowed. */
  overflow-wrap: anywhere;
}

.game-over-headline {
  font-size: 1.1rem;
  font-weight: 600;
}

.final-scores {
  list-style: none;
  margin: 1rem 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* --------------------------------------------------------------------------
   High Score mode (#27): the record book in the setup dialog, and the offer to
   keep a finished card at the end of a run.
   -------------------------------------------------------------------------- */

.high-score-outcome {
  margin: 0.85rem 0;
  padding: 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  background-color: var(--color-surface-alt);
}

.high-score-offer,
.high-score-saved {
  margin: 0 0 0.6rem;
}

.high-score-saved {
  margin-bottom: 0;
  font-weight: 600;
}

/* The two answers wrap rather than shrink, so a large text size or a narrow phone
   never squeezes them below the 44px target (§11 2.5.8, and #24's lesson). */
.high-score-offer-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.high-score-offer-actions button {
  flex: 1 1 auto;
  min-height: 44px;
}

.high-score-title {
  margin: 1rem 0 0.4rem;
  font-size: 1rem;
}

.high-score-empty {
  margin: 0 0 0.5rem;
  color: var(--color-text-secondary);
}

.high-score-list {
  list-style: none;
  margin: 0 0 0.6rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  /* A full book is twenty rows; it scrolls inside the dialog rather than growing it
     past the screen. */
  max-height: 40vh;
  overflow-y: auto;
}

.high-score-row {
  display: grid;
  /* rank, the score-and-date button, and the delete button pinned to the right-hand end. */
  grid-template-columns: 1.75rem 1fr auto;
  align-items: center;
  gap: 0.6rem;
  padding: 0.4rem 0.6rem;
  border-radius: 8px;
  background-color: var(--color-surface-alt);
}

/* The score and date are one button that opens the record's sheet (#30). It fills the row's
   middle track so the whole width of the row is the target, not just the digits, and lays its
   two halves out exactly as the row did before it was a button: score at the left, date at the
   right. The row already has the row's background, so the button is transparent and only says
   it is one on hover and focus. The row's own padding, plus the button's, keep it at the 44px
   §11 2.5.8 comfortably wants rather than the 24px it demands. */
.high-score-view {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 0.6rem;
  min-width: 0;
  min-height: 36px;
  margin: 0;
  padding: 0.2rem 0.4rem;
  border: 0;
  border-radius: 6px;
  background: transparent;
  text-align: left;
  cursor: pointer;
}

.high-score-view:hover {
  background-color: var(--color-surface);
}

/* Deleting one record (#27). Filled in the skin's accent rather than drawn as an accent-coloured
   glyph on the row: on `surface-alt` the accent is 4.02:1 in the green skin and 3.58:1 in the red,
   under §11 1.4.3's 4.5:1 for text. The accent/accent-text pair is the one this palette guarantees
   at that ratio in every skin — SkinContrastTests pins it — so filling the button is what lets the
   cross actually be the highlight colour rather than merely near it.

   28px square: over WCAG 2.2 2.5.8's 24px minimum, without making twenty rows a screen and a half. */
.high-score-delete {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  font-size: 0.8rem;
  line-height: 1;
  cursor: pointer;
  color: var(--color-accent-text);
  background-color: var(--color-accent);
}

.high-score-delete:hover {
  background-color: var(--color-accent-hover);
}

.high-score-row-best {
  border: 2px solid var(--color-accent);
  padding: calc(0.4rem - 2px) calc(0.6rem - 2px);
}

.high-score-rank {
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}

.high-score-points {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.high-score-when {
  color: var(--color-text-secondary);
  font-size: 0.82rem;
  text-align: right;
}

.high-score-clear {
  margin-bottom: 0.4rem;
}

/* The score sheet behind a record (#30): the same Scorecard the game was played on, inside a
   dialog. Its header mirrors the setup dialog's — title on the left, Close on the right — and
   the card below it is the only thing that scrolls if the layout is linear and the screen is
   short; the modal's own max-height and overflow already see to that. */
.high-score-card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.25rem;
}

.high-score-card-title {
  margin: 0;
  font-size: 1.15rem;
  min-width: 0;
}

.high-score-card-close {
  flex: 0 0 auto;
}

.high-score-card-when {
  margin: 0 0 0.75rem;
  color: var(--color-text-secondary);
  font-size: 0.9rem;
}

.final-score-row {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem 0.75rem;
  border-radius: 8px;
  background-color: var(--color-surface-alt);
}

.final-score-winner {
  border: 2px solid var(--color-accent);
  font-weight: 800;
}

/* The score never breaks or shrinks — it is short, and it is the thing being read; the
   name beside it takes the squeeze instead (#18). */
.final-score-points {
  flex: none;
  font-variant-numeric: tabular-nums;
}

.game-over-actions,
.quit-game-actions {
  display: flex;
  /* Wrapping, because three buttons on a phone fit only by a hair — measured at a 360 px
     viewport, Cancel + Pause Game + Quit Game come to exactly the 278 px available, and
     they get there by being squeezed until their labels wrap mid-word. A longer label, a
     different font or a larger system text size and there is nowhere left to squeeze. A
     second row is a better answer than a squeeze at the best of times (#24). */
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.75rem;
  margin-top: 1rem;
}

/* --------------------------------------------------------------------------
   Setup menu (§9.1): the four ways to play, laid out like a scorecard

   One card with a caption strip, holding its options packed two-up on a grid —
   the same packing the compact scorecard gives its category rows — and each
   option wearing that card's pill idiom: rounded, with tinted left and right
   edges. Everything an option needs is in its dialog instead (SetupModal).

   The tiles are sized by what is in them, not by the screen. They used to split
   the shell's whole leftover height between them, which on a tall phone left
   each one mostly empty — a pillar of dead space above and below its icon, four
   times over. Now the card closes up around its contents and the menu centres in
   what is left, so the space that used to sit trapped inside the tiles becomes
   margin around the card, where the dice backdrop shows through and the card's
   own edges break it up. Icon and type sizes stay clamped against the viewport
   height, and the tiles keep min-height: 0, so a short landscape phone shrinks
   them rather than overflowing — the menu is still exactly one screenful with
   nothing to scroll.
   -------------------------------------------------------------------------- */

.setup-menu {
  flex: 1;
  min-height: 0; /* let the card shrink inside the shell instead of scrolling it */
  width: 100%;
  max-width: 760px;
  margin: 0 auto;
  padding: clamp(0.5rem, 2vh, 1.25rem) 1rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.75rem;
}

/* Unfinished game (§13.6): a strip above the menu, not a fifth option — it is one
   game to pick up, not a way to play. Accent-edged, as the old rejoin card was. */
.setup-resume {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--color-accent);
  border-radius: 12px;
  background-color: var(--color-surface);
}

.setup-resume-text {
  display: flex;
  flex-direction: column;
  margin: 0;
  min-width: 0;
  font-size: 0.9rem;
  color: var(--color-text-secondary);
}

.setup-menu-card {
  /* Shrinkable but never stretched: the card is as tall as its title strip plus
     its two rows of tiles. */
  flex: 0 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 12px;
  overflow: hidden;
}

/* The scorecard's caption strip, on the menu card. */
.setup-menu-title {
  margin: 0;
  padding: 0.55rem 0.75rem;
  font-size: 1rem;
  font-weight: 700;
  background-color: var(--color-surface-alt);
  border-bottom: 1px solid var(--color-border);
}

.setup-modes {
  min-height: 0;
  list-style: none;
  margin: 0;
  padding: 0.5rem;
  display: grid;
  /* minmax(0, …) on both axes: tiles shrink with the card rather than pushing it
     past the viewport (no scrolling, and reflow at 320 px — §11 1.4.10). Rows are
     auto-height — content, not screen — but still floored at 0 so a viewport too
     short for the clamped icon squeezes them instead of spilling. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  grid-auto-rows: minmax(0, auto);
  gap: 0.5rem;
}

.setup-mode {
  display: flex;
  min-width: 0;
  min-height: 0;
}

/* An odd number of options — the WiFi tile is hidden where LAN play is impossible
   (§21.8) — would leave the last one alone in its column; let it span instead so
   the grid still divides the card evenly. */
.setup-mode:last-child:nth-child(odd) {
  grid-column: 1 / -1;
}

/* One option: the scorecard category pill, grown to a tile — same rounded shape and
   tinted edges, here in the skin's accent. Pressing it opens that mode's dialog. */
.mode-tile {
  --bracket: var(--color-accent);
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.35rem;
  padding: 0.7rem 0.6rem;
  overflow: hidden;
  text-align: center;
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--bracket);
  border-right: 3px solid var(--bracket);
  border-radius: 10px;
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
}

.mode-tile:hover {
  border-top-color: var(--color-control-border);
  border-bottom-color: var(--color-control-border);
}

/* Offered but not usable right now (no server reachable). The edge and the glyph both
   drop to the neutral grey the scorecard uses for an undecided category — an accent-bright
   icon on a tile that cannot be used reads as available. Neither is carrying the meaning
   alone: the tile's own status line says why (§11 1.4.1), and the grey clears 3:1 on the
   tile fill like every other indicator (§11 1.4.11, SkinContrastTests). */
.mode-tile-muted {
  --bracket: var(--color-bracket-open);
}

.mode-tile-muted .mode-icon {
  color: var(--color-bracket-open);
}

.mode-icon {
  display: inline-flex;
  flex: none;
  color: var(--color-accent);
}

.mode-tile-mark .mode-svg {
  display: block;
  width: clamp(2.25rem, 7vh, 3.5rem);
  height: clamp(2.25rem, 7vh, 3.5rem);
}

.mode-tile-name {
  font-weight: 700;
  font-size: clamp(0.95rem, 2.2vh, 1.15rem);
  line-height: 1.15;
}

.mode-tile-desc {
  font-size: clamp(0.72rem, 1.6vh, 0.85rem);
  line-height: 1.25;
  color: var(--color-text-secondary);
}

/* Very short or very narrow viewports: the name and icon carry the tile on their
   own, and the same words are in the dialog one tap away. */
@media (max-height: 420px), (max-width: 360px) {
  .mode-tile-desc {
    display: none;
  }
}

/* The dialog behind a tile (SetupModal): the mode's glyph, its name, and Close on
   one header row, with that mode's controls beneath. */
.setup-modal-header {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 1rem;
}

.setup-modal-mark .mode-svg {
  display: block;
  width: 1.6rem;
  height: 1.6rem;
}

.setup-modal-title {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-size: 1.15rem;
}

.setup-modal-body {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* --------------------------------------------------------------------------
   Setup screen
   -------------------------------------------------------------------------- */

.setup-screen {
  flex: 1;
  width: 100%;
  max-width: 520px;
  margin: 0 auto;
  padding: 1.5rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.setup-card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 14px;
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.setup-lead {
  margin: 0;
  color: var(--color-text-secondary);
}

.setup-players {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* minmax(0, 1fr), not 1fr (#18): a text input's min-content width is its intrinsic size —
   about 20 characters, whatever the column is worth — so a plain 1fr track cannot shrink
   below it and the row overflows the dialog on a 320 px screen. */
.setup-player-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.25rem 0.5rem;
}

.setup-player-row label {
  grid-column: 1 / -1;
  font-weight: 600;
  font-size: 0.9rem;
}

/* Standalone field label on the setup cards, matching the inline labels above the inputs —
   used where the control is a button group rather than a form field (§11 1.3.1). */
.setup-field-label {
  margin: 0;
  font-weight: 600;
  font-size: 0.9rem;
}

.setup-player-input {
  font: inherit;
  padding: 0.5rem 0.75rem;
  min-height: 44px;
  border-radius: 8px;
  border: 1px solid var(--color-control-border);
  background-color: var(--color-surface-alt);
  color: var(--color-text-primary);
}

.setup-offline-note {
  margin: 0;
  padding: 0.75rem;
  border-radius: 8px;
  background-color: var(--color-surface-alt);
  color: var(--color-text-secondary);
  font-size: 0.9rem;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* Same minmax(0, 1fr) as .setup-player-row above, for the same reason. */
.setup-join-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.25rem 0.5rem;
}

.setup-join-row label {
  grid-column: 1 / -1;
  font-weight: 600;
  font-size: 0.9rem;
}

.setup-join-input {
  text-transform: uppercase;
  letter-spacing: 0.15em;
  font-variant-numeric: tabular-nums;
}

/* Hotspot / LAN play (spec §21): the host address players read out and type. Unlike a
   room code it is not uppercased, but it reads the same way — fixed-width digits, a
   little tracking, and never broken across lines. */
.setup-lan-input,
.setup-lan-address {
  letter-spacing: 0.05em;
  font-variant-numeric: tabular-nums;
}

.setup-lan-address {
  white-space: nowrap;
}

/* Nearby Games (spec §21.4.2): a plain list of hosts found on this network, each row
   naming the host and offering one tap into their room. */
.lan-nearby {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.lan-nearby-title {
  margin: 0;
  font-size: 1rem;
}

.lan-nearby-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.lan-nearby-game {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  background-color: var(--color-surface-alt);
}

.lan-nearby-host {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* Starts/stops discovery (see SetupScreen.ToggleSearchAsync). Sized to its label rather
   than the card, so it reads as an action on the list above it and not as the card's
   primary button — that is "Host on this device". */
.lan-nearby-search {
  align-self: start;
}

/* --------------------------------------------------------------------------
   Welcome (first load) and online lobby screens
   -------------------------------------------------------------------------- */

.app-loading {
  flex: 1;
  display: grid;
  place-items: center;
  color: var(--color-text-secondary);
  font-size: 1.1rem;
}

.welcome-card form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.lobby-code {
  margin: 0;
  align-self: center;
  padding: 0.5rem 1.25rem;
  border: 2px dashed var(--color-border);
  border-radius: 10px;
  background-color: var(--color-surface-alt);
  font-size: 1.8rem;
  font-weight: 800;
  letter-spacing: 0.25em;
  font-variant-numeric: tabular-nums;
}

.lobby-roster-title {
  margin: 0.25rem 0 0;
  font-size: 1rem;
}

.lobby-roster {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.lobby-player {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background-color: var(--color-surface-alt);
}

.lobby-badge {
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  border: 1px solid var(--color-border);
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--color-text-secondary);
}

.lobby-badge-you {
  border-color: var(--color-accent);
  color: var(--color-text-primary);
}

/* Read-only rule-set summary (§22.3.4): shown to a joiner whenever the room's rule set
   differs from Yahtzee.Core.RuleSet.Default, since a joiner has no local name for it. */
.lobby-ruleset-summary {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.lobby-ruleset-list {
  margin: 0;
  padding: 0.75rem 1rem;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  background-color: var(--color-surface-alt);
  color: var(--color-text-secondary);
  font-size: 0.85rem;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

/* Rules Customisation (§22.3.4): the saved-preset list and the new-rule-set form in the
   settings dialog. */
.ruleset-list {
  list-style: none;
  margin: 0 0 0.75rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.ruleset-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background-color: var(--color-surface-alt);
}

.ruleset-item-name {
  font-weight: 700;
}

.ruleset-form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* --------------------------------------------------------------------------
   Responsive: scorecard under 768 px (§9.1)

   Previously a position:fixed overlay sheet collapsed to a peek handle by
   default. That left a dead gap where the handle should be, forced a swipe
   to reveal the card, and — since a fixed overlay isn't in document flow —
   sat on top of the dice tray once opened. The scorecard now stays in the
   same in-flow stacked column used at 768–1023 px (§9.1 grid-template-columns
   rule above): always visible below the dice tray, no overlay, nothing to
   swipe open.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   Large screens: tablets and desktop (§9.1 — the game fills the screen it is given)

   Everything above this point is sized for a phone, which on a 10-inch tablet
   leaves the game as a phone-sized island: 76 px dice on an 800 px-wide screen,
   9-point scorecard type, and — because .game-main is a grid whose rows stretch
   to fill a tall viewport — 150 px of nothing between the roll button and the
   scorecard. The layout was not wrong, only fixed.

   So the pieces the player actually reads — the dice, and the scorecard — grow
   with the screen instead. The gate is deliberately on *both* dimensions: a
   phone held sideways is 900 px wide and 410 px tall, and would otherwise take
   the width-driven sizes with no height to put them in. 640 px each way is a
   small tablet in portrait (and any desktop window worth the name) while
   excluding every phone in both orientations.

   Every value is a clamp with a viewport-relative middle, so the sizes are
   continuous rather than stepped: an 8-inch tablet, a 13-inch iPad and a desktop
   window each land somewhere sensible on the same curve instead of on the
   nearest of a handful of breakpoints. The upper bounds stop a very large
   display from turning the dice into furniture. The numbers themselves were
   measured on a real 800x1333 device, at the point where the whole board — dice,
   controls and a full scorecard — still fits without the page scrolling.
   -------------------------------------------------------------------------- */

@media (min-width: 640px) and (min-height: 640px) {
  /* The menu is the first screen, and the most obviously marooned: a 760 px card
     centred in a 1333 px-tall screen. Wider, with taller tiles. */
  .setup-menu {
    max-width: min(1100px, 100%);
    gap: clamp(0.75rem, 1.5vh, 1.5rem);
    padding: clamp(0.5rem, 2vh, 2rem) clamp(1rem, 3vw, 2.5rem);
  }

  .setup-menu-title {
    padding: clamp(0.55rem, 1.2vh, 1rem) clamp(0.75rem, 1.5vw, 1.5rem);
    font-size: clamp(1rem, 1.9vh, 1.35rem);
  }

  .setup-modes {
    gap: clamp(0.5rem, 1.4vh, 1.25rem);
    padding: clamp(0.5rem, 1.4vh, 1.25rem);
  }

  .mode-tile {
    gap: clamp(0.35rem, 1vh, 0.8rem);
    padding: clamp(0.7rem, 4.5vh, 3rem) clamp(0.6rem, 2vw, 1.5rem);
  }

  /* align-content: start is what closes the gap in the middle. The grid's rows
     otherwise stretch to fill a tall viewport and, with align-items: start, the
     panel and the sidebar sit at the top of rows far taller than they are —
     which reads as a hole between the dice and the scorecard rather than as
     margin. Started from the top, any space left over collects at the foot,
     where it looks like the breathing room it is. */
  .game-main {
    max-width: min(1600px, 100%);
    align-content: start;
    padding: clamp(1rem, 2vh, 2rem) clamp(1rem, 2.5vw, 2.5rem);
  }

  .dice-tray {
    gap: clamp(0.9rem, 2vw, 2rem);
    /* Both terms, not min(): in landscape the vh alone leaves the tray shrink-
       wrapped around the dice, and in portrait the vw alone barely moves. */
    padding: clamp(1.5rem, calc(2.6vh + 1vw), 3rem) clamp(1.25rem, 3vw, 3rem);
  }

  /* min(), so five dice across still fit a narrow window and one die is never
     taller than the tray it sits in. */
  .die {
    width: clamp(76px, min(13vw, 13vh), 128px);
  }

  .scorecard {
    font-size: clamp(0.9rem, min(2.2vw, 1.7vh), 1.3rem);
  }

  .scorecard tbody {
    gap: clamp(0.4rem, 0.55vh, 0.8rem);
    padding: clamp(0.5rem, 1vh, 1rem);
  }

  /* Taller rows are most of what fills the card, and they make a bigger touch
     target of every category (§11 2.5.8) into the bargain. */
  .scorecard-category-row {
    min-height: clamp(40px, 4.4vh, 60px);
    padding: 0.2rem 0.6rem 0.2rem 0.75rem;
  }

  .scorecard caption {
    padding: clamp(0.55rem, 1.2vh, 1rem) clamp(0.75rem, 1.5vw, 1.25rem);
    font-size: 1.05em;
  }

  .scorecard-summary-row,
  .scorecard-total-row {
    padding: clamp(0.15rem, 0.45vh, 0.45rem) 0.5rem;
  }

  .roll-controls .btn-accent,
  .roll-button {
    font-size: clamp(1rem, 1.6vh, 1.35rem);
    padding: clamp(0.6rem, 1.4vh, 1rem) clamp(1.25rem, 3vw, 2.25rem);
  }
}

/* Two panels side by side (the rule at the top of this file), with a sidebar that
   grows with the window instead of a fixed 400 px. */
@media (min-width: 1024px) and (min-height: 640px) {
  .game-main {
    grid-template-columns: minmax(0, 1fr) clamp(360px, 32vw, 560px);
  }
}

/* The scorecard now has a column of its own to fill rather than sharing the page's
   height with the dice, so its rows can be taller than the stacked layout allows —
   4.4vh of a landscape tablet's 800 px is below the 40 px floor, which is why the
   stacked figure alone leaves the card stranded at the top of a half-empty sidebar.

   1125 px, not 1024, because that is where 32vw finally clears the sidebar's 360 px
   floor. Below it the sidebar is pinned at its narrowest, the two-up category labels
   wrap onto a second line, and the rows are already taller than any min-height would
   make them — adding to that is what pushed a 1024x768 iPad's card off the bottom of
   the screen. */
@media (min-width: 1125px) and (min-height: 640px) {
  .scorecard-category-row {
    min-height: clamp(44px, 5.9vh, 68px);
  }

  .scorecard tbody {
    gap: clamp(0.4rem, 1vh, 1rem);
  }
}

/* --------------------------------------------------------------------------
   Reduced motion (§11): drop animations and theme transitions
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
    animation: none !important;
  }
}
