:root,
:root[data-theme="light"] {
  --bg: #eef1f8;
  --panel: #ffffff;
  --ink: #1f2544;
  --muted: #6b7194;
  --line: #2b3a67;
  --accent: #2b3a67;
  --accent-ink: #ffffff;
  --danger: #e0405e;
  /* --danger is tuned to be seen as a shape — a conflict outline, a queen, a
     status pill — where it sits on 3-4px of border or a tinted plate. As small
     TEXT on the panel it only reaches ~4.2:1, just under AA, so a warning that
     has to be read rather than spotted uses this deeper red instead (5.9:1).
     Don't collapse the two: the reason they differ is the contrast, and this
     one is redefined per theme where --danger is not. */
  --danger-text: #c0243f;
  --shadow: 0 10px 30px rgba(31, 37, 68, 0.14);
  --cell-line: rgba(31, 37, 68, 0.28);
  --cell-line-strong: #1b2040;
  /* Board grid: hairline that survives a phone screen (was 0.5px / 28%). */
  --grid-line: rgba(27, 32, 64, 0.16);
  --queen-ink: #1b2040;
  --queen-bad: #9c0f28;
  --btn-shadow: 0 4px 14px rgba(31, 37, 68, 0.12);
  --ok: #17a06a;
  --ok-text: #0f7a4f;
  /* Dead-region outline. Deliberately more saturated than --danger so it
     stays legible on top of a reddish region. Shared across themes because
     the region palette is identical in light and dark. */
  --dead-bright: #ff2d43;
  --dead-deep: #7a0016;
  --dead-static: #e00027;
  /* Radius scale — one family instead of a dozen ad-hoc values.
     --r-btn: interactive controls + the board; --r-card: floating cards
     (overlay, hint, party, win); pills use 999px directly. */
  --r-btn: 12px;
  --r-card: 16px;
}

/* Dark tokens: the OS preference by default, then the viewer's explicit toggle
   (data-theme) which must win over the media query in both directions. */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #171a2b;
    --panel: #21263d;
    --ink: #eef1f8;
    --muted: #a4abd0;
    --line: #cdd5f5;
    --accent: #6f8bff;
    --accent-ink: #0f1220;
    /* The deep red goes the wrong way on a dark panel — lighten instead (6.9:1). */
    --danger-text: #ff8fa0;
    --shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
    --cell-line: rgba(0, 0, 0, 0.3);
    --cell-line-strong: #0f1220;
    --grid-line: rgba(0, 0, 0, 0.28);
    --queen-ink: #f4f6ff;
    --queen-bad: #ffb3c0;
    --btn-shadow: inset 0 0 0 1px rgba(205, 213, 245, 0.12);
    --ok-text: #5ed7a2;
  }
}

:root[data-theme="dark"] {
  --bg: #171a2b;
  --panel: #21263d;
  --ink: #eef1f8;
  --muted: #a4abd0;
  --line: #cdd5f5;
  --accent: #6f8bff;
  --accent-ink: #0f1220;
  --danger-text: #ff8fa0;
  --shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  --cell-line: rgba(0, 0, 0, 0.3);
  --cell-line-strong: #0f1220;
  --grid-line: rgba(0, 0, 0, 0.28);
  --queen-ink: #f4f6ff;
  --queen-bad: #ffb3c0;
  --btn-shadow: inset 0 0 0 1px rgba(205, 213, 245, 0.12);
  --ok-text: #5ed7a2;
}

/* Forced light on a dark-OS device: undo the media-query tokens. */
:root[data-theme="light"] {
  --cell-line-strong: #1b2040;
  --grid-line: rgba(27, 32, 64, 0.16);
  --queen-ink: #1b2040;
  --queen-bad: #9c0f28;
  --btn-shadow: 0 4px 14px rgba(31, 37, 68, 0.12);
  --ok-text: #0f7a4f;
}

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

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  /* Safety net: the app is a fixed-width shell — nothing should ever be
     reachable by panning sideways. Clip any residual horizontal overflow
     (e.g. a transiently oversized element during the intro spin) so a swipe
     off the board can never wiggle the page left/right. */
  overflow-x: clip;
  /* No double-tap zoom anywhere. iOS Safari has ignored user-scalable=no in the
     viewport meta since iOS 10, so a quick second tap (empty -> dot -> queen is
     exactly that) zoomed the page mid-game. The effective touch-action is the
     intersection over ancestors, so setting it here covers every element;
     manipulation keeps pinch-zoom for anyone who needs to enlarge the page. */
  touch-action: manipulation;
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-tap-highlight-color: transparent;
  min-height: 100dvh;
}

/* index.html ships ENGLISH text inline (the default language), and main.js
   swaps it for the resolved one before the first board is drawn. Without this
   gate a German player would see one frame of English chrome. Hiding the shell
   until then costs nothing: the board, the puzzle and every overlay are drawn
   by JS anyway, so there is no meaningful page without it. */
.app {
  visibility: hidden;
}
html[data-i18n-ready] .app {
  visibility: visible;
}

.app {
  max-width: 640px;
  margin: 0 auto;
  padding: env(safe-area-inset-top) 16px calc(env(safe-area-inset-bottom) + 16px);
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-height: 100dvh;
}

/* ---------- Top bar ---------- */
/* The row's width is language-dependent: `.toolbar` is flex:none and its labels
   are nowrap, so "New game" (8 chars) and "Nouvelle partie" (15) need different
   amounts of room, and on a small phone the longest pack decides. Wrapping is
   the honest answer — below ~360px the row does not fit in ANY language (the
   toolbar alone wants 238px of the 288px available), so the toolbar drops to a
   second line instead of the title sliding under the buttons. */
.topbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-top: 12px;
}

/* Title + timer stacked; the column is only as wide as its widest child
   (the title), so the timer never reserves horizontal space of its own. */
.brand {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 5px;
  min-width: 0;
}

/* Last-resort bound on the title. `.brand` is a column flex with
   align-items:flex-start, which sizes children to their own content and lets
   them spill PAST the column's width — so a shrunken `.brand` did not shrink
   the h1, it just let "Queens" paint underneath the buttons, silently, because
   `overflow-x: clip` on <body> hides the page-level symptom. (German already
   shaved the "s" off before French made it obvious.) max-width pins the h1 to
   the column so the worst case is an ellipsis, not a collision; the wrap above
   is what keeps it from being reached. */
.topbar h1 {
  margin: 0;
  font-size: clamp(1.4rem, 5vw, 1.9rem);
  letter-spacing: 0.5px;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
}

/* Compact timer chip: a subtle tinted pill with a small clock glyph, sized to
   its content so it sits neatly under the title. */
.status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px 4px 8px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent);
  font-weight: 700;
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.timer-icon {
  flex: none;
}

/* Phones: trim the row so it stays on ONE line at the common widths (375–430px)
   in every shipped language, and only wraps on genuinely narrow screens. The
   threshold is 430px rather than 360px because the width needed is
   language-dependent — "New game" is 8 characters, "Nouvelle partie" is 15, and
   the longest pack decides. Tighter gaps and horizontal padding buy back ~16px,
   the smaller title another ~14px; together that covers the spread across en/de/
   fr/es down to 375px. Only the HORIZONTAL padding shrinks below the base rule —
   9px vertical is the touch-target height, which this game cannot afford to
   trim. The `.icon` line looks redundant but is not: `.btn.icon` is declared
   AFTER this block and ties on specificity with `.toolbar .btn`, so without its
   own override the later rule would win and icon buttons would keep 10px 12px. */
@media (max-width: 430px) {
  .topbar h1 {
    font-size: clamp(1.2rem, 5vw, 1.9rem);
  }
  .toolbar {
    gap: 6px;
  }
  .toolbar .btn {
    padding: 0 12px;
  }
  .toolbar .btn.icon {
    padding: 0;
  }
}

/* ---------- Hint cost: the price tag and the pill that flies off ----------
   A hint costs HINT_PENALTY seconds, and until now nothing said so before the
   win screen. Two surfaces answer that, and they must agree: the suffix baked
   into the hint button's label (the standing price) and the pill that rises off
   it the moment a hint is really charged (the receipt). Both read the same
   i18n key — see decorateHintButton()/flyHintCost() in js/main.js. */

/* Dimmer and smaller than the label it trails, so the button still reads as
   "Hinweis" at a glance and the price is a footnote, not a second word. The
   button is white-space: nowrap and sits in a fixed-width column in landscape,
   so this deliberately buys its width back with font-size rather than adding
   a line. */
.btn-cost {
  position: absolute;
  top: -7px;
  /* Inside the button's box: a negative offset inflates scrollWidth, which
     the fit tests (rightly) read as clipped content. */
  right: 2px;
  padding: 2px 6px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.66rem;
  font-weight: 700;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
}

/* Fixed, not absolute: positioned from the button's bounding rect at fire time,
   which keeps it out of the .actions flex row entirely (portrait wraps that row,
   landscape turns it into a fixed-width column — neither may be disturbed by an
   element that only exists for 900 ms). pointer-events: none so it can never
   swallow the tap that spawned it. */
.cost-fly {
  position: fixed;
  z-index: 15;
  pointer-events: none;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.82rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  box-shadow: var(--shadow);
  will-change: transform, opacity;
}

/* The other half of the causal link: the clock jumps by the same amount at the
   same moment, so it pulses once instead of silently changing under the eye.
   Kept to a tint + a hair of scale — the timer must not become the loudest
   thing on the screen for a penalty the player chose to pay. */
@keyframes timer-bump {
  0% {
    transform: scale(1);
  }
  35% {
    transform: scale(1.12);
    background: color-mix(in srgb, var(--accent) 30%, transparent);
    color: var(--ink);
  }
  100% {
    transform: scale(1);
  }
}
.status.bumped {
  animation: timer-bump 0.55s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  /* The information still has to arrive, so the pill is not suppressed — it is
     shown in place and faded out without travel (flyHintCost reads this same
     query), and the clock takes its jump with no pulse. */
  .status.bumped {
    animation: none;
  }
}

/* ---------- Buttons ---------- */
.btn {
  appearance: none;
  border: none;
  border-radius: var(--r-btn);
  padding: 10px 14px;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--ink);
  background: var(--panel);
  box-shadow: var(--btn-shadow);
  cursor: pointer;
  transition: transform 0.05s ease, filter 0.15s ease;
  touch-action: manipulation;
  white-space: nowrap;
  position: relative;
}
.btn:active {
  transform: translateY(1px);
}
.btn.icon {
  min-width: 44px;
  height: 44px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 1.15rem;
  line-height: 1;
}
.btn.primary {
  background: var(--accent);
  color: var(--accent-ink);
}
#new-game.btn.primary {
  box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 35%, transparent);
}
.btn.subtle {
  background: transparent;
  box-shadow: none;
  /* Full-contrast ink + a visible outline so these read as active controls,
     not disabled — Hinweis/Prüfen are core interactions. (--cell-line is
     near-invisible on the dark background, hence the color-mix border.) */
  color: var(--ink);
  border: 1px solid color-mix(in srgb, var(--ink) 24%, transparent);
}
.btn:hover {
  filter: brightness(1.03);
}
.btn:disabled {
  opacity: 0.4;
  cursor: default;
  box-shadow: none;
}
.btn:disabled:hover {
  filter: none;
}

/* ---------- Board ---------- */
.board-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  flex: 1;
  justify-content: center;
}

/* Board group (board + its inline status readouts). In portrait this is just a
   transparent vertical stack that behaves exactly as the loose children did
   before; in landscape it becomes the left column with the action buttons
   beside it. */
.board-main {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.board {
  --n: 8;
  --intro-rot: 0deg;
  --intro-scale: 1;
  width: min(92vw, 70vh, 560px);
  aspect-ratio: 1 / 1;
  display: grid;
  grid-template-columns: repeat(var(--n), 1fr);
  grid-template-rows: repeat(var(--n), 1fr);
  border: 3px solid var(--cell-line-strong);
  border-radius: var(--r-btn);
  overflow: hidden;
  box-shadow: var(--shadow);
  /* The intro sequence spins the board while the regions flood in, then eases
     back to 0deg. The scale keeps the rotating square inside its own box (its
     corners would otherwise sweep past the edges); it eases back to 1 as the
     board settles. Driven per-frame from JS; identity at rest. */
  transform: rotate(var(--intro-rot, 0deg)) scale(var(--intro-scale, 1));
  /* Custom tap + swipe handling: stop the browser scrolling/zooming on the
     board so a finger drag paints cells instead. */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

/* Boards above 12 columns get a bigger box, because the cell — not the board —
   is what has to stay tappable: at the normal cap a 14x14 cell is 40px against
   a 12x12's 47px. Leaning harder on the viewport buys that back on the screens
   these sizes are offered on at all (a laptop is height-limited by 70vh, so
   raising that matters more there than the px cap).

   This rule is also what maxSizeForViewport() in js/main.js measures: it toggles
   this class on the real element and reads the resulting width back, rather than
   restating the formula in JS where the two would drift apart. */
.board.board-xl {
  width: min(94vw, 78vh, 640px);
}

/* Intro placeholder cells carry no --reg; keep the old dampening for them. */
@media (prefers-color-scheme: dark) {
  .board .cell.intro-cell {
    filter: saturate(0.82) brightness(0.9);
  }
}
:root[data-theme="dark"] .board .cell.intro-cell {
  filter: saturate(0.82) brightness(0.9);
}

/* Solved: green ring plus one light sweep across the board. */
.board.solved {
  box-shadow: 0 0 0 4px rgba(23, 160, 106, 0.55), var(--shadow);
}
.board.win-shine::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.6) 50%, transparent 65%);
  animation: board-shine 1.1s ease-out both;
}
@keyframes board-shine {
  from {
    transform: translateX(-110%);
  }
  to {
    transform: translateX(110%);
  }
}
@media (prefers-reduced-motion: reduce) {
  .board.win-shine::after {
    animation: none;
    opacity: 0;
  }
}

.cell {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 0;
  background-color: var(--reg, transparent);
  border: 1px solid var(--grid-line);
  cursor: pointer;
  touch-action: none;
}

/* Dark theme: a darker tone of the same hue, derived per cell, instead of
   filtering the whole board (which greyed every colour equally). */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .cell {
    background-color: color-mix(in srgb, var(--reg, transparent) 66%, #0f1220);
  }
}
:root[data-theme="dark"] .cell {
  background-color: color-mix(in srgb, var(--reg, transparent) 66%, #0f1220);
}

/* Children never intercept hit-testing, so elementFromPoint during a swipe
   always resolves to the cell itself. */
.cell > * {
  pointer-events: none;
}

/* Thicker borders on region boundaries */
.cell.bt {
  border-top: 3px solid var(--cell-line-strong);
}
.cell.br {
  border-right: 3px solid var(--cell-line-strong);
}
.cell.bb {
  border-bottom: 3px solid var(--cell-line-strong);
}
.cell.bl {
  border-left: 3px solid var(--cell-line-strong);
}

.cell .queen {
  width: 66%;
  height: 66%;
  color: var(--queen-ink);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}

.cell .dot {
  width: 20%;
  height: 20%;
  border-radius: 50%;
  background: color-mix(in srgb, var(--queen-ink) 55%, transparent);
}

/* Conflict: a red ring plus a light red wash, pulsing slowly. The queen alone
   turning red disappeared on red and orange regions. */
.cell.conflict {
  animation: conflict-pulse 1.4s ease-in-out infinite;
}
.cell.conflict .queen {
  color: var(--queen-bad);
}
@keyframes conflict-pulse {
  0%,
  100% {
    box-shadow: inset 0 0 0 3px #d6203f, inset 0 0 0 40px rgba(214, 32, 63, 0.18);
  }
  50% {
    box-shadow: inset 0 0 0 3px #ff5a73, inset 0 0 0 40px rgba(214, 32, 63, 0.04);
  }
}
@media (prefers-reduced-motion: reduce) {
  .cell.conflict {
    animation: none;
    box-shadow: inset 0 0 0 3px #d6203f, inset 0 0 0 40px rgba(214, 32, 63, 0.14);
  }
}

/* Dead region: fully dotted with no queen, so it can never hold one. Draw a
   bold red outline around it. The colour pulses between a bright and a deep
   red so the outline stays unmistakable even on top of a reddish region. */
@property --dead-c {
  syntax: "<color>";
  inherits: false;
  initial-value: #ff2d43;
}
.cell.dead {
  animation: dead-pulse 1.15s ease-in-out infinite;
  z-index: 1; /* keep the red edges above neighbours' region borders */
}
.cell.dead.dt {
  border-top: 3.5px solid var(--dead-c);
}
.cell.dead.dr {
  border-right: 3.5px solid var(--dead-c);
}
.cell.dead.db {
  border-bottom: 3.5px solid var(--dead-c);
}
.cell.dead.dl {
  border-left: 3.5px solid var(--dead-c);
}
@keyframes dead-pulse {
  0%,
  100% {
    --dead-c: var(--dead-bright);
  }
  50% {
    --dead-c: var(--dead-deep);
  }
}
@media (prefers-reduced-motion: reduce) {
  .cell.dead {
    animation: none;
    --dead-c: var(--dead-static);
  }
}

.cell.pop .queen {
  animation: queen-drop 0.36s cubic-bezier(0.2, 1.2, 0.4, 1) both;
}
/* Ring that spreads out from the landing queen. */
.cell.pop:not(.hint-target)::after {
  content: "";
  position: absolute;
  width: 90%;
  height: 90%;
  border-radius: 50%;
  border: 2px solid var(--queen-ink);
  pointer-events: none;
  animation: queen-ripple 0.5s ease-out both;
}
@keyframes queen-drop {
  0% {
    transform: translateY(-70%) scale(1.25);
    opacity: 0;
  }
  55% {
    transform: translateY(6%) scale(0.94);
    opacity: 1;
  }
  100% {
    transform: none;
  }
}
@keyframes queen-ripple {
  0% {
    transform: scale(0.3);
    opacity: 0.7;
  }
  100% {
    transform: scale(1.5);
    opacity: 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .cell.pop .queen,
  .cell.pop::after {
    animation: none;
  }
}

/* ---------- Intro / generation reveal ---------- */
/* Placeholder cells shown while a puzzle is being generated in the worker. They
   carry no region data (nothing is known yet); JS paints a slow colour "bloom"
   over them each frame so the wait reads as the algorithm exploring. */
.cell.intro-cell {
  cursor: default;
}

/* Once the real board is built, its cells start hidden and fade in staggered
   from each region's centre outward (per-cell --reveal-delay set in JS), so the
   colour regions appear to flood across the board. */
.board.intro-revealing .cell {
  transition: opacity 0.7s ease var(--reveal-delay, 0s);
}
.board.intro-revealing .cell.intro-hidden {
  opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
  /* Intro is skipped entirely in JS under reduced motion; this is a safety net
     so a board can never get stuck invisible. */
  .board {
    transform: none;
  }
  .board.intro-revealing .cell {
    transition: none;
  }
  .board.intro-revealing .cell.intro-hidden {
    opacity: 1;
  }
}

/* ---------- Hint mode ---------- */
/* Grey everything out, then let the highlighted cells shine through. */
.board.hinting .cell {
  opacity: 0.45;
  filter: grayscale(1);
  transition: opacity 0.2s ease, filter 0.2s ease;
}
.board.hinting .cell.hint-reason,
.board.hinting .cell.hint-line,
.board.hinting .cell.hint-target,
.board.hinting .cell.hint-x,
.board.hinting .cell.hint-bad {
  opacity: 1;
  filter: none;
}

.cell.hint-line {
  opacity: 0.75 !important;
}
.cell.hint-reason {
  box-shadow: inset 0 0 0 3px rgba(43, 58, 103, 0.85);
  animation: hint-pulse 1.3s ease-in-out infinite;
}
.cell.hint-target {
  box-shadow: inset 0 0 0 4px #17a06a;
  animation: hint-pulse 1.3s ease-in-out infinite;
}
/* Ghost queen on the target cell so the suggested placement reads clearly. */
.cell.hint-target::after {
  content: "";
  position: absolute;
  inset: 17%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(23, 160, 106, 0.55), rgba(23, 160, 106, 0));
}
.cell.hint-x {
  background-image: repeating-linear-gradient(
    45deg,
    rgba(31, 37, 68, 0.6) 0 3px,
    transparent 3px 8px
  );
  box-shadow: inset 0 0 0 2px rgba(224, 64, 94, 0.75);
}
.cell.hint-bad {
  box-shadow: inset 0 0 0 4px var(--danger);
  animation: hint-pulse 1.1s ease-in-out infinite;
}
@keyframes hint-pulse {
  0%,
  100% {
    filter: brightness(1);
  }
  50% {
    filter: brightness(1.18);
  }
}
@media (prefers-reduced-motion: reduce) {
  .cell.hint-reason,
  .cell.hint-target,
  .cell.hint-bad {
    animation: none;
  }
}

.hint-card {
  position: fixed;
  left: 50%;
  bottom: calc(env(safe-area-inset-bottom) + 14px);
  transform: translateX(-50%);
  width: min(460px, calc(100vw - 24px));
  background: var(--panel);
  color: var(--ink);
  border-radius: var(--r-card);
  padding: 16px 18px;
  box-shadow: var(--shadow);
  z-index: 25;
  animation: rise 0.2s ease;
}
.hint-card[hidden] {
  display: none;
}
.hint-card-title {
  font-weight: 700;
  font-size: 1.05rem;
  margin-bottom: 6px;
}
.hint-card-text {
  margin: 0 0 10px;
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.45;
}
.hint-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 0.78rem;
  color: var(--muted);
  margin-bottom: 12px;
}
.hint-legend:empty {
  display: none;
}
.hint-legend span {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.hint-legend i {
  width: 13px;
  height: 13px;
  border-radius: 4px;
  display: inline-block;
}
.hint-legend i.lg-reason {
  box-shadow: inset 0 0 0 3px rgba(43, 58, 103, 0.85);
}
.hint-legend i.lg-target {
  box-shadow: inset 0 0 0 3px #17a06a;
}
.hint-legend i.lg-x {
  background-color: #cfd3de;
  background-image: repeating-linear-gradient(45deg, rgba(31, 37, 68, 0.55) 0 2px, transparent 2px 5px);
}
.hint-card-buttons {
  display: flex;
  gap: 8px;
}
.hint-card-buttons .btn {
  flex: 1;
}

/* ---------- Messages / actions ---------- */
.message {
  min-height: 1.4em;
  font-weight: 600;
  color: var(--danger);
  text-align: center;
}
.message.ok {
  color: #17a06a;
}

/* Prüf-Status lamp: a small pill with a coloured dot. Only ever a yes/no —
   never reveals a position. Colours mirror the message line (ok green /
   error red). Hidden until a check has run. The slot keeps a fixed height even
   while the pill is hidden, so the status appearing/disappearing never
   reflows the board (which would jump under the player's finger mid-tap). */
.check-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 32px;
}
.check-status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  border-radius: 999px;
  font-size: 0.9rem;
  font-weight: 600;
  line-height: 1;
  border: 1px solid transparent;
  animation: check-pop 0.18s ease;
}
.check-status[hidden] {
  display: none;
}
.check-status::before {
  content: '';
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent);
  flex: none;
}
.check-status.ok {
  color: #17a06a;
  background: color-mix(in srgb, #17a06a 12%, var(--panel));
  border-color: color-mix(in srgb, #17a06a 35%, transparent);
}
.check-status.error {
  color: var(--danger);
  background: color-mix(in srgb, var(--danger) 12%, var(--panel));
  border-color: color-mix(in srgb, var(--danger) 35%, transparent);
}
@keyframes check-pop {
  from {
    transform: scale(0.92);
    opacity: 0;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}
@media (prefers-reduced-motion: reduce) {
  .check-status {
    animation: none;
  }
}

.actions {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 8px;
  width: min(460px, 100%);
  margin-top: 6px;
}
.actions .btn,
.actions .btn.subtle {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  min-height: 62px;
  padding: 8px 2px;
  border: none;
  border-radius: 14px;
  background: var(--panel);
  box-shadow: var(--btn-shadow);
  font-size: 0.74rem;
  line-height: 1.1;
}
.actions .btn .btn-label {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  letter-spacing: -0.01em;
}
.actions .btn[hidden] {
  display: none;
}
#debug-copy {
  grid-column: 1 / -1;
}

/* Narrowest phones (320px): the four action tiles are ~62px of text wide, and
   "Rückgängig" / "Réinitialiser" / "Проверить" run 1-5px over at 0.74rem. A
   smaller face fixes it without touching the labels; the ellipsis stays as the
   last resort for anything longer. */
@media (max-width: 340px) {
  .actions .btn,
  .actions .btn.subtle {
    font-size: 0.68rem;
  }
}

/* ---------- Landscape (tablets) ----------
   On a wide landscape screen the board is height-limited (min(…, 70vh, …)),
   so stacking the action buttons *below* it pushes them off the bottom while
   the left/right margins sit empty. Widen the app and lay the board group and
   the actions side by side: the buttons become a vertical column beside the
   board, costing no vertical space. Portrait is untouched. Gated on width so
   short/narrow landscape phones keep the simple stacked layout. */
@media (orientation: landscape) and (min-width: 720px) {
  .app {
    max-width: min(1040px, 96vw);
  }

  .board-wrap {
    flex-direction: row;
    align-items: center;
    justify-content: center;
    gap: clamp(16px, 4vw, 52px);
  }

  /* Vertical, equal-width button column, vertically centred next to the board. */
  .actions {
    display: flex;
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
    align-self: center;
    gap: 10px;
    margin-top: 0;
    /* 165px floor: both priced buttons ("Prüfen +14 s", "Hinweis +30 s")
       have to fit in every pack; Russian and Spanish were the widest at 740px. */
    width: clamp(175px, 20vw, 220px);
    flex: none;
  }
  .actions .btn,
  .actions .btn.subtle {
    flex-direction: row;
    justify-content: flex-start;
    gap: 12px;
    width: 100%;
    min-height: 48px;
    padding: 0 14px;
    font-size: 0.95rem;
    text-align: left;
  }
  .actions .btn .btn-label {
    flex: 1;
  }
  .actions .btn-cost {
    position: static;
    background: none;
    color: var(--muted);
    padding: 0;
    font-size: 0.75rem;
  }
  .actions #hint,
  .actions #hint.subtle {
    background: var(--accent);
    color: var(--accent-ink);
  }
  .actions #hint .btn-cost {
    color: inherit;
    opacity: 0.85;
  }
}

/* ---------- Voice Mode ---------- */
/* Inline card (not fixed) so it never fights the bottom-anchored hint/win
   cards. Only shown while the preference is on. */
.voice-panel {
  display: flex;
  align-items: center;
  gap: 12px;
  width: min(460px, calc(100vw - 24px));
  padding: 10px 12px;
  background: var(--panel);
  border-radius: var(--r-card);
  box-shadow: var(--shadow);
  animation: check-pop 0.18s ease;
}
.voice-panel[hidden] {
  display: none;
}
.voice-listen {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: none;
}
.voice-mic {
  font-size: 1.15rem;
  line-height: 1;
}
/* Listening: a soft red pulse so it's obvious the mic is live. */
.voice-listen.listening {
  background: var(--accent);
  color: var(--accent-ink);
  animation: voice-pulse 1.4s ease-in-out infinite;
}
@keyframes voice-pulse {
  0%,
  100% {
    box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
  }
  50% {
    box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent);
  }
}
.voice-readout {
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.voice-help-btn {
  flex: none;
  box-shadow: none;
  background: transparent;
  color: var(--muted);
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  font-style: normal;
}
.voice-transcript {
  font-size: 0.92rem;
  font-weight: 600;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.voice-transcript:empty::before {
  content: attr(data-placeholder);
  color: var(--muted);
  font-weight: 400;
}
.voice-status {
  font-size: 0.8rem;
  color: var(--muted);
  min-height: 1.1em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.voice-status.ok {
  color: #17a06a;
}
.voice-status.warn {
  color: var(--danger);
}
@media (prefers-reduced-motion: reduce) {
  .voice-panel,
  .voice-listen.listening {
    animation: none;
  }
}

/* Board stage: the board plus the optional edge coordinate rulers. The board
   keeps its own explicit size and defines the grid tracks; the ruler tracks are
   `auto`, so they collapse to zero (board sits exactly where it did before)
   until Voice Mode's edge option turns them on. The rulers live outside .board
   so the intro rotation/scale never carries them. */
.board-stage {
  --n: 8;
  display: grid;
  grid-template-columns: auto auto;
  grid-template-rows: auto auto;
  align-items: center;
  justify-items: center;
}
.board-stage .board {
  grid-column: 2;
  grid-row: 2;
}
.coord-cols,
.coord-rows {
  display: none;
  color: var(--muted);
  font-weight: 800;
  line-height: 1;
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
}
.coord-cols {
  grid-column: 2;
  grid-row: 1;
  width: 100%;
  grid-template-columns: repeat(var(--n), 1fr);
  padding-bottom: 5px;
}
.coord-rows {
  grid-column: 1;
  grid-row: 2;
  height: 100%;
  grid-template-rows: repeat(var(--n), 1fr);
  padding-right: 7px;
}
.board-stage.show-edge-coords .coord-cols,
.board-stage.show-edge-coords .coord-rows {
  display: grid;
}
/* Leave room for the rulers by trimming the board a touch when they're shown,
   so the left number strip never gets clipped on a full-width phone board. */
.board-stage.show-edge-coords .board {
  width: min(84vw, 66vh, 528px);
}
.coord-label {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(0.62rem, 2.7vw, 0.98rem);
}

/* Voice Mode tutorial overlay: a compact "phrase → what it does" list. */
.voice-help-h {
  margin: 16px 0 8px;
  font-size: 0.95rem;
}
.voice-help-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.voice-help-list li {
  display: flex;
  align-items: baseline;
  gap: 10px;
}
.vh-cmd {
  flex: none;
  min-width: 6.6em;
  font-weight: 700;
  color: var(--ink);
}
.vh-desc {
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.35;
}

/* Chess-style coordinate label in each cell's corner, shown only in Voice Mode
   so a player can read out exactly what to say. A pseudo-element (not a child)
   so updateBoard's innerHTML rewrites can't wipe it. */
.board.show-coords .cell::before {
  content: attr(data-coord);
  position: absolute;
  top: 1px;
  left: 3px;
  z-index: 1;
  font-size: clamp(0.42rem, 1.9vw, 0.66rem);
  font-weight: 700;
  line-height: 1;
  color: rgba(31, 37, 68, 0.6);
  text-shadow: 0 0 2px rgba(255, 255, 255, 0.55);
  pointer-events: none;
}

/* Brief highlight on the cell a voice command just addressed. */
.cell.voice-flash {
  animation: voice-flash 0.65s ease;
}
@keyframes voice-flash {
  0% {
    box-shadow: inset 0 0 0 3px var(--accent);
  }
  100% {
    box-shadow: inset 0 0 0 0 transparent;
  }
}
@media (prefers-reduced-motion: reduce) {
  .cell.voice-flash {
    animation: none;
  }
}

.hint {
  text-align: center;
  color: var(--muted);
  font-size: 0.85rem;
  line-height: 1.4;
  padding: 0 8px;
}

/* ---------- Overlays ---------- */
.overlay {
  position: fixed;
  inset: 0;
  background: rgba(15, 18, 32, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 20;
  backdrop-filter: blur(2px);
}
.overlay[hidden] {
  display: none;
}

.overlay-card {
  background: var(--panel);
  color: var(--ink);
  border-radius: var(--r-card);
  padding: 22px;
  width: min(420px, 100%);
  box-shadow: var(--shadow);
  animation: rise 0.2s ease;
  /* Cap the card to the viewport (minus the overlay's padding) and let it scroll
     internally, so a tall settings list never pushes the buttons off-screen —
     e.g. on a short split-screen window. dvh tracks the mobile URL bar. */
  max-height: calc(100vh - 40px);
  max-height: calc(100dvh - 40px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
@keyframes rise {
  from {
    transform: translateY(10px);
    opacity: 0;
  }
}
.overlay-card h2 {
  margin: 0 0 14px;
}
.overlay-card p {
  color: var(--muted);
}

/* Title row of the win card: heading left, a small close button right — same
   layout as .settings-head. Closing hides the card so the solved board (which
   is otherwise frozen and unobstructed above it) can be seen in full; tapping
   the board again brings the card back. */
.win-card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 8px;
}
.win-card-head .hint-card-title {
  margin-bottom: 0;
}
.win-close-btn {
  flex: none;
  padding: 6px 10px;
  font-size: 1rem;
}

.win-card {
  border: none;
  /* No `position` here: .hint-card's `fixed` is what floats the card over the
     board, and a fixed box already anchors the ::before bar. A `relative` here
     once dropped the card into the page flow, below the board. */
  overflow-x: hidden;
  /* Last resort on very short viewports: the list shrinks first (flex), but if
     the fixed parts alone outgrow the cap the controls stay reachable. */
  overflow-y: auto;
  padding: 24px 22px 20px;
  /* Never grow over the top bar. The card is anchored at the bottom and its
     fixed parts (title, result, comparison, tabs, name field, status, buttons)
     already add up to ~390px, so on a short phone it reached the header even
     with a ten-row list. Capping the card and letting the LIST be the part that
     gives way keeps every control reachable; `dvh` so a mobile browser's
     collapsing toolbar doesn't change the answer mid-game. */
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 96px);
  max-height: calc(100dvh - 96px);
}
/* The list is the flexible part: it must be allowed to shrink below its
   content height (min-height:auto would refuse), but never to a slit. */
.win-card .score-panel {
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.win-card .score-list {
  flex: 1 1 auto;
  min-height: 96px;
}
.win-card::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 5px;
  background: linear-gradient(90deg, #17a06a, #5ed7a2);
}
.win-card .hint-card-title {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--ok-text);
  font-size: 1.35rem;
  font-weight: 800;
  margin-bottom: 8px;
}
.win-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ok);
  color: #fff;
}
.win-card .hint-card-text {
  font-size: 1rem;
  margin-bottom: 14px;
}
.win-card .hint-card-buttons {
  flex-direction: column;
}
.win-card .hint-card-buttons .btn {
  min-height: 44px;
  padding: 12px 16px;
  font-size: 1.05rem;
  border-radius: 14px;
}
.win-card .hint-card-buttons .btn.primary {
  min-height: 52px;
  background: var(--ok);
  color: #fff;
  font-weight: 700;
}
.win-card .hint-card-buttons .btn.subtle {
  border: none;
  box-shadow: none;
  color: var(--accent);
  font-size: 0.95rem;
}
.win-close-btn {
  color: var(--muted);
  box-shadow: none !important;
  background: transparent !important;
}
/* Podium colours for the first three rows of any score list. */
.score-row:nth-child(-n + 3) .score-rank {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  min-width: 24px;
  border-radius: 50%;
  font-size: 0.75rem;
  color: #1b2040;
}
.score-row:nth-child(1) .score-rank {
  background: #f5d63d;
}
.score-row:nth-child(2) .score-rank {
  background: #aeb4c0;
}
.score-row:nth-child(3) .score-rank {
  background: #d9a26b;
}
.score-row.me .score-name,
.score-row.me .score-val {
  font-weight: 800;
}
.win-personal-main {
  padding: 6px 14px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.win-personal-main.best {
  background: rgba(23, 160, 106, 0.14);
  color: var(--ok-text);
}

/* Win confetti: reuses the .confetti pieces but as a full-screen fixed layer
   above the board (below the win card), cleared shortly after the win. */
.win-confetti {
  position: fixed;
  z-index: 24;
}
.win-confetti[hidden] {
  display: none;
}

/* ---------- Highscores (win card + Bestenliste) ---------- */
/* Result summary: the ranked "Ergebnis" (effective time) big, with the raw
   time / hints / mistakes breakdown underneath. */
.win-summary {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  margin-bottom: 12px;
}
.win-score {
  font-size: 3rem;
  line-height: 1.1;
  font-weight: 800;
  color: var(--ok-text);
  font-variant-numeric: tabular-nums;
}
.win-breakdown {
  font-size: 0.85rem;
  color: var(--muted);
}

/* Personal placement line under the summary: how this solve compares to the
   player's own previous ones. Empty on the very first solve of a bucket — the
   :empty rule then collapses it so the card keeps its height. */
.win-personal {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  margin: -6px 0 12px;
  text-align: center;
}
.win-personal:empty {
  display: none;
  margin: 0;
}
.win-personal-main {
  font-size: 0.95rem;
  font-weight: 700;
}
.win-personal-main.best {
  color: #17a06a;
}
.win-personal-detail {
  font-size: 0.8rem;
  color: var(--muted);
}
/* Third line: the same solve against the last N days. Shown only when it adds
   something (see getPersonalStats), so it costs card height rarely — and when
   it does, it is the smallest line of the three. */
.win-personal-window {
  font-size: 0.8rem;
  color: var(--muted);
}
.win-personal-window.best {
  color: #17a06a;
  font-weight: 700;
}

/* Debug-only row under the win buttons — its own line so the two real actions
   keep their full width, and deliberately smaller than them. */
.win-debug-row {
  display: flex;
  margin-top: 8px;
}
.win-debug-row[hidden] {
  display: none;
}
.win-debug-row .btn {
  flex: 1;
  font-size: 0.85rem;
  padding: 8px 10px;
}

.score-panel {
  margin-bottom: 12px;
}

/* Lokal / Global toggle (only shown when the online leaderboard is configured). */
.score-tabs {
  display: flex;
  gap: 6px;
  background: var(--bg);
  padding: 4px;
  border-radius: 10px;
  margin-bottom: 8px;
}
.score-tabs[hidden] {
  display: none;
}
.score-tab {
  flex: 1;
  border: none;
  background: transparent;
  padding: 7px 8px;
  border-radius: 8px;
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--muted);
  cursor: pointer;
  touch-action: manipulation;
  /* Three tabs have to survive a 320px phone in four languages: never wrap to a
     second line, clip instead. Only the Bestenliste has a third tab. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.score-tab[hidden] {
  display: none;
}
/* Three tabs at 320px is the tightest case in the app: at the base size the
   widest label ("Global 🌐" — the emoji alone is ~14px) overruns its third of
   the row in every language and gets ellipsed. Shrinking the type and the gaps
   below 380px buys back the ~10px that needs; vertical padding is untouched, so
   the touch target keeps its height. The win card has only two tabs and is
   unaffected either way. */
@media (max-width: 380px) {
  .score-tabs {
    gap: 3px;
    padding: 3px;
  }
  .score-tab {
    font-size: 0.75rem;
    padding: 7px 4px;
  }
}
.score-tab[aria-selected="true"] {
  background: var(--accent);
  color: var(--accent-ink);
}

.score-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 40vh;
  overflow-y: auto;
}
.score-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  border-radius: 8px;
  font-size: 0.9rem;
}
.score-row:nth-child(odd) {
  background: rgba(128, 128, 128, 0.08);
}
.score-rank {
  min-width: 1.8em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}
.score-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* How long ago the entry was set. Sits between the name and the score, never
   wraps, and never squeezes the score: the NAME is the flexible column (it has
   min-width:0 and ellipses), so a long "vor 2 Monaten" shortens the name rather
   than pushing the time out of the row. */
.score-age {
  flex: 0 0 auto;
  font-size: 0.72rem;
  color: var(--muted);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
/* Anything from the last week. Colour alone carries no meaning here — the text
   already says "vor 3 Tagen"; this only makes fresh play catch the eye. */
.score-age.fresh {
  color: #17a06a;
  font-weight: 600;
}
.score-val {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--ink);
}
/* The just-solved / own entry, called out in the list. */
.score-row.me {
  background: rgba(23, 160, 106, 0.16);
  box-shadow: inset 0 0 0 1.5px rgba(23, 160, 106, 0.5);
}
.score-empty {
  padding: 12px 10px;
  text-align: center;
  color: var(--muted);
  font-size: 0.88rem;
}

/* Foot of the global list when the per-player cap held rows back. Muted and
   small: it explains the list, it isn't part of it. Wraps rather than
   ellipsing — the numbers in it are the point. */
.score-note {
  padding: 8px 10px 4px;
  text-align: center;
  color: var(--muted);
  font-size: 0.78rem;
  line-height: 1.35;
}

/* Name field + submit button on the win card. */
.win-entry {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
}
.win-entry[hidden] {
  display: none;
}
.nickname-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--cell-line);
  border-radius: 12px;
  padding: 12px 14px;
  font-size: 1rem;
  font-family: inherit;
  background: var(--bg);
  color: var(--ink);
}
.nickname-input:focus {
  outline: none;
  border-color: var(--accent);
}
.win-entry .btn {
  flex: 0 0 auto;
}
.win-submit-status {
  margin: 0 0 10px;
  min-height: 1.1em;
  font-size: 0.85rem;
  text-align: center;
  color: var(--muted);
}
.win-submit-status.ok {
  color: #17a06a;
}
.win-submit-status.err {
  color: var(--danger);
}
.win-submit-status:empty {
  display: none;
  margin: 0;
}

/* Fixed (not max) height: the entry count varies a lot as you drag the size
   slider (or switch difficulty), and letting the list size to its content
   made the whole card — title and slider included — grow/shrink and wiggle
   on every step. A constant height with internal scrolling keeps everything
   above the list pinned in place; short lists just leave blank space below,
   except the empty state, which centers in the fixed box instead. */
.leaderboard-card .lb-list {
  height: min(40vh, 320px);
}
.leaderboard-card .lb-list:has(.score-empty) {
  justify-content: center;
}

/* ---------- Settings ---------- */
.field {
  display: block;
  margin: 16px 0;
}
/* A hidden sub-option (e.g. the Voice edge-coords / Extended-Debug toggles) must
   actually collapse. `.toggle-field { display: flex }` is an author rule and so
   overrides the UA `[hidden] { display: none }`; this wins it back. */
.field[hidden] {
  display: none;
}
.field-label {
  display: block;
  font-weight: 700;
  margin-bottom: 6px;
}
.field-hint[hidden] {
  display: none;
}
.field-hint {
  display: block;
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 400;
  margin-top: 2px;
}
/* The settings card is a wall of grey explanatory hints, and the size warning
   is the one that has to be noticed rather than read in passing — it only
   appears when it applies, so it is not competing for attention the rest of
   the time. Colour, not weight: bolding it would make the card look like it
   has two headings. */
.field-hint.warn {
  color: var(--danger-text);
}
/* Language picker. A native <select> rather than a segmented control: it stays
   usable when more languages join, and the OS picker is the better touch target
   on a phone. Full width so long endonyms don't get clipped. */
.lang-select {
  display: block;
  width: 100%;
  border: 1px solid var(--cell-line);
  border-radius: 12px;
  padding: 11px 12px;
  font-size: 1rem;
  font-family: inherit;
  background: var(--bg);
  color: var(--ink);
}
.lang-select:focus {
  outline: none;
  border-color: var(--accent);
}
.range-row {
  display: flex;
  align-items: center;
  gap: 12px;
}
.range-row input[type="range"] {
  flex: 1;
  accent-color: var(--accent);
}
.range-value {
  min-width: 2ch;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.segmented {
  display: flex;
  gap: 6px;
  background: var(--bg);
  padding: 4px;
  border-radius: 12px;
}
.segmented button {
  flex: 1;
  border: none;
  background: transparent;
  padding: 10px 8px;
  border-radius: 9px;
  font-weight: 600;
  color: var(--muted);
  cursor: pointer;
  touch-action: manipulation;
}
.segmented button[aria-checked="true"] {
  background: var(--accent);
  color: var(--accent-ink);
}
.segmented button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.toggle-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.switch {
  appearance: none;
  width: 48px;
  height: 28px;
  border-radius: 999px;
  background: var(--cell-line);
  position: relative;
  cursor: pointer;
  flex: 0 0 auto;
  transition: background 0.15s ease;
}
.switch::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  transition: transform 0.15s ease;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.switch:checked {
  background: var(--accent);
}
.switch:checked::after {
  transform: translateX(20px);
}

.settings-note {
  font-size: 0.8rem;
}
.settings-buttons {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
}

/* Title row of the settings card: heading left, the QR share button right and
   level with it. Sharing is a rare action, so it gets a small icon button
   inside the dialog rather than another permanent button in the top bar. */
.settings-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
}
.settings-head h2 {
  margin: 0;
}
.qr-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px;
}
.qr-icon {
  width: 22px;
  height: 22px;
  display: block;
}

/* ---------- QR share dialog ---------- */
/* Sits above the settings overlay it is opened from (which stays visible
   behind it), so closing returns there instead of to the board. */
.qr-overlay {
  z-index: 21;
}
.qr-card {
  width: min(340px, 100%);
  text-align: center;
}
.qr-hint {
  display: block;
  margin: 0 0 14px;
}
/* The SVG carries its own white plate including the 4-module quiet zone a
   scanner needs — the dialog behind it is dark, so the code must not be
   transparent. */
.qr-code {
  width: min(230px, 62vw);
  margin: 0 auto 10px;
  border-radius: 10px;
  overflow: hidden;
  line-height: 0;
}
.qr-svg {
  display: block;
  width: 100%;
  height: auto;
}
.qr-url {
  margin: 0 0 4px;
  font-size: 0.85rem;
  word-break: break-all;
}
.qr-url a {
  color: var(--ink);
}
.qr-repo-hint {
  margin: 14px 0 6px;
  padding-top: 14px;
  border-top: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
}
.qr-repo-link {
  margin: 0 0 4px;
  font-size: 0.85rem;
  word-break: break-all;
}
.qr-repo-link a {
  color: var(--ink);
}

/* ---------- Loading ---------- */
.loading {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(238, 241, 248, 0.4);
  z-index: 30;
}
.loading[hidden] {
  display: none;
}
.spinner {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 4px solid var(--cell-line);
  border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* ---------- Party mode (Easter egg) ---------- */
/* Fires when the player dots every single cell. Sits above every other layer
   (loading = 30, overlays = 20). Purely visual — no audio, by design. */
.party {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  overflow: hidden;
  background: rgba(8, 10, 22, 0.55);
  backdrop-filter: blur(2px);
}
.party[hidden] {
  display: none;
}

/* Alternating blue emergency lights (Blaulicht) sweeping the two edges. */
.party-lights {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.blaulicht {
  position: absolute;
  top: -20%;
  width: 65vw;
  height: 140%;
  filter: blur(45px);
  opacity: 0;
  mix-blend-mode: screen;
}
.blaulicht.left {
  left: -22vw;
  background: radial-gradient(
    ellipse at center,
    rgba(70, 120, 255, 0.95),
    rgba(70, 120, 255, 0) 70%
  );
  animation: blaulicht-left 0.85s ease-in-out infinite;
}
.blaulicht.right {
  right: -22vw;
  background: radial-gradient(
    ellipse at center,
    rgba(110, 160, 255, 0.95),
    rgba(110, 160, 255, 0) 70%
  );
  animation: blaulicht-right 0.85s ease-in-out infinite;
}
@keyframes blaulicht-left {
  0%,
  100% {
    opacity: 0.95;
  }
  50% {
    opacity: 0;
  }
}
@keyframes blaulicht-right {
  0%,
  100% {
    opacity: 0;
  }
  50% {
    opacity: 0.95;
  }
}

/* Confetti: pieces are created in JS and each gets random left/colour/size and
   its own --x drift, --spin and timing via inline style. */
.confetti {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}
.confetti i {
  position: absolute;
  top: 0;
  width: 10px;
  height: 14px;
  will-change: transform;
  animation: confetti-fall linear infinite;
}
@keyframes confetti-fall {
  0% {
    transform: translate3d(0, -12vh, 0) rotate(0deg);
  }
  100% {
    transform: translate3d(var(--x, 0px), 112vh, 0) rotate(var(--spin, 720deg));
  }
}

.party-card {
  position: relative;
  z-index: 1;
  text-align: center;
  background: var(--panel);
  color: var(--ink);
  border-radius: var(--r-card);
  padding: 26px 24px;
  width: min(440px, 100%);
  box-shadow: 0 0 0 3px rgba(111, 139, 255, 0.6), var(--shadow);
  animation: party-pop 0.4s cubic-bezier(0.2, 1.4, 0.5, 1);
}
@keyframes party-pop {
  from {
    transform: scale(0.7);
    opacity: 0;
  }
}
.party-beacon {
  font-size: 3.4rem;
  line-height: 1;
  animation: beacon 0.55s ease-in-out infinite;
}
@keyframes beacon {
  0%,
  100% {
    transform: rotate(-14deg) scale(1);
  }
  50% {
    transform: rotate(14deg) scale(1.15);
  }
}
.party-kicker {
  margin-top: 10px;
  text-transform: uppercase;
  letter-spacing: 2px;
  font-size: 0.72rem;
  font-weight: 700;
  color: var(--accent);
}
.party-title {
  margin: 4px 0 12px;
  font-size: clamp(1.9rem, 8vw, 2.7rem);
  font-weight: 800;
  letter-spacing: 0.5px;
  background: linear-gradient(90deg, #ff8a8a, #ffe066, #7ed99a, #79c7ff, #bd93f9, #ff8a8a);
  background-size: 200% auto;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: party-shimmer 3s linear infinite;
}
@keyframes party-shimmer {
  to {
    background-position: 200% center;
  }
}
.party-text {
  margin: 0 0 18px;
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.5;
}

/* Respect reduced-motion: keep the celebration, drop the flashing/spinning.
   Confetti is skipped entirely in JS under this preference. */
@media (prefers-reduced-motion: reduce) {
  .blaulicht {
    animation: none;
  }
  .blaulicht.left,
  .blaulicht.right {
    opacity: 0.4;
  }
  .party-beacon,
  .party-title,
  .party-card {
    animation: none;
  }
}
