/* =====================================================================
   Sunoku — styles.css
   Dark by default, light on request, and sized so the board is the
   biggest thing on the screen whether that screen is a phone or a laptop.
   ===================================================================== */

:root {
  --bg: #0e1219;
  --bg-2: #151b26;
  --surface: #1a2231;
  --surface-2: #212b3d;
  --line: #2c3850;
  --text: #eef2f9;
  --text-dim: #97a3b8;
  --text-faint: #6b7789;
  --accent: #ffcb33;
  --accent-ink: #2a1f00;
  --danger: #ff5a4d;
  /* --bad was used (the voided-time asterisks, the daily's offline line)
     but never defined, so all of them fell back to plain text colour.
     It is the danger red. */
  --bad: var(--danger);
  --good: #38d39f;

  --ink-out: #ffffff; /* the ✕ you place yourself */
  --ink-wrong: #ff4d43; /* the ✕ a wrong guess leaves behind */

  --radius: 14px;
  --tile-radius: 24%;
  --shadow: 0 10px 34px rgba(0, 0, 0, 0.45);

  --board-max: 34rem;
  color-scheme: dark;
}

:root[data-theme='light'] {
  --bg: #f2f4f9;
  --bg-2: #e9edf5;
  --surface: #ffffff;
  --surface-2: #f4f6fb;
  --line: #dbe1ec;
  --text: #161c26;
  --text-dim: #5c6982;
  --text-faint: #8b96a9;
  --accent: #f0a500;
  --accent-ink: #2a1f00;
  --shadow: 0 10px 30px rgba(24, 38, 66, 0.14);
  color-scheme: light;
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) {
    --bg: #f2f4f9;
    --bg-2: #e9edf5;
    --surface: #ffffff;
    --surface-2: #f4f6fb;
    --line: #dbe1ec;
    --text: #161c26;
    --text-dim: #5c6982;
    --text-faint: #8b96a9;
    --accent: #f0a500;
    --shadow: 0 10px 30px rgba(24, 38, 66, 0.14);
    color-scheme: light;
  }
}

* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;
}

/* several things below set `display`, which would otherwise beat [hidden] */
[hidden] {
  display: none !important;
}

/* Installed on a phone, the page runs edge to edge underneath the status bar
   and the home indicator. The background therefore has to be painted on the
   root and pinned to the viewport — put it on <body> instead and those inset
   strips end up unpainted, which is the black bar at the bottom and the white
   bar at the top. The safe areas are then paid for by the header and toolbar,
   not by insetting the whole page. */
html {
  margin: 0;
  height: 100%;
  overflow: hidden; /* the game is one screen; nothing here ever scrolls */
  background-color: var(--bg);
  background-image: radial-gradient(120% 80% at 50% 0%, var(--bg-2), var(--bg) 70%);
  background-attachment: fixed;
  background-repeat: no-repeat;
  overscroll-behavior: none;
}

/* Pinned to the viewport rather than merely as tall as it: a fixed body has
   nothing to scroll, which is what stops the rubber-band drag on iOS and any
   stray pan on a phone. `touch-action: none` then removes pinch-zoom and
   double-tap-zoom as well, so the board never moves under a finger mid-sweep.
   The one thing that does need to scroll — a sheet — opts back in below. */
body {
  /* THE SAFE AREAS ARE PART OF THE COLUMN, and forgetting that is what made
     this look right on every desktop and wrong on every iPhone.

     The page runs edge to edge under the notch and the home indicator
     (viewport-fit=cover), and the header and toolbar pay for that with
     padding. On a desktop both insets are zero, so the arithmetic below
     balanced and nothing overlapped. On an iPhone 16 Pro they are 63px and
     49px — 112px of column height that the reserve simply did not know
     about, so the board was sized 112px too generously and the overflow
     went up into the header.

     They are named here rather than used inline so that ONE definition
     feeds both the padding that creates the space and the reserve that
     accounts for it. Two places reading the same variable cannot drift, and
     a simulation can drive both by setting it. */
  --inset-top: env(safe-area-inset-top);
  --inset-bottom: env(safe-area-inset-bottom);

  /* Edge to edge, less only what the notch cuts off in landscape. This was
     94vw — a hair of margin on each side that cost about 6% of the board's
     area for no purpose the board itself served. */
  --board-width: calc(100vw - env(safe-area-inset-left) - env(safe-area-inset-right));

  /* Everything in the column that is NOT the board, in parts so all four
     combinations come out right without a table of magic numbers: the
     message slot is the piece that comes and goes, the insets are the piece
     that varies by device, and the rest is the header, status line, gaps,
     Auto X bar and toolbar. All measured, not guessed — a short viewport
     measures smaller because the toolbar drops its labels there. The header
     is a stacked crest / name / < Level N > / stars now, a bit taller than
     the two rows it replaced.

     +3px on top of that for the chevron under "Level N" (measured: the
     stacked number+chevron button is taller than the nav arrows either
     side of it now, where it used to be the other way around, so the
     row's height follows the button instead of the arrows).

     282 → 256 in the 2026-09-12 pass that dropped the "solved" chip's own
     reserved row below the header and gave some of that space back to the
     header itself — 6px more top padding, a point bigger title/level
     text. Re-measured the honest way, not by tallying the individual
     changes (main is flex:1 and top-aligned, so its slack silently
     absorbs small arithmetic errors and hides them until a long name
     wraps): with the board excluded, .topbar + .autox-bar + .toolbar's
     own rendered heights, plus main's fixed 4px top padding, the status
     line, and its 10px gap to the board — everything that is fixed
     regardless of how much slack main is carrying.

     256 → 267 the same day, Auto X / Solve it! grown 37px → 48px tall
     (+30%, on request — "a bit skinny on my phone"). Re-measured the
     same honest way, not by adding 11px on faith.

     267 → 322 on 2026-09-13: Undo/Redo/Hint/Restart swapped places with
     Auto X / Solve it! and a large Daily Puzzles button took the bottom
     row, then the whole swap was reverted the same day — "I don't like
     this really." The topbar's Daily icon grew a label under it in the
     same pass (Home left the corner, Daily took it), which re-measured
     0.5–2px taller across a few subpixel roundings than the plain icon
     it replaced, so 267 does not quite clear it either. 322 → 270:
     115.375 (topbar) + 48 (Auto X / Solve it!, back on top) + 73
     (toolbar, back on the bottom) + 4 (main's top padding) + 18.75
     (status line) + 10 (its gap to the board), ceiled.

     270 → 287 on 2026-09-14: the star row is back under "Level N" for the
     regular sets (the daily and the builder already spell difficulty out
     in words, so they keep the row hidden and take none of this). Measured
     directly rather than by adding the row's own numbers up: .topbar's
     rendered height with the row showing minus its height with the row
     display:none, 17px, on the same board.

     287 → 274 the same day: that 17px read as cramped on a real phone —
     five lines stacked in the title block where there used to be three,
     with no more room given back anywhere else. Cut the header's own
     padding instead of the row's margins alone (18px top / 6px bottom →
     14px / 3px), which also shrinks the row's true cost: re-measured the
     same way as above, topbar with the star row shown vs today's original
     (no row, old padding) baseline of 119.5px — now only 4px apart, not
     17.

     274 → 244 on 2026-09-27: the Auto X / Solve it! row above the toolbar
     is gone — Solve joined the toolbar beside Hint and Auto X moved into
     Settings — which gave back its 48px. But re-measured the honest way
     (560×760, board top 160.25 + its 10px gap + the 73px toolbar), 274
     had already drifted 17px short of the truth: the board was running
     under the Auto X row by about 7px on any screen where height, not
     width, sets its size. 274 − 48 would have kept that error, so this is
     the measurement, ceiled, not the subtraction.

     244 → 256 the same day: main's top padding 4px → 16px, to open a
     little space between the star row and the status line. */
  --reserve-chrome: 256px;
  /* ALWAYS ZERO. The board no longer pays for the message bars at all.

     It used to pay twice over: first by reserving the slot's full height
     whether or not anything was in it, and then — once that was fixed — by
     shrinking whenever a bar opened. Neither is necessary. Top-aligning
     `main` pools all the spare height at the bottom of the column, and
     collapsing the Auto X row while a bar is up adds its 37px to the pile:
     161px on a 16 Pro, which is the taller of the two bars exactly. So a
     bar opens into space that was already empty, and the board does not
     move. */
  --reserve-slot: 0px;
  --board-reserve: calc(
    var(--reserve-chrome) + var(--reserve-slot) + var(--inset-top) + var(--inset-bottom)
  );
  margin: 0;
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  height: auto;
  touch-action: none;
  background: transparent;
  color: var(--text);
  font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
    Arial, sans-serif;
  font-synthesis-weight: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  overscroll-behavior: none;
}

/* Standalone launches only — display-mode: standalone is how a Home Screen
   install reports itself, so a normal browser tab (any device) never sees
   this and keeps the plain env() above.

   On a cold Home Screen launch, iOS can report 0 for env(safe-area-inset-*)
   on the very first layout and only resolve the real value a moment later
   — confirmed directly, on a page with none of this app's code at all, by
   comparing against a PWA that never reads this value and never shows the
   jump. calc(12px + 0) is nowhere near enough to clear the notch, so the
   header sits up under it, then drops once the real value lands — the
   exact motion reported. 63px / 49px are this project's own measured
   iPhone 16 Pro figures (see --board-reserve above); flooring at them
   means the "wrong" 0 state already equals the settled one on that
   reference device, so there is nothing left to visibly correct. Older,
   non-Dynamic-Island phones keep a little more padding than their real
   notch strictly needs, but only while installed — a small, permanent
   trade confined to exactly the launches that had the jump. */
@media (display-mode: standalone) {
  body {
    --inset-top: max(63px, env(safe-area-inset-top));
    --inset-bottom: max(49px, env(safe-area-inset-bottom));
  }
}

/* ------------------------------------------------------------- header */

/* Two rows: what you are looking at, then where puzzles come from.

   They shared one row until there were four choosers in it, at which point
   the title had so little width left that "Daily #1 · Easy" wrapped. Giving
   the choosers their own row buys the title the full width back and lets
   the four sit close enough together to read as one set of choices rather
   than four loose buttons. It costs a row of height, which this layout has
   to spare — the board is capped by 58dvh, so the space above it was slack,
   not something the board was using. */
.topbar {
  display: flex;
  flex-direction: column;
  /* NEVER SQUASHED. body is a flex column and main takes flex: 1, so without
     this the header is a shrinkable item — and when the viewport got tight it
     was compressed below the height of its own contents, which put the title
     straight on top of "9/9 placed". The board absorbs the difference
     instead, which is what --board-reserve is for. */
  flex: none;
  /* clears the status bar / notch on an installed phone app. --inset-top,
     not env() directly: --board-reserve has to account for exactly this
     much, and two copies of the same value are two chances to drift.

     18px → 14px top, 6px → 3px bottom on 2026-09-14, the same day the star
     row arrived: with five lines stacked in the title block now instead of
     three, the header read as cramped everywhere at once rather than
     anywhere in particular, so the fix is less padding at both its edges
     rather than surgery on one gap in the middle.

     14px → 11px on 2026-10-03, toward the 27px crest; main's top padding
     and .level-line's margin paid the rest, so the board did not move. */
  padding: calc(11px + var(--inset-top)) calc(10px + env(safe-area-inset-right)) 3px
    calc(10px + env(safe-area-inset-left));
}

/* Width of the help + settings cluster, which the title reserves on BOTH
   sides so its centre stays the centre of the screen. */
.topbar {
  --top-actions: 80px;
}

.topbar-row {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 78px;
}

/* Reserved rather than shrunk: a title centred in "whatever is left after the
   buttons" is visibly off centre. Equal room on both sides puts the centre
   back where the eye expects it, and stops a long set name running under the
   buttons or the daily pill. */
.topbar-row .title-block {
  flex: 1;
  padding: 0 var(--top-actions);
}

.title-block {
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}

.topbar-actions,
.topbar-actions-left {
  position: absolute;
  /* Sits on the set-name line (title row one), not centred on the whole
     three-line title — the arrows and stars below it stay clear. Anchored to
     the top so a wrapped sub-line can't drag it down. */
  top: 13px;
  display: flex;
  align-items: center;
}
/* Help moved into Settings (a row there opens it), so .topbar-actions is
   down to Settings alone — no gap to speak of with one child, it just
   sits flush at the right edge same as always. */
.topbar-actions {
  right: 0;
}
/* Home is gone from here — players do not need a way back to it once
   they are in-game, and it stays reachable from Settings if that ever
   changes. Daily has the corner now, the only icon here, so there is no
   gap to speak of, same as .topbar-actions above. */
.topbar-actions-left {
  left: 0;
}

/* Crest + name and "Level N" are two plain buttons — tap either to open the
   level menu. They stay flat on press (no box, no flash) so the pair reads
   as the title it also is. The < > arrows flank only the "Level N" line. */
.level-open {
  border: 0;
  background: transparent;
  font: inherit;
  color: var(--text);
  cursor: pointer;
  padding: 0;
  -webkit-tap-highlight-color: transparent;
}
.level-open:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 8px;
}

.level-open-name {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  max-width: 100%;
  padding: 2px 6px 0;
}
.level-open-name #level-name {
  /* Shrinks on a narrow phone rather than truncating: the third header icon
     took room the longest set names ("7×7 Extreme") were using. */
  font-size: clamp(14.5px, 4.2vw, 18.5px);
  font-weight: 700;
  letter-spacing: -0.01em;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.level-line {
  display: flex;
  /* Top, not centre: the button between the arrows is two lines tall
     ("Level 1" over its ▾), and centring on the pair sat the arrows 7.5px
     below the number they step through. See .level-line .nav-arrow. */
  align-items: flex-start;
  justify-content: center;
  /* Pulled in from 8px — even the widest the centre box ever gets
     ("Level 100 · 9★" plus the solved badge) still left the arrows reading
     as far off from it as they did next to a bare "Level 1". */
  gap: 3px;
  /* Room under the set name, about halfway between it and the status line
     (2026-10-03). Paid for out of main's top padding, so the board stays put.
     10px → 8px with the 27px crest, to keep the gaps above and below even. */
  margin-top: 8px;
}
/* Level with the number's own row: that row's middle is 12.9px below the
   top of the line (2px of button padding + half its 21.75px height), and
   the 34px arrow's middle is 17px down, so it rises 4px. */
.level-line .nav-arrow {
  margin-top: -4px;
}
/* Stacked, not inline — the chevron sits on its own line under the
   number rather than beside it, so it can't pull the number off-centre
   the way an inline trailing icon does (the number needs equal space
   on both sides to land centred between the < and > arrows either
   way, but stacking means the text's own width is never in question).
   The whole two-line block is one button: pressing the number OR the
   chevron below it opens the level menu, same as pressing the title. */
.level-open-num {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  font-size: 14.5px;
  font-weight: 600;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  /* Reserved for the widest this button ever shows — "Level 100 · 9★" plus
     the solved badge (measured 124px) — so the < > arrows either side of it
     never shift. Without this, "Level 1" and an unbadged, unsolved board
     left the button narrower, and the arrows drifted inward to meet it;
     solving a three-digit level then swung them back out. align-items:
     center on the column above does the actual centring, the same as it
     always did — this only fixes how much room that centring happens in. */
  min-width: 142px;
}

/* The number's own row, separate from the stack above — centred on its
   own, so with the badge hidden this is just "Level 1" dead centre, and
   with it showing the row centres the pair instead: the number nudges
   left to make room rather than the badge tacking onto one side. */
.level-num-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
}

/* Green circle, white check — replaces the old "✓ solved" chip. Small
   enough to sit beside the number rather than needing its own line. */
.level-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--good);
  color: #fff;
}

.level-badge svg {
  width: 10px;
  height: 10px;
  stroke: currentColor;
}

/* The only hint that "Level 1" opens the level menu — everything else
   here (stars, the placed/time/mistakes line) is read-only, so this is
   worth a quiet nudge the way the crest+name button above it doesn't
   need (bold text and its own icon already read as "special"). */
.level-open-num-chevron {
  width: 11px;
  height: 11px;
  flex: none;
  stroke: currentColor;
  fill: none;
  opacity: 0.7;
}

.level-crest {
  display: inline-flex;
  flex: none;
}
.level-crest svg {
  /* 18px → 21px → 27px on 2026-10-03 (15%, then 30% more). The 9px it
     gained comes out of main's top padding and .topbar's, so the board
     stays put. */
  width: 27px;
  height: 27px;
  display: block;
}
.crest-flame {
  color: #ff7a2f;
}
.crest-nova {
  color: #8b6cf0;
}
.crest-blocks {
  color: #f0559b;
}
.crest-daily {
  color: #f0a92c;
}
.crest-builder {
  color: #38c6d9;
}

/* Only the tiered secret set's form chip lives here now — the "solved"
   chip that used to sit below it is gone, replaced by the small badge
   beside "Level 1". So this row is normally empty and takes no height at
   all; body.tiered-token below is the one case that still needs it
   reserved. */
.sub-line {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 5px 7px;
  margin-top: 2px;
  /* This line sits BELOW the header buttons, not beside them, so it does not
     need the reservation the title block carries on both sides for the name.
     Cancelling it gives a long form name room to sit on one row without
     wrapping underneath and nudging the board down. */
  margin-left: calc(-1 * var(--top-actions));
  margin-right: calc(-1 * var(--top-actions));
}

/* The exception, and the reason the reservation existed: the secret token
   set puts a form name up here — "Mastered Ultra Instinct" and friends —
   which does wrap onto a second row. Reserve it only where it can happen. */
body.tiered-token .sub-line {
  min-height: 41px;
}

.chip {
  flex: none;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.03em;
  white-space: nowrap;
  color: var(--text-dim);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 1px 8px;
  font-variant-numeric: tabular-nums;
}

.chip-form {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 38%, transparent);
  background: color-mix(in srgb, var(--accent) 11%, transparent);
}

.stars {
  flex: none;
  font-size: 11px;
  letter-spacing: 1px;
  white-space: nowrap;
  color: var(--accent);
}

.stars .off {
  color: var(--line);
}

/* past five stars the row becomes "7★" — bolder, since it has to carry
   in two characters what five glyphs used to say in width */
.stars-many,
.lv .st b {
  font-weight: 800;
  letter-spacing: 0;
}

/* Home and Daily live in the top-left corner, plain .icon-btn buttons
   like Help and Settings on the right — the corner was already empty:
   the title block reserves the width of a two-icon cluster on BOTH
   sides to keep its centre true, so there was a matching gap on the
   left doing nothing at all. Badges still lives one tap further in,
   through Home; Daily is back to its own icon, no caption underneath,
   grey rather than carrying its own accent colour. */

/* A streak worth keeping, or a reward not yet looked at, deserves to be
   visible from the top bar without opening anything — the dot is the
   nag, the detail is inside. Each now rides its own icon (Daily's
   streak, Home's reward for Badges) rather than sharing one button. */
.streak-pip {
  position: absolute;
  top: 1px;
  right: 6px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #f0a92c;
  box-shadow: 0 0 0 2px var(--bg);
}

.icon-btn,
.nav-arrow {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  flex: none;
  border: 0;
  background: transparent;
  color: var(--text-dim);
  border-radius: 12px;
  cursor: pointer;
  transition: color 0.15s, background 0.15s, transform 0.1s;
}

/* A solid target with 8px of clear space on each side — this is the control
   you reach for most, and it must never be a near-miss for the menu. */
.nav-arrow {
  width: 34px;
  height: 34px;
}

/* 21px → 25.2px (+20%, on request — the header row as a whole). */
.icon-btn svg,
.nav-arrow svg {
  width: 25.2px;
  height: 25.2px;
}

/* Daily's corner of the header: icon over a small caps label, the way the
   home screen's own action row pairs an icon with a word under it. Amber
   throughout — Daily's own accent (.crest-daily, .daily-fifty-mark,
   .levels-daily-mark) — rather than the topbar's usual grey, so it reads
   as a destination, not just another icon. position:relative is its own
   (not inherited from .topbar-actions-left) so its streak-pip lands on
   the button itself. */
.topbar-daily {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  width: 44px;
  padding: 5px 0 4px;
  border: 0;
  background: transparent;
  color: #f0a92c;
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.15s, transform 0.1s;
}

.topbar-daily svg {
  width: 22px;
  height: 22px;
  color: #f0a92c;
}

.topbar-daily span {
  font-size: 9px;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #f0a92c;
}

.topbar-daily:active {
  transform: scale(0.94);
}

.nav-arrow svg {
  width: 16px;
  height: 16px;
}

.icon-btn:hover,
.nav-arrow:hover {
  color: var(--text);
  background: var(--surface);
}

.icon-btn:active {
  transform: scale(0.92);
}

/* The header controls — the arrows and Settings — stay flat on press. No
   box, no flash, no scale; the icon darkens and that is all. */
.nav-arrow:hover,
.topbar-actions .icon-btn:hover {
  background: transparent;
}
.nav-arrow:active,
.topbar-actions .icon-btn:active {
  transform: none;
  background: transparent;
}

.nav-arrow:disabled {
  opacity: 0.25;
  pointer-events: none;
}

/* --------------------------------------------------------------- game */

main {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* TOP-ALIGNED, so the spare height collects in one place: the bottom,
     which is exactly where the hint and solve bars appear.

     Centred, the slack was split evenly above and below the board — 59px
     over, 65px under on a 16 Pro — so neither half was big enough to hold a
     bar, and opening one had to take the difference out of the board. Put it
     all in one pile and there is room for the bar without the board moving
     at all.

     It is also the safe direction to overflow. Centred content that does not
     fit spills BOTH ways, and upwards means into the header — which is the
     bug that has been chased twice this week. From the top it can only ever
     spill down. */
  justify-content: flex-start;
  gap: 10px;
  /* No horizontal padding: the board is edge to edge now, and padding here
     would have quietly capped it a few pixels short of the screen. The
     things that DO want a margin ask for one themselves.

     4px → 16px on 2026-09-27: with the Auto X row gone there was room to
     spare below, so the status line and board came down 12px together to
     give the header's star row some air. --reserve-chrome pays for it.
     16px → 6px on 2026-10-03: the star row is gone, and those 10px went to
     .level-line's margin-top instead, so the board did not move. 6px →
     3px → 2px the same day, for the larger crest above the set name. */
  padding: 2px 0 0;
  min-height: 0;
  /* anchors the message slot when it has to float — see the short-screen
     block at the end of the file */
  position: relative;
}

.statusbar {
  display: flex;
  align-items: center;
  gap: 14px;
  /* Its own margin now that main has none — see main's padding. */
  padding: 0 calc(10px + env(safe-area-inset-left));
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}

.stat b {
  color: var(--text);
  font-weight: 650;
}

.stat-dim {
  color: var(--text-faint);
}

.stat-bad b {
  color: var(--ink-wrong);
}

.board-wrap {
  position: relative;
  display: grid;
  place-items: center;
}

.board {
  --n: 8;
  position: relative;
  /* The height term used to be a flat 58dvh — a guess at what is left once
     the header, status line, message slot and two toolbars have had theirs.
     On a tall phone the guess never binds, because 94vw is smaller. On a
     short one it was too generous: the column came out taller than the
     screen, and `main` centres its content, so the overflow went UPWARDS
     and put the status line under the header.

     So the furniture is subtracted by name instead. --board-reserve is the
     sum of everything else in the column, which means it can be corrected
     by measuring rather than by taste — and the daily, which has no message
     slot, simply declares a smaller one.

     The floor only exists so a pathological window cannot ask for a board of
     zero or fewer pixels. It is deliberately low: set high enough to look
     respectable it becomes the binding constraint on a landscape phone, and
     then the floor itself is what pushes the column past the bottom of the
     screen and the header back into the status line. A small board is worse
     than a big one and better than a broken layout. */
  width: max(96px, min(var(--board-width), calc(100dvh - var(--board-reserve)), var(--board-max)));
  aspect-ratio: 1;
  display: grid;
  grid-template-columns: repeat(var(--n), minmax(0, 1fr));
  grid-template-rows: repeat(var(--n), minmax(0, 1fr));
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 18px;
  padding: 5px;
  box-shadow: var(--shadow);
  touch-action: none;
  transition: opacity 0.18s;
}

.board.solved-glow {
  box-shadow: var(--shadow), 0 0 0 2px color-mix(in srgb, var(--good) 55%, transparent);
}

.cell {
  position: relative;
  min-width: 0;
  min-height: 0;
}

.tile {
  position: absolute;
  inset: clamp(1px, 5%, 3.5px);
  border-radius: var(--tile-radius);
  background: var(--tile);
  transition: transform 0.11s ease, filter 0.11s;
}

.cell:focus-visible {
  outline: none;
}

.cell:focus-visible .tile {
  box-shadow: 0 0 0 3px var(--text), 0 0 0 5px var(--bg);
  z-index: 3;
}

/* The ✕ — two straight bars, square ends, no outline. Contrast comes from
   a drop shadow on the mark itself, which is why it lives in its own
   element rather than on the tile: a filter here cannot touch the tile. */
.cell .x {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.45));
}

.cell .x::before,
.cell .x::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 60%;
  height: 12.5%;
  min-height: 2px;
  border-radius: 1px;
  background: var(--ink-out);
  transform: translate(-50%, -50%) rotate(45deg);
}

.cell .x::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}

.cell[data-mark='1'] .x,
.cell[data-mark='4'] .x {
  opacity: 1;
}

.cell[data-mark='4'] .x {
  filter: drop-shadow(0 0 1.5px rgba(255, 255, 255, 0.65))
    drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35));
}

.cell[data-mark='4'] .x::before,
.cell[data-mark='4'] .x::after {
  background: var(--ink-wrong);
}

/* Drain the colour out of a wrong square so the red ✕ owns it. An inset
   shadow rather than a filter, so it dims the tile and not the ✕ on top. */
.cell[data-mark='4'] .tile {
  box-shadow: inset 0 0 0 100px color-mix(in srgb, var(--bg) 74%, transparent);
}

/* The token. Two shapes share these rules: the sun (inline SVG, carries its
   own dark rim, so it needs nothing but a shadow) and a secret-set portrait
   (a photo, so it gets clipped to a circle and ringed to lift it off the
   tile underneath). */
.cell .face {
  position: absolute;
  inset: 6%;
  width: 88%;
  height: 88%;
  opacity: 0;
  transform: scale(0.4);
  transition: opacity 0.14s, transform 0.2s cubic-bezier(0.2, 1.5, 0.4, 1);
  pointer-events: none;
}

.cell svg.face {
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

.cell img.face {
  inset: 9%;
  width: 82%;
  height: 82%;
  border-radius: 50%;
  object-fit: cover;
  box-shadow:
    0 0 0 max(1.5px, 3%) rgba(255, 255, 255, 0.95),
    0 1px 4px rgba(0, 0, 0, 0.45);
}

.cell[data-mark='3'] .face {
  opacity: 1;
  transform: scale(1);
}

/* A square the player has lit up.

   This used to be a small pencilled-in sun in a dimmed square, which said
   one specific thing: "he might go here". The mark is worth more when it
   says nothing at all — light up four squares and you are looking at an
   X-wing, nine and you are looking at a swordfish, and neither of those is
   a guess about where a sun goes.

   So it borrows the hint's pool of light (see the hint section below for
   how the glow is built) and holds it still. Still, not breathing: a hint
   pulses because it is talking to you right now and wants answering, while
   these sit there for as long as you are thinking. Held a little under the
   hint's peak brightness too, because `plus-lighter` piles glows up and a
   swordfish's worth of them at full strength washes the board out. */
.cell[data-mark='2'] {
  z-index: 5; /* the pool bleeds past the tile; let it over its neighbours */
}

.cell[data-mark='2'] .glow {
  opacity: 0.85;
}

.cell[data-mark='2'] .tile {
  filter: brightness(1.1) saturate(1.12);
  box-shadow:
    0 0 0 2.5px rgba(255, 231, 140, 0.92),
    0 0 0 4.5px rgba(150, 74, 0, 0.5);
}

/* A hint drains the board down to the squares it is talking about, and it
   cannot do that with the player's own lights still blazing — two kinds of
   glow competing reads as neither. So they go out for the duration and come
   straight back after. A lit square that IS part of the hint keeps its
   glow: the hint's own rule, later and no less specific, takes it over. */
.board.hinting .cell[data-mark='2']:not(.hl-cell) .glow,
.board.solving .cell[data-mark='2']:not(.hl-cell) .glow {
  opacity: 0;
}

.cell.pop .tile {
  animation: pop 0.24s ease;
}

.cell.shake .tile {
  animation: shake 0.32s ease;
}

@keyframes pop {
  40% {
    transform: scale(1.13);
  }
}

@keyframes shake {
  20% {
    transform: translateX(-13%) rotate(-3deg);
  }
  50% {
    transform: translateX(11%) rotate(3deg);
  }
  80% {
    transform: translateX(-6%);
  }
}

/* letters for when colour alone will not do */
.cell .letter {
  position: absolute;
  left: 8%;
  top: 3%;
  font-size: max(7px, 26%);
  font-weight: 800;
  line-height: 1;
  opacity: 0;
  pointer-events: none;
  color: rgba(0, 0, 0, 0.5);
}

.board.letters .cell .letter {
  opacity: 1;
}

.board.letters .cell.light-tile .letter {
  color: rgba(0, 0, 0, 0.45);
}

.board.letters .cell.dark-tile .letter {
  color: rgba(255, 255, 255, 0.6);
}

/* ------------------------------------------------- hint highlighting

   A hint is worthless if you cannot see which square it means, so the
   square the hint is about radiates heat: a wide, soft pool of light that
   bleeds well past the tile. The pool is additive (`plus-lighter`), so
   when a hint names several squares at once their glows pile up and the
   cluster genuinely reads hotter than a lone square would. Everything the
   hint is not about is drained of colour at the same time, which does
   more for legibility than any amount of ring drawing.
                                                                        */

.cell .glow {
  position: absolute;
  inset: -105%;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  background: radial-gradient(
    closest-side,
    rgba(255, 226, 130, 0.95) 0%,
    rgba(255, 196, 60, 0.66) 22%,
    rgba(255, 150, 25, 0.34) 44%,
    rgba(255, 120, 10, 0.13) 64%,
    rgba(255, 110, 0, 0) 80%
  );
  mix-blend-mode: plus-lighter;
  transition: opacity 0.2s;
}

/* Fading rather than darkening, so an ignored tile recedes in either theme —
   toward the dark background in one, toward the light one in the other. */
.board.hinting .cell:not(.hl-cell):not(.hl-unit):not(.hl-will-cross) .tile {
  filter: saturate(0.22) opacity(0.4);
}

.board.hinting .cell.hl-unit:not(.hl-cell):not(.hl-will-cross) .tile {
  filter: saturate(0.9) opacity(0.85);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
}

/* Half the dimming while stepping through a solve. A hint asks one pointed
   question, so burying the rest of the board is right; a solve is something
   you watch unfold, and the colours are what you are reading. */
.board.solving .cell:not(.hl-cell):not(.hl-unit):not(.hl-will-cross) .tile {
  filter: saturate(0.6) opacity(0.7);
}

.board.solving .cell.hl-unit:not(.hl-cell):not(.hl-will-cross) .tile {
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
}

.cell.hl-cell {
  z-index: 6;
}

.board.solving .cell.hl-cell .glow {
  opacity: 0.9;
}

.cell.hl-cell .glow {
  opacity: 1;
  animation: pulse-mark var(--pulse, 1.6s) ease-in-out var(--pulse-delay, 0s) infinite;
}

.cell.hl-cell .tile {
  filter: brightness(1.12) saturate(1.15);
  box-shadow:
    0 0 0 3px rgba(255, 231, 140, 0.95),
    0 0 0 5.5px rgba(150, 74, 0, 0.55);
}

/* ONE animation for everything that pulses.

   Matching two keyframe rules by hand kept leaving them subtly apart — the
   glow overshot its rest size while the ✕ only grew up to it, and the tile
   had a third animation of its own on a slightly different curve. Three
   near-identical breaths read worse than one. So there is now a single
   rule, used by the halo, the ✕ and the sun alike, and the tile no longer
   animates at all.

   The depth is deliberate: it goes all the way to nothing. A white ✕ at
   45% over a coloured tile is still plainly a ✕, which is why the old pulse
   was hard to see at all.

   --pulse must stay declared here, right next to the keyframes it drives.
   An undeclared custom property does not fall back to a default duration:
   the substitution fails, the whole `animation` shorthand becomes invalid,
   and every one of these pulses silently stops running. (The shorthands now
   carry fallbacks too, after that bit once.)

   --pulse-delay is the metronome. Sharing one keyframe rule was not enough:
   a CSS animation starts when its class lands on the element, and the causes
   land before the effects — a tap apart in a hint, a beat apart in the solve
   player — so identical pulses ran identically out of phase, and the ✕
   peaked just off the glow. The board sets --pulse-delay to the negative
   remainder of now ÷ --pulse whenever highlights change, which anchors every
   pulse's start to the same tick of a global clock. Any two pulses, started
   any time apart, now peak together. */
:root {
  --pulse: 1.6s;
}

@keyframes pulse-mark {
  0%,
  100% {
    opacity: 0;
    transform: scale(0.8);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}

/* plus-lighter would blow a light theme out to white, so tone it there */
:root[data-theme='light'] .cell .glow {
  mix-blend-mode: screen;
  filter: saturate(1.6);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .cell .glow {
    mix-blend-mode: screen;
    filter: saturate(1.6);
  }
}

/* ---------------------------------------------------------- hint bar */

.hint-bar {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  width: min(94vw, var(--board-max));
  max-width: 100%;
  background: var(--surface);
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: 12px;
  padding: 10px 6px 9px 12px;
  font-size: 13.5px;
  line-height: 1.45;
  cursor: pointer;
  animation: rise 0.2s ease;
}

.hint-body {
  flex: 1;
  min-width: 0; /* without this a long hint pushes the box off-screen */
}

.hint-text {
  overflow-wrap: break-word;
  word-break: normal;
  hyphens: auto;
}

.hint-text b {
  color: var(--accent);
  font-weight: 650;
}

.hint-gesture {
  margin-top: 5px;
  font-size: 11px;
  letter-spacing: 0.02em;
  color: var(--text-faint);
}

.hint-gesture.teaching {
  color: var(--accent);
  font-size: 12px;
  line-height: 1.45;
}

.hint-gesture b {
  font-weight: 650;
}

.hint-x {
  width: 30px;
  height: 30px;
  flex: none;
  margin-top: -2px;
}

.hint-x svg {
  width: 15px;
  height: 15px;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}

/* ---------------------------------------------------------- toolbar */

.toolbar {
  display: flex;
  justify-content: center;
  flex: none; /* same reason as .topbar: shrinking it overlaps the board */
  gap: 6px;
  /* clears the home indicator — see .topbar for why this is a variable */
  padding: 10px calc(10px + env(safe-area-inset-right)) calc(10px + var(--inset-bottom))
    calc(10px + env(safe-area-inset-left));
}

.tool {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  /* 68px wherever five of them fit, and a little less where they do not:
     5 × 68 + 4 × 6 + 20 of padding is 384px, wider than a 375px phone.
     The labels hold them at about 60px before anything overflows. */
  flex: 0 1 68px;
  min-width: 0;
  padding: 8px 10px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--text-dim);
  border-radius: 14px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: transform 0.1s, color 0.15s, border-color 0.15s;
}

.tool svg {
  width: 20px;
  height: 20px;
}

.tool:hover:not(:disabled) {
  color: var(--text);
  border-color: color-mix(in srgb, var(--text-dim) 45%, transparent);
}

.tool:active:not(:disabled) {
  transform: scale(0.94);
}

.tool:disabled {
  opacity: 0.32;
  cursor: default;
}

/* Solve sits beside Hint and wears the same yellow: the two ways to be
   helped, told apart from the three that only move your own marks. */
#btn-hint,
#btn-solve-main {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}

/* THE COACH (Developer Mode, in Solve's place). Green where Hint and Solve
   are yellow: those two point at the next move, the coach is about WHICH
   move. --coach leans the green toward the text colour, so it stays
   readable on the light theme's white as well as on the dark. */
:root {
  --coach: color-mix(in srgb, var(--good) 72%, var(--text));
}

#btn-coach {
  color: var(--coach);
  border-color: color-mix(in srgb, var(--good) 40%, transparent);
}

/* ------------------------------------------------------------- win */

.win {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--bg) 74%, transparent);
  backdrop-filter: blur(3px);
  border-radius: 18px;
  z-index: 5;
  animation: fade 0.25s ease;
  /* the flick-to-dismiss gesture owns vertical drags on the banner */
  touch-action: none;
}

.win-card {
  position: relative;
  text-align: center;
  padding: 20px 24px;
}

/* Closing the banner is the "stay here" path now — a plain X in the corner,
   dead for the same beat as the buttons so the tap that solved the level
   cannot also dismiss it. */
.win-x {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 34px;
  height: 34px;
  pointer-events: none;
}

.win-x svg {
  width: 16px;
  height: 16px;
}

.win.armed .win-x {
  pointer-events: auto;
}

.win-face {
  width: 76px;
  height: 76px;
  animation: bounce 0.6s cubic-bezier(0.2, 1.6, 0.4, 1);
}

svg.win-face {
  filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45));
}

/* Classic's badge is the sun itself — a full disc. The other worlds ride a
   line icon, so on the banner they get a tinted disc behind them in the
   world's colour, to sit as solidly as the sun does. */
.win-face.crest-flame,
.win-face.crest-nova,
.win-face.crest-blocks {
  box-sizing: border-box;
  padding: 15px;
  border-radius: 50%;
  background: color-mix(in srgb, currentColor 15%, transparent);
}

img.win-face {
  border-radius: 50%;
  object-fit: cover;
  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.95), 0 6px 18px rgba(0, 0, 0, 0.4);
}

.win-note {
  margin: -10px 0 16px !important;
  font-size: 12px;
  font-weight: 650;
  color: var(--accent) !important;
  letter-spacing: 0.01em;
}

.win-card h2 {
  margin: 6px 0 2px;
  font-size: 26px;
  letter-spacing: -0.02em;
}

.win-card p {
  margin: 0 0 16px;
  color: var(--text-dim);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}

.win-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  /* the tap that solved the level must not also dismiss the banner */
  pointer-events: none;
}

/* The banner's two calls to action, stacked: "Next level" over "Daily",
   both full width so they read as a pair. */
.win-actions-stack {
  flex-direction: column;
  align-items: stretch;
  width: min(15rem, 68vw);
  margin: 0 auto;
}

.btn-lg {
  font-size: 15px;
  padding: 12px 18px;
  border-radius: 12px;
}

/* A built board's card has a third button, Back to Level Builder, and the
   card sits on the board: below about 380px wide the board is too short for
   three, and the last one hung off it (48px at 320). There, and only on
   that card, the sun and the padding give the room back. A level's card in
   Developer Mode gets a third too (Review My Solve) and the same treatment.
   Kept after the rules it overrides. */
@media (max-width: 380px) {
  .win-card:has(#win-builder:not([hidden]), #win-review:not([hidden])) {
    padding-top: 12px;
    padding-bottom: 12px;
  }
  .win-card:has(#win-builder:not([hidden]), #win-review:not([hidden])) .win-face {
    width: 44px;
    height: 44px;
  }
  .win-card:has(#win-builder:not([hidden]), #win-review:not([hidden])) .win-actions-stack {
    gap: 6px;
  }
  .win-card:has(#win-builder:not([hidden]), #win-review:not([hidden])) .win-actions-stack .btn-lg {
    padding-top: 8px;
    padding-bottom: 8px;
  }
}

/* Same box as "Next level", plus the calendar mark the top bar uses so the
   shortcut reads as the same Daily. The icon carries the accent; the box
   itself stays quiet so it does not compete with the primary action.
   "Back to Level Builder" on a built board's card is the same box with the
   Builder's wand. */
.btn-daily,
.btn-builder {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
}

.btn-daily svg,
.btn-builder svg {
  width: 16px;
  height: 16px;
  display: block;
  color: var(--accent);
}

.win.armed .win-actions {
  pointer-events: auto;
}

@keyframes bounce {
  0% {
    transform: scale(0.2) rotate(-25deg);
  }
  60% {
    transform: scale(1.12) rotate(6deg);
  }
}

/* --------------------------------------------------- the confirm modal

   Every "are you sure" wears this instead of the browser's confirm() —
   which is a system dialog wearing a debugger's face. Same arming trick
   as the win banner so the opening tap cannot also answer it. */

.modal {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--bg) 62%, transparent);
  backdrop-filter: blur(4px);
  z-index: 80;
  animation: fade 0.18s ease;
}

.modal-card {
  width: min(88vw, 22rem);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 20px 20px 16px;
  text-align: center;
  box-shadow: 0 18px 50px -12px rgba(0, 0, 0, 0.45);
  animation: modal-pop 0.22s cubic-bezier(0.2, 1.4, 0.4, 1);
}

.modal-card h2 {
  margin: 0 0 6px;
  font-size: 18px;
  letter-spacing: -0.01em;
}

.modal-card p {
  margin: 0 0 16px;
  font-size: 13.5px;
  color: var(--text-dim);
  line-height: 1.5;
}

/* Arming, done inside out. The base .win-actions rule above starts every
   action row dead, so a modal's row is switched ON here and taken away
   again only for the beat right after it opens. That way round, forgetting
   to arm a new modal costs a stray tap — the other way round it cost the
   daily news card two permanently untappable buttons behind a full-screen
   backdrop, which is a frozen phone, not a cosmetic bug. */
.modal .win-actions {
  pointer-events: auto;
}

.modal.arming .win-actions {
  pointer-events: none;
}

@keyframes modal-pop {
  from {
    transform: scale(0.9);
    opacity: 0;
  }
}

/* ------------------------------------------- the whole-hundred splash

   The small banner celebrates a level; this celebrates a HUNDRED, so it
   takes the whole screen, not just the board. Same arming trick as the
   banner: the tap that placed the final sun cannot dismiss what it
   triggered. */

.winall {
  position: fixed;
  inset: 0;
  border-radius: 0;
  z-index: 60;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(6px);
}

.winall-card {
  padding: 28px 26px;
}

.winall-card h2 {
  font-size: 32px;
  margin: 14px 0 8px;
}

.winall-card p {
  font-size: 15px;
  line-height: 1.55;
  max-width: 30rem;
  margin: 0 auto 22px;
}

.winall-rays {
  position: relative;
  width: 130px;
  height: 130px;
  margin: 0 auto;
  display: grid;
  place-items: center;
}

/* a slow wheel of rays behind the sun — pure gradient, no assets */
.winall-rays::before {
  content: '';
  position: absolute;
  inset: -55px;
  border-radius: 50%;
  background: repeating-conic-gradient(
    rgba(255, 190, 60, 0.28) 0 8deg,
    transparent 8deg 24deg
  );
  -webkit-mask-image: radial-gradient(circle, black 30%, transparent 68%);
  mask-image: radial-gradient(circle, black 30%, transparent 68%);
  animation: ray-wheel 26s linear infinite;
}

.winall-sun {
  width: 110px;
  height: 110px;
  position: relative;
  animation: bounce 0.7s cubic-bezier(0.2, 1.6, 0.4, 1);
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.45));
}

@keyframes ray-wheel {
  to {
    transform: rotate(1turn);
  }
}

/* ------------------------------------------------ choosing the set */

/* The worlds. Frozen — it and the set row below sit outside the sheet's
   scroll area (see #sheet-levels .sheet-card), so the choices you scroll
   back for are always exactly where you left them. Four worlds until the
   Builder badge is earned; then a fifth crest appears and the row tightens
   to fit it. Builder is the odd one: no set row, no grid — it swaps a
   request form into the scroll area. */
.family-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
  padding: 5px;
  margin: 0 0 8px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 14px;
}
.family-row:has(> .family[data-group='builder']:not([hidden])) {
  grid-template-columns: repeat(5, 1fr);
  gap: 3px;
}

.family {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  border: 0;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-weight: 650;
  font-size: 12px;
  padding: 8px 4px 6px;
  border-radius: 10px;
  cursor: pointer;
  transition: color 0.14s, background 0.14s;
}
.family-row:has(> .family[data-group='builder']:not([hidden])) .family {
  font-size: 11px;
  padding: 8px 2px 6px;
}

.family .fam-crest {
  width: 22px;
  height: 22px;
}
.family .crest-flame {
  color: #ff7a2f;
}
.family .crest-nova {
  color: #8b6cf0;
}
.family .crest-blocks {
  color: #f0559b;
}
.family .crest-builder {
  color: #38c6d9;
}

.family:hover {
  color: var(--text);
}

.family.on {
  background: var(--bg-2);
  color: var(--text);
  box-shadow:
    inset 0 0 0 1.5px var(--accent),
    0 1px 5px rgba(0, 0, 0, 0.16);
}

.batch-tabs {
  display: flex;
  gap: 4px;
  padding: 4px;
  margin: 0 0 4px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 12px;
}

/* Blocks is a single hundred — no set to choose, so no row. */
.batch-tabs.single {
  display: none;
}

.batch-tab {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  border: 0;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-weight: 650;
  font-size: 13px;
  padding: 7px 4px 6px;
  border-radius: 9px;
  cursor: pointer;
}

.batch-tab i {
  font-style: normal;
  font-size: 11px;
  font-weight: 550;
  opacity: 0.8;
  font-variant-numeric: tabular-nums;
}

.batch-tab.on {
  background: var(--surface);
  color: var(--text);
  box-shadow:
    inset 0 0 0 1.5px var(--accent),
    0 1px 5px rgba(0, 0, 0, 0.16);
}

/* The set preview (Developer Mode, on trial): a board that flicks through
   the set when it is picked. Drawn on a canvas by setPreview in js/ui.js. */
.set-preview {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  margin: 4px 0 10px;
  padding: 10px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 14px;
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.set-preview-board {
  flex: none;
  width: 96px;
  height: 96px;
  border-radius: 11px;
  background: var(--surface);
}
.set-preview-text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}
.set-preview-text b {
  font-size: 16px;
  font-weight: 700;
}
.set-preview-text span {
  font-size: 13px;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

@keyframes fade {
  from {
    opacity: 0;
  }
}

/* --------------------------------------------------------- buttons */

.btn {
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
  font-weight: 600;
  font-size: 14px;
  padding: 9px 16px;
  border-radius: 11px;
  cursor: pointer;
  transition: transform 0.1s, filter 0.15s;
}

.btn:active {
  transform: scale(0.96);
}

.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

.btn-ghost {
  background: transparent;
}

.btn-danger {
  background: transparent;
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}

.btn-small {
  font-size: 12.5px;
  padding: 6px 11px;
  border-radius: 9px;
}

.btn-small svg {
  width: 14px;
  height: 14px;
  display: block;
}

/* ---------------------------------------------------------- sheets */

.sheet {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: grid;
  place-items: end center;
  background: rgba(2, 5, 12, 0.55);
  backdrop-filter: blur(2px);
  animation: fade 0.18s ease;
}

/* Set by openSheet() in ui.js for a direct sheet-to-sheet hop (Settings ›
   Rewards, the level sheet's Daily, …), where a backdrop already dimming the board
   would otherwise flash back to fully transparent for a frame or two as
   this sheet's own fade restarts from opacity 0. The card's slideup
   animation is untouched, so the swap still reads as a transition. */
.sheet.sheet-swap {
  animation: none;
}

/* A stacked tray (Help, Contact us): slides up over whatever is showing,
   which stays where it is and blurs behind it, rather than replacing it.
   Lighter dim than a sheet's, since there may be a sheet under it already
   doing the dimming. Over the home screen every sheet is at 96 and these,
   being later in the page, still land on top. */
.sheet.sheet-stack {
  z-index: 21;
  background: rgba(2, 5, 12, 0.35);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
}

/* Set by ui.js when the page opens straight onto a sheet (the way back
   from fifty.html): no fade and no slide, so the sheet is there on the
   first frame and the board never flashes through. openSheet() clears
   it, so every later open animates as usual. */
.sheet.sheet-instant,
.sheet.sheet-instant .sheet-card {
  animation: none;
}

/* Friday First to Fifty's layer (ui.js openFifty): fifty.html in a frame
   over everything, edge to edge. The frame pays for the safe areas
   itself, with the values this page measures (#inset-probe), since env()
   inside a frame does not see the notch. */
#fifty-layer {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: var(--bg);
}
#fifty-frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}
#inset-probe {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  visibility: hidden;
  pointer-events: none;
  padding: var(--inset-top) 0 var(--inset-bottom);
}

.sheet-card {
  touch-action: pan-y;
  width: min(100%, 34rem);
  max-height: 92dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-bottom: 0;
  border-radius: 20px 20px 0 0;
  padding: 4px 18px calc(24px + env(safe-area-inset-bottom));
  animation: slideup 0.24s cubic-bezier(0.2, 0.9, 0.3, 1);
}

@media (min-width: 640px) {
  .sheet {
    place-items: center;
  }
  .sheet-card {
    border-radius: 20px;
    border-bottom: 1px solid var(--line);
    max-height: 86dvh;
    padding-bottom: 24px;
  }
}

/* The daily sheet carries the leaderboard panel, which is pinned to the
   card's bottom edge and slid in and out — so the card is its containing
   block and clips it while it is off-screen. The card itself never resizes
   when the panel opens; the panel just slides up over the lower part of it.
   It is a tray like the level sheet, the same height (Joel, 4 Oct 2026: "as
   high as the level selector tray"), with the same grab handle and swipe
   down (below and in js/ui.js). The taller card gives the leaderboard more
   rows. */
#sheet-daily .sheet-card {
  position: relative;
  overflow: hidden;
  padding-top: 0;
  height: calc(100dvh - var(--inset-top) - 10px);
  max-height: none;
}
@media (min-width: 640px) {
  #sheet-daily .sheet-card {
    height: auto;
    max-height: 80dvh;
  }
}
#sheet-daily .sheet-head {
  padding-top: 8px;
}
/* The handle sits where the head's top padding was, as on the daily sheet. */
#sheet-settings .sheet-card,
#sheet-badges .sheet-card {
  padding-top: 0;
}
#sheet-settings .sheet-head,
#sheet-badges .sheet-head {
  padding-top: 8px;
}
/* The Awards tabs sit in the pinned head, under the title, so they stay
   put while the list scrolls. Two of them, so two columns. */
#sheet-badges .sheet-head {
  flex-wrap: wrap;
}
.awards-tabs {
  flex-basis: 100%;
  grid-template-columns: repeat(2, 1fr);
  margin: 4px 0 0;
}
.awards-tabs .family {
  flex-direction: row;
  justify-content: center;
  gap: 8px;
  font-size: 14px;
  padding: 9px 8px;
}
/* Badges and Rewards rise as high as Settings and stay that height, so
   switching between them never makes the tray jump. */
#sheet-badges .sheet-card {
  height: 92dvh;
}
@media (min-width: 640px) {
  #sheet-badges .sheet-card {
    height: 86dvh;
  }
}

@keyframes slideup {
  from {
    transform: translateY(18px);
    opacity: 0.4;
  }
}

.sheet-head {
  position: sticky;
  top: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 14px 0 10px;
  background: linear-gradient(var(--bg-2) 72%, transparent);
  z-index: 6;
}

/* The level sheet is a full-height tray, like a phone's own large sheets:
   it rises to just under the status bar (--inset-top, so the notch and the
   installed app's status bar are cleared) with a sliver of the dimmed board
   still showing above it, and it is a fixed height so switching worlds never
   makes it jump. Swipe it down to put it away (js/ui.js). The card is a
   column that does NOT scroll: the head, the four worlds and the set row are
   frozen, and only .level-scroll inside moves. */
#sheet-levels .sheet-card {
  display: flex;
  flex-direction: column;
  height: calc(100dvh - var(--inset-top) - 10px);
  max-height: none;
  overflow: hidden;
  padding: 0 18px;
}
/* The dimmed backdrop fading out behind a tray swiped away. */
.sheet.sheet-leaving {
  opacity: 0;
  transition: opacity 0.22s ease;
}
/* The grab handle on every tray: the level sheet, the daily sheet,
   Settings and Badges. On the last two the whole card scrolls, so the
   handle scrolls away with it — and a pull only works at the top, which is
   exactly when it shows. */
#sheet-levels .sheet-card::before,
#sheet-daily .sheet-card::before,
#sheet-help .sheet-card::before,
#sheet-contact .sheet-card::before,
#sheet-privacy .sheet-card::before,
#sheet-faq .sheet-card::before,
#sheet-settings .sheet-card::before,
#sheet-badges .sheet-card::before {
  content: '';
  flex: none;
  display: block;
  width: 34px;
  height: 4px;
  margin: 8px auto 2px;
  border-radius: 999px;
  background: var(--line);
}
/* Tighter round the title than the other sheets (Joel, 5 October 2026),
   so the room goes to the worlds and the Builder's form below it. */
#sheet-levels .sheet-head {
  position: static;
  flex: none;
  background: none;
  padding: 2px 0 5px;
}
#sheet-levels .family-row,
#sheet-levels .batch-tabs {
  flex: none;
}
#sheet-levels .level-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
@media (min-width: 640px) {
  #sheet-levels .sheet-card {
    height: auto;
    max-height: 80dvh;
  }
}

.sheet-card h2 {
  margin: 0;
  font-size: 19px;
  letter-spacing: -0.01em;
}

.sheet-card h3 {
  margin: 22px 0 8px;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-faint);
}

.sheet-note {
  margin: 0 0 4px;
  color: var(--text-dim);
  font-size: 13px;
  line-height: 1.55;
}

.credits {
  margin-top: 22px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
  font-size: 12px;
  color: var(--text-faint);
}

/* Testing-only entry point for the not-yet-live home screen — quiet on
   purpose, no label, just the icon Daily/Badges/Help already use. */
.settings-home-preview {
  display: flex;
  justify-content: center;
  margin-top: 6px;
}

/* level browser */

.level-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
  gap: 7px;
  margin-top: 12px;
}

.lv {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--text);
  border-radius: 12px;
  cursor: pointer;
  font: inherit;
  transition: transform 0.1s, border-color 0.15s;
}

.lv:active {
  transform: scale(0.94);
}

.lv .num {
  font-size: 16px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
}

.lv .sz {
  font-size: 9.5px;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.lv .st {
  font-size: 8px;
  letter-spacing: 0.5px;
  color: var(--accent);
  line-height: 1;
}

.lv.done {
  border-color: color-mix(in srgb, var(--good) 45%, transparent);
  background: color-mix(in srgb, var(--good) 10%, var(--surface));
}

.lv.done::after {
  content: '';
  position: absolute;
  top: 5px;
  right: 5px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--good);
}

.lv.started::after {
  content: '';
  position: absolute;
  top: 5px;
  right: 5px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}

.lv.current {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}

/* settings rows */

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 13px 0;
  border-bottom: 1px solid var(--line);
  font-size: 14px;
}

.row > span {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* A row that runs on into the next one as a single section: no rule
   between them, and the gap closed up. */
.row.row-joined {
  border-bottom: 0;
  padding-bottom: 4px;
}
/* The Awards row's button: the home tile's medal, with its dot. */
/* Settings' Help (?) and Awards (ribbon) buttons: the bare icon, no box
   round it (Jeremy, 18 Oct 2026). */
.btn.btn-awards {
  position: relative;
  display: grid;
  place-items: center;
  padding: 6px 8px;
  background: none;
  border-color: transparent;
  box-shadow: none;
  color: var(--text);
}
.btn-awards svg {
  width: 26px;
  height: 26px;
}
.btn-awards .streak-pip {
  top: 3px;
  right: 6px;
}

.row small {
  color: var(--text-faint);
  font-size: 11.5px;
  line-height: 1.4;
}

.row select {
  font: inherit;
  font-size: 13px;
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 6px 8px;
}

.row input[type='checkbox'] {
  appearance: none;
  flex: none;
  width: 46px;
  height: 27px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--line);
  position: relative;
  cursor: pointer;
  transition: background 0.18s, border-color 0.18s;
}

.row input[type='checkbox']::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 21px;
  height: 21px;
  border-radius: 50%;
  background: var(--text-faint);
  transition: transform 0.18s, background 0.18s;
}

.row input[type='checkbox']:checked {
  background: color-mix(in srgb, var(--accent) 30%, transparent);
  border-color: var(--accent);
}

.row input[type='checkbox']:checked::after {
  transform: translateX(19px);
  background: var(--accent);
}

.row-danger {
  border-bottom: 0;
}

/* toast */

.toast {
  position: fixed;
  left: 50%;
  bottom: calc(90px + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--text);
  font-size: 13px;
  padding: 9px 15px;
  border-radius: 999px;
  box-shadow: var(--shadow);
  z-index: 40;
  animation: rise 0.2s ease;
  pointer-events: none;
}

/* --------------------------------------------- roomier on big screens */

@media (min-width: 720px) and (min-height: 700px) {
  :root {
    --board-max: 37rem;
  }
  .board {
    width: max(96px, min(80vw, calc(100dvh - var(--board-reserve)), var(--board-max)));
    /* a big screen has width to spare; the board does not need all of it */
  }
  .toolbar {
    gap: 10px;
  }
}

@media (max-height: 620px) {
  /* The toolbar drops its labels here, so there is genuinely less furniture
     to pay for — measured at 375×600 rather than scaled by eye, the same
     honest way as the base value above (259 → 240 → 251 across the same
     2026-09-12 passes, for the same reasons each time; 251 → 307 → 255 on
     2026-09-13 with the base value — see its comment. Same formula, toolbar
     re-measured at 58px with its labels hidden). 255 → 259 on 2026-09-14
     with the base value, same +4px: the star row isn't hidden by this
     query, so it costs the same here as everywhere else. 259 → 228 on
     2026-09-27 with the base value: the Auto X row gone, and measured at
     430×600 (board top 159.6 + 10 + the 58px toolbar) rather than 259 − 48,
     which would have kept the same 17px shortfall. 228 → 240 with main's
     top padding, 4px → 16px, as with the base value. */
  body {
    --reserve-chrome: 240px;
  }

  .tool span {
    display: none;
  }
  .tool {
    flex-basis: 52px;
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    /* Once, not forever: an infinite keyframe at 0.01ms would sample a
       random phase every frame — a flicker, which is the opposite of what
       the setting asks for. */
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* ------------------------------------------------------- locked levels */

.lv.locked {
  cursor: default;
  opacity: 0.6;
  background: var(--surface-2);
  border-style: dashed;
}

.lv.locked .num {
  font-weight: 600;
  color: var(--text-faint);
}

.lv .lock {
  position: absolute;
  top: 4px;
  right: 5px;
  font-size: 9px;
  line-height: 1;
  filter: grayscale(1);
  opacity: 0.75;
}

.levels-note {
  margin-top: 8px;
  color: var(--accent);
  font-size: 12.5px;
  font-weight: 600;
}

/* ------------------------------------- levels reachable only in test mode */

.lv.beyond {
  border-style: dashed;
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  opacity: 0.85;
}

.lv.beyond .num {
  color: var(--text-dim);
}

.levels-note.testing {
  color: var(--danger);
}

/* ------------------------------------------- swipe between levels

   A horizontal swipe outside the grid pages through the levels. The board
   slides a touch in the direction of travel so the gesture feels answered
   rather than merely obeyed.
                                                                        */

.board-wrap.slide-left {
  animation: slide-from-right 0.24s ease-out;
}

.board-wrap.slide-right {
  animation: slide-from-left 0.24s ease-out;
}

@keyframes slide-from-right {
  from {
    transform: translateX(26px);
    opacity: 0.25;
  }
}

@keyframes slide-from-left {
  from {
    transform: translateX(-26px);
    opacity: 0.25;
  }
}

/* =====================================================================
   THE FIRST-RUN WALKTHROUGH

   A real 4×4 board rather than a diagram, because the player has to make
   the moves themselves for any of it to stick. Reuses the board's own
   classes, so what they learn on looks exactly like what they play on.
   ===================================================================== */

.tut {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: grid;
  place-items: center;
  padding: calc(12px + env(safe-area-inset-top)) 12px calc(12px + env(safe-area-inset-bottom));
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(6px);
  animation: fade 0.25s ease;
}

.tut-card {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: min(100%, 26rem);
  max-height: 100%;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: 20px;
  padding: 16px 18px 18px;
  box-shadow: var(--shadow);
  animation: slideup 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);
}

.tut-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.tut-card h2 {
  margin: 0;
  font-size: 18px;
  letter-spacing: -0.01em;
}

.tut-dots {
  display: flex;
  gap: 4px;
}

.tut-dots i {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--line);
  transition: background 0.2s, transform 0.2s;
}

.tut-dots i.past {
  background: color-mix(in srgb, var(--accent) 45%, transparent);
}

.tut-dots i.on {
  background: var(--accent);
  transform: scale(1.5);
}

.tut-board-wrap {
  display: grid;
  place-items: center;
}

/* overrides the main board's viewport-relative sizing */
.tut-board {
  width: min(58vw, 40dvh, 15rem);
}

.tut-text {
  margin: 0;
  min-height: 6.4em;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--text-dim);
}

.tut-text b {
  color: var(--text);
  font-weight: 650;
}

.tut-text i {
  color: var(--accent);
  font-style: normal;
  font-weight: 650;
}

.tut-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.tut-actions .btn-primary {
  min-width: 7.5rem;
}

@media (max-height: 700px) {
  .tut-board {
    width: min(48vw, 32dvh, 12rem);
  }
  .tut-text {
    min-height: 0;
    font-size: 13px;
  }
}

/* ------------------------------------------------ playing out the solve */

.solve-bar {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: min(94vw, var(--board-max));
  max-width: 100%;
  background: var(--surface);
  border: 1px solid var(--line);
  border-left: 3px solid var(--good);
  border-radius: 12px;
  padding: 9px 11px 10px 12px;
  animation: rise 0.2s ease;
}

.solve-count {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--good);
}

.solve-text {
  margin-top: 3px;
  font-size: 13px;
  line-height: 1.45;
  overflow-wrap: break-word;
}

.solve-text b {
  color: var(--accent);
  font-weight: 650;
}

.solve-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 7px;
}

.solve-actions .btn {
  padding: 5px 10px;
  font-size: 12px;
}

/* Four buttons on one line on a 360px phone: the coach's menu (Easiest,
   Fastest, Review My Solve, Close) is 12px too wide for it otherwise, and
   a wrapped Close squeezes the menu's words into a scroll. Kept after the
   rule it overrides. At 320px it wraps regardless. */
@media (max-width: 380px) {
  .solve-actions .btn {
    padding-left: 8px;
    padding-right: 8px;
  }
}

.solve-arrow {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.solve-arrow svg {
  width: 13px;
  height: 13px;
  display: block;
}

.solve-arrow:disabled {
  opacity: 0.35;
  pointer-events: none;
}

/* the two ways out when a ✕ is sitting where a sun belongs */
.solve-choice {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}

/* REVIEW MINE'S "SHOW FASTER": the coach's move lit in the coach's green,
   so it never reads as the player's own, which keeps the hint's yellow.
   More specific than the yellow rules it overrides, whatever the order. */
.board.suggesting .cell .glow {
  background: radial-gradient(
    closest-side,
    rgba(170, 248, 210, 0.95) 0%,
    rgba(90, 225, 165, 0.66) 22%,
    rgba(45, 195, 135, 0.34) 44%,
    rgba(25, 170, 115, 0.13) 64%,
    rgba(0, 150, 100, 0) 80%
  );
}

.board.suggesting .cell.hl-cell .tile {
  box-shadow:
    0 0 0 3px rgba(160, 245, 200, 0.95),
    0 0 0 5.5px rgba(0, 105, 70, 0.55);
}

.board.suggesting.solving .cell.hl-unit:not(.hl-cell):not(.hl-will-cross) .tile {
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--good) 55%, transparent);
}

/* The coach's words inside the solve bar: the board-size rule of thumb
   under its menu, and the "why this one" under a Fastest step or a review
   beat. Quieter than the sentence they follow. */
.coach-rule {
  display: block;
  margin-top: 5px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--text-faint);
}

.coach-why {
  display: block;
  margin-top: 5px;
  font-size: 12.5px;
  line-height: 1.4;
  color: var(--text-dim);
}

/* Review My Solve's score card (Developer Mode's % optimal trial): one
   score to a line. */
.score-big {
  display: block;
  font-size: 22px;
  font-weight: 700;
  line-height: 1.2;
  color: var(--coach);
}
.score-line {
  display: block;
  margin-top: 4px;
}

/* the menu's choices sit in the button row, before Close */
.coach-picks {
  display: contents;
}

/* The coach's choices — Easiest, Fastest, Review My Solve — all alike (Joel,
   5 Oct 2026: none singled out), told apart from Close by the tint. */
.btn-coach-pick {
  background: color-mix(in srgb, var(--good) 18%, var(--surface-2));
  border-color: color-mix(in srgb, var(--good) 55%, transparent);
}

/* The quiet line on a level's Solved card: how far the next feature is. */
.win-reward {
  margin: 2px 0 0;
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--text-faint);
}

/* "Review My Solve" on the win card: the Daily button's box, the coach's whistle */
.btn-coach {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
}

.btn-coach svg {
  width: 16px;
  height: 16px;
  display: block;
  color: var(--coach);
}

/* ------------------------------------------- the walkthrough's colour tour

   Lifts one colour at a time at full strength rather than dimming the rest:
   the point is to see the whole board and notice that one colour owns a
   single square, which is why that square is the way in.
                                                                        */

.tut-board .cell.tour {
  z-index: 4;
}

.tut-board .cell.tour .tile {
  transform: scale(1.09);
  box-shadow:
    0 0 0 3px rgba(255, 255, 255, 0.92),
    0 0 0 4.5px rgba(0, 0, 0, 0.22),
    0 5px 14px rgba(0, 0, 0, 0.3);
  transition: transform 0.28s cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 0.28s;
}

/* The held half of an autoplayed move: same words, board already changed.
   The accent shifts so a glance tells you whether you are reading what is
   about to happen or looking at what just did. */
.solve-bar.did-it {
  border-left-color: var(--accent);
}

.solve-bar.did-it .solve-count {
  color: var(--accent);
}

/* --------------------------------------------- the squares about to be ✕'d

   The yellow heat marks what forces a move; this marks what the move does
   to the board — the ✕ itself, pulsing into place, so the consequence is
   watched arriving rather than described. Full colour underneath, since a
   square worth crossing off is a square worth seeing.
                                                                        */

.board.hinting .cell.hl-will-cross .tile,
.board.solving .cell.hl-will-cross .tile {
  filter: none;
}

.cell.hl-will-cross .x {
  opacity: 1;
  animation: pulse-mark var(--pulse, 1.6s) ease-in-out var(--pulse-delay, 0s) infinite;
}

/* a sun about to be placed gets the same treatment as a ✕ about to be drawn */
.cell.hl-will-place .face {
  opacity: 1;
  animation: pulse-mark var(--pulse, 1.6s) ease-in-out var(--pulse-delay, 0s) infinite;
}

.why-note {
  display: block;
  height: 1.2rem;
  margin-top: 4px;
  font-size: 11.5px;
  line-height: 1.2rem;
  color: var(--text-faint);
}

/* =====================================================================
   THE MESSAGE SLOT

   A hint can run to one line or four, and while the bar sat in the flex
   flow that difference re-centred everything above it — the board jumped
   a little every time the wording changed length, and the buttons under
   the text moved with it.

   So the slot is a fixed height whether it holds anything or not, and the
   text block inside it is a fixed height too. The board's position is now
   a function of the viewport alone, and Back / Next / Play / Stop sit at
   the same place on every move of every level.
   ===================================================================== */

/* Three lines is the true maximum across all 1654 deductions in the shipped
   set — measured, not guessed — so that is exactly what gets reserved. */
:root {
  /* SIZED TO THE LONGEST MESSAGE THAT EXISTS, not to a round number.

     3,319 hint and solve-step texts were generated across the daily
     calendar and the hardest shipped sets: median 96 characters, p99 167,
     longest 191. At the ~46 characters a line fits in this bar on a phone,
     the longest needs five lines. It was being given three, so the tail of
     real hints did not fit and the bar became a little scrolling window —
     which is the worst way to read a paragraph you are trying to think
     about.

     Five lines of text, plus the solve player's step counter and its row of
     buttons, plus padding, comes to a little under 12rem. */
  --msg-slot-h: 12rem;
}

.msg-slot {
  /* ONE HEIGHT, ALWAYS — and this is the whole reason the slot exists.

     Content-sizing it was a mistake: the solve player's step texts vary in
     length, so the bar grew and shrank between moves and took its Next,
     Play and Stop buttons with it. A control that moves while you are
     reaching for it is worse than a bar with some space in it.

     So the height is fixed, the bars fill it, and each bar pins its own
     controls to its bottom edge. Short message, long message, hint or
     solve: the buttons are in the same place.

     Still shrinkable as a last resort — a screen too short for the full
     height gets a smaller bar rather than one hanging over the toolbar. */
  flex: 0 1 auto;
  min-height: 0;
  width: 100%;
  height: 0;
  display: flex;
  align-items: stretch;
  justify-content: center;
}

body.msg-open .msg-slot {
  height: var(--msg-slot-h);
}

/* Fill the slot, and scroll only if a message somehow outgrows even this.
   With five lines budgeted against a measured maximum of five, it should
   not — but clipping the end of a hint would be worse than scrolling it. */
.msg-slot > * {
  height: 100%;
  overflow-y: auto;
}

/* THE BOTTOM TWO ROWS FOLD AWAY while a bar is up, and their height goes to
   the message rather than being held behind it.

   Auto X first: the solve player already hid that row because it has its own
   controls, and hiding it for a hint too costs a toggle nobody reaches for
   mid-hint.

   And then the toolbar. Undo, Redo, Hint and Restart are 107px on a 16 Pro
   once the home-indicator inset is counted, and while a message is up none
   of them is what you want — the hint bar is itself the control (tap for
   the reasoning, double-tap to take it), the solve player has Back, Next,
   Play and Stop of its own, and a replay has its own bar too. That 107px is
   what buys the message enough room to be read in one piece instead of
   scrolled. It comes straight back when the bar closes. */
body.msg-open .toolbar {
  height: 0;
  padding-top: 0;
  padding-bottom: 0;
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
}

/* the reserved height belongs to the slot, so the bars fill it from the top */
.msg-slot > * {
  margin: 0;
}

/* No min-heights any more — the bars are a fixed height, so the lines do not
   need reserving by hand. Reserving them was an approximation of this, and
   one that had to be re-measured every time either bar changed.

   THE SOLVE PLAYER pins its buttons to the bottom edge. Its step texts vary
   in length from move to move, and Back / Next / Play / Stop shifting under
   your thumb between moves is the thing that makes it unpleasant to use.
   The text takes the slack instead. */
.solve-bar {
  display: flex;
  flex-direction: column;
}

.solve-text {
  flex: 1;
  min-height: 0;
  /* a text that outgrows the slot scrolls inside it rather than painting
     over the buttons pinned below it */
  overflow-y: auto;
}

/* THE HINT does NOT do the same, deliberately. Its caption is "tap for the
   detail · double-tap to do it", which belongs with the sentence it refers
   to; pinned to the bottom of a tall bar it reads as unrelated furniture.
   Nothing in the hint bar is a button whose position matters, so the
   caption stays where the text leaves it. */

@media (max-height: 700px) {
  :root {
    --msg-slot-h: 9.5rem;
  }
}

/* ------------------------------------------------------ the daily sheet

   Three cards, one per tier, and they carry their own state: not started,
   part-way through, or finished with the time it took. The point is that
   opening this tells you where you stand today without tapping anything.
                                                                        */

.daily-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin: 2px 0 12px;
}

.daily-date {
  font-size: 15px;
  font-weight: 700;
}

.daily-streak {
  font-size: 13px;
  color: var(--muted);
}

.daily-streak b {
  color: #f0a92c;
}

/* Three across, one per tier. */
.daily-tiers {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}

.daily-tier {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  min-width: 0;
  padding: 12px 6px 10px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  color: inherit;
  font: inherit;
  text-align: center;
  cursor: pointer;
  transition: border-color 0.15s, transform 0.1s;
}

.daily-tier:active {
  transform: scale(0.98);
}

.daily-tier:hover {
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}

.daily-tier[disabled] {
  opacity: 0.55;
  cursor: default;
}

/* A finished tier stops shouting and just confirms. */
.daily-tier.is-done {
  border-color: color-mix(in srgb, var(--good) 45%, transparent);
}

.daily-tier-mark {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 800;
  color: #fff;
}

.daily-tier-mark.t-easy {
  background: linear-gradient(160deg, #6fbf5f, #4e9c46);
}

.daily-tier-mark.t-medium {
  background: linear-gradient(160deg, #f0a92c, #d98315);
}

.daily-tier-mark.t-hard {
  background: linear-gradient(160deg, #e4573d, #b8342d);
}

/* A tier named in text (the watch question): its card's colour, darker
   on light for contrast, as the card's own gradient in the dark. */
.tier-word.t-easy {
  color: #3f8a37;
}
.tier-word.t-medium {
  color: #c06f0c;
}
.tier-word.t-hard {
  color: #b8342d;
}
:root[data-theme='dark'] .tier-word.t-easy {
  color: #6fbf5f;
}
:root[data-theme='dark'] .tier-word.t-medium {
  color: #f0a92c;
}
:root[data-theme='dark'] .tier-word.t-hard {
  color: #ef6a50;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .tier-word.t-easy {
    color: #6fbf5f;
  }
  :root:not([data-theme='light']) .tier-word.t-medium {
    color: #f0a92c;
  }
  :root:not([data-theme='light']) .tier-word.t-hard {
    color: #ef6a50;
  }
}

.daily-tier-name {
  font-size: 13.5px;
  font-weight: 700;
}

.daily-tier-sub {
  font-size: 11.5px;
  color: var(--muted);
  white-space: nowrap;
}

.daily-tier-state {
  margin-top: 2px;
  font-size: 11.5px;
  font-weight: 700;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  max-width: 100%;
  text-overflow: ellipsis;
}

.daily-tier.is-done .daily-tier-state {
  color: var(--good);
}

/* The key to a card's * and **: one line each, a gap between. */
.daily-foot {
  margin-top: 12px;
}

.daily-foot > span {
  display: block;
}

.daily-foot > span + span {
  margin-top: 8px;
}

/* Friday only — First to Fifty, under the three tiers. The wordmark's
   orange and a "50" mark to match the run's own leaderboard. It carried a
   New pill for its first three Fridays; that came off on 27 Sep 2026.
   buildDailySheet adds and removes it with the day. */
.daily-fifty-slot:empty {
  display: none;
}

.daily-fifty {
  position: relative;
  width: 100%;
  margin-top: 10px;
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 14px 16px;
  border-radius: 16px;
  border: 1px solid color-mix(in srgb, #f0a92c 55%, var(--line));
  background:
    radial-gradient(130% 100% at 0% 0%, color-mix(in srgb, #f0a92c 22%, transparent), transparent 60%),
    var(--surface);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s, transform 0.1s;
}

.daily-fifty:active {
  transform: scale(0.99);
}

.daily-fifty:hover {
  border-color: color-mix(in srgb, #f0a92c 78%, transparent);
}

.daily-fifty-mark {
  flex: none;
  width: 42px;
  height: 42px;
  border-radius: 12px;
  display: grid;
  place-items: center;
  background: linear-gradient(155deg, #ffd24d, #f0a92c 55%, #e4573d);
  box-shadow: 0 2px 8px -1px color-mix(in srgb, #e4573d 55%, transparent);
  color: #fff;
  font-weight: 800;
  font-size: 17px;
  letter-spacing: -0.02em;
}

.daily-fifty-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.daily-fifty-title {
  font-family: 'Fredoka', -apple-system, 'Segoe UI', Roboto, sans-serif;
  font-weight: 500;
  font-size: 14.5px;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: #f0a92c;
  line-height: 1.15;
}

.daily-fifty-sub {
  font-size: 11.5px;
  color: var(--text-dim);
}

/* Run already today: the button stops inviting and reports, like a finished
   tier card — green edge, and the total for all seven boards on
   the line under the title (an asterisk if a board reset along the way, or
   the DNF and how far it got). */
.daily-fifty.is-done {
  border-color: color-mix(in srgb, var(--good) 45%, transparent);
  background: var(--surface);
}

.daily-fifty-state {
  font-size: 12.5px;
  font-weight: 700;
  color: var(--good);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* .time-ast is the same mark on a time that did not count: the Solved
   banner, the header clock and a daily's card (ui.js timeHtml). */
.daily-fifty-ast,
.time-ast {
  color: var(--bad);
  font-weight: 800;
}

/* Settings' "Preview Friday in Daily" (Developer Mode): a pill on the
   top edge and a dashed border, so a preview is never mistaken for the
   real race. */
.daily-fifty.is-preview {
  border-style: dashed;
  border-color: #7c5cff;
}
.daily-fifty-badge {
  position: absolute;
  top: -9px;
  right: 14px;
  padding: 2px 9px;
  border-radius: 999px;
  background: #7c5cff;
  color: #fff;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.5;
  pointer-events: none;
}

/* amber reads thin on the light theme's pale ground — deepen the title */
:root[data-theme='light'] .daily-fifty-title {
  color: #d97a00;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .daily-fifty-title {
    color: #d97a00;
  }
}

.daily-offline {
  color: var(--bad);
}

/* Choose-your-puzzles sheet: a shortcut to the Daily sheet, sized and dressed
   like the button above — Daily's own accent (#f0a92c, `.crest-daily`) is
   the same amber Fifty borrowed, so the two read as one family rather than
   a clash. The pip is the same fact as the header icon's and the home
   tile's (renderStreakPip in ui.js) — a third place to notice a streak. */
.levels-daily {
  position: relative;
  width: 100%;
  margin: 14px 0 4px;
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 14px 16px;
  border-radius: 16px;
  border: 1px solid color-mix(in srgb, #f0a92c 55%, var(--line));
  background:
    radial-gradient(130% 100% at 0% 0%, color-mix(in srgb, #f0a92c 22%, transparent), transparent 60%),
    var(--surface);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s, transform 0.1s;
}

.levels-daily:active {
  transform: scale(0.99);
}

.levels-daily:hover {
  border-color: color-mix(in srgb, #f0a92c 78%, transparent);
}

.levels-daily-mark {
  position: relative;
  flex: none;
  width: 42px;
  height: 42px;
  border-radius: 12px;
  display: grid;
  place-items: center;
  background: linear-gradient(155deg, #ffd24d, #f0a92c 55%, #e4573d);
  box-shadow: 0 2px 8px -1px color-mix(in srgb, #e4573d 55%, transparent);
  color: #fff;
}

.levels-daily-mark svg {
  width: 22px;
  height: 22px;
  display: block;
}

.levels-daily-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.levels-daily-title {
  font-family: 'Fredoka', -apple-system, 'Segoe UI', Roboto, sans-serif;
  font-weight: 500;
  font-size: 14.5px;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: #f0a92c;
  line-height: 1.15;
}

.levels-daily-sub {
  font-size: 11.5px;
  color: var(--text-dim);
}

:root[data-theme='light'] .levels-daily-title {
  color: #d97a00;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .levels-daily-title {
    color: #d97a00;
  }
}

/* ------------------------------------------------- the "new feature" card

   Deliberately not a sheet. A sheet slides up from the bottom and is a
   place you went; this is an announcement that arrived, so it sits in the
   middle and interrupts once. It borrows the win banner's card and rays so
   it reads as part of the game rather than as a notice bolted on.
                                                                        */

.news-card {
  text-align: center;
  max-width: 340px;
}

.news-rays {
  display: grid;
  place-items: center;
  margin: 2px auto 10px;
  width: 76px;
  height: 76px;
  border-radius: 50%;
  background: radial-gradient(
    closest-side,
    color-mix(in srgb, #f0a92c 40%, transparent),
    transparent 78%
  );
}

.news-icon {
  width: 40px;
  height: 40px;
  color: #f0a92c;
}

.news-kicker {
  margin: 0;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: #d1881a;
}

.news-card h2 {
  margin: 2px 0 8px;
}

.news-body {
  margin: 0 0 8px;
  font-size: 14px;
  line-height: 1.5;
}

.news-fine {
  margin: 0 0 14px;
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--muted);
}

:root[data-theme='dark'] .news-kicker {
  color: #f0a92c;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .news-kicker {
    color: #f0a92c;
  }
}

/* ------------------------------------------------------ the leaderboard

   Lives inside the daily sheet rather than in a panel of its own. The two
   questions — "which one shall I play" and "how did everyone do" — get
   asked in the same breath, and making the second one a separate trip would
   mean nobody took it.
                                                                        */

/* Nobody was going to find this. It was one dashed grey box with grey text
   in it, sitting between the tiers and the leaderboard, and a dashed
   outline is the visual language of "placeholder, ignore me" — exactly
   backwards for the one control on this sheet that needs an action.

   So: a heading that says what it is for, a solid field that looks like
   something you type in, and the whole thing set apart from the tiers
   above it. */
.daily-name {
  margin: 16px 0 4px;
  padding: 12px;
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line));
  border-radius: 14px;
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}

/* Sentence case and full contrast, against the sheet's own h3 style, which
   is a faint uppercase section label. That is right for "Today's fastest",
   which names a thing you are looking at, and wrong for this, which asks
   you to do something. */
.daily-name-head {
  margin: 0 0 8px;
  font-size: 14.5px;
  font-weight: 700;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text);
}

/* Once you HAVE a name this is just a status line, so it stops shouting. */
.daily-name.is-named {
  border-color: var(--line);
  background: transparent;
  padding: 10px 12px;
}

.daily-name.is-named .daily-name-head {
  display: none;
}

.daily-name-show {
  width: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 13px;
  text-align: left;
  cursor: pointer;
}

.daily-name-show b {
  color: var(--text);
}

/* The unnamed state gets the field's own affordance rather than a button
   that happens to look like a box. */
.daily-name:not(.is-named) .daily-name-show {
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 11px;
  background: var(--surface);
  color: var(--muted);
  font-size: 14px;
}

.daily-name-edit {
  display: grid;
  gap: 8px;
}

.daily-name-field {
  display: block;
}

.daily-name-edit input {
  width: 100%;
  min-width: 0;
  padding: 11px 12px;
  border: 1.5px solid color-mix(in srgb, var(--accent) 45%, var(--line));
  border-radius: 11px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  /* 16px or larger, or iOS zooms the whole page when the field is focused
     and leaves it zoomed — which on this layout means a board that no
     longer fits the screen. */
  font-size: 16px;
  -webkit-user-select: text;
  user-select: text;
}

.daily-name-edit input::placeholder {
  color: var(--muted);
}

.daily-name-edit input:focus {
  outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);
  outline-offset: 1px;
  border-color: transparent;
}

/* "That name is taken", and the like. Only ever shown while the editor is
   open, sitting between the field and the buttons. */
.daily-name-msg {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.35;
  color: var(--danger);
}

.daily-name-actions {
  display: flex;
  gap: 8px;
}

.daily-name-actions .btn {
  flex: 1;
}

/* visible to a screen reader, not to the eye */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The button that calls the leaderboard up. A quiet full-width control
   between the name box and the note. */
.daily-board-open {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  margin: 16px 0 12px;
  padding: 11px 16px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-weight: 650;
  font-size: 13.5px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: border-color 0.15s, background 0.15s;
}

.daily-board-open svg {
  width: 16px;
  height: 16px;
  color: var(--accent);
}

.daily-board-open:hover {
  border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}

.daily-board-open[aria-expanded='true'] {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}

/* The leaderboard itself: a panel spanning from just under the date row
   (top set in js) to the bottom of the sheet card, slid down out of sight
   until called. It covers the tiers, the name box, the button and the note
   — nothing behind it dims, it just has an opaque back. The card does not
   move. Drag the grip or the heading down, or tap the grip, to send it back. */
.daily-board-panel {
  position: absolute;
  left: 0;
  right: 0;
  top: 9rem; /* placeholder; js sets the real value under the date */
  bottom: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  padding: 0 18px calc(14px + env(safe-area-inset-bottom));
  background: var(--bg-2);
  border-top: 1px solid var(--line);
  border-radius: 18px 18px 0 0;
  box-shadow: 0 -14px 34px -14px rgba(0, 0, 0, 0.45);
  transform: translateY(101%);
  transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);
}

.daily-board-panel.open {
  transform: translateY(0);
}

/* The grip and the head are the drag surface — a downward pull there sends
   the panel back. The rows keep their own touch scrolling. */
.board-grip,
.board-panel-head {
  touch-action: none;
}

/* The grab handle — a plain bar, but the whole strip around it is the hit
   target and it doubles as a close button. */
.board-grip {
  align-self: center;
  width: 56px;
  height: 22px;
  margin: 1px 0 4px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.board-grip::before {
  content: '';
  display: block;
  width: 100%;
  height: 4px;
  margin-top: 9px;
  border-radius: 999px;
  background: var(--line);
  transition: background 0.15s;
}

.board-grip:hover::before,
.board-grip:active::before {
  background: var(--muted);
}

/* The two views — today's times and the all-time tally — sit where the
   panel's heading was and read like it: small capitals, the chosen one in
   full ink with an accent rule under it. */
.board-modes {
  display: flex;
  gap: 18px;
  margin: 2px 0 8px;
}

.board-mode {
  padding: 0 0 3px;
  border: 0;
  border-bottom: 2px solid transparent;
  background: none;
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-faint);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.board-mode.on {
  color: var(--text);
  border-bottom-color: var(--accent);
}

#board-tabs.is-overall [data-tier='fifty'] {
  display: none;
}

.board-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-auto-rows: max-content;
  gap: 1px;
  /* The grip, head and count stay put; the rows take the slack and scroll
     inside it — `min-height: 0` is what lets a flex child shrink below its
     content so the overflow is the list's and not the panel's. */
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}


.board-row {
  display: grid;
  grid-template-columns: 2.2em 1fr auto;
  align-items: baseline;
  gap: 10px;
  padding: 7px 10px;
  border-radius: 9px;
  font-size: 13.5px;
}

.board-row:nth-child(odd) {
  background: color-mix(in srgb, var(--surface) 70%, transparent);
}

/* Your own row, wherever it lands — including pinned below an ellipsis on a
   day the list is capped and you finished past the end of it. */
.board-row.is-mine {
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  font-weight: 700;
}

.board-rank {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.board-row.is-mine .board-rank {
  color: inherit;
}

.board-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.board-name.is-anon {
  color: var(--muted);
  font-style: italic;
}

.board-time {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}

/* The Friday race on the panel: a heading between each group of rows —
   clean finishers, unofficial, did not finish — an asterisk on a time that
   was not clean, and DNF where a time would be. */
.board-head {
  padding: 10px 10px 3px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted);
}

.board-head:first-child {
  padding-top: 2px;
}

.board-ast {
  color: var(--bad);
  font-weight: 800;
}

.board-dnf {
  color: var(--muted);
  letter-spacing: 0.04em;
}

.board-dnf-sub {
  color: var(--muted);
  font-weight: 400;
  font-size: 12px;
}

/* The gap shown when your row sits below the end of a capped list. */
.board-gap {
  padding: 2px 10px;
  color: var(--muted);
  text-align: center;
  letter-spacing: 0.2em;
}

/* The count under the rows. It sits below the scroll area, pinned to the
   foot of the panel, with a hairline to set it off from the last row. */
.board-empty {
  flex: none;
  margin: 0;
  padding: 9px 2px 2px;
  border-top: 1px solid var(--line);
  font-size: 12.5px;
  color: var(--muted);
}

.board-report {
  flex: none;
  margin: 0;
  padding: 8px 2px 0;
  text-align: right;
  font-size: 12.5px;
}
.board-report button {
  padding: 4px 0;
  border: 0;
  background: none;
  color: var(--muted);
  font: inherit;
  text-decoration: underline;
  cursor: pointer;
}

/* The Help tray's list (#sheet-help). */
.help-list {
  display: grid;
  gap: 8px;
  padding-bottom: 6px;
}
.help-item {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 13px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-weight: 600;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.help-item span {
  display: grid;
  gap: 2px;
}
.help-item small {
  font-size: 12.5px;
  font-weight: 400;
  color: var(--muted);
}
.help-item::after {
  content: '›';
  margin-left: auto;
  padding-left: 12px;
  font-size: 20px;
  color: var(--muted);
}

/* The Privacy policy and FAQ trays (#sheet-privacy, #sheet-faq): their
   web pages' own markup, restyled to sit in a sheet. Its h1 is dropped by ui.js, since the
   tray's head already says what it is; the date stays, as the policy's
   "Changes" paragraph points at it. */
.page-body {
  font-size: 14.5px;
  line-height: 1.5;
  color: var(--muted);
}
.page-body h2 {
  margin: 20px 0 6px;
  font-size: 16px;
  color: var(--text);
}
.page-body p,
.page-body ul {
  margin: 0 0 10px;
}
.page-body ul {
  padding-left: 20px;
}
.page-body li {
  margin-bottom: 6px;
}
.page-body > ul:first-child,
.page-body > section > ul:first-child {
  margin-top: 0;
}
.page-body li strong:first-child {
  display: block;
  margin-bottom: 2px;
}
.page-body strong {
  color: var(--text);
}
.page-body .date {
  margin: 0 0 12px;
  font-size: 13px;
}
.page-body .short {
  padding: 2px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
}
.page-body .short p {
  margin: 10px 0;
}
.privacy-link {
  margin: 18px 0 4px;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 13px;
  color: var(--muted);
}
.page-body a,
.privacy-link a {
  color: var(--accent);
}

/* The Contact us / Report a name tray (#sheet-contact). */
.report-form,
.report-done {
  display: grid;
  gap: 12px;
  padding-bottom: 6px;
}
.report-form .sheet-note {
  margin: 0;
}
.report-field {
  display: grid;
  gap: 6px;
  font-size: 14px;
  font-weight: 600;
}
.report-field i {
  font-style: normal;
  font-weight: 400;
  color: var(--muted);
}
.report-field input,
.report-field select,
.report-field textarea {
  width: 100%;
  min-width: 0;
  padding: 11px 12px;
  border: 1.5px solid color-mix(in srgb, var(--accent) 45%, var(--line));
  border-radius: 11px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-weight: 400;
  font-size: 16px; /* under 16px, iOS zooms the page on focus */
  -webkit-user-select: text;
  user-select: text;
  resize: vertical;
}
.report-field input:focus,
.report-field select:focus,
.report-field textarea:focus {
  outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);
  outline-offset: 1px;
  border-color: transparent;
}
.report-msg {
  margin: 0;
  font-size: 12.5px;
  color: var(--danger);
}
.report-done p {
  margin: 0;
}

/* Just a message — loading, an error, or nobody-has-finished. No list to
   scroll and no hairline; the text sits straight under the tabs. */
.daily-board-panel.board-empty-state .board-rows {
  display: none;
}

.daily-board-panel.board-empty-state .board-empty {
  border-top: 0;
  padding-top: 2px;
}

/* ------------------------------------------- the very short screen, last

   A landscape phone is about 400px tall, and the fixed furniture alone
   wants more than that — which is why this case has always been broken: the
   column could not fit, `main` centres what it holds, and the overflow went
   up into the header.

   The message slot is what gives. It exists to stop the board jumping when
   a hint appears, and on a screen this shallow the board has bigger
   problems than jumping. A hint here will crowd the toolbar; a permanent
   collision with the header is worse.

   AT THE END OF THE FILE ON PURPOSE. `.msg-slot` sets its own height a
   thousand lines below where this rule would naturally have gone, and at
   equal specificity the later declaration wins — so the media query looked
   right and did nothing. (Twice, in the same afternoon: the same trap ate
   the highlight glow earlier.) */
@media (max-height: 470px) {
  /* On a screen this shallow, an open bar cannot be paid for out of the
     board: the bar alone is 166px of a 393px-tall landscape phone, and the
     header, status line and toolbars have already spent most of the rest.
     Shrinking the board does not close that gap — it hits its floor and the
     column still overflows, which is how the header ended up back under the
     status line.

     So here, and only here, the bar FLOATS: pinned over the bottom of the
     board, taking no layout space at all. It covers part of a small board,
     which is a poor look and a working one — and its dismiss button is on
     top, so it is never a trap. */
  body.msg-open {
    --reserve-slot: 0px;
  }

  .msg-slot,
  body.msg-open .msg-slot {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: auto;
    z-index: 30;
  }
}

/* ------------------------------------------------------- watching a run

   The board plays somebody else's solve back at the speed they did it. The
   controls are shaped like a video player because that is exactly what this
   is — a running clock against a total, a scrub line, and a speed you can
   change if their thinking pause outlasts your patience.
                                                                        */

.replay-bar {
  display: flex;
  flex-direction: column;
  /* the name takes the slack, so the scrub line and controls sit on the
     bottom edge exactly where the other two bars put theirs */
  justify-content: space-between;
  gap: 7px;
  width: min(94vw, var(--board-max));
  max-width: 100%;
  background: var(--surface);
  border: 1px solid var(--line);
  border-left: 3px solid #f0a92c;
  border-radius: 12px;
  padding: 9px 11px 10px 12px;
  animation: rise 0.2s ease;
}

.replay-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.replay-who {
  font-size: 12.5px;
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.replay-who b {
  color: #d1881a;
}

:root[data-theme='dark'] .replay-who b {
  color: #f0a92c;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .replay-who b {
    color: #f0a92c;
  }
}

.replay-x {
  width: 28px;
  height: 28px;
  flex: none;
}

/* The scrub line. Drag the knob, click anywhere on it, or arrow-key it to
   move around the run. Watching already cost the day when daily-replay
   handed the log over, so there is nothing left for a jump to undermine —
   the rail is just a 4px line, the tall box around it is the grab target. */
.replay-track {
  position: relative;
  display: flex;
  align-items: center;
  height: 16px;
  cursor: pointer;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}

.replay-track::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: 4px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--text) 14%, transparent);
}

.replay-track > i {
  position: absolute;
  left: 0;
  height: 4px;
  width: 0;
  border-radius: 2px;
  background: #f0a92c;
}

/* The knob, riding the right edge of the fill. */
.replay-track > i::after {
  content: '';
  position: absolute;
  right: -6px;
  top: 50%;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #f0a92c;
  transform: translateY(-50%);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}

.replay-track:focus-visible {
  outline: 2px solid color-mix(in srgb, #f0a92c 70%, transparent);
  outline-offset: 3px;
  border-radius: 2px;
}

.replay-controls {
  display: flex;
  align-items: center;
  gap: 10px;
}

.replay-play {
  min-width: 74px;
}

.replay-clock {
  flex: 1;
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.replay-speeds {
  display: flex;
  gap: 2px;
  flex: none;
}

.replay-speed {
  padding: 5px 8px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
}

.replay-speed.on {
  border-color: color-mix(in srgb, #f0a92c 60%, transparent);
  background: color-mix(in srgb, #f0a92c 16%, transparent);
  color: var(--text);
}

/* HANDS OFF THE BOARD. A replay is somebody else's run; touching it would
   be editing a recording. pointer-events on the board is the whole lock in
   one line, and the toolbar goes with it — there is nothing on it that
   means anything while you are watching. */
body.replaying #board {
  pointer-events: none;
}

/* The coach's lent board is the same: a line or a review played over a
   finished board, which taps must not be able to edit. */
body.lent #board {
  pointer-events: none;
}

body.replaying .toolbar {
  pointer-events: none;
  opacity: 0.35;
}

/* The ▶ on the fastest row. Only the fastest: it is the only run watching
   costs you anything for, and the only one anybody asked to see. */
.board-play {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  margin: -4px -4px -4px 2px;
  border: 0;
  border-radius: 50%;
  background: color-mix(in srgb, #f0a92c 20%, transparent);
  color: #b8760f;
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
}

:root[data-theme='dark'] .board-play {
  color: #f0a92c;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .board-play {
    color: #f0a92c;
  }
}

.board-row.has-play {
  grid-template-columns: 2.2em 1fr auto auto;
}

/* ===================================================================
   BADGES

   Two faces: the cabinet (a sheet of rows, earned in colour and locked
   in grey, secrets as a "?" until they happen) and the splash (one badge
   landing, on an eighties starburst — the sun theme at full volume).

   Appended after everything else on purpose: equal specificity means
   the later declaration wins, and nothing above may quietly restyle a
   medal. The splash commits to a dark sunset backdrop in BOTH themes —
   its text colours are therefore hard-coded, not tokens.
   =================================================================== */

/* ------------------------------------------------------ the cabinet */

/* The trophy shelf: earned badges as bare medals in a wrapping row, a
   number under the ones that stand for one (a streak's days, a set's
   hundred). Each is a button — tapping replays the badge's splash, which
   is where its name, story and date live. */
.badge-shelf {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 10px 14px;
  padding: 2px 0 4px;
}

.shelf-badge {
  border: 0;
  background: transparent;
  padding: 0;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  font: inherit;
  color: var(--text-dim);
  -webkit-tap-highlight-color: transparent;
  transition: transform 0.1s;
}
.shelf-badge:active {
  transform: scale(0.92);
}
.shelf-badge .badge-medal {
  width: 44px;
  height: 44px;
}
.shelf-badge .badge-medal i {
  font-size: 20px;
}
.shelf-badge b {
  font-size: 11px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.badge-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 8px;
}

.badge-list h3 {
  margin: 20px 0 6px;
}

.badge-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* Every medal is a little sun: a warm disc in a ring. The ring is where
   the tiers differ — amber, blaze, mythic — via the vars set below. */
.badge-medal {
  --ring-a: var(--line);
  --ring-b: var(--line);
  flex: none;
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 2px solid transparent;
  background:
    radial-gradient(circle at 35% 30%, var(--surface-2), var(--surface)) padding-box,
    linear-gradient(150deg, var(--ring-a), var(--ring-b)) border-box;
}

.badge-medal i {
  font-style: normal;
  font-size: 21px;
  line-height: 1;
}

.badge-earned .badge-medal,
.badge-medal-big {
  background:
    radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--ring-a) 26%, var(--surface)), var(--surface)) padding-box,
    conic-gradient(var(--ring-a), var(--ring-b), var(--ring-a)) border-box;
  box-shadow: 0 0 14px -4px var(--ring-b);
}

.badge-t1 .badge-medal { --ring-a: #ffe27a; --ring-b: #f0a92c; }
.badge-t2 .badge-medal { --ring-a: #ffcb33; --ring-b: #ff6a3d; }
.badge-t3 .badge-medal { --ring-a: #ff9b3d; --ring-b: #c86bff; }

/* Locked: the medal goes to pencil, the words stay readable — a goal is
   something to read, not something to squint at. */
.badge-locked .badge-medal {
  filter: grayscale(1) opacity(0.5);
  box-shadow: none;
}

.badge-secret .badge-medal,
.badge-unknown {
  border: 2px dashed var(--line);
  background: transparent;
  box-shadow: none;
}
.badge-unknown i {
  font-weight: 800;
  font-size: 19px;
  color: var(--text-faint);
}
.badge-secret {
  border-style: dashed;
}

.badge-info {
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.badge-info b {
  font-size: 14px;
  letter-spacing: -0.01em;
}
.badge-locked .badge-info b {
  color: var(--text-dim);
}
.badge-desc {
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--text-dim);
}
.badge-when {
  margin-top: 2px;
  font-size: 11px;
  color: var(--text-faint);
}

.badge-track {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 5px;
}
.badge-bar {
  flex: 1;
  height: 6px;
  border-radius: 999px;
  background: var(--bg-2);
  border: 1px solid var(--line);
  overflow: hidden;
}
.badge-bar i {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #f0a92c, #ffcb33);
}
.badge-frac {
  font-size: 11px;
  font-weight: 600;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ------------------------------------------------------- the splash

   A committed sunset: deep violet backdrop whatever the theme, so the
   starburst always has a night to burn against. The burst is two ray
   wheels out of phase plus a halftone dot ring — pure gradients, no
   assets — and the whole halo scales in while the rays turn, which is
   why the entrance lives on the halo and the spin on the wheels: both
   animate transform, and one element cannot honestly do both at once. */

.badge-pop {
  z-index: 90;
  /* Near-opaque, and tested against a WHITE board: at 86% the light theme
     bled through and the night sky came out lavender. */
  background:
    radial-gradient(120% 90% at 50% 10%, rgba(255, 122, 21, 0.14), transparent 60%),
    color-mix(in srgb, #170a2e 96%, transparent);
  backdrop-filter: blur(6px);
  --burst-a: rgba(255, 203, 51, 0.5);
  --burst-b: rgba(255, 77, 157, 0.38);
}
/* the mythic tier turns the burst violet-pink; amber stays the default */
.badge-pop.badge-t3 {
  --burst-a: rgba(200, 107, 255, 0.5);
  --burst-b: rgba(255, 122, 21, 0.4);
}

.badge-stage {
  max-width: min(88vw, 26rem);
  text-align: center;
  padding: 12px;
}

.badge-halo {
  position: relative;
  width: 160px;
  height: 160px;
  margin: 0 auto 14px;
  display: grid;
  place-items: center;
  animation: badge-arrive 0.7s cubic-bezier(0.2, 1.5, 0.4, 1);
}

.badge-burst {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}
.badge-burst::before {
  content: '';
  position: absolute;
  inset: -95px;
  border-radius: 50%;
  background:
    repeating-conic-gradient(from 5deg, var(--burst-b) 0 7deg, transparent 7deg 30deg),
    repeating-conic-gradient(var(--burst-a) 0 11deg, transparent 11deg 30deg);
  -webkit-mask-image: radial-gradient(circle, black 24%, transparent 70%);
  mask-image: radial-gradient(circle, black 24%, transparent 70%);
  animation: ray-wheel 44s linear infinite;
}
/* the halftone ring — the 80s print-dot texture */
.badge-burst::after {
  content: '';
  position: absolute;
  inset: -110px;
  border-radius: 50%;
  background-image: radial-gradient(rgba(255, 255, 255, 0.32) 1.2px, transparent 1.4px);
  background-size: 13px 13px;
  -webkit-mask-image: radial-gradient(circle, transparent 30%, black 44%, transparent 64%);
  mask-image: radial-gradient(circle, transparent 30%, black 44%, transparent 64%);
  animation: ray-wheel 70s linear infinite reverse;
}

.badge-medal-big {
  --ring-a: #ffe27a;
  --ring-b: #ff6a3d;
  position: relative;
  width: 118px;
  height: 118px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 3px solid transparent;
  background:
    radial-gradient(circle at 35% 28%, #4a2d14, #241129 72%) padding-box,
    conic-gradient(var(--ring-a), var(--ring-b), #ff4d9d, var(--ring-a)) border-box;
  box-shadow:
    0 0 38px -6px rgba(255, 155, 61, 0.75),
    0 14px 40px -10px rgba(0, 0, 0, 0.6);
  animation: bounce 0.7s cubic-bezier(0.2, 1.6, 0.4, 1);
}
.badge-pop.badge-t3 .badge-medal-big {
  --ring-a: #ff9b3d;
  --ring-b: #c86bff;
}
.badge-medal-big i {
  font-style: normal;
  font-size: 56px;
  line-height: 1;
  filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.5));
}

.spark {
  position: absolute;
  font-style: normal;
  color: #ffe27a;
  text-shadow: 0 0 10px rgba(255, 203, 51, 0.9);
  animation: spark-twinkle 1.7s ease-in-out infinite;
}
.spark.s1 { top: -6px;  left: 16px;  font-size: 17px; }
.spark.s2 { top: 24px;  right: -14px; font-size: 12px; animation-delay: 0.35s; }
.spark.s3 { bottom: -4px; right: 26px; font-size: 15px; animation-delay: 0.7s; color: #ff9ecb; }
.spark.s4 { bottom: 26px; left: -16px; font-size: 11px; animation-delay: 1.05s; }
.spark.s5 { top: -18px; right: 48px;  font-size: 10px; animation-delay: 1.3s; color: #ff9ecb; }
.spark.s6 { bottom: -18px; left: 44px; font-size: 13px; animation-delay: 0.5s; }

.badge-kicker {
  margin: 0 0 6px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.34em;
  text-indent: 0.34em; /* recentre what the tracking pushes right */
  text-transform: uppercase;
  background: linear-gradient(180deg, #ffe27a, #ff9b3d);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.badge-stage h2 {
  margin: 0 0 8px;
  font-size: 28px;
  letter-spacing: -0.01em;
  color: #fff7ea;
}

.badge-pop-desc {
  margin: 0 auto 6px;
  max-width: 24rem;
  font-size: 14px;
  line-height: 1.5;
  color: rgba(255, 238, 214, 0.78);
}

.badge-pop-more {
  margin: 0 0 4px;
  font-size: 12px;
  color: rgba(255, 238, 214, 0.55);
}

.badge-pop .win-actions {
  margin-top: 16px;
  justify-content: center;
}

@keyframes badge-arrive {
  from {
    transform: scale(0.3);
    opacity: 0;
  }
}

@keyframes spark-twinkle {
  0%, 100% { opacity: 0.15; transform: scale(0.7); }
  50% { opacity: 1; transform: scale(1.15); }
}


/* ===================================================================
   REWARDS

   The badge sheet as a hub — shelf, a Rewards spotlight led by the Level
   Builder, the two badges nearest to landing — and its two drill-downs.
   Reward cards reuse the badge vocabulary: a ringed tile where the medal
   would be, name, one-line state. Appended after the BADGES block for the
   same reason it was: nothing above may restyle these.
   =================================================================== */

.sheet-head-left {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.sheet-head-left .icon-btn {
  margin-left: -10px;
}
.sheet-h3-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
.sheet-more {
  border: 0;
  background: transparent;
  padding: 0;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  color: var(--accent);
  cursor: pointer;
}

/* Same amber dot as the header/home pips, inline rather than a corner
   overlay — this is the last stop of the trail those two point along,
   so it sits right on the words that clear it. */
.sheet-more-pip {
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-left: 4px;
  border-radius: 50%;
  background: #f0a92c;
  vertical-align: middle;
}
.badge-home-h {
  margin: 18px 0 8px;
}
#badge-home > .badge-home-h:first-child {
  margin-top: 6px;
}
.reward-list h3 {
  margin: 16px 0 6px;
}
.reward-list {
  padding-bottom: 8px;
}
.reward-list > .rw-strip {
  margin-bottom: 14px;
}
.rw-note {
  margin: -2px 0 6px;
  font-size: 12px;
}

/* the feature cards (Coach, Level Builder, Review My Solve) — badge rows that grew */
.rw-hero {
  align-items: flex-start;
  margin-bottom: 8px;
}
.rw-hero .badge-medal {
  width: 50px;
  height: 50px;
}
.rw-hero .badge-medal svg {
  width: 24px;
  height: 24px;
  color: #c86bff;
}
.rw-hero.badge-locked .badge-medal svg {
  color: var(--text-dim);
}
/* The coach wears its own green where the builder wears violet. After the
   rules above on purpose: same specificity, so order wins. */
.rw-hero-coach .badge-medal svg {
  color: var(--coach);
}
.rw-hero-coach.badge-locked .badge-medal svg {
  color: var(--text-dim);
}
/* the card's title only — a bold word in its small print stays small */
.rw-hero .badge-info > b {
  font-size: 15px;
}
.rw-fine {
  font-size: 11.5px;
}
.rw-cta {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}

/* the spotlight strip: a few cards, scrolls sideways */
.rw-strip {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scrollbar-width: none;
  padding-bottom: 2px;
  margin: 0 -18px;
  padding-left: 18px;
  padding-right: 18px;
}
.rw-strip::-webkit-scrollbar {
  display: none;
}
.rw-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
}

.rw-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  min-width: 0;
  padding: 8px 4px 7px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  text-align: center;
  cursor: pointer;
  transition: transform 0.1s, border-color 0.15s;
}
.rw-card:active:not([disabled]) {
  transform: scale(0.97);
}
.rw-card[disabled] {
  cursor: default;
}
.rw-compact {
  flex: none;
  width: 76px;
  border-radius: 12px;
}
.rw-name {
  font-size: 12.5px;
  font-weight: 700;
  line-height: 1.1;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rw-compact .rw-name {
  font-size: 11.5px;
}
.rw-state {
  font-size: 10.5px;
  color: var(--text-faint);
  white-space: nowrap;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* "From <badge>": how an unlocked reward was won, in two lines at most. */
.rw-from {
  font-size: 9.5px;
  line-height: 1.2;
  color: var(--text-faint);
  text-align: center;
  max-width: 100%;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* a locked card names its badge and progress — two short lines beat an ellipsis */
.rw-locked .rw-state {
  white-space: normal;
  line-height: 1.25;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.rw-compact .rw-state {
  font-size: 10px;
}
/* One colour for the whole tray (Jeremy, 18 Oct 2026): the reward in use
   is outlined in yellow and says On in yellow; a reward not yet looked at
   carries the menus' small yellow dot; everything else is plain. */
.rw-card {
  position: relative;
  /* held down to show its badge: no iOS callout or text selection */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
.rw-on {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}
.rw-on .rw-state {
  color: var(--accent);
  font-weight: 700;
}
.rw-dot {
  position: absolute;
  top: 7px;
  right: 7px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #f0a92c;
  box-shadow: 0 0 0 2px var(--bg-2);
}
.rw-locked .rw-pv,
.rw-paused .rw-pv {
  filter: grayscale(1) opacity(0.55);
}
.rw-locked .rw-name,
.rw-paused .rw-name {
  color: var(--text-dim);
}
.rw-hidden {
  border-style: dashed;
}

/* the little preview tile */
.rw-pv {
  width: 32px;
  height: 32px;
  border-radius: 10px;
  display: grid;
  place-items: center;
  background: var(--bg-2);
  border: 1px solid var(--line);
  position: relative;
  overflow: hidden;
}
.rw-pv svg {
  width: 22px;
  height: 22px;
  display: block;
}
.rw-pv-q {
  border-style: dashed;
  background: transparent;
}
.rw-pv-q i {
  font-style: normal;
  font-weight: 800;
  font-size: 17px;
  color: var(--text-faint);
}
/* marks: the real glyph, drawn on a dark tile the way the board draws it */
.rw-pv-x {
  background: #2c3850;
}
.rw-pv-x i {
  position: absolute;
  inset: 0;
  /* No drop-shadow filter here: on iPhone, a filtered glyph inside this
     clipped, rounded tile was sometimes left unpainted until it was tapped
     (build 14, 18 Oct 2026). At 32px the shadow was barely visible anyway. */
}
.rw-pv-x i::before,
.rw-pv-x i::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 58%;
  height: 12.5%;
  min-height: 2px;
  border-radius: 1px;
  background: #fff;
  transform: translate(-50%, -50%) rotate(45deg);
}
.rw-pv-x i::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}
.rw-pv-x[data-xmark='pebble'] i::after {
  display: none;
}
.rw-pv-x[data-xmark='pebble'] i::before {
  width: 30%;
  height: 30%;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
.rw-pv-x[data-xmark='tally'] i::after {
  display: none;
}
.rw-pv-x[data-xmark='tally'] i::before {
  width: 52%;
  transform: translate(-50%, -50%);
}
.rw-pv-x[data-xmark='hairline'] i::after {
  display: none;
}
.rw-pv-x[data-xmark='hairline'] i::before {
  width: 66%;
  height: 9%;
  transform: translate(-50%, -50%) rotate(-45deg);
}
.rw-pv-x[data-xmark='hatch'] i::before,
.rw-pv-x[data-xmark='hatch'] i::after {
  width: 52%;
  height: 9%;
  transform: translate(-50%, -50%) rotate(-45deg);
  box-shadow: 0 -7px 0 #fff, 0 7px 0 #fff;
}
.rw-pv-x[data-xmark='hatch'] i::after {
  display: none;
}
/* skies: a still swatch of each */
.rw-sky-plain {
  background: radial-gradient(120% 80% at 50% 0%, #151b26, #0e1219 70%);
}
.rw-sky-dawn {
  background: linear-gradient(180deg, #1b1f3a 0%, #5b3a63 55%, #d9744f 82%, #f6b35a 100%);
}
.rw-sky-night {
  background:
    radial-gradient(1px 1px at 20% 30%, #fff 60%, transparent 70%),
    radial-gradient(1px 1px at 70% 60%, #fff 60%, transparent 70%),
    radial-gradient(1px 1px at 45% 80%, #fff 60%, transparent 70%),
    radial-gradient(1.4px 1.4px at 80% 20%, #ffe6c0 60%, transparent 70%),
    linear-gradient(160deg, #0b1030, #070a12);
}
.rw-sky-aurora {
  background:
    linear-gradient(115deg, transparent 30%, rgba(56, 211, 159, 0.55) 45%, rgba(139, 92, 240, 0.5) 60%, transparent 75%),
    linear-gradient(160deg, #081226, #050812);
}
.rw-sky-nova {
  background: radial-gradient(60% 60% at 50% 55%, #ffb347 0, #ff6a3d 28%, #8b5cf0 62%, #160b2a 100%);
}
/* palettes: four of the twelve */
.rw-pv-pal {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: repeat(2, 1fr);
  padding: 0;
}
.rw-pv-pal i {
  display: block;
  width: 100%;
  height: 100%;
}

/* the splash carries the reward */
.badge-pop-reward {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 10px auto 2px;
  padding: 8px 14px;
  border-radius: 999px;
  background: rgba(255, 203, 51, 0.12);
  border: 1px solid rgba(255, 203, 51, 0.35);
  font-size: 13px;
  font-weight: 600;
  color: #ffe27a;
}
.badge-pop .win-actions {
  gap: 10px;
}
.badge-pop-ghost {
  color: #fff7ea;
  border-color: rgba(255, 238, 214, 0.28);
  background: transparent;
}
/* when a second button appears the first steps back to a ghost; alone it is primary */
#badge-pop-alt:not([hidden]) ~ * {
  display: none;
}
.badge-pop .win-actions:has(#badge-pop-alt[hidden]) .badge-pop-ghost {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}
#badge-pop-alt[disabled] {
  opacity: 0.7;
}

/* ===================================================================
   THE LEVEL BUILDER

   Lives in the level sheet now, as the fifth world: #builder-form and
   #builder-forge sit inside #sheet-levels .level-scroll and are shown in
   place of the blurb, note and grid when the Builder family is picked.
   =================================================================== */

#builder-form h3 {
  margin: 14px 0 7px;
}
.h3-aside {
  text-transform: none;
  letter-spacing: 0;
  font-weight: 500;
}
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.chip-b {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 34px;
  padding: 0 12px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--text-dim);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: border-color 0.12s, background 0.12s;
}
/* Size and Difficulty are one line each, however narrow the phone: the
   chips share the row equally and shrink their padding rather than wrap,
   so the whole form and its Build button fit without scrolling. */
.chips-row {
  flex-wrap: nowrap;
  gap: 5px;
}
.chips-row .chip-b {
  flex: 1 1 0;
  min-width: 0;
  justify-content: center;
  padding: 0 2px;
  white-space: nowrap;
  letter-spacing: -0.01em;
}
@media (max-width: 359px) {
  .chips-row {
    gap: 3px;
  }
  .chips-row .chip-b {
    font-size: 11.5px;
  }
}
.chip-b.on {
  color: var(--text);
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
  background: color-mix(in srgb, var(--accent) 11%, var(--surface));
}
.chip-b.off {
  opacity: 0.42;
  border-style: dashed;
  cursor: default;
}
.chips-divider {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0 8px;
  font-size: 11.5px;
  color: var(--text-faint);
}
.chips-divider::before,
.chips-divider::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--line);
}
.builder-fine {
  margin: 6px 0 0;
  font-size: 11.5px;
  color: var(--text-faint);
}
.builder-est {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0 10px;
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  font-size: 13px;
  color: var(--text-dim);
}
.builder-est b {
  color: var(--text);
}
.builder-est .dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--good);
  box-shadow: 0 0 8px var(--good);
}
.builder-est.is-none .dot {
  background: var(--text-faint);
  box-shadow: none;
}
.builder-go {
  width: 100%;
}
.builder-go[disabled] {
  opacity: 0.5;
}
/* The form is spaced tightly enough that a 390-wide phone, running as an
   installed app with its notch and home bar, shows every chip and the
   Build button without scrolling. Check there before loosening any of it. */
#builder-form h3 {
  margin: 10px 0 6px;
}
#builder-form .chip-b {
  min-height: 32px;
}
/* "55 solved", centred over Size; tucked up so the form still fits a phone */
.builder-count {
  margin: 4px 0 -4px;
  text-align: center;
  font-size: 11.5px;
  line-height: 14px;
  color: var(--text-faint);
}
#builder-form .chips-divider {
  margin: 8px 0 6px;
}
#builder-form .builder-est {
  margin: 8px 0;
  padding: 8px 12px;
}
#sheet-levels .level-scroll:has(> #builder-form:not([hidden])),
#sheet-levels .level-scroll:has(> #builder-forge:not([hidden])) {
  padding-bottom: calc(10px + env(safe-area-inset-bottom));
}
/* Developer Mode's benchmark: quiet, under the Build button, and its
   results in the forge (js/builder.js showBenchRun). */
.builder-bench {
  width: 100%;
  margin-top: 8px;
}
.bench-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}
.bench-table th,
.bench-table td {
  padding: 4px 2px;
  border-bottom: 1px solid var(--line);
  text-align: right;
}
.bench-table th:first-child,
.bench-table td:first-child {
  text-align: left;
}
.bench-table th {
  color: var(--text-faint);
  font-weight: 600;
}

/* the forge view */
#builder-forge {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 6px 0 4px;
}
/* A board found: only the board and its buttons, no text (Joel, 4 October
   2026). builder.js sets is-found in finishWith and clears it per build. */
#builder-forge.is-found > :not(.forge-mini, .forge-actions) {
  display: none;
}
.asked {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  justify-content: center;
}
.asked span {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.03em;
  color: var(--text-dim);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 1px 8px;
}
.asked span.acc {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 38%, transparent);
  background: color-mix(in srgb, var(--accent) 11%, transparent);
}
.forge-stage {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-dim);
}
.forge-mini {
  --n: 8;
  /* 28dvh: on a short phone the board gives way first, so the forge still
     fits without scrolling (220px from about 790px tall up). */
  width: min(220px, 60vw, 28dvh);
  aspect-ratio: 1;
  display: grid;
  grid-template-columns: repeat(var(--n), minmax(0, 1fr));
  grid-template-rows: repeat(var(--n), minmax(0, 1fr));
  gap: 2px;
  padding: 6px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: var(--shadow);
  transition: box-shadow 0.3s;
}
.forge-mini.is-found {
  box-shadow: var(--shadow), 0 0 0 2px color-mix(in srgb, var(--good) 55%, transparent);
}
.forge-mini i {
  display: block;
  border-radius: 22%;
  background: var(--c, var(--surface-2));
  transition: background 0.25s;
}
.forge-counters {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
  width: 100%;
}
.forge-counter {
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--surface);
  border: 1px solid var(--line);
}
.forge-counter b {
  display: block;
  font-size: 20px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.forge-counter span {
  display: block;
  font-size: 11.5px;
  color: var(--text-dim);
  line-height: 1.3;
}
.forge-counter.hot b {
  color: var(--accent);
}
.forge-result {
  width: 100%;
  padding: 14px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.forge-result b {
  font-size: 15px;
  letter-spacing: -0.01em;
}
.forge-result .sub {
  font-size: 12.5px;
  color: var(--text-dim);
  line-height: 1.45;
}
.forge-actions {
  display: flex;
  gap: 10px;
  width: 100%;
}
.forge-actions .btn {
  flex: 1;
}


/* =====================================================================
   Sunoku — ✕ mark skins

   Chosen with html[data-xmark='…']. Each skin restyles ONLY the player's
   own mark, `.cell:not([data-mark='4']) .x`. data-mark='4' is the red ✕ a
   wrong guess leaves behind: it is game state, and no skin may touch it.
   The :not() is what keeps it safe — a skin selector can never match a
   wrong-mark cell, so the styles.css rules for it are the only ones that
   apply. The reset at the bottom is belt and braces on top of that.

   Everything else stays as styles.css has it: the drop shadow lives on
   `.cell .x` and is untouched, the ink is var(--ink-out), and no skin uses
   sun-yellow, so a mark can never be mistaken for a face.

   Geometry is all percentages of the cell, so a skin scales from a 28px
   square on an 11×11 board to a 60px one on a laptop without a breakpoint.
   ===================================================================== */

/* ---- Pebble: a single round dot, about a third of the cell */
html[data-xmark='pebble'] .cell:not([data-mark='4']) .x::before {
  width: 30%;
  height: 30%;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
html[data-xmark='pebble'] .cell:not([data-mark='4']) .x::after {
  display: none;
}

/* ---- Tally: one level bar, the stroke you would make on a scorecard */
html[data-xmark='tally'] .cell:not([data-mark='4']) .x::before {
  width: 52%;
  height: 12.5%;
  transform: translate(-50%, -50%);
}
html[data-xmark='tally'] .cell:not([data-mark='4']) .x::after {
  display: none;
}

/* ---- Hairline: one thin bar at 45°. The min-height is what keeps it a
   line and not a rumour on a 28px cell. */
html[data-xmark='hairline'] .cell:not([data-mark='4']) .x::before {
  width: 66%;
  height: 9%;
  min-height: 2px;
  transform: translate(-50%, -50%) rotate(45deg);
}
html[data-xmark='hairline'] .cell:not([data-mark='4']) .x::after {
  display: none;
}

/* ---- Hatch: three thin parallel strokes at 45°. ::before is the long
   middle stroke; ::after is a box that paints the two shorter outer strokes
   as a hard-stopped gradient, so their thickness and spacing scale with the
   cell (box-shadow offsets cannot take a percentage). The outer strokes are
   shorter than the middle one on purpose — three equal bars read as a swatch
   of texture, a staggered trio reads as pen strokes. */
html[data-xmark='hatch'] .cell:not([data-mark='4']) .x::before {
  width: 66%;
  height: 7%;
  min-height: 2px;
  transform: translate(-50%, -50%) rotate(45deg);
}
html[data-xmark='hatch'] .cell:not([data-mark='4']) .x::after {
  width: 48%;
  height: 40%;
  min-height: 0;
  border-radius: 0;
  background: linear-gradient(
    to bottom,
    var(--ink-out) 0 17.5%,
    transparent 17.5% 82.5%,
    var(--ink-out) 82.5% 100%
  );
  transform: translate(-50%, -50%) rotate(45deg);
}

/* ---- The wrong-guess ✕, restated. Not needed while every skin above keeps
   its :not([data-mark='4']) — nothing else here can reach these cells — but
   if a future skin ever drops the guard, this puts the two red bars back.
   Mirrors the geometry in styles.css (.cell .x::before / ::after and the
   data-mark='4' colour); if that moves, move this with it. */
html[data-xmark] .cell[data-mark='4'] .x::before,
html[data-xmark] .cell[data-mark='4'] .x::after {
  display: block;
  width: 60%;
  height: 12.5%;
  min-height: 2px;
  border-radius: 1px;
  background: var(--ink-wrong);
  transform: translate(-50%, -50%) rotate(45deg);
}
html[data-xmark] .cell[data-mark='4'] .x::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}

/* =====================================================================
   Sunoku — skies

   A sky is painted on <div id="sky" aria-hidden="true">, the FIRST child of
   body, and chosen with html[data-sky='…']. The div carries four empty
   children (any inline element will do — <i></i> ×4); the layers below are
   addressed by :nth-child so the markup never has to name them:

     <div id="sky" aria-hidden="true"><i></i><i></i><i></i><i></i></div>

   Rules every sky keeps:
   - two scrims, #sky::before at the top and #sky::after at the bottom, each
     96px plus the safe-area inset, fading from var(--bg) into the sky. The
     header, status line and toolbar have no ground of their own, and this
     is what keeps their text on near-theme colour whatever is behind;
   - animations move only transform and opacity, so the compositor carries
     them and the board never repaints;
   - under prefers-reduced-motion they are `animation: none`. The global
     0.01ms rule in styles.css makes an infinite loop flicker, not stop;
   - they pause while a blurred overlay is open (body.overlay-open) and after
     a few minutes without a tap (body.sky-idle). Those rules are !important
     because a layer's `animation:` shorthand resets play-state at higher
     specificity than a body-class selector, and it must lose;
   - skies are for the dark theme. In a light theme #sky is hidden, except
     First Light, which has a paler daytime variant.
   ===================================================================== */

#sky {
  position: fixed;
  inset: 0;
  z-index: -1;
  display: none;
  overflow: hidden;
  pointer-events: none;
  background: var(--bg);
  contain: layout paint style;
}

html[data-sky='dawn'] #sky,
html[data-sky='night'] #sky,
html[data-sky='aurora'] #sky,
html[data-sky='nova'] #sky {
  display: block;
}

#sky > * {
  position: absolute;
  inset: 0;
  display: block;
}

/* ---- the scrims; above every layer, below the page */
#sky::before,
#sky::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  z-index: 2;
}
#sky::before {
  top: 0;
  height: calc(96px + env(safe-area-inset-top, 0px));
  background: linear-gradient(
    to bottom,
    var(--bg) 0%,
    var(--bg) 40%,
    color-mix(in srgb, var(--bg) 72%, transparent) 70%,
    transparent 100%
  );
}
#sky::after {
  bottom: 0;
  height: calc(96px + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(
    to top,
    var(--bg) 0%,
    var(--bg) 40%,
    color-mix(in srgb, var(--bg) 72%, transparent) 70%,
    transparent 100%
  );
}

/* ---- motion guards */
@media (prefers-reduced-motion: reduce) {
  #sky *,
  #sky *::before,
  #sky *::after {
    animation: none !important;
  }
}
body.overlay-open #sky *,
body.overlay-open #sky *::before,
body.overlay-open #sky *::after,
body.sky-idle #sky *,
body.sky-idle #sky *::before,
body.sky-idle #sky *::after {
  animation-play-state: paused !important;
}

/* ---- dark theme only, First Light excepted */
html[data-theme='light'] #sky {
  display: none;
}
html[data-theme='light'][data-sky='dawn'] #sky {
  display: block;
}
@media (prefers-color-scheme: light) {
  html:not([data-theme='dark']) #sky {
    display: none;
  }
  html:not([data-theme='dark'])[data-sky='dawn'] #sky {
    display: block;
  }
}

/* =====================================================================
   First Light — static. Night above, the day not yet arrived below, and
   the glow of the coming sun held to the middle third of the screen, which
   on a phone is roughly where the board sits.
   ===================================================================== */
html[data-sky='dawn'] #sky {
  background: linear-gradient(
    to bottom,
    #0a0f22 0%,
    #121a3a 26%,
    #2b2748 36%,
    #6b3c4e 43%,
    #b8583f 49%,
    #e79a54 54%,
    #f3b86b 57%,
    #9a5a52 62%,
    #2a2236 67%,
    #141826 76%,
    var(--bg) 100%
  );
}
html[data-sky='dawn'] #sky > :nth-child(1) {
  background: radial-gradient(
    46% 16% at 50% 56%,
    rgba(255, 214, 140, 0.55),
    rgba(255, 170, 96, 0.18) 55%,
    transparent 100%
  );
}
html[data-sky='dawn'] #sky > :nth-child(2) {
  /* a thin band of high cloud catching colour, just above the glow */
  background: radial-gradient(70% 5% at 42% 41%, rgba(255, 160, 120, 0.22), transparent 100%),
    radial-gradient(52% 4% at 64% 45.5%, rgba(255, 190, 140, 0.2), transparent 100%);
}

/* the daytime variant: the same dawn with the lights on */
html[data-theme='light'][data-sky='dawn'] #sky {
  background: linear-gradient(
    to bottom,
    #d7e2f5 0%,
    #cbd9f2 28%,
    #dccde6 38%,
    #f5cdb9 45%,
    #ffd9a6 52%,
    #ffe3b7 56%,
    #f0d3cc 62%,
    #e4e6f0 70%,
    var(--bg) 100%
  );
}
html[data-theme='light'][data-sky='dawn'] #sky > :nth-child(1) {
  background: radial-gradient(
    46% 16% at 50% 55%,
    rgba(255, 236, 190, 0.9),
    rgba(255, 220, 170, 0.3) 55%,
    transparent 100%
  );
}
html[data-theme='light'][data-sky='dawn'] #sky > :nth-child(2) {
  background: radial-gradient(70% 5% at 42% 41%, rgba(255, 190, 170, 0.35), transparent 100%),
    radial-gradient(52% 4% at 64% 45.5%, rgba(255, 210, 180, 0.3), transparent 100%);
}
@media (prefers-color-scheme: light) {
  html:not([data-theme='dark'])[data-sky='dawn'] #sky {
    background: linear-gradient(
      to bottom,
      #d7e2f5 0%,
      #cbd9f2 28%,
      #dccde6 38%,
      #f5cdb9 45%,
      #ffd9a6 52%,
      #ffe3b7 56%,
      #f0d3cc 62%,
      #e4e6f0 70%,
      var(--bg) 100%
    );
  }
  html:not([data-theme='dark'])[data-sky='dawn'] #sky > :nth-child(1) {
    background: radial-gradient(
      46% 16% at 50% 55%,
      rgba(255, 236, 190, 0.9),
      rgba(255, 220, 170, 0.3) 55%,
      transparent 100%
    );
  }
  html:not([data-theme='dark'])[data-sky='dawn'] #sky > :nth-child(2) {
    background: radial-gradient(70% 5% at 42% 41%, rgba(255, 190, 170, 0.35), transparent 100%),
      radial-gradient(52% 4% at 64% 45.5%, rgba(255, 210, 180, 0.3), transparent 100%);
  }
}

/* =====================================================================
   Small Hours — a clear night. Three tiled star fields drift a few percent
   over three to four minutes, each its own way, so the parallax is real but
   nobody will ever see a star move. The nearest field also twinkles on a
   seven-second breath. Two faint nebulae sit still behind them.
   ===================================================================== */
html[data-sky='night'] #sky {
  background: linear-gradient(to bottom, #04070f 0%, #080d1f 45%, #0b1020 75%, var(--bg) 100%);
}
html[data-sky='night'] #sky > :nth-child(1) {
  background: radial-gradient(42% 28% at 24% 30%, rgba(139, 92, 240, 0.22), transparent 70%),
    radial-gradient(38% 24% at 78% 64%, rgba(56, 211, 159, 0.14), transparent 70%);
}
html[data-sky='night'] #sky > :nth-child(n + 2) {
  /* oversized so a drift never shows an edge */
  inset: -12%;
  background-repeat: repeat;
}
/* Far field: many, small, cool. Each layer is one tile of a handful of dots,
   repeated; the tile sizes are unrelated so the three never line up. */
html[data-sky='night'] #sky > :nth-child(2) {
  background-image: radial-gradient(1px 1px at 21px 37px, rgba(226, 236, 255, 0.9) 45%, transparent 100%),
    radial-gradient(0.8px 0.8px at 118px 12px, rgba(226, 236, 255, 0.75) 45%, transparent 100%),
    radial-gradient(1.1px 1.1px at 173px 88px, rgba(255, 244, 214, 0.95) 45%, transparent 100%),
    radial-gradient(0.8px 0.8px at 64px 141px, rgba(226, 236, 255, 0.7) 45%, transparent 100%),
    radial-gradient(0.9px 0.9px at 149px 172px, rgba(226, 236, 255, 0.8) 45%, transparent 100%),
    radial-gradient(0.7px 0.7px at 96px 76px, rgba(226, 236, 255, 0.65) 45%, transparent 100%),
    radial-gradient(0.8px 0.8px at 12px 108px, rgba(226, 236, 255, 0.7) 45%, transparent 100%),
    radial-gradient(0.7px 0.7px at 137px 151px, rgba(226, 236, 255, 0.6) 45%, transparent 100%);
  background-size: 176px 176px;
  animation: sky-drift-a 260s ease-in-out infinite alternate;
}
html[data-sky='night'] #sky > :nth-child(3) {
  background-image: radial-gradient(0.9px 0.9px at 44px 19px, rgba(226, 236, 255, 0.85) 45%, transparent 100%),
    radial-gradient(0.7px 0.7px at 132px 58px, rgba(226, 236, 255, 0.65) 45%, transparent 100%),
    radial-gradient(1px 1px at 201px 133px, rgba(226, 236, 255, 0.85) 45%, transparent 100%),
    radial-gradient(0.7px 0.7px at 17px 176px, rgba(255, 244, 214, 0.75) 45%, transparent 100%),
    radial-gradient(0.8px 0.8px at 88px 214px, rgba(226, 236, 255, 0.7) 45%, transparent 100%),
    radial-gradient(0.7px 0.7px at 166px 30px, rgba(226, 236, 255, 0.6) 45%, transparent 100%),
    radial-gradient(0.8px 0.8px at 108px 112px, rgba(226, 236, 255, 0.7) 45%, transparent 100%),
    radial-gradient(0.6px 0.6px at 190px 196px, rgba(226, 236, 255, 0.55) 45%, transparent 100%);
  background-size: 214px 214px;
  background-position: 71px 40px;
  animation: sky-drift-b 180s ease-in-out infinite alternate;
}
/* Near field: fewer, brighter, and the ones that twinkle. */
html[data-sky='night'] #sky > :nth-child(4) {
  background-image: radial-gradient(1.4px 1.4px at 33px 51px, rgba(255, 252, 240, 1) 40%, transparent 100%),
    radial-gradient(1.2px 1.2px at 127px 121px, rgba(226, 236, 255, 0.95) 40%, transparent 100%),
    radial-gradient(1.3px 1.3px at 88px 9px, rgba(255, 244, 214, 0.95) 40%, transparent 100%),
    radial-gradient(1.1px 1.1px at 156px 66px, rgba(226, 236, 255, 0.9) 40%, transparent 100%),
    radial-gradient(1.1px 1.1px at 14px 138px, rgba(226, 236, 255, 0.9) 40%, transparent 100%);
  background-size: 168px 168px;
  background-position: 23px 87px;
  animation: sky-drift-c 220s ease-in-out infinite alternate, sky-twinkle 7s ease-in-out infinite alternate;
}

@keyframes sky-drift-a {
  from { transform: translate3d(-3%, -2%, 0); }
  to { transform: translate3d(3%, 2%, 0); }
}
@keyframes sky-drift-b {
  from { transform: translate3d(2.5%, -3%, 0); }
  to { transform: translate3d(-2.5%, 3%, 0); }
}
@keyframes sky-drift-c {
  from { transform: translate3d(-2%, 3%, 0); }
  to { transform: translate3d(2%, -3%, 0); }
}
@keyframes sky-twinkle {
  from { opacity: 0.35; }
  to { opacity: 1; }
}

/* =====================================================================
   Aurora — a dark polar sky with three translucent curtains, green at the
   top running to violet below, each leaning a little and sliding across on
   its own slow clock with a forty-second breathe. The mask gives each
   curtain soft sides and faint vertical rays.
   ===================================================================== */
html[data-sky='aurora'] #sky {
  background: linear-gradient(to bottom, #03060e 0%, #061426 48%, #0a1b28 78%, var(--bg) 100%);
}
/* Each curtain runs nearly the full height — green where it clears the top
   scrim, violet where it shows again under the board — and is wide enough
   that its slide never shows an edge. */
html[data-sky='aurora'] #sky > :nth-child(-n + 3) {
  inset: auto;
  top: -4%;
  height: 100%;
  background: linear-gradient(
    to bottom,
    transparent 4%,
    rgba(56, 211, 159, 0.3) 20%,
    rgba(72, 190, 190, 0.29) 40%,
    rgba(110, 130, 230, 0.28) 60%,
    rgba(139, 92, 240, 0.27) 76%,
    transparent 96%
  );
  -webkit-mask-image: linear-gradient(to right, transparent 6%, #000 32%, #000 68%, transparent 94%),
    repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0 6px, #000 6px 17px, rgba(0, 0, 0, 0.7) 17px 23px);
  -webkit-mask-composite: source-in;
  mask-image: linear-gradient(to right, transparent 6%, #000 32%, #000 68%, transparent 94%),
    repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0 6px, #000 6px 17px, rgba(0, 0, 0, 0.7) 17px 23px);
  mask-composite: intersect;
  transform-origin: 50% 0;
}
html[data-sky='aurora'] #sky > :nth-child(1) {
  left: -22%;
  width: 74%;
  animation: aurora-slide-a 130s ease-in-out infinite alternate, aurora-breathe 40s ease-in-out infinite alternate;
}
html[data-sky='aurora'] #sky > :nth-child(2) {
  left: 18%;
  width: 66%;
  animation: aurora-slide-b 90s ease-in-out infinite alternate, aurora-breathe 40s ease-in-out -13s infinite alternate;
}
html[data-sky='aurora'] #sky > :nth-child(3) {
  left: 50%;
  width: 74%;
  animation: aurora-slide-c 170s ease-in-out infinite alternate, aurora-breathe 40s ease-in-out -27s infinite alternate;
}
html[data-sky='aurora'] #sky > :nth-child(4) {
  display: none;
}

@keyframes aurora-slide-a {
  from { transform: skewX(-14deg) translate3d(-16%, 0, 0); }
  to { transform: skewX(-14deg) translate3d(16%, 0, 0); }
}
@keyframes aurora-slide-b {
  from { transform: skewX(-21deg) translate3d(14%, 0, 0); }
  to { transform: skewX(-21deg) translate3d(-14%, 0, 0); }
}
@keyframes aurora-slide-c {
  from { transform: skewX(-9deg) translate3d(-12%, 0, 0); }
  to { transform: skewX(-9deg) translate3d(12%, 0, 0); }
}
@keyframes aurora-breathe {
  from { opacity: 0.55; }
  to { opacity: 1; }
}

/* =====================================================================
   Supernova — near-black, a core that breathes on twenty seconds, and one
   shell of light leaving it: eight minutes from a spark to past the edge of
   the screen, fading the whole way. Nobody should ever catch it moving.
   ===================================================================== */
html[data-sky='nova'] #sky {
  background: radial-gradient(90% 70% at 50% 46%, #0d0a1a 0%, #050409 62%, #020204 100%);
}
html[data-sky='nova'] #sky > :nth-child(1) {
  background: radial-gradient(
    circle at 50% 46%,
    rgba(255, 226, 122, 0.36) 0%,
    rgba(255, 122, 21, 0.17) 11%,
    rgba(139, 92, 240, 0.07) 25%,
    transparent 40%
  );
  transform-origin: 50% 46%;
  animation: nova-breathe 20s ease-in-out infinite alternate;
}
html[data-sky='nova'] #sky > :nth-child(2) {
  inset: auto;
  left: 50%;
  top: 46%;
  width: 100vmax;
  height: 100vmax;
  margin: -50vmax 0 0 -50vmax;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    transparent 57%,
    rgba(255, 150, 80, 0.2) 63%,
    rgba(255, 201, 60, 0.34) 67%,
    rgba(139, 92, 240, 0.18) 73%,
    transparent 79%
  );
  animation: nova-shell 480s linear infinite;
}
html[data-sky='nova'] #sky > :nth-child(3),
html[data-sky='nova'] #sky > :nth-child(4) {
  display: none;
}

@keyframes nova-breathe {
  from { opacity: 0.7; transform: scale(0.94); }
  to { opacity: 1; transform: scale(1.06); }
}
@keyframes nova-shell {
  0% { transform: scale(0.08); opacity: 0.9; }
  60% { opacity: 0.4; }
  100% { transform: scale(1.4); opacity: 0; }
}

/* ------------------------------------------- the locked-reward card

   Tapping a reward you cannot wear yet explains itself here: the art,
   the name, what it is, and the badge that earns it with its bar. A
   .modal-card with the preview centred above the title. */
.reward-info-card {
  text-align: center;
}
.reward-info-pv {
  display: grid;
  place-items: center;
  margin: 2px auto 12px;
}
.reward-info-pv .rw-pv {
  width: 56px;
  height: 56px;
  border-radius: 14px;
}
.reward-info-pv .rw-pv svg {
  width: 38px;
  height: 38px;
}
.reward-info-how {
  margin: 0 0 4px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--accent);
}
.reward-info-card .badge-track {
  margin: 10px 2px 0;
}

/* The whole game screen stays invisible until ui.js adds .boot-ready to
   body, at the end of its synchronous boot decisions (home shown or not,
   the tutorial or the daily news card in front, or a sheet opened straight
   over the board for a fifty.html "Back to Daily" link). ui.js is a module
   script, which runs after the document is parsed but with no guarantee it
   beats the browser's first paint of that raw markup — and the raw markup
   has all four of these sitting there, un-hidden, with #home not yet
   unhidden either. Without this, a slow module load flashes the real
   board (and its Auto X row and toolbar — easy to miss, since neither
   lives inside <main>) for however long that gap is.

   All four are plain siblings of #home, so hiding them does not touch it;
   visibility, not display, so nothing that measures them during boot gets
   a zero rect.

   This exact rule (plus the themed html background above it) is ALSO
   inlined in a <style> tag in index.html's <head>, ahead of this file's
   own <link>. That duplication is deliberate — this stylesheet only
   blocks first paint up to a point, and a cold network reconnect can lose
   that race, so the guard needs a copy with no network dependency at all.
   Keep both in sync by hand if either changes. */
body:not(.boot-ready) header,
body:not(.boot-ready) main,
body:not(.boot-ready) .toolbar {
  visibility: hidden;
}

/* =====================================================================
   Home screen — the very first thing a launch shows, sat on top of the
   board (which is already built underneath by the time this paints; see
   the boot section in ui.js). PLAY reveals the game as it stands; the
   four actions below reveal it too, straight into that sheet.

   z-index 95 — above .badge-pop (90) and .modal (80), the two highest
   things anywhere else in the app, so nothing already open can show
   through on a fresh launch. Tiles are real board colours (PALETTE,
   js/palette.js) and the real .tile-radius squircle, but this is a
   decorative cluster, not gameplay: the colours are named the same as
   the palette for upkeep, not read off any board state. */
#home {
  position: fixed;
  inset: 0;
  z-index: 95;
  display: flex;
  flex-direction: column;
  background-color: var(--bg);
  background-image: radial-gradient(120% 80% at 50% 0%, var(--bg-2), var(--bg) 70%);
  /* Bottom padding matches the top's 28px now (was 8px) — the cluster and
     the row below it sat flush against the bottom edge before, since
     .home-top is the only flex:1 sibling here and absorbs all the slack
     above the cluster, none of it below. 2026-09-12: moved the cluster up
     by giving the bottom room to breathe instead, rather than shrinking
     the gap between the wordmark and the cluster to compensate. */
  padding: calc(28px + var(--inset-top)) 20px calc(28px + var(--inset-bottom));
  transition: opacity 0.22s ease;

  /* Opaque from the very first frame, deliberately — this used to start
     at opacity:0 and fade in on a timer, on the theory that a settling
     safe-area inset was shifting the tile cluster right after paint.
     That timer ran independently of ui.js actually building the board,
     and on any connection fast enough for ui.js to finish first, the
     real board was visible through the transparent screen for however
     long was left on the clock — a much worse glitch than the one it
     was meant to fix. Covering the board immediately and unconditionally
     is the one guarantee that actually matters here. */

  --home-red: #e4573d;
  --home-amber: #f5a425;
  --home-lime: #93c83d;
  --home-sky: #3fa9f5;

  /* The open animation (a fresh launch — see bootHome() and openHome(),
     and #home.home-anim below). These are one baked-in tuning, lifted
     straight from the standalone motion workbench; the numbers are a set,
     not knobs. On 2026-09-27 the whole sequence moved 220ms earlier —
     --w-lead 260 → 40 and every other absolute cue with it — because a
     quarter-second of nothing moving after the load read as more loading.
     Same motion, same gaps; it just starts sooner. */
  --w-lead: 40ms;       /* wordmark: before the first tile drops           */
  --w-stagger: 103ms;   /*           gap between letters                    */
  --w-dur: 620ms;       /*           each tile's pop, and both suns' spin   */
  --w-over: 18px;       /*           how far a tile rises above its line    */
  --w-squash: 0.19;     /*           squash depth as it lands               */
  --tag-appear: 2160ms; /* tagline "catch the sun": when the fade starts    */
  --tag-fade: 1380ms;   /*           how long the fade runs                 */
  --c-start: 980ms;     /* cluster: before the first piece falls            */
  --c-stagger: 90ms;    /*          gap between pieces (random order)       */
  --c-fall: 580ms;      /*          each piece's fall + bounce              */
  --c-squash: 0.19;     /*          compression on impact                   */
  --p-sy: -207px;       /*          Z-drop start vector: 0.35 x distance up */
  --p-sz: 590px;        /*                               distance toward you*/
  --p-by: 0px;          /*          bounce vector (Z axis, same as the fall)*/
  --p-bz: 24px;
  --sun-delay: 150ms;   /* blue tile's sun: wait after the sequence settles */
  --row-delay: 2095ms;  /* action row fades up as the cluster lands         */
  --seq-end: 2730ms;    /* everything settled — the sun keys off this       */
}

/* "catch the sun" and (in Developer Mode) nothing else uses Fredoka; the
   latin subset is enough for those eleven letters. Self-hosted so the PWA
   stays offline-clean — SIL OFL, see fonts/Fredoka-OFL.txt. */
@font-face {
  font-family: 'Fredoka';
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url('../fonts/fredoka-latin.woff2') format('woff2');
}

#home.home-gone {
  opacity: 0;
  pointer-events: none;
}

/* A sheet opened from one of the home screen's own actions (Daily, Badges,
   Settings) is stacked ABOVE #home instead of replacing it — so the
   opaque home screen, not the board, is what sits behind the sheet's
   translucent backdrop, and the board never flashes through on the way in
   or out. openSheetFromHome() adds the class; closeSheets()/dismissSheets()
   take it off. Modals and badge splashes ride above the sheet, keeping
   their usual order (badge-pop over modal). */
body.sheet-from-home .sheet {
  z-index: 96;
}
body.sheet-from-home .modal {
  z-index: 97;
}

/* The Developer Mode note is only ever opened from the home screen's tap
   sequence, and the home screen stays up behind it, so it sits above #home
   (95) whatever else is open. */
#dev-note {
  z-index: 97;
}
body.sheet-from-home .badge-pop {
  z-index: 98;
}

/* The walkthrough, when it is run over the home screen (its Help, or
   Settings opened from it), stacks above #home the same way — the home
   screen is its backdrop, and "Start playing" fades it to the board exactly
   as PLAY does. runTutorial() in ui.js puts the class on and takes it off
   when the walkthrough closes. */
body.tut-from-home .tut {
  z-index: 96;
}
/* Settings opened from the home screen sits at 96 too, and the walkthrough
   run from its How to play opens on top of it. */
body.sheet-from-home .tut {
  z-index: 97;
}

/* This used to hold .home-word/.home-stage/.home-row invisible until a
   script confirmed env(safe-area-inset-*) had settled, because both live
   in #home's own padding (see --inset-top/--inset-bottom above) and used
   to drift after first paint on a cold standalone launch. That's fixed at
   the source now — the standalone media query floors both to this
   project's measured figures, so the values #home reads are correct from
   the first layout and nothing here needs to wait on them any more. */
.home-top {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* Two equal flex:1 regions split whatever room is above the cluster
   evenly, so the wordmark sits at the midpoint between the top of the
   screen and the cluster's top edge — not pinned to either. */
.home-spacer {
  flex: 1;
  min-height: 0;
}

.home-word {
  display: block;
  width: min(340px, 82vw);
  height: auto;
  flex: 0 0 auto;
  overflow: visible;
}

/* Each letter is its own group so it can drop in on its own beat; the
   pivots are set here and only matter once an animation is running. */
.home-word .lt,
.home-word .sun-spin {
  transform-box: fill-box;
}
.home-word .lt {
  transform-origin: center bottom;
}
.home-word .sun-spin {
  transform-origin: center;
}

/* The tagline sits just under the wordmark — deliberately much closer to
   it than to the cluster — in the amber of the U tile it echoes. Visible
   at rest; it only fades in under #home.home-anim. Set as a spaced-caps
   kicker rather than a spoken phrase: small, widely tracked, tucked right
   up under the tiles. text-indent adds back a trailing character's worth
   of the letter-spacing so the line stays optically centred. */
.home-tagline {
  flex: 0 0 auto;
  margin-top: 2px;
  text-align: center;
  color: var(--home-amber);
  font-family: 'Fredoka', -apple-system, 'Segoe UI', Roboto, sans-serif;
  font-size: 12.5px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.38em;
  text-indent: 0.38em;
}

/* The amber that carries the kicker on the near-black dark theme is thin
   against the light theme's pale ground — deepen it a step there. */
:root[data-theme='light'] .home-tagline {
  color: #d97a00;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .home-tagline {
    color: #d97a00;
  }
}

/* "Welcome back <name>" — the leaderboard name from the daily, set in
   openHome() (ui.js). Plain ink in the system UI face, NOT the kicker's
   Fredoka or its amber — black on the light theme, white on dark. No rule,
   no flourish: a returning player's name, nothing more.

   It sits just above the action row, down by where the thumb goes next,
   rather than up in the wordmark's space. It is a sibling of .home-top
   (not inside it), so the cluster's own position never depends on it.

   The element always holds its line, name or not — a new player gets it
   at full height but `visibility: hidden` (.has-name is off). Its box plus
   the trimmed .home-stage margin-bottom below sum to the same 56px that
   margin alone used to be, so the cluster and the row land in exactly the
   same place they do today whether the greeting shows or not. */
.home-welcome {
  flex: 0 0 auto;
  margin: 0 auto 12px;
  max-width: 84vw;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: center;
  color: #fff;
  font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  font-size: 16.5px;
  font-weight: 500;
  letter-spacing: 0.01em;
  min-height: 1.35em;
  line-height: 1.35;
  visibility: hidden;
}
.home-welcome.has-name {
  visibility: visible;
}
:root[data-theme='light'] .home-welcome {
  color: #000;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) .home-welcome {
    color: #000;
  }
}

.home-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  /* Was a flat 56px. The "Welcome back" line and its 12px margin now take
     the other 32px of that gap (see .home-welcome), so cluster-to-row
     spacing is unchanged whether or not a greeting shows. */
  margin-bottom: 24px;
}

/* The iOS app (ios/ puts .ios-app on <html>). It fills the screen to
   the real bottom inset, so the action row sits lower than on the installed
   web app — which Joel liked (11 Oct 2026) — but the cluster, anchored a
   fixed gap above the row, came down with it and the wordmark half as far.
   This puts that drop back under the cluster: on a 874pt iPhone the
   wordmark and cluster land where the installed web app has them, while the
   row stays low. Short phones have no room to spare and get none. */
.ios-app .home-stage {
  margin-bottom: calc(24px + clamp(0px, 100dvh - 780px, 75px));
}

.home-cluster {
  /* Sized independently of the wordmark above, deliberately — the two
     tile sizes read better than a single matched size. */
  width: min(324px, 84vw);
  aspect-ratio: 1;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(5, 1fr);
  gap: 6px;
}

.home-tile {
  position: relative;
  border: none;
  padding: 0;
  border-radius: var(--tile-radius);
  background: var(--home-c);
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.28);
  transform-origin: center center;
}

.home-tile.home-play,
.home-tile.home-sun-here {
  display: flex;
  align-items: center;
  justify-content: center;
}

.home-tile.home-play {
  cursor: pointer;
}

.home-tile.home-play:active {
  transform: scale(0.96);
}

/* Absolutely positioned rather than sized in flow — a percentage-sized
   replaced element centred by flex has no definite box to size against
   until the row already has a height, which is exactly what the row is
   waiting on it for. Out of flow, it sizes against the tile instead,
   same as .cell .face does for the real board. */
.home-sun {
  position: absolute;
  inset: 8%;
  width: 84%;
  height: 84%;
  transform-origin: 50% 50%;
}

/* -------------------------------------------------- the open animation

   Developer Mode only. openHome() adds #home.home-anim (and shuffles each
   piece's --pi) when settings.devMode is on, then strips it once the run
   is done; without it the screen is entirely static. The tuning is the
   one set of --w-* / --tag-* / --c-* / --p-* custom properties on #home
   above — knobs there, not here. Timeline at those values:
     wordmark settles ~1.4s · 14 cluster pieces land ~2.95s ·
     tagline fades 2.38–3.76s · blue tile's sun spins in ~3.1–3.72s.       */

/* wordmark: each letter rises past its line, drops, squats, settles */
#home.home-anim .lt {
  opacity: 0;
  animation: home-tile-in var(--w-dur) cubic-bezier(0.34, 1.56, 0.64, 1) both;
  animation-delay: calc(var(--w-lead) + var(--i) * var(--w-stagger));
}
@keyframes home-tile-in {
  0%   { opacity: 0; transform: translateY(52px) scaleX(0.92) scaleY(1.1); }
  22%  { opacity: 1; }
  45%  { opacity: 1; transform: translateY(calc(-1 * var(--w-over))) scaleX(0.97) scaleY(1.05); }
  64%  { opacity: 1; transform: translateY(0) scaleX(calc(1 + var(--w-squash) * 0.8)) scaleY(calc(1 - var(--w-squash))); }
  80%  { transform: translateY(-4px) scaleX(0.99) scaleY(1.03); }
  100% { opacity: 1; transform: translateY(0) scaleX(1) scaleY(1); }
}

/* both suns turn in the same way — the wordmark O in its own slot, the
   blue cluster tile's sun after the whole sequence has settled. */
@keyframes home-sun-turn {
  0%   { opacity: 1; transform: rotate(-90deg) scale(0.6); }
  60%  { opacity: 1; transform: rotate(12deg) scale(1.08); }
  100% { opacity: 1; transform: rotate(0) scale(1); }
}
#home.home-anim .home-word .sun-spin {
  animation: home-sun-turn var(--w-dur) cubic-bezier(0.34, 1.4, 0.64, 1) both;
  animation-delay: calc(var(--w-lead) + 3 * var(--w-stagger) + 40ms);
}
#home.home-anim .home-sun-here .home-sun {
  opacity: 0;
  animation: home-sun-turn var(--w-dur) cubic-bezier(0.34, 1.4, 0.64, 1) forwards;
  animation-delay: calc(var(--seq-end) + var(--sun-delay));
}

/* tagline fades in with the cluster */
#home.home-anim .home-tagline {
  opacity: 0;
  animation: home-tag-fade var(--tag-fade) ease both;
  animation-delay: var(--tag-appear);
}
@keyframes home-tag-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* cluster: each piece rushes in toward the viewer (Z), recedes onto the
   screen plane and rebounds once on that same axis. --pi (openHome) is its
   place in a fresh random order every open. */
#home.home-anim .home-tile {
  opacity: 0;
  animation: home-piece-drop var(--c-fall) linear both;
  animation-delay: calc(var(--c-start) + var(--pi, 0) * var(--c-stagger));
}
@keyframes home-piece-drop {
  0%   { opacity: 0;
         transform: perspective(760px) translate3d(0, var(--p-sy), var(--p-sz)) scale(1);
         animation-timing-function: cubic-bezier(0.38, 0, 0.8, 0.45); }
  14%  { opacity: 1; }
  58%  { opacity: 1;
         transform: perspective(760px) translate3d(0, 0, 0) scale(calc(1 - var(--c-squash)));
         animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); }
  76%  { transform: perspective(760px) translate3d(0, var(--p-by), var(--p-bz)) scale(1);
         animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1); }
  90%  { transform: perspective(760px) translate3d(0, calc(-0.3 * var(--p-by)), calc(-0.3 * var(--p-bz))) scale(calc(1 - var(--c-squash) * 0.16)); }
  100% { opacity: 1; transform: perspective(760px) translate3d(0, 0, 0) scale(1); }
}

/* action row — and the "Welcome back" line just above it — fade up as the
   cluster lands */
#home.home-anim .home-row,
#home.home-anim .home-welcome {
  opacity: 0;
  animation: home-row-in 420ms ease both;
  animation-delay: var(--row-delay);
}
@keyframes home-row-in {
  0%   { opacity: 0; transform: translateY(10px); }
  100% { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  #home.home-anim .lt,
  #home.home-anim .home-word .sun-spin,
  #home.home-anim .home-sun-here .home-sun,
  #home.home-anim .home-tagline,
  #home.home-anim .home-welcome,
  #home.home-anim .home-tile,
  #home.home-anim .home-row {
    animation-duration: 1ms !important;
  }
}

/* ---------------- Halloween (31 Oct ET, or Developer Mode's preview)

   homeHalloween() in ui.js puts .halloween on #home and three ghosts in
   .home-ghosts, absolutely placed inside the cluster. Each hides behind an
   edge tile (the tiles sit above it), slides out past the edge, bobs and
   looks about, then ducks back. Their loops are different lengths so they
   drift out of step. All three pop out as soon as the screen opens;
   --ghost-lead holds them only while the open animation is still landing. */
.home-ghosts {
  display: none;
}
#home.halloween .home-ghosts {
  display: block;
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
}
#home.halloween .home-cluster {
  position: relative;
}
#home.halloween .home-tile {
  z-index: 1;
}
.home-ghost {
  position: absolute;
  width: 15%;
  aspect-ratio: 1;
  margin: -7.5% 0 0 -7.5%;
  opacity: 0;
  filter: drop-shadow(0 3px 5px rgba(40, 30, 70, 0.25));
}
.home-ghost svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.home-ghost .ghost-body {
  fill: #f8f6ff;
  stroke: #5b5470;
  stroke-width: 1.6;
  stroke-linejoin: round;
}
.home-ghost .ghost-eyes,
.home-ghost .ghost-mouth {
  fill: #2b2540;
}
.home-ghost .ghost-blush {
  fill: #ff9fb8;
  opacity: 0.55;
}
.home-ghost .ghost-eyes {
  transform-box: fill-box;
  transform-origin: center;
}
/* where each one hides: behind the red tile on the right edge, the blue
   sun tile on the left, and the amber tile on top */
.ghost-right { left: 90%; top: 50%; --gx: 105%; --gy: 0%; --tilt: 14deg; --look: 2.4px; }
.ghost-left  { left: 10%; top: 70%; --gx: -105%; --gy: 0%; --tilt: -14deg; --look: -2.4px; }
.ghost-top   { left: 50%; top: 10%; --gx: 0%; --gy: -92%; --tilt: -6deg; --look: 1.8px; }

#home.halloween .home-ghost {
  animation: ghost-peek 9s ease-in-out infinite;
  animation-delay: var(--ghost-lead);
}
#home.halloween .ghost-left { animation-duration: 10.4s; animation-delay: calc(var(--ghost-lead) + 0.3s); }
#home.halloween .ghost-top { animation-duration: 11.8s; animation-delay: calc(var(--ghost-lead) + 0.6s); }
#home.halloween .home-ghost .ghost-eyes {
  animation: ghost-look 9s ease-in-out infinite;
  animation-delay: inherit;
  animation-duration: inherit;
}
@keyframes ghost-peek {
  /* out at once, so a ghost is already peeking the moment its loop starts */
  0%       { opacity: 0; transform: translate(0, 0) rotate(0); }
  1%       { opacity: 1; transform: translate(0, 0) rotate(0); }
  /* a slow, cautious first peek, then the rest of the way out */
  9%       { transform: translate(calc(var(--gx) * 0.4), calc(var(--gy) * 0.4)) rotate(var(--tilt)); }
  18%      { transform: translate(calc(var(--gx) * 0.4), calc(var(--gy) * 0.4)) rotate(var(--tilt)); }
  25%      { transform: translate(var(--gx), calc(var(--gy) - 4px)) rotate(calc(var(--tilt) * 0.3)); }
  32%      { transform: translate(var(--gx), calc(var(--gy) + 3px)) rotate(calc(var(--tilt) * -0.2)); }
  39%      { transform: translate(var(--gx), calc(var(--gy) - 4px)) rotate(calc(var(--tilt) * 0.3)); }
  46%      { transform: translate(var(--gx), calc(var(--gy) + 3px)) rotate(calc(var(--tilt) * -0.2)); }
  /* spotted: a quick duck back */
  52%      { opacity: 1; transform: translate(calc(var(--gx) * 1.08), calc(var(--gy) * 1.08 - 6px)) rotate(var(--tilt)); }
  58%      { opacity: 1; transform: translate(0, 0) rotate(0); }
  59%, 100% { opacity: 0; transform: translate(0, 0) rotate(0); }
}
@keyframes ghost-look {
  0%, 20%  { transform: translateX(var(--look)); }
  26%, 34% { transform: translateX(calc(var(--look) * -1)); }
  40%, 48% { transform: translateX(0) translateY(0.6px); }
  52%, 100% { transform: translateX(var(--look)); }
}
@media (prefers-reduced-motion: reduce) {
  #home.halloween .home-ghost,
  #home.halloween .home-ghost .ghost-eyes {
    animation: none;
  }
}

.home-play-label {
  position: relative;
  color: #fff;
  font-weight: 800;
  font-size: clamp(12px, 3.6vw, 17px);
  letter-spacing: 0.02em;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}

/* the ✕ — the same two plain bars as .cell .x, on a tile that isn't a
   real board cell, so it gets its own (identical) copy rather than
   depending on board-only selectors. */
.home-x {
  position: absolute;
  inset: 0;
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.45));
}

.home-x::before,
.home-x::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 60%;
  height: 12.5%;
  min-height: 2px;
  border-radius: 1px;
  background: var(--ink-out);
  transform: translate(-50%, -50%) rotate(45deg);
}

.home-x::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}

.home-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
  width: 100%;
  max-width: 26rem;
  /* Used to pull up into #home's own padding-bottom — worth doing when
     that padding was a thin 8px sliver mostly there for the safe-area
     inset, not to waste on non-notched phones. Now that padding is a
     deliberate 28px of breathing room (2026-09-12), pulling into it would
     just eat straight back into the room it was added for. */
  margin: 0 auto;
}

.home-action {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 2px;
  background: none;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  font: inherit;
}

.home-action:active {
  transform: scale(0.96);
}

/* Just the icon's own box — the pip anchors to this, not to the whole
   button, which is wider than the icon to fit the label underneath it. */
.home-action-icon {
  position: relative;
  display: inline-flex;
}

.home-action svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
}

.home-action span {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.02em;
}
