/* Motify: navy and gold, taken from the knight mark. One accent (gold) for anything interactive;
   green and red stay reserved for semantic state (a move was right, a move was wrong) and are not
   part of the brand palette. The board squares themselves are deliberately left alone: they are the
   familiar lichess colours and the pieces are drawn for them. That is true in both themes, so the
   board looks the same either way and only its surroundings change.

   Every colour below is a variable so the light theme is a second palette rather than a second
   stylesheet. A handful of them exist purely because a value that is "the dark one" in one theme is
   "the light one" in the other, `--on-gold` is the text on a gold button, and it must stay dark
   whichever theme is running, so it cannot just be `--bg`. */
:root {
  /* Graphite. The ground is a neutral rather than a colour: a warm near-black with a little of the
     accent mixed through it, so every grey on the page reads as chosen rather than inherited, and
     the board is the only saturated thing in view. It replaced an indigo that the board had to
     compete with on every screen. */
  --bg: #131316;
  --surface: #1b1b20;
  --surface-2: #26262d;
  --raised: #2c2c34;
  --raised-hover: #383841;
  --sunken: #0e0e11;
  --border: #33333c;

  /* The accent is `--ember`, the orange this app already owned, and it is spent on things you press
     and on nothing else. The tokens keep the name `--gold` because every rule in this file and in
     `ui.css` reads them by that name; renaming them would be a rename of four hundred call sites
     for no change on screen. */
  --gold: #e8551a;
  --gold-bright: #ff6b33;
  --gold-dim: #6d2a10;
  --ember: #e8551a;
  --on-gold: #1a0d06;
  /* Gold used as *ink* rather than as a fill.
   *
   * On a dark background these are the same colour and this token is redundant, which is exactly
   * why it did not exist. In light mode they cannot be: a gold bright enough to look like a gold
   * button is a gold that measures 2.4:1 as text on white, and every gold word on the light theme
   * was failing AA by more than half. Splitting them lets the button stay gold while the text goes
   * dark enough to read. Only `color:` was moved onto this; `border-color`, `background` and
   * `accent-color` still take `--gold`. */
  /* **Not `var(--gold)` any more, and that split is the whole point of the scheme.** In the old
     palette the accent and the figure colour were one value, so gold was the brand, the primary
     button, every number worth reading and the current item in the rail all at once; once one hue
     means "important" everywhere, nothing on the page can be more important than anything else and
     the only lever left is more of it. A paler sand reads as bright without claiming a button's
     weight. Measured on this theme: 9.00 on a card, 7.89 on `--surface-2`, 7.27 on `--raised`. */
  --gold-ink: #e8b17e;

  --text: #ecebe8;
  --text-bright: #ffffff;
  --text-dim: #a8a6a1;
  /* Was #6f6a96, which is the same failure the light theme's `--text-faint` carries a note about,
     on the same token, left standing here because only that theme was ever measured. It read 3.46
     on `--surface`, 3.04 on `--surface-2` and 3.80 on the page, under AA on all three, and this is
     the token behind every uppercase section label, caption and status line on the dashboard:
     "Last 30 days", "By motif", "Difficulty", "Games read", "Primers seen", "not connected". Those
     are the smallest type on the page as well as the faintest, which is the combination worth
     avoiding. #948fbe clears it: 5.75 on `--surface`, 5.04 on `--surface-2`, 6.31 on `--bg`.
     Measured with `contrast_audit.js`, which took the dashboard from 23 failures to 4. */
  /* Measured against the three surfaces it lands on: 5.66 on a card, 4.96 on `--surface-2`, 4.57 on
     `--raised`. The last is the one that had to be checked rather than assumed; it clears AA by
     0.07 and nothing fainter than this does. */
  --text-faint: #96948f;

  /* The *unfilled* part of a meter, the primer bar, the difficulty slider's track, the ring behind
   * the statistics arc, a day with nothing on it. It was `--surface-2` everywhere, which works here
   * and does not work at all in light mode: see the note on the light value. */
  --track: var(--surface-2);

  --good: #4bbf72;
  --good-bright: #7fe0a0;
  /* Green used as *ink* rather than as a fill, the same split `--gold-ink` above exists for and for
   * exactly the same reason. On this theme they are the same colour. On the light one they cannot
   * be: `--good` there is a green chosen to be a fill, and as a word it measures 4.44:1 on `--bg`
   * and 3.58:1 on a chip tinted with itself, both under AA. `background`, `border-color` and
   * `accent-color` still take `--good`. */
  --good-ink: var(--good);
  --bad: #e8615c;

  /* The dashboard's form guide: a won, drawn or lost game as a solid block with a letter on it.
   *
   * Four tokens of its own rather than `--good` and `--bad` directly, because the letter has to
   * clear AA against the fill and the two themes need it the opposite way round. Here the fills are
   * bright and the ink is near-black, measured 8.3:1 on the win, 5.9 on the loss, 8.0 on the draw;
   * on the light theme the fills go dark and the ink white, see the note there.
   *
   * Both halves are required. Shipping only the light ones left every pip on the dark theme with no
   * background at all, which `contrast_audit.js` reported as passing, because a transparent chip
   * measures its letter against the card behind it. An audit cannot catch a colour that never
   * applied. */
  --pip-win: var(--good);
  --pip-draw: var(--text-dim);
  --pip-loss: var(--bad);
  --pip-ink: #0b0818;

  /* One colour per time control, for the chips in the dashboard's tactic settings. Fastest to
   * slowest, which is also hot to cold. The only place in the app with a colour per value rather
   * than one accent, because these four are a set of parallel choices and gold could only say
   * "selected" four times.
   *
   * Two tokens each, for the reason `--gold-ink` above exists: the plain one is a fill and a
   * border, mixed down to 14% and 55%, where any hue works; the `-ink` one is the *word*, which
   * has to clear 4.5:1 on it. On this theme they are the same colour, as with gold. Measured on
   * the tinted chip against the worst surface a settings card sits on: 4.79 / 6.80 / 5.17 / 4.51.
   * The obvious orange, #f2762a, came out at 4.25 and was brightened until it passed.
   *
   * Note `--tc-rapid` is deliberately *not* `--good`. Green is reserved for semantic state, see the
   * header of this file, and a chip borrowing that token would tie the two together so that fixing
   * the contrast of one silently moved the other. */
  --tc-bullet: #f9873a;
  --tc-bullet-ink: var(--tc-bullet);
  --tc-blitz: #f2ca2b;
  --tc-blitz-ink: var(--tc-blitz);
  --tc-rapid: #4bc47a;
  --tc-rapid-ink: var(--tc-rapid);
  --tc-classical: #52a8f5;
  --tc-classical-ink: var(--tc-classical);

  /* The evaluation bar is two halves and a number that has to stay legible on each of them. */
  --eval-white: #ece9f5;
  --eval-white-text: #16132e;
  --eval-black: #221d55;
  --eval-black-text: #ece9f5;

  --shadow-board: 0 8px 24px rgba(0, 0, 0, 0.55);
  --shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.6);
}

/* Light theme. Same navy-and-gold identity, read the other way up: near-white surfaces, a deeper
   gold that still clears AA against them, and ink rather than parchment for text. */
:root[data-theme="light"] {
  /* The same scheme with the lights on: a warm off-white rather than a cool lilac, so the board's
     own timbers sit on it without a colour cast.

     **Two steps down from where it started, because it was too bright.** The ground was #f4f2ef
     under pure-white cards, which on a full-screen dashboard is most of a monitor at close to
     maximum luminance and reads as glare rather than as a light theme. The ground is #eceae4 now
     and the cards are #f8f6f3 rather than #ffffff: still unmistakably light, with the cards still
     clearly lifted off the page, but nothing on screen is pure white any more.

     It costs about a point of contrast everywhere, which is why every value below was re-measured
     rather than assumed to still pass. */
  --bg: #eceae4;
  --surface: #f8f6f3;
  --surface-2: #e0dcd5;
  --raised: #dad5cd;
  --raised-hover: #cec8be;
  --sunken: #e5e2db;
  --border: #d5d0c8;

  --gold: #e8551a;
  --gold-bright: #ff6b33;
  --gold-dim: #e0b7a3;
  --ember: #e8551a;
  /* Near-black on the ember fill, not white: measured 5.20 against 3.66. The fill is the same
     colour in both themes, so this is the same answer in both. */
  --on-gold: #1a0d06;
  /* Measured, not picked, and for the reason the old value carried: the ember that makes a good
     fill is nowhere near dark enough to be a word on white. #9c3b0c clears 4.5:1 on every surface
     in this theme, 6.90 on `--surface`, 5.59 on `--surface-2`, 5.25 on `--raised`, which is the
     upsell panel and the darkest thing accent text lands on. The button fills keep `--gold`.

     #93370b since the ground came down: the old #9c3b0c measured 4.72 on the new `--raised`, which
     passes and leaves nothing in hand. This reads 6.97 on a card, 5.50 on `--surface-2` and 5.15 on
     `--raised`. */
  --gold-ink: #93370b;

  --text: #1a1815;
  --text-bright: #000000;
  --text-dim: #56514a;
  /* The same care the old value needed, on the new hue: this is the token every label, caption and
     "not connected" line uses, and `--raised` is the darkest thing it lands on. Darkened with the
     ground: #635e56 fell to 4.40 on the new `--raised`, under AA, and #5a554d reads 6.85 on
     `--surface`, 6.14 on `--bg`, 5.41 on `--surface-2` and 5.06 on `--raised`. */
  --text-faint: #5a554d;

  /* Not `--surface-2` here. A meter's empty half was #e9e6f5 against a white card and against the
     #eceaf6 sunken panel the primer bar sits in, 1.06:1 against the latter, which is to say
     invisible. So the difficulty slider looked like a gold stub ending in nothing, and an empty
     primer bar looked like no bar at all rather than like a bar at zero. #a9a1c9 reads at 2.4:1 on
     white and 2.0:1 on the sunken panel: quiet, but unmistakably a track with a value in it. */
  --track: #a49d92;

  --good: #16803c;
  --good-bright: #1a9c49;
  /* Measured: 6.91:1 as a word on `--bg`, and 5.6:1 on a chip tinted 16% with `--good`, which is
   * the darkest thing green ink lands on. `--good` itself managed 4.44 and 3.58 on those two. */
  --good-ink: #0f5f2c;
  --bad: #c0332e;

  /* Dark fills and white ink, the reverse of the dark theme, because the bright greens and reds
     that carry a near-black letter on navy cannot carry one against white: measured 3.94 and 3.53
     there, both under AA. These clear it, 4.9 on the win, 6.6 on the loss, 7.0 on the draw. */
  --pip-win: #1f7a45;
  --pip-draw: #56514a;
  --pip-loss: #b02a24;
  --pip-ink: #ffffff;

  /* Split here, exactly as gold is: a hue bright enough to be a fill is not dark enough to be a
   * word on white. Yellow is what makes this compulsory rather than tidy. Worst case across every
   * surface a settings card sits on, `--raised` being the darkest: 4.67 / 4.91 / 5.38 / 4.99. */
  --tc-bullet: #c9510b;
  --tc-bullet-ink: #933c08;
  --tc-blitz: #d9a800;
  --tc-blitz-ink: #715500;
  --tc-rapid: #1a9c49;
  --tc-rapid-ink: #0f5c2c;
  --tc-classical: #2b7fd4;
  --tc-classical-ink: #175690;

  --eval-white: #ffffff;
  --eval-white-text: #1a1815;
  --eval-black: #4a463f;
  --eval-black-text: #f6f4f1;

  /* Neutral rather than tinted, like everything else here: a navy-cast shadow on a warm ground
     reads as a colour rather than as depth. */
  --shadow-board: 0 6px 20px rgba(38, 32, 24, 0.20);
  --shadow-pop: 0 8px 26px rgba(38, 32, 24, 0.24);
}

/* --- Moving between pages -----------------------------------------------------------------------
 *
 * Every rail item that is not a dashboard tab is a real navigation: Home to Train tears the document
 * down and builds another one, and nothing in the old page survives to animate. That is the whole
 * reason the rail's pill can only slide between the dashboard's own tabs on its own.
 *
 * This is the browser's answer to that. `navigation: auto` opts both documents into a cross-document
 * view transition, and the names below tell it which elements on the two pages are *the same thing*
 * rather than two things that happen to look alike. The rail, the mark and the bottom bar are
 * identical on every page, so naming them holds them still while the content under them changes;
 * the pill is named separately, which lifts it out of the rail's snapshot and lets it travel from
 * the item you left to the item you arrived on.
 *
 * Both documents have to opt in, which is free here: every page loads this file.
 *
 * Everything not named is captured as one `root` group and cross-fades, which is the difference
 * between arriving at a page and the page appearing. It costs one frame of held snapshot at the
 * start of the new document, and it degrades to a plain navigation in any browser without it.
 *
 * **It will not run to or from `/train` or `/analysis.html`, and that is not fixable here.** Those
 * two send `Cross-Origin-Opener-Policy: same-origin` because the in-tab Stockfish needs
 * `SharedArrayBuffer` and therefore cross-origin isolation, see `ISOLATED_PATHS` in server.py.
 * Navigating between a document with that header and one without swaps the browsing context group,
 * and a cross-document view transition requires both documents to be in the same one. So the six
 * ordinary pages transition between each other and the two isolated ones do not, which is worth
 * knowing before anybody spends an afternoon on why Home to Train looks different from Home to
 * Openings. Dropping the isolation would trade this for the local engine, which is not a trade.
 *
 * Verified as far as this machine allows: the rule parses with `navigation: auto`, the four names
 * below resolve and are unique per page, and the pill exists on the new document before its first
 * render. The animation itself was **not** watched: the browser here reports
 * `visibilityState: "hidden"`, and no browser runs a view transition on a document it is not
 * painting. */
@view-transition { navigation: auto; }

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  /* Anchored to the top on purpose: the side panel grows as feedback appears, and centring
     would re-centre the whole layout, making the board jump on every move. */
  align-items: flex-start;
  /* `safe`, for the same reason the landing page needs it on the other axis. Centring a child that
     is wider than the window pushes the overflow off *both* edges, and the left half is then
     unreachable: scrolling only ever reaches what is off the right. Measured on the analysis board
     in a 494px window, the page sat at -119px with no way to scroll to it, so the evaluation bar
     and the left third of the board were simply gone. `safe` centres while it fits and
     start-aligns when it does not, which is the behaviour that was wanted both times. */
  justify-content: safe center;
  background: var(--bg);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  color: var(--text);
}

.page {
  display: flex;
  gap: 24px;
  align-items: flex-start;
  padding: 24px;
  margin-top: min(5vh, 48px);
}

/* The board takes whatever the window has left once the side panel, the tool column and the page
   padding are accounted for, so it grows and shrinks with the window instead of sitting at a fixed
   size. Each page sets its own budget because each has a different amount of furniture.

   **Every number below is measured rather than estimated**, at 1440x900 with a run in progress, and
   the slack each one carries is stated. That is a change: the budgets used to carry 54px of
   horizontal and 61px of vertical slack that nobody could account for, and since height is what
   binds on essentially every desktop, all 61 of those came straight off the board.

   The vertical budget is the page's own margin-top (48px at its ceiling of `min(5vh, 48px)`), 48px
   of page padding, the board's rail (a 30px chip plus its 8px margin), and, on the trainer, the
   primer track (44px). The track appears and disappears mid-session, so its height has to be
   allowed for whether it is showing or not, otherwise starting a run pushes the board off the
   bottom. The trainer's rail is 26px rather than 38 because the turn chip has moved into the panel
   and what is left is one reserved line of prompt, so that is 166px; the budget is 184, giving the
   same 18px of slack. The analysis board
   has no track and no row over its board (its turn chip is in the panel), and trades them for what
   sits *under* the board, the source line and the FEN/PGN boxes, 8 + 16 + 8 + 109.5, for 237.5px
   against a budget of 244. It was 296, still allowing for that row, which left the board and its
   boxes 98px short of the bottom of a 990px window. With the rail, `ui.css` sets the budget again.

   The horizontal budget is the panel beside the board (220px in the trainer, 300px on the analysis
   board) and its 16px margin, 48px of page padding, the flip button's column (26px plus its 8px
   gap), and on the analysis board the evaluation bar as well, 34px plus another 8px. Leaving the
   bar out is what let the board grow into it and push the whole page sideways. 326px on the
   trainer against a budget of 352, and 448 against 476. The slack is smaller than the vertical
   slack and stays that way on purpose: `100vw` counts the scrollbar, so a horizontal budget that is
   exactly right produces a vertical bar which then produces a horizontal one, and ~15px of what is
   left is reserved against exactly that.

   **There is no ceiling any more, and the reason there was one has gone.** The board stopped at
   900px, which on anything 1440p or taller was the only thing still holding it back: a 2560x1440
   screen has ~1329px of viewport and drew the same 900px board as a laptop with half the pixels.
   The old comment justified the slack by saying that sizing the board to the leftover height made
   the page overflow the moment the side panel grew, and that is no longer true of this layout:
   `.side-panel` is capped at `--board-size` and `.side-panel-body` scrolls inside it, so a
   refutation several lines long can no longer push the page taller than the board. The panel being
   unable to grow the page is what makes the tight budget safe. */
.page { --board-size: max(260px, min(calc(100vw - 352px), calc(100svh - 184px))); }
.analysis-page { --board-size: max(260px, min(calc(100vw - 476px), calc(100svh - 244px))); }

.board-area {
  display: flex;
  align-items: flex-start;
  gap: 8px;
}

/* A grid rather than a stack, because everything here has to line up with the board and nothing
   else: the rail above it, the primer track under that, the evaluation bar beside it. Expressed as
   rows and columns that alignment is structural, an earlier version indented the primer track by
   the flip button's width plus the gap and landed 2px out, because those numbers are only correct
   until one of them changes.

   Both pages use it. Column 2 holds the analysis board's evaluation bar and is simply empty in the
   trainer, which is what lets one set of rules serve both, the analysis page previously let its
   rows auto-place into this grid and put the board and the status side by side, which is what threw
   the page into a horizontal scrollbar. */
/* Three columns: the tool rail, the board, and the evaluation bar. The middle one is the only one
   with a size, the outer two are as wide as what is in them, and the analysis board's bar column
   is simply empty in the trainer. Four rows: the turn chip, the primer track, the board, the
   caption.

   Everything is placed by name. Two separate bugs came out of not doing that: an early version
   indented the primer track by the flip button's width plus the gap and landed 2px out, because
   those numbers are only correct until one of them changes; and the analysis board let its rows
   auto-place, which put the board in column 1 and the status box in column 2 *beside* it, a page
   as wide as two boards with the left half off the screen. Anything added here needs its own
   `grid-column` and `grid-row`. */
.board-column {
  display: grid;
  grid-template-columns: auto var(--board-size) auto;
  align-items: start;
  column-gap: 8px;
}

/* The analysis board has one column more than the trainer: the evaluation bar and then the engine
   and notation panel, which is a child of this grid for the same reason the trainer's side panel is.
   That is what puts its top edge on the board's top edge rather than on the turn chip's. */
.analysis-page .board-column { grid-template-columns: auto var(--board-size) auto auto; }

/* Board controls live in a narrow column down the board's left edge, top-aligned, against the
   *board*, not against anything stacked above it. */
.board-tools {
  grid-column: 1;
  grid-row: 3;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* The board's top edge: what you are being asked. It is a property of the position directly
   underneath, which is why it is here and not in the side panel, the one place your eyes had to
   leave the board to read it.

   Whose move it is used to sit here too, on the same argument, and is now the first thing in the
   side panel instead, directly above the button. It has not left the board's top edge to get
   there: the panel starts in row 3, the board's own row, so the chip's top edge is still the
   board's. See the comment on it in index.html. */
.board-head {
  grid-column: 2;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  margin-bottom: 8px;
  /* One line of `.board-prompt`, reserved whether there is a prompt or not.

     The 30px chip used to hold this row open. Without it the row is sized by the prompt, and
     `showPuzzlePrompt` writes a prompt only for a forced mate: an ordinary tactic leaves it empty,
     the row collapses to nothing, and the board rises 18px. That is a board that moves between one
     position and the next depending on what kind of puzzle the next one is, which is the same
     complaint that moved the knight-tour text off the panel and onto the board. */
  min-height: 18px;
}

/* Sits against the board's right edge with the turn chip, and gives up its width first: the chip is
   the fixed thing, and a wrapped prompt would push the board down mid-puzzle. */
.board-prompt {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 13px;
  color: var(--text-dim);
}

.board-prompt a { color: var(--gold-ink); }

/* Where the board sits **inside `.board-column`**, and nowhere else.
 *
 * Scoped, and it has to be. Unscoped this said "every `.board-wrap` on any page that loads this
 * file belongs in the second column of the third row", and `grid-column` only does anything to a
 * direct child of a grid, so for a long time it did nothing anywhere but here. Then
 * `/tournaments/<id>` put a board in a two-column grid of its own and the board jumped to column
 * two with the panel in column one, in reverse order, overflowing a 308px column by 132px. It
 * reads as a broken stylesheet on the new page and the cause is four characters in this one.
 *
 * `>` rather than a descendant combinator for the same reason: the placement is a fact about this
 * element's relationship to that grid, not about the page it is on. */
.board-column > .board-wrap { grid-column: 2; grid-row: 3; }

/* The dissolve between one position and the next. `app.js` puts `board-swap` on, hands over while
   the board is a ghost of itself, and takes it off once the next position is up; see the comment on
   `BOARD_SWAP_MS` there, which is this duration and has to stay this duration.

   0.12 rather than 0 because this element has no background of its own: a true fade would show the
   page through a board-shaped hole for a fifth of a second, and the board is the one thing on this
   screen that never moves. Dimming to almost nothing says the same thing without taking the anchor
   away, which is the same trade `.board-wrap.loading`'s `filter` makes.

   On the whole wrap and not on the pieces, because between two positions the last-move highlight
   moves, the glyph goes, and the board flips outright whenever the next position is the other
   colour, taking the coordinates and the square colours with it. Pieces fading over a checkerboard
   that snaps is worse than no fade at all. */
.board-wrap { transition: opacity 180ms ease; }
.board-wrap.board-swap { opacity: 0.12; }

/* Before the first puzzle arrives. The squares stay, they are the shape of the page and removing
   them would make the layout jump, but the pieces are hidden, because the ones sitting there are
   the opening position the board was built with and not anything the solver asked to see. */
.board-wrap.loading .piece,
.board-wrap.loading .arrows-layer,
.board-wrap.loading .glyph-layer { visibility: hidden; }

.board-wrap.loading .board { filter: saturate(0.45) brightness(0.92); }

/* The knight overlay is a wait as well, over the same square, so without this the two sentences
   land on top of each other in the middle of the board. That is what "there are two loading texts"
   was. `waiting` is set by `showKnightTour` and cleared by the disposer it returns, so the label
   below is only ever the wait that has nothing better to say. Everything else `loading` does, the
   dimmed squares and the hidden opening array, stays: the knight sits over a board that is still
   visibly not ready. */
.board-wrap.loading.waiting::after { display: none; }

/* Nothing to load, as opposed to something still loading. The veil and the hidden opening array
   stay, because there is still no position to show, but the sentence goes: the side panel is
   already explaining why the set is empty, and "Finding your first tactic" over the top of it is a
   second sentence contradicting the first. Set by `advancePuzzle` and only when no scan is
   running, since during one the label is telling the truth. */
.board-wrap.loading.no-puzzle::after { content: ''; }

.board-wrap.loading::after {
  content: 'Finding your first tactic…';
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  border-radius: 4px;
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  color: var(--text);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* While Stockfish is mid-thought and the board is refusing input.

   It used to refuse it in silence: `canMove` returned false and clicks were dropped, with nothing
   anywhere to say why. A second of that reads as lag and three seconds reads as a broken page, and
   the second is what a multipv-2 search costs on a heavy position in this tab.

   Deliberately not a dim, a blur or an overlay. The solver is calculating, and the position is the
   one thing on this page that must not get harder to read while they wait. A cursor that says
   "working" and a hairline travelling along one edge of the board are enough to tell the difference
   between thinking and broken, and neither touches a square.

   The **bottom** edge, and that is the second half of the same bug report. The top edge sits 7px
   under the primer rail, which is itself a row of thin bars with a gold one in it, so a gold
   hairline there does not read as the board's own and was taken for the rail's shine coming loose.
   The bottom edge has nothing near it. */
.board-wrap.thinking,
.board-wrap.thinking .piece { cursor: progress; }

/* The travel is a moving **background**, not a moving element, and that is the fix rather than the
   tidy way to write it. Written as a 34%-wide box slid with `translateX`, the highlight leaves the
   board: `.board-wrap` is `position: relative` with no `overflow: hidden`, so at the start of each
   cycle the strip hung ~100px off the left edge and at the end it ran out over the side panel. On
   screen it reads as a streak detaching from the primer rail sitting directly above, which is how
   it was reported. Percentage `background-position` is clipped to the element's own box by
   definition, so there is nothing to escape from, and it is the same technique `seg-glisten` uses
   a few hundred lines down.

   `overflow: hidden` on `.board-wrap` would also have contained it, and is the wrong fix: the
   promotion picker and the drag ghost are children of that box and are meant to be able to leave
   it. */
.board-wrap.thinking::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 2px;
  background-image: linear-gradient(90deg, transparent, var(--gold), transparent);
  background-repeat: no-repeat;
  background-size: 34% 100%;
  animation: board-thinking 1.25s ease-in-out infinite;
  pointer-events: none;
  z-index: 3;
}

/* `background-position` percentages align the image inside the box rather than offsetting from an
   edge, so 0% is flush left and 100% is flush right whatever the board's width. Going a little
   beyond both is what lets the highlight arrive and leave rather than appearing and vanishing. */
@keyframes board-thinking {
  from { background-position: -25% 0; }
  to { background-position: 125% 0; }
}

/* The bar is motion for its own sake to anyone who has asked for less of it, and the cursor already
   carries the message, so only the travel goes. */
@media (prefers-reduced-motion: reduce) {
  .board-wrap.thinking::before {
    animation: none;
    background-image: linear-gradient(90deg, var(--gold), var(--gold));
    background-size: 100% 100%;
    opacity: 0.4;
  }
}

/* Stretched to the board's own height by the row, rather than given a length of its own: a bar that
   has to be measured against the board is a bar that goes wrong the moment the board resizes. */
.eval-bar { grid-column: 3; grid-row: 3; align-self: stretch; }

.board-source {
  grid-column: 2;
  grid-row: 4;
  margin-top: 8px;
  font-size: 12.5px;
  color: var(--text-dim);
}

/* The FEN and PGN boxes, under the board and as wide as it. They describe the position, so they
   belong with the position rather than in the column of things that analyse it, which is also
   where every other board on the internet puts them. Row 5, explicitly, like everything else on
   this grid: auto-placement is what once put the board and the status side by side. */
.board-io {
  grid-column: 2;
  grid-row: 5;
  display: flex;
  flex-direction: column;
  gap: 5px;
  margin-top: 8px;
}

/* `stretch` rather than `center`, which is what makes Copy and Load exactly as tall as the box
   they sit beside. They were 11.5px text in 5px of padding against an 11px monospace box in 5px of
   padding plus a border, so the two came out a couple of pixels apart: near enough to look like a
   mistake and not near enough to look deliberate. Sized by the row, they cannot drift again when
   either font changes. */
.board-io-row {
  display: flex;
  align-items: stretch;
  gap: 6px;
}

/* Stretching the row would otherwise stretch the label with it and hang its text off the top. */
.board-io-label { display: flex; align-items: center; }

/* The height now comes from the row, so the vertical padding must not fight it.

   Which height that is differs by pointer, and deliberately so. Under a cursor the row is sized by
   the box, so both come out at 25px and the button has come *down* to meet it, which is the way
   round that was asked for. Under a finger the `pointer: coarse` rule in `ui.css` puts a 44px floor
   under the button, so the row is 44 and the box grows to meet *it*: the touch target is not given
   up to win a pixel argument, and a taller box is the better end of the bargain on a phone anyway,
   since pasting a FEN into it is a tap as well. Either way they match, which is the whole point. */
.board-io-row .btn-tiny { padding-top: 0; padding-bottom: 0; }

.board-io-label {
  flex: none;
  width: 30px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* Monospace, because a FEN is a string you compare character by character, and editable, so the
   box that shows you the position is also the box you paste one into. */
.board-io-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 5px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--sunken);
  color: var(--text-dim);
  font-family: "Cascadia Mono", Consolas, monospace;
  font-size: 11px;
}

.board-io-input:focus {
  outline: none;
  border-color: var(--gold);
  color: var(--text);
}

.board-io-note {
  margin: 0;
  min-height: 1em;
  font-size: 11.5px;
  color: var(--text-dim);
}

/* The board itself, squares, pieces, marks, the promotion picker, is in board.css, beside the
   board.js that writes those classes. Every page with a board loads it. */

/* Column 3 of `.board-column`, on the board's own row, so the panel's top edge and the board's top
   edge are the same line, and `max-height` makes its bottom edge the board's too. The trainer's
   third column is otherwise empty; the analysis board's holds the evaluation bar.

   The margin is the gap. `column-gap` on that grid is 8px because it also spaces the flip-button
   rail and the evaluation bar, both of which sit tight against the board, and the panel wants the
   24px `.page` used to give it. 16px + the 8px gap is that. */
.side-panel {
  grid-column: 3;
  grid-row: 3;
  width: 220px;
  margin-left: 16px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* `.board-column` is `align-items: start`, which is what puts the panel's top edge on the board's
     rather than centring it in the row. That also shrink-wraps it, and a shrink-wrapped column has
     no slack in it: `margin-top: auto` on `.last-links` resolved to nought and the links sat
     directly under the controls instead of at the foot of the board. Stretching pins both edges to
     row 3, which *is* the board's row, so the top edge does not move and the bottom one arrives. */
  align-self: stretch;
  max-height: var(--board-size);
}

/* Whose move it is belongs to the board, not to the controls, so it is pinned outside the scroll
   area. Inside it, it was squeezed and clipped by the scrollbar as soon as a scan added rows
   below it, and it scrolled away from the board it describes. */
.side-panel-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* Take the height the panel now has, rather than only as much as the controls need. This is the
     other half of `align-self: stretch` above: stretching the panel is no use while the box inside
     it that actually holds the children goes on shrink-wrapping. With `min-height: 0` below, this
     is the ordinary scrollable-flex-child pair and the overflow behaviour is unchanged. */
  flex: 1 1 auto;
  /* Feedback text can run long. Let the panel scroll on its own rather than growing the page,
     which would move the board and bring the scrollbars back. */
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  /* Reserve the gutter permanently: without it the controls jump sideways the moment enough
     feedback arrives to need a scrollbar. */
  scrollbar-gutter: stable;
}

/* Everything in the panel keeps its natural height; the panel scrolls instead of compressing its
   children, which is what distorted the status box and the buttons. */
.side-panel-body > * { flex: none; }

/* Whose move it is, at the board's top-right corner, mirroring the flip button opposite. It used to
   be a full-width 48px panel header, which is a lot of furniture for four words and put the one
   thing that describes the position in the column of things that do not.

   The dot carries the actual answer, a white or black disc, read at a glance without the sentence
   being parsed, and `data-turn` is set by board.js from the same render that writes the text, so
   the two cannot disagree. Fixed height, because the text changes length ("White to move" against
   "Checkmate, Black wins") and the board must not shift when it does. */
/* The two things that sit at the board's top-right, the turn chip and, during a run, the "Skip to
   my tactic" button directly under it, share a width. They are stacked one above the other against
   the same edge, and two pills of *almost* the same length reads as a mistake rather than as two
   different controls. Wide enough for the longest thing either has to say, which is
   "Checkmate, Black wins". */
:root { --rail-pill-width: 152px; }

/* In the trainer's panel it is as wide as the panel's content, because what it now sits above is a
   full-width primary button and two pills of almost the same length read as one of them being
   wrong. `--rail-pill-width` still governs it at a board's top-right, which is where the analysis
   board keeps its own.

   Sticky, so that the one thing in this column that is a fact about the *position* stays put while
   the things that are commentary on it scroll. A refutation runs several lines and the panel is
   only ever as tall as the board, so without this the turn indicator scrolls off the top while the
   position it names is still on the board, which is the objection that had it pinned outside this
   box in the first place. It is opaque and bordered already, so there is nothing to see through it
   as the explanation passes underneath. */
.side-panel-body > .turn-chip {
  position: sticky;
  top: 0;
  z-index: 1;
  width: 100%;
}

.turn-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: var(--rail-pill-width);
  height: 30px;
  padding: 0 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
}

.turn-chip::before {
  content: '';
  width: 11px;
  height: 11px;
  flex: none;
  border-radius: 50%;
  background: var(--eval-white);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, #000 25%, transparent);
  transition: background 160ms ease;
}

.turn-chip[data-turn="b"]::before {
  background: var(--eval-black);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, #fff 30%, transparent);
}

/* The game is over: there is no side to move, so the disc would be claiming something false. */
.turn-chip[data-turn="over"] {
  border-color: color-mix(in srgb, var(--gold) 45%, transparent);
  color: var(--gold-ink);
}

.turn-chip[data-turn="over"]::before { display: none; }

/* How hard the primer just solved was. Shown only after it is solved, see revealPrimerRating. */
.primer-rating {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 999px;
  background: var(--sunken);
  color: var(--text-dim);
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
}

/* `hidden` means hidden, whatever else an element is styled as.
   The attribute works by way of a UA rule of exactly one class's weight, so *any* class that sets
   `display` silently defeats it, and every component here that sets one has: `.btn` is
   `inline-flex` below, which is why a Disconnect button carrying `hidden` sat in the signup form
   next to Connect in every state, permanently. The pattern is visible in this stylesheet as a trail
   of one-off repairs, `.settings-panel[hidden]`, `.auth-error[hidden]`, `.auth-divider[hidden]`,
   `.verify-steps[hidden]`, `.upsell[hidden]`, each written the next time somebody was bitten.
   This is that rule once, for everything, so the next `hidden` added to a styled element does what
   whoever wrote it expected. `!important` is the point rather than a shortcut: it has to outrank a
   component's own `display`, which is the whole problem. */
[hidden] { display: none !important; }

/* Buttons, including the ones that are links. Several things here only exist for the `<a class="btn">`
   case: an anchor is inline and underlined by default, so without these a link styled as a button is
   a button with a line under its label and padding that does not push anything out of the way. It
   is set on `.btn` rather than on each such link because the next one added would need it too. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--raised);
  color: var(--text);
  border: none;
  border-radius: 6px;
  padding: 10px 14px;
  font-size: 14px;
  font-weight: 600;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
}

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

/* Gold is the one accent, so it is spent on the two buttons that actually start something. Dark
   navy text on gold clears WCAG AA comfortably, in both themes, which is why this is `--on-gold`
   and not `--bg`. */
.btn-primary {
  background: var(--gold);
  color: var(--on-gold);
}

.btn-primary:hover { background: var(--gold-bright); }

/* Hover is a state a finger cannot leave. Tapping a button on iOS applies `:hover` and holds it
   until something else is tapped, so the last button pressed stays lit as though it were still
   under a cursor, and on the trainer that is usually the one button on screen. Neutralised rather
   than rewritten: the two rules above are correct wherever there is a pointer that can hover, and
   `hover: none` is the only place they are wrong. */
@media (hover: none) {
  .btn:hover { background: var(--raised); }
  .btn-primary:hover { background: var(--gold); }
}

.divider {
  border: none;
  border-top: 1px solid var(--raised);
  width: 100%;
  margin: 4px 0;
}

.puzzle-info {
  font-size: 13px;
  color: var(--text-dim);
  line-height: 1.4;
}

/* Which opening is being trained, when the set has been narrowed to one. First in the panel,
   because it is the frame around everything under it: a set that runs out after three tactics
   reads as a bug until you can see it was three Sicilians rather than three tactics.

   The way out is part of the banner rather than a separate control. A filter you can see and
   cannot leave is the shape of "the app is stuck on one opening", and the openings page is a
   navigation away while `/train` is one click. */
.puzzle-scope {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 2px 10px;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--gold);
  border-radius: 6px;
  background: var(--surface-2);
  font-size: 13px;
}

.puzzle-scope.hidden { display: none; }

.puzzle-scope-name {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-weight: 600;
  color: var(--text);
}

.puzzle-scope-out {
  flex: none;
  font-size: 12px;
  color: var(--gold-ink);
}

/* The variation the platform recorded for this game, on its own line under the family.
 *
 * It is the answer to "why is this filed under the Ruy Lopez when it was a Philidor": the families
 * are ECO ranges, and `Ruy Lopez Opening Old Steinitz Defense` is C62 however much the structure
 * resembles something else. Without it the family name arrives as an assertion nobody can check. */
.puzzle-scope-variation {
  flex-basis: 100%;
  /* Two lines, not one. Clipped to a single line it came out as "Ruy Lopez Opening Old Steinitz
     Defe…" in a 186px panel, which is the half that says Ruy Lopez and none of the half that
     explains it. Two lines carry the variation's name on every real example measured; the move
     fragments chess.com tacks on are what gets cut, and the full string is on the `title`. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  font-size: 11.5px;
  line-height: 1.35;
  color: var(--text-dim);
}

/* An empty element in this panel is not free: it still costs the 12px column gap on each side of
   it, and on a good day four of the seven children have nothing in them. That is where the odd
   space between the button and "The board is back where you went wrong" came from, three empty
   boxes stacked between them, contributing 36px of gap and not one pixel of content.
   `.puzzle-result` keeps its `min-height` for when it *does* have something to say, which is what
   stops the panel twitching as a verdict is written and cleared. */
.side-panel-body > .puzzle-info:empty,
.side-panel-body > .puzzle-result:empty,
.side-panel-body > .puzzle-explain:empty,
.side-panel-body > .puzzle-played:empty { display: none; }

.puzzle-result {
  font-size: 14px;
  font-weight: 700;
  min-height: 20px;
  line-height: 1.4;
}

/* A rating glyph inline in a sentence: the board's badge, shrunk to the line.
   Same duotone as `.move-glyph`, a white shape on a coloured disc with a darker ring, because
   this is the same mark as the one on the square and it should not have to be recognised twice. */
/* Sized against the line, not against the font. At 1.35em the badge was 19px inside a 19.6px line
   box and sat 0.32em below the baseline, so it was taller than the words beside it and dropped
   below them, on a wrapped verdict it collided with the second line. 1.05em clears the cap height
   of the text without exceeding the leading.

   `vertical-align: middle` rather than a hand-tuned offset. The offset was -0.2em, and a hand-tuned
   offset is only correct for one font at one size: the badge is 1.05em tall, so centring it on the
   lower-case body needs half the difference between its height and the x-height, which is a number
   that moves with the typeface. `middle` asks the browser for exactly that, against the x-height it
   actually measured, and it stays right at every size the badge appears at. It was visibly high
   beside "Best move." at 14px, which is where this came from. */
.glyph-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05em;
  height: 1.05em;
  margin-right: 0.2em;
  vertical-align: middle;
  border-radius: 50%;
  background: var(--glyph-face);
  box-shadow: inset 0 0 0 1.25px var(--glyph-ring);
}

.glyph-badge svg {
  display: block;
  width: 62%;
  height: 62%;
  fill: #fff;
  shape-rendering: geometricPrecision;
}

/* Keyed off the glyph rather than a verdict class, because the caller only has a sentence. It comes
   to the same thing: a glyph belongs to exactly one rating, which is why the board can colour its
   badge from the verdict and this can colour the identical badge from the character. */
.glyph-badge[data-glyph="★"] { --glyph-face: #3fae5f; --glyph-ring: #1e7a3b; }
.glyph-badge[data-glyph="\1F44D"] { --glyph-face: #8ed07c; --glyph-ring: #5da54c; }
.glyph-badge[data-glyph="✓"] { --glyph-face: #6f9a72; --glyph-ring: #4b7150; }
.glyph-badge[data-glyph="✗"] { --glyph-face: #ea8d88; --glyph-ring: #bb5b56; }

.puzzle-result.correct { color: var(--good); }
.puzzle-result.incorrect { color: var(--bad); }
.puzzle-result.thinking { color: var(--text-dim); font-weight: 600; }

.puzzle-explain {
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
}

.puzzle-explain .line {
  display: block;
  margin-top: 4px;
  color: var(--text);
  font-family: "Segoe UI Semibold", "Segoe UI", sans-serif;
  word-break: break-word;
}

.puzzle-explain .eval {
  display: inline-block;
  margin-top: 4px;
  padding: 1px 6px;
  border-radius: 4px;
  background: var(--sunken);
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

.puzzle-played {
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--gold-ink);
  border-left: 2px solid var(--gold-dim);
  padding-left: 8px;
}

.puzzle-played:empty { display: none; }

.puzzle-actions { display: flex; flex-direction: column; gap: 8px; }
.puzzle-actions.hidden { display: none; }
/* A column, so each of the three is the width the primary button above them used to be. Across a
   row they were 64px each in a 210px panel, which fits "Hint" and breaks "Show solution" over two
   lines, and it was only ever a row because there was a full-width button above it setting the
   panel's rhythm. With that gone these are the controls, and they take its width. */
.puzzle-actions-row { display: flex; flex-direction: column; gap: 8px; }

/* Once the position is over, the only one of the three that still means anything is the way out of
   it. A hint about a position you have already answered is not a smaller hint, it is a control that
   should not be there, and `showLastPosition` used to hide the whole row for exactly that reason,
   back when the primary button was left holding the way forward. It is not there any more, so the
   row stays and loses the two that no longer apply. */
.puzzle-actions.resolved #hint-btn,
.puzzle-actions.resolved #solution-btn { display: none; }

/* The pause before the next position, drawn along the one control that would otherwise be pressed
   to cut it short. See "the pause, drawn along the button" in `app.js`: the line is what makes the
   wait read as a handover already under way rather than as the trainer waiting to be told to move,
   which is what a still screen reads as and is how a press ended up landing on the position after
   the one it was aimed at.

   `transform` and not `width`, so the whole of the fill is composited and a five-second linear
   sweep costs no layout on a page that has an engine running in it. The duration arrives as
   `--advance-ms` because it is the script that knows which pause this is: 1.4s after a primer,
   2.2s after your own tactic, and 2.2s more on top of either if the answer was played out rather
   than found.

   The rule is on the button at all times so that the line has a resting state to travel from; at
   `scaleX(0)` there is nothing to see, and only `.advancing` carries a transition, so taking the
   class off snaps it back rather than unwinding it. */
#giveup-btn {
  position: relative;
  /* Clips the line to the button's own corners. The focus ring is an `outline` and is drawn
     outside the box, so it is not clipped by this. */
  overflow: hidden;
}

#giveup-btn::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  /* The graphic gold, not `--gold-ink`: this is a drawn line and not text, so it takes the same
     value as every other gold mark on the board rather than the text-contrast variant. */
  background: var(--gold);
  transform: scaleX(0);
  transform-origin: left center;
}

#giveup-btn.advancing::after {
  transform: scaleX(1);
  transition: transform var(--advance-ms, 0ms) linear;
}

.btn-small { padding: 8px 10px; font-size: 13px; }
.btn:disabled { opacity: 0.55; cursor: default; }

/* Disabled controls, drawn rather than faded, light theme only.
 *
 * `opacity` composites the whole element toward whatever is behind it, which on a dark page pushes
 * a gold button *down* into the background and still leaves dark text on a mid-gold fill. On a
 * light page it pushes everything *up* into the white, and the fill and the label fade together,
 * so the contrast between them collapses rather than the button merely looking quieter. Measured:
 * the primary button's own label against its own fill fell to 2.47:1, worse than any text on the
 * page, on the one control whose whole job is to be read before it is pressed. Raising the opacity
 * does not fix it either; 0.72 only reached 3.78.
 *
 * So in light mode a disabled button gets a flat muted fill and readable ink, 5.7:1, and loses
 * its shadow, which is what actually said "pressable". Dark mode keeps the fade, where it works.
 */
:root[data-theme="light"] .btn:disabled {
  opacity: 1;
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-dim);
  box-shadow: none;
}

/* Shown while a call to the engine is being retried. Fixed and centred at the top so it is seen
   without costing the board a single pixel of layout, the side panel is already the taller column,
   and anything inserted into the flow shoves the board down mid-puzzle. */
.connection-banner {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  padding: 8px 14px;
  border: 1px solid var(--gold-dim);
  border-radius: 999px;
  background: var(--surface);
  box-shadow: var(--shadow-pop);
  color: var(--gold-ink);
  font-size: 12.5px;
  font-weight: 600;
}

.connection-banner.hidden { display: none; }

/* --- A trophy arriving ---------------------------------------------------------------------------
 *
 * Top centre, where the connection banner is, and above it in the stack because the two cannot both
 * be the thing to read: a retry is an apology and a trophy is a reward, and if a retry is on screen
 * the reward can wait its turn underneath. `pointer-events: none` on the layer, because nothing in
 * here is pressable and a fixed strip over the top of the board that eats clicks is the sort of bug
 * that only shows up on the one puzzle where the answer is on the top rank. */
.trophy-toasts {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 41;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  pointer-events: none;
}

/* Starts above itself and transparent; `is-in` brings it down. Transformed rather than animated on
   `top`, so the browser can do it on the compositor and it cannot fight the layout of a page that
   is mid-way through animating a piece. */
.trophy-toast {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: baseline;
  gap: 2px 9px;
  max-width: min(360px, 92vw);
  padding: 9px 15px;
  border: 1px solid var(--gold-dim);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: var(--shadow-pop);
  opacity: 0;
  transform: translateY(-10px);
  transition: opacity 260ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}

.trophy-toast.is-in { opacity: 1; transform: none; }

/* The word "Trophy" as a rubric down the left, so the name beside it does not have to explain
   itself. Uppercase and small, the same treatment `.launch-label` gets on the dashboard. */
.trophy-toast-label {
  grid-row: 1 / 3;
  align-self: center;
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--gold-ink);
}

.trophy-toast-name {
  font-size: 13.5px;
  font-weight: 700;
  color: var(--text-bright);
}

/* What it took, under the name. It is one line and it is the half that makes the name mean
   anything: "Unbroken" says nothing, "twenty-five in a row" says what just happened. */
.trophy-toast-blurb {
  font-size: 11.5px;
  line-height: 1.3;
  color: var(--text-dim);
}

/* Gold is the end of a ladder, one per group, so it is the one
   tier that gets a lit border. Bronze and silver keep the plain one: three shades of toast for
   three tiers would be three things to learn for a strip that is on screen for five seconds. */
.trophy-toast.is-gold {
  border-color: var(--gold);
  box-shadow: var(--shadow-pop), 0 0 0 3px color-mix(in srgb, var(--gold) 15%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  /* Still fades, does not move. The toast has to appear from somewhere, and a strip that pops into
     existence with no transition at all is harder to notice than one that does not slide. */
  .trophy-toast { transform: none; transition: opacity 260ms ease; }
}

/* Links inside the side panel's prose, "connect an account", "log in", which otherwise inherit
   the browser's blue and read as if they came from a different site. */
/* The knight touring a 3x3 board while a tactic's primers are looked up. Built by `showKnightTour`
   in `app.js`, and see the comment there for why the cycle is the one it is.

   The squares alternate by `:nth-child` parity, which is a proper checkerboard only because the
   board is an odd number of squares wide. On a 3x3, index parity and colour agree; on a 4x4 every
   row would come out the same colour, so this trick does not generalise and is not meant to. */
/* The wait sits over the board rather than in the panel. See the note at the call site: in the
   panel it was directly above the primary button and moved it 30px each way. `z-index: 60` clears
   the arrow layer at 40 and the glyph layer at 45; the drag ghost at 100 is parented elsewhere and
   is not in this stacking context anyway. */
.knight-wait {
  position: absolute;
  inset: 0;
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 20px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--bg) 74%, transparent);
  text-align: center;
}

.knight-tour-text {
  max-width: 30ch;
  font-size: 13px;
  line-height: 1.4;
  color: var(--text-dim);
}

.knight-tour {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 16px);
  grid-template-rows: repeat(3, 16px);
  border-radius: 3px;
  overflow: hidden;
}

.knight-tour i:nth-child(odd) { background: var(--sq-light, #b58863); }
.knight-tour i:nth-child(even) { background: var(--sq-dark, #7a5230); }

/* Two animations on one element, which works because they drive different properties: the tour
   moves `left`/`top` between squares, the hop lifts `transform`. They are separate because they run
   at different rates. The tour is one 4s loop of eight moves; the hop is a 500ms loop that simply
   repeats eight times inside it. 4000 divides by 500 exactly, so the two never drift apart, and
   expressing the lift as its own short loop avoids writing the same arc into the long one eight
   times. */
.knight-tour-knight {
  position: absolute;
  width: 16px;
  height: 16px;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 90%;
  animation:
    knight-tour 4s ease-in-out infinite,
    knight-hop 500ms ease-in-out infinite;
}

/* The eight perimeter squares of a 3x3, in knight-move order, ending where it started. Each pair is
   a legal (1,2) or (2,1) jump; the centre never appears because a knight on a 3x3 cannot reach it.

   Each square is held rather than slid through, which is what the doubled percentages are for: the
   knight sits on a square from the moment it lands until 8%, then takes the next 4.5% travelling.
   At 4s that is 320ms still and 180ms moving, so it reads as a piece being played rather than a
   sprite orbiting a box. The first version had `steps(1, end)` and no travel at all. */
@keyframes knight-tour {
  0%,     8%    { left: 0;    top: 0; }
  12.5%,  20.5% { left: 32px; top: 16px; }
  25%,    33%   { left: 0;    top: 32px; }
  37.5%,  45.5% { left: 16px; top: 0; }
  50%,    58%   { left: 32px; top: 32px; }
  62.5%,  70.5% { left: 0;    top: 16px; }
  75%,    83%   { left: 32px; top: 0; }
  87.5%,  95.5% { left: 16px; top: 32px; }
  100%          { left: 0;    top: 0; }
}

/* The lift, over the 180ms the tour above spends travelling. 64% of 500ms is 320ms, which is the
   instant the tour starts moving, so the knight is on the ground for exactly as long as it is on a
   square. The peak is at 80% rather than the midpoint because a piece picked up and put down lands
   sooner than it rose; the slight scale is the piece being nearer the eye at the top of the arc. */
@keyframes knight-hop {
  0%, 64% { transform: translateY(0) scale(1); }
  80%     { transform: translateY(-4px) scale(1.08); }
  100%    { transform: translateY(0) scale(1); }
}

.puzzle-info a {
  color: var(--gold-ink);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* The border is not decoration: on the light theme a sunken field and the page behind it are close
   enough in value that without an outline the input has no edge at all. */
.username-input {
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--sunken);
  color: var(--text);
  font-size: 14px;
}

.username-input:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 1px;
}

.username-input::placeholder { color: var(--text-faint); }

.threshold-label {
  font-size: 12px;
  color: var(--text-dim);
}

.scan-progress { display: flex; flex-direction: column; gap: 5px; }
.scan-progress.hidden { display: none; }

.scan-bar {
  position: relative;
  height: 6px;
  border-radius: 3px;
  /* `--track`, not `--sunken`: in light mode `--sunken` is barely off-white, so a scan at 4% was a
     thin coloured stub floating in nothing rather than a bar mostly still to go. */
  background: var(--track);
  overflow: hidden;
}

.scan-bar-fill {
  height: 100%;
  width: 0;
  border-radius: 3px;
  background: linear-gradient(90deg, var(--good), var(--gold));
  transition: width 300ms ease-out;
}

/* Before the game count is known there's nothing to measure, so sweep instead of sitting at zero. */
.scan-bar.indeterminate .scan-bar-fill {
  width: 35%;
  animation: scan-sweep 1.1s ease-in-out infinite;
}

@keyframes scan-sweep {
  0% { margin-left: -35%; }
  100% { margin-left: 100%; }
}

.scan-bar.done .scan-bar-fill { background: linear-gradient(90deg, var(--raised), var(--raised-hover)); }

.scan-progress-label {
  font-size: 11.5px;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* --- Icon button (flip) ------------------------------------------------------------------- */

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: var(--raised);
  color: var(--text);
  cursor: pointer;
}

.icon-btn:hover { background: var(--raised-hover); }
.icon-btn svg { width: 15px; height: 15px; display: block; }

.btn:focus-visible,
.icon-btn:focus-visible,
.notation-move:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
}

/* --- Trainer: cutting a refutation short --------------------------------------------------- */

/* The one control on screen while a refutation is playing out, and it is at the foot of the panel
   because the row it would otherwise join is deliberately not there: `handleWrongMove` hides
   `.puzzle-actions` for the duration, so a lone button appearing in that space would read as the
   row coming back rather than as something new.

   `margin-top: auto` for the same reason `.last-links` has it, and it takes the slack *from* it:
   two auto margins in one flex column split the free space and would leave a gap between two
   things that belong together. So while this is up it is the one pinned to the bottom and the
   links follow directly under it, which is the rule below. */
.refutation-retry { margin-top: auto; }
.refutation-retry.hidden { display: none; }
.refutation-retry .btn { width: 100%; }

/* The links stop claiming the slack, but keep every pixel of their own separation. That 12px of
   padding plus the panel's 12px gap is the guard against a press meant for the button above
   landing on a link that opens a new tab, and here it is needed more rather than less: this is the
   one button in the panel that is pressed in a hurry, at a board that is still moving. */
.refutation-retry:not(.hidden) + .last-links { margin-top: 0; }

/* --- Trainer: the position you have just left --------------------------------------------- */

/* Stacked rather than side by side: at the panel's width these labels wrap onto two lines when
   they share a row, and a two-line button reads as broken.

   `margin-top: auto` is what puts it at the foot of the panel rather than directly under the
   verdict, and it is the reason this is the last child of `.side-panel-body`. The panel is a flex
   column capped at `--board-size`, so the auto margin eats the slack and the links land level with
   the bottom edge of the board. Below 900px the panel stacks under the board with `max-height:
   none`, the slack is nought, the auto margin resolves to nought with it, and the links simply
   follow the rest of the panel. That is the right answer in both layouts and it needs no second
   rule to get there.

   The gap above is larger than the panel's own 12px on purpose: these two open a new tab, and the
   primary button is directly above them. Distance is the guard against a press meant for Next
   landing on a link out of the page. */
.last-links {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: auto;
  padding-top: 12px;
}

/* Which position these are about, because they now sit under a board showing a different one.
   Small and dim: it is a caption on two controls, not a heading in the panel's own hierarchy. */
.last-links-label {
  margin: 0;
  font-size: 0.75rem;
  line-height: 1.3;
  color: var(--muted);
  /* Two lines, then clipped. One line does not fit what this has to say: "Last tactic:
     Matsumoto77777 vs daniil move 46" is 44 characters against about 35 that fit across 220px, so
     a single ellipsised line cut it at the username and lost the move number, which is the half
     that identifies the position. Two lines hold the whole of a normal game label and still cannot
     grow taller than the pair of buttons underneath it.
     `-webkit-` prefixed and unprefixed together: the prefixed trio is what every current engine
     actually implements, and the standard property is there for whatever stops needing it. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  overflow-wrap: anywhere;
}

/* Centring and the missing underline come from `.btn` itself now. What is left is this column's
   own: these two labels must not wrap onto a second line in a narrow side panel. */
.last-links .btn { white-space: nowrap; }

/* --- Trainer: the primer run --------------------------------------------------------------- */

/* Where you are in the run of four: three lichess puzzles on the motif, then your own tactic.
   A rail across the top of the board, one segment per position, rather than a counter, the shape
   of the run (three of one kind building to one of another) is the thing worth reading, and it
   reads at a glance without moving your eyes off the position. */
/* The board's column, in the row between the board's rail and the board itself: as wide as the
   board, hard against its top edge, and below the flip button and turn chip rather than beside
   them. Placed explicitly for the reason the whole grid exists, auto-placement put it in the
   evaluation bar's column, which turned the page into a horizontal scrollbar. */
.ladder {
  grid-column: 2;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  /* Tight between the label and the bar, the label names what the bar is showing, so they read as
     one thing, and a little air between the bar and the board, which flush against it looked
     squashed. */
  gap: 3px;
  padding-bottom: 7px;
}

.ladder.hidden { display: none; }

.ladder-track {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 10px;
}

/* Segments fill left to right as the run is worked through. The fill is a child rather than a
   background so it can be animated with a transform, which does not force layout every frame. */
.seg {
  position: relative;
  flex: 1 1 0;
  height: 5px;
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  overflow: hidden;
  transition: height 200ms ease, border-color 200ms ease;
}

.seg::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--good);
  transform: scaleX(0);
  transform-origin: left center;
  /* Fast off the mark and settling slowly, so it reads as the segment being filled rather than a
     bar snapping to full. The overshoot in the curve is what gives it the small "click" at the
     end. This is the moment a puzzle is solved, and it should feel like one. */
  transition: transform 560ms cubic-bezier(0.16, 0.9, 0.26, 1.06);
}

.seg.done::after { transform: scaleX(1); }

/* A finished segment is a link back to the puzzle it stands for. Only finished ones carry an
   `href`, so this is also what says which of them can be clicked.

   It grows by `transform`, not by `height`. Reusing the height transition looked like the tidy
   option and felt awful: changing a flex item's height re-lays-out the track and re-centres all
   four segments on every hover, and it inherited that transition's 200ms, a long time to wait for
   a 4px response to a pointer. A scale is composited rather than laid out, and 90ms is about the
   limit of what still reads as instant. */
.seg[href] {
  cursor: pointer;
  transition: transform 90ms ease, height 200ms ease, border-color 200ms ease;
}

.seg[href]:hover,
.seg[href]:focus-visible { transform: scaleY(1.8); }

.seg[href]:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 3px;
}

/* A single soft flare as the fill lands, then nothing. It runs once because it marks an event; a
   repeating pulse would turn a finished step into something still asking for attention. */
.seg.done { animation: seg-complete 620ms ease-out 1; }

@keyframes seg-complete {
  0%   { box-shadow: 0 0 0 0 transparent; }
  45%  { box-shadow: 0 0 8px 1px color-mix(in srgb, var(--good-bright) 60%, transparent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* How well the position went, as a colour.

   `--seg-quality` is set by app.js, 0 to 100, and is the only thing that decides the shade: 100 is
   `--good` exactly and 0 is `--bad` exactly, so the two ends are still the theme's own two colours
   and everything between them is a mix. The interpolation runs through **oklch**, which takes the
   short way round the hue wheel from green to red and therefore passes through amber. In sRGB the
   same mix goes through a muddy olive that reads as "broken" rather than as "nearly". */
.seg[style*="--seg-quality"]::after {
  background: color-mix(in oklch, var(--good) calc(var(--seg-quality) * 1%), var(--bad));
}

.seg[style*="--seg-quality"] {
  border-color: color-mix(in oklch, var(--good) calc(var(--seg-quality) * 1%), var(--bad));
}

.seg.failed { border-color: var(--bad); }
.seg.failed::after { background: var(--bad); }
.seg.failed { animation-name: seg-complete-failed; }

@keyframes seg-complete-failed {
  0%   { box-shadow: 0 0 0 0 transparent; }
  45%  { box-shadow: 0 0 8px 1px color-mix(in srgb, var(--bad) 55%, transparent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* Skipped, or answered by being shown the answer: grey, not red. There is no score to shade it
   with, because nothing was answered. Red is the bottom of the "you solved it, and here is what it
   cost" scale; this is the absence of a solve, and the two should not look like neighbours on one
   scale. Last, so it wins over `.failed`, which such a segment also carries. */
.seg.given { border-color: var(--border); }
.seg.given::after { background: var(--text-faint); opacity: 0.5; }
.seg.given { animation-name: seg-complete-given; }

@keyframes seg-complete-given {
  0%   { box-shadow: 0 0 0 0 transparent; }
  45%  { box-shadow: 0 0 6px 1px color-mix(in srgb, var(--text-faint) 40%, transparent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* The one you are on: taller, gold-edged, with a light travelling through it. Deliberately not
   filled, the segment completes when the puzzle does, and a full bar you had not solved yet was
   the one thing the old pips got wrong. */
.seg.current {
  height: 10px;
  border-color: var(--gold);
  background: color-mix(in srgb, var(--gold) 16%, var(--surface-2));
}

/* Finished, even though it is still the position on the board. "Current" is taller because it means
   you are working on it, and once it is resolved you are not, so it settles to the same height as
   every other finished step. Without this a step you just skipped sits visibly taller than the ones
   you solved, which reads as the red bar being a different size rather than a different state. */
.seg.current.done { height: 5px; }

.seg.current::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--gold-bright) 55%, transparent) 50%,
    transparent 100%);
  transform: translateX(-100%);
  animation: seg-sweep 1.9s ease-in-out infinite;
}

/* Solved: the fill lands and the light stops travelling, the sweep means "working on this",
   and it must not carry on over a finished position. The tactic overrides this below to glisten
   instead, which is why this sets opacity rather than only stopping the animation: without it
   the later, more specific rule would restart the light on an invisible layer. */
.seg.done::before { animation: none; opacity: 0; }

@keyframes seg-sweep {
  0%   { transform: translateX(-100%); }
  60%  { transform: translateX(100%); }
  100% { transform: translateX(100%); }
}

/* The tactic is the destination, not a fourth primer: wider, always gold-edged so you can see how
   far off it is from the first position, and it fills gold rather than green. */
.seg-tactic {
  flex-grow: 1.5;
  border-color: var(--gold-dim);
}

/* Gold as a metal rather than a flat orange: a deep amber at the edges, a bright highlight sitting
   just off-centre, and a thin sheen along the top. A single flat fill is what made it read as
   traffic-cone orange, real gold is dark at the rim and bright where the light hits it. */
.seg-tactic::after {
  background:
    linear-gradient(180deg,
      color-mix(in srgb, #fff 45%, transparent) 0%,
      transparent 42%),
    linear-gradient(100deg,
      color-mix(in srgb, var(--gold) 72%, #6b3f04) 0%,
      var(--gold) 22%,
      var(--gold-bright) 44%,
      #ffe6a3 54%,
      var(--gold-bright) 66%,
      var(--gold) 84%,
      color-mix(in srgb, var(--gold) 72%, #6b3f04) 100%);
}

.seg-tactic.current {
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--gold) 45%, transparent),
    0 0 9px -1px color-mix(in srgb, var(--gold-bright) 55%, transparent);
}

/* The tactic solved: a highlight that travels slowly along the gold, the way light moves across
   metal when you tilt it. Deliberately slow and mostly transparent. This is the reward for
   finishing a run, not a notification, and anything faster would nag from the corner of the eye.
   The `.done` flare fires once first; this takes over after it. */
.seg-tactic.done:not(.failed)::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* Above the fill. Both pseudo-elements are positioned at `z-index: auto`, so tree order decides
     and `::after`, the gold, paints last. The light was animating correctly the whole time,
     underneath an opaque layer, which looks exactly like a still image. */
  z-index: 1;
  background: linear-gradient(105deg,
    transparent 38%,
    color-mix(in srgb, #fff 62%, transparent) 48%,
    color-mix(in srgb, #fff 78%, transparent) 51%,
    color-mix(in srgb, #fff 62%, transparent) 54%,
    transparent 64%);
  background-size: 260% 100%;
  opacity: 1;
  animation: seg-glisten 3.6s ease-in-out 620ms infinite;
}

@keyframes seg-glisten {
  0%        { background-position: 150% 0; }
  /* Most of the cycle is the highlight sitting off the end, so the glint is occasional rather
     than a constantly sweeping bar. */
  55%, 100% { background-position: -60% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .seg.done { animation: none; }
  .seg-tactic.done:not(.failed)::before { animation: none; opacity: 0.3; }
}

/* Light theme: the same metal against a white ground needs the highlight pulled back, or it washes
   out into a pale smear. */
:root[data-theme="light"] .seg-tactic::after {
  background:
    linear-gradient(180deg,
      color-mix(in srgb, #fff 30%, transparent) 0%,
      transparent 45%),
    linear-gradient(100deg,
      #a86a08 0%,
      var(--gold) 24%,
      var(--gold-bright) 46%,
      #ffd583 54%,
      var(--gold-bright) 64%,
      var(--gold) 82%,
      #a86a08 100%);
}

@media (prefers-reduced-motion: reduce) {
  .seg::after { transition: none; }
  .seg.current::before { animation: none; opacity: 0.35; transform: none; }
}

/* The label sits on the *bottom* of this row, hard against the bar underneath.
   The row is as tall as the skip button (31px) and the label is 16px, so aligning the row's items
   on their baselines put the text at the top and left it floating 18px above the bar it names,
   reading as a caption for the board rather than for the run. `.ladder-label` keeps the stage and
   the note aligned with each other on their baselines; the row only decides where that pair sits. */
.ladder-head {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  min-height: 24px;
}

.ladder-label {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  align-items: baseline;
  gap: 10px;
}

/* The count and the rung's name, and only the count is load-bearing.

   This row has to hold the stage label and the "Skip to my tactic" pill inside the board's width,
   and on a phone it cannot: the board bottoms out around 285px, the longest label ("3 of 3 · The
   idea in a busy position") is 201px and the pill is a fixed 152px, so the pill used to hang 45px
   off the board's right edge and out of the window.

   The pill is not the thing to shrink. It shares `--rail-pill-width` with the turn chip stacked
   directly above it, and they have to stay identical, and the chip is the tighter of the two
   anyway: "Checkmate, Black wins" measures 178px and is already squeezed at 152. So the label gives
   way instead, and it gives way from the right, keeping "1 of 3" whatever the width. What is left
   fixed is 34px of count plus the gap plus the pill, which fits inside the 240px the board clamps
   at, so there is no width at which this overflows, and no breakpoint holding that together.

   **Both halves of that pairing have since left this row**, and the paragraph above is kept as the
   reason the label ellipsises rather than as a live invariant. The "Skip to my tactic" pill is no
   longer in `index.html` at all, so `.ladder .btn` below matches nothing; and the turn chip is now
   the first thing in the side panel, where it is the width of that panel's content and no longer
   has anything to do with `--rail-pill-width`. Nothing is competing with the label for this row's
   width any more, so it is wider than the comment assumes, never narrower. */
.ladder-stage {
  display: flex;
  flex: 0 1 auto;
  min-width: 0;
  white-space: nowrap;
  font-size: 12.5px;
  font-weight: 700;
  color: var(--text);
}

.ladder-count { flex: none; }

/* Ellipsises away, the same way `.ladder-note` and `.board-prompt` do, this file's answer to text
   that has to survive being squeezed. The separator is generated inside it so the two go together. */
.ladder-rung {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Non-breaking spaces, not ordinary ones. This is the first inline content of a flex item, and a
   leading ordinary space there is stripped as line-box padding, which closed the gap on the left
   only, so "1 of 3 · The idea" came out as "1 of 3· The idea" and read as a hyphen. */
.ladder-rung::before { content: '\00a0·\00a0'; }

/* Takes whatever width is left and gives it up first: on a narrow board the stage name and the
   skip button are what matter, and a wrapped sentence here would move the board down.

   `flex-basis: 0`, not `auto`, and that is the whole of "gives it up first". With `auto` the note's
   long sentence counts as its base size, so flexbox shrinks it and the stage label *in proportion*,
   which meant the label started ellipsising at 961px wide, with 200px of the note's own text
   still on screen. At zero the note contributes nothing to that division and simply occupies what
   is left over. */
.ladder-note {
  margin: 0;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 11.5px;
  color: var(--text-dim);
}

/* The same width as the turn chip above it, and the same pill shape: the two sit stacked against
   the board's right edge, so anything short of identical reads as one of them being slightly
   wrong. */
.ladder .btn {
  flex: none;
  align-self: center;
  justify-content: center;
  /* Pinned to the right edge by its own margin, not by whatever happens to sit to its left. It used
     to be held there only as a side effect of `.ladder-note` growing to fill the row, so the
     moment the note is hidden in the stacked layout, the button drifted 177px inwards and stopped
     lining up under the turn chip it is supposed to match. With the note present this absorbs
     nothing, because the note has already taken the free space. */
  margin-inline-start: auto;
  width: var(--rail-pill-width);
  border-radius: 999px;
}

/* --- Trainer: the end of the set ----------------------------------------------------------- */

/* Shown instead of "Next Puzzle" once every tactic has been resolved. It replaces the button
   rather than sitting under it: a Next that has nothing to go to is worse than no Next. */
.all-done {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 14px;
  border: 1px solid var(--gold-dim);
  border-radius: 8px;
  background: var(--surface);
}

.all-done.hidden { display: none; }

.all-done-title {
  margin: 0;
  font-size: 14.5px;
  font-weight: 700;
  color: var(--gold-ink);
}

.all-done-text {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
}

/* --- Analysis board ----------------------------------------------------------------------- */

/* `.board-row` used to wrap the board and the evaluation bar here. It is gone: it was a flex row
   auto-placed into `.board-column`'s grid, which put it in column 1 and left the status box to land
   in column 2 *beside* the board, a page as wide as two boards, with the left half of the real one
   cut off the screen. Both are now placed on the grid explicitly. */

.eval-bar {
  position: relative;
  width: 34px;
  flex: none;
  border-radius: 4px;
  overflow: hidden;
  /* The bar is Black's colour; the fill is White's share of it. */
  background: var(--eval-black);
  /* Black's end of the bar is #221d55 against a #0e0c26 page, so without this the bar has no top:
     the white fill floats in the dark and there is nothing to read it as a *share* of. A share
     needs its whole to be visible. */
  box-shadow: inset 0 0 0 1px var(--border);
}

/* Level, marked. Whether the fill is above or below halfway is the first thing anyone reads off a
   bar like this, and eyeballing the midpoint of a 580px column is not something to ask of them. */
.eval-bar::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  z-index: 1;
  height: 1px;
  background: color-mix(in srgb, var(--gold) 55%, transparent);
}

/* No transition on `height`. The engine reports many times a second and a transition restarted on
   every report never finishes, which is what made the bar twitch instead of move, analysis.js
   drives the height on a frame loop instead, and explains it above `aimEval`. */
.eval-fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 50%;
  background: var(--eval-white);
}

/* Flipping the board flips the bar, so White stays on White's side of it. */
.eval-bar.flipped .eval-fill { bottom: auto; top: 0; }

/* Nothing has been measured yet, so show no opinion rather than a confident 0.00. */
.eval-bar.idle { background: var(--surface-2); }
.eval-bar.idle .eval-fill { background: var(--surface-2); }
.eval-bar.idle .eval-label { color: var(--text-dim); }

/* The number always sits at the winning side's end of the bar, so it reads against a filled
   background rather than floating over a sliver of the losing colour. The sign is dropped: which
   end of the bar it sits at already says who is better. */
.eval-label {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 5px;
  z-index: 2; /* over the midline, which is over the fill */
  text-align: center;
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  color: var(--eval-white-text);
  pointer-events: none;
}

.eval-bar.flipped .eval-label { bottom: auto; top: 5px; }

.eval-bar.black-ahead .eval-label { bottom: auto; top: 5px; color: var(--eval-black-text); }
.eval-bar.flipped.black-ahead .eval-label { top: auto; bottom: 5px; }

/* Column 4 of `.board-column`, on the board's own row, so the panel's top edge and the board's top
   edge are the same line. It used to be a sibling of `.board-area`, which started it at the top of
   the whole column: the engine switch ended up level with the turn chip, floating some 40px above
   the board it reports on, and the gap grew or shrank with whatever the chip row happened to hold.

   No `max-height` here, unlike the trainer's side panel. The notation list is the thing that grows,
   and a long game is worth scrolling the page for; clipping it to the board's height would hide
   moves behind a scrollbar inside a scrollbar.

   The margin is the gap, exactly as it is for `.side-panel`: this grid's `column-gap` is 8px
   because it also spaces the flip rail and the evaluation bar, both of which sit tight against the
   board, and the panel wants the 24px `.page` used to give it. 16px + the 8px gap is that. */
.analysis-panel {
  grid-column: 4;
  grid-row: 3;
  width: 300px;
  margin-left: 16px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* As wide as the panel, for the reason the trainer's is: `--rail-pill-width` is a width for a chip
   floating at a board's corner, and against a 300px card a 152px pill reads as a control that
   failed to fill its space rather than as a label. */
.analysis-panel > .turn-chip { width: 100%; }

/* The same card the dashboard uses, border, radius and a gold hairline along the top edge,
   brightest at the left. This panel was four flat grey rectangles of identical weight, which is
   what made a page with a chess engine in it look like a settings screen. Reusing the dashboard's
   card rather than inventing a third kind of box also means the two pages read as one app. */
.panel-block {
  position: relative;
  overflow: hidden;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
}

.panel-block::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--gold) 55%, transparent) 0%,
    color-mix(in srgb, var(--gold) 12%, transparent) 45%,
    transparent 80%);
}

/* --- The engine, as a strip on top of the notation ------------------------------------------ */
/*
   A row across the top of the block: the switch, the score, and what the search is doing. It had a
   card of its own with a border, a heading reading "Engine" above a switch already labelled Engine,
   and a 27px score that said "-" whenever it was off, which is most of the time. Off it is now one
   row; on, `.engine-body` appears underneath and the block grows into it.
*/
.engine-strip {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-bottom: 9px;
  margin-bottom: 10px;
  border-bottom: 1px solid var(--border);
}

.engine-switch-label {
  font-size: 12px;
  font-weight: 700;
  color: var(--text);
}

/* Pushed to the right, against the state. Still the largest thing in the strip, it is the number
   the board exists to produce, but sized to sit on a row rather than to be a headline. */
.engine-score {
  margin-left: auto;
  font-size: 18px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--text-faint);
}

.engine-body {
  display: flex;
  flex-direction: column;
  gap: 7px;
  margin-bottom: 12px;
}

.engine-body.hidden { display: none; }

/* Whose advantage it is, said by the number's own colour before the sentence beside it is read.
   Not green and red: those are reserved for "your move was good/bad", and an evaluation is not a
   verdict on anybody. The board's own two colours are the honest pair here. */
.engine-score.white-ahead { color: var(--text-bright); }
.engine-score.black-ahead { color: var(--gold-ink); }

/* Dimmed while the search is still shallow, so a number that is about to move looks different from
   one that has settled. It is the same signal the depth readout gives, in the place the eye is
   already looking. */
.engine-score.settling { opacity: 0.55; }

.engine-verdict {
  min-width: 0;
  font-size: 12px;
  line-height: 1.35;
  color: var(--text-dim);
}

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

.panel-title {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--text);
}

.panel-head-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: none;
}

.panel-link {
  font-size: 12px;
  color: var(--gold-ink);
}

.panel-hint {
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-dim);
}

/* --- Move list ----------------------------------------------------------------------------- */

/* A game score: a row per move number, White and Black in fixed columns. */
.notation {
  display: flex;
  flex-direction: column;
  max-height: 260px;
  overflow-y: auto;
  scrollbar-width: thin;
  font-size: 15px;
}

/* The number column is sized once, in ch, so it holds a three-digit move number without the two
   move columns shifting when a game passes 99, the thing that makes a list like this feel loose. */
.notation-row {
  display: grid;
  grid-template-columns: 3.2ch 1fr 1fr;
  align-items: center;
  gap: 2px;
}

/* Zebra striping, because the eye tracking across two columns needs the row to be a thing. Faint
   enough that the highlighted move is still the only thing that stands out.

   Carried by a class rather than by `:nth-child`, and that is a fix rather than a preference:
   variations are printed between the rows they branch from, so the parity of a row's position
   among its siblings stopped being the parity of the row. `renderNotation` counts rows. */
.notation-row.alt { background: color-mix(in srgb, var(--text) 3%, transparent); }

/* Black moved first in this line, so White's cell is empty rather than absent. */
.notation-gap {
  padding: 3px 6px;
  color: var(--text-faint);
}

.notation-empty {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-dim);
}

.notation-number {
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  font-size: 12.5px;
  text-align: right;
  padding-right: 3px;
}

/* Left-aligned, so the moves form a column you can run your eye down. Centred they wandered with
   the length of the word, which is most of why the old panel looked untidy. */
.notation-move {
  border: none;
  border-radius: 4px;
  padding: 3px 6px;
  text-align: left;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.notation-move:hover { background: var(--surface-2); }

.notation-move.current {
  background: var(--gold);
  color: var(--on-gold);
}

.notation-move:focus-visible { outline: 2px solid var(--gold); outline-offset: -2px; }

/* --- Variations ------------------------------------------------------------------------------ */
/*
   A line you played instead of the one that was there, printed under the row it branches from,
   which is where a book prints it.

   A run rather than rows, and the difference is the point. The main line is a game score and gets
   columns you can read down; a variation is an aside about one move, usually two or three moves
   long, and giving it the same grid would say it is a second game of equal standing. Smaller, dim
   until you hover, indented, and set off by a rule down its left edge so that where it starts and
   where it ends are visible without reading it.

   Nested ones step further in. There is no depth limit and there does not need to be one: each
   level costs 10px and a panel this wide runs out of room long before anybody runs out of ideas,
   at which point the moves wrap, which is what a run is for. */
.notation-variation {
  margin: 2px 0 2px 3.2ch;
  padding-left: 7px;
  border-left: 2px solid var(--border);
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-dim);
}

.notation-variation .notation-variation {
  margin-left: 4px;
  font-size: 12.5px;
}

.notation-vnumber {
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  font-size: 11.5px;
  padding-left: 2px;
}

/* Inline, so the moves flow and wrap as a sentence does, and lighter than the main line's, which
   is what keeps a variation an aside when it is sitting directly under the move it replaces. */
.notation-vmove {
  display: inline;
  padding: 1px 3px;
  font-weight: 500;
  color: inherit;
}

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

/* Its own row above the score, rather than a cell in it: it is not a move and it should not sit in
   a move's column pretending to be one. */
.notation-start {
  align-self: flex-start;
  margin-bottom: 3px;
  font-weight: 500;
  font-size: 13px;
  color: var(--text-dim);
}

/* --- Engine panel --------------------------------------------------------------------------- */

.engine-switch {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--text-dim);
  cursor: pointer;
  user-select: none;
}

/* The real checkbox stays in the accessibility tree and keeps keyboard behaviour; the pill is
   drawn from the two spans beside it. */
.engine-switch input {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.switch-track {
  position: relative;
  flex: none;
  width: 32px;
  height: 18px;
  border-radius: 9px;
  background: var(--raised);
  transition: background 150ms ease;
}

.switch-thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--text-dim);
  transition: transform 150ms ease, background 150ms ease;
}

.engine-switch input:checked + .switch-track { background: var(--gold); }

.engine-switch input:checked + .switch-track .switch-thumb {
  transform: translateX(14px);
  background: var(--text-bright);
}

.engine-switch input:focus-visible + .switch-track {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
}

.engine-state.live { color: var(--gold-ink); }
.engine-state.busy { color: var(--gold-ink); }
.engine-state.error { color: var(--bad); }

.engine-lines {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* One row per candidate: rank, score, line. It was score-then-wrapped-notation, so three rows of
   moves broke across six lines and the block had no shape to scan down. The line is kept to one
   row and clipped instead, the point of the second and third candidates is *what the move is* and
   how much it costs, and both of those are at the left. The full line is in the tooltip. */
.engine-line {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 4px;
  border-radius: 5px;
  font-size: 12.5px;
  line-height: 1.45;
}

.engine-line:hover { background: var(--surface-2); }

/* The engine's own ranking, which the order alone was left to imply. */
.engine-rank {
  flex: none;
  width: 14px;
  font-size: 10.5px;
  font-weight: 700;
  text-align: center;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.engine-eval {
  flex: none;
  min-width: 52px;
  padding: 2px 6px;
  border-radius: 999px;
  background: var(--sunken);
  color: var(--text-dim);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

/* Same three-way split as the score above, so a glance down the column says who each line favours
   without reading a single sign. */
.engine-eval.white-ahead {
  color: var(--text-bright);
  background: color-mix(in srgb, var(--text) 12%, var(--sunken));
}

.engine-eval.black-ahead {
  color: var(--gold-ink);
  background: color-mix(in srgb, var(--gold) 13%, var(--sunken));
}

.engine-moves {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--text);
}

.engine-message {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
}

/* What the board is showing, and how to drive it. Both are help, so they sit together at the foot
   of the panel and neither is a box. This used to be a bordered block in the panel's top slot,
   which spent the most prominent position on the page explaining the page. */
.panel-foot {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 0 2px;
}

.about-line {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--text-dim);
}

.about-line:empty { display: none; }

.engine-foot {
  margin: 8px 0 0;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--text-faint);
}

/* The four moves the arrow keys make, as buttons. They existed only as keys, which is fine once you
   know and invisible until then, and a touch screen has no arrow keys at all. */
/* "3 of 4" used to sit between the back and forward buttons and has gone. It was a count of the
   main line, so with variations on the page it was a count of something the cursor need not even
   be on, and it was never the answer to a question anybody asks at a board: the move list directly
   above it already shows where you are, in the only terms that mean anything, which is the move.
   `centre` rather than `flex-start` because four buttons left-aligned under a full-width list read
   as the start of a row that was never finished. */
.move-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

.nav-btn {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--surface-2);
  color: var(--text-dim);
  cursor: pointer;
  transition: color 120ms ease, border-color 120ms ease;
}

.nav-btn:hover:not(:disabled) { color: var(--text); border-color: var(--gold); }
.nav-btn:disabled { opacity: 0.35; cursor: default; }
.nav-btn svg { width: 14px; height: 14px; display: block; }

.nav-btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }

/* The position as text, for getting it out of here and into anything else. Selectable in one click
   because that is the only thing anyone ever does with a FEN. */
.fen-box {
  display: block;
  padding: 9px 10px;
  border-radius: 8px;
  background: var(--sunken);
  color: var(--text-dim);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10.5px;
  line-height: 1.5;
  word-break: break-all;
  user-select: all;
}

/* A button that reads as the panel's other links, since it sits where they do. */
button.panel-link {
  border: none;
  background: none;
  font: inherit;
  cursor: pointer;
}

/* Under a certain width the panel cannot sit beside the board, so it goes underneath it. */
@media (max-width: 900px) {
  body { align-items: stretch; }
  .page { flex-direction: column; align-items: center; }
  /* The analysis panel is a grid child too, and this rule was written as though it were not: giving
     it a width never moved it out of column 4, so under 900 it went on sitting beside a board that
     no longer had room for it. Measured at 820 by 1180, a tablet held upright, the page came out
     924px wide inside an 820px window, 45px of it off the right edge and the board's left edge two
     pixels off the left. It is the same move `.side-panel` gets directly below, one row further
     down because this column has the FEN and PGN boxes in row 5. */
  .analysis-panel {
    grid-column: 2;
    grid-row: 6;
    width: auto;
    margin: 12px 0 0;
  }

  /* The trainer's panel is a grid child rather than a `.page` child, so stacking it is a move to
     the board's column and a row of its own, turning `.page` into a column does not reach it. */
  .side-panel {
    grid-column: 2;
    grid-row: 5;
    width: auto;
    max-height: none;
    margin: 12px 0 0;
    /* Row 5 is sized by this panel, so stretching it to that row is stretching it to its own
       content. Named rather than left implicit: it means `margin-top: auto` on `.last-links` has
       nothing to eat here and the links follow the controls instead of being pushed anywhere,
       which is the right answer once the panel is under the board rather than beside it. */
    align-self: stretch;
  }
  /* Back in a row here. The column above is an answer to a 210px panel, where three buttons across
     leave 64px each; stacked under the board this panel is the board's width, so the same three are
     about 120px each, which is wider than the column version ever needed to be and costs three rows
     of height instead of one. Vertical space is the scarce thing in this layout, not horizontal. */
  .puzzle-actions-row { flex-direction: row; }
  /* Equal thirds, as they were. Deliberately not on the column above: `flex: 1` carries
     `flex-basis: 0`, which along a column is a *height* of zero, and in a container whose height
     comes from its content there is no free space to grow back out of. The buttons collapse. Along
     a row the basis is a width and this is the thirds it has always meant. */
  .puzzle-actions-row .btn { flex: 1; }

  /* It has already ellipsised to nothing at these widths, so all it still costs the row is the 10px
     gap beside it. Given back to the stage label, which can use it. */
  .ladder-note { display: none; }
  /* Stacked, the board no longer competes with the panel for width, only for height. */
  .page,
  .analysis-page { --board-size: clamp(240px, min(calc(100vw - 90px), calc(100svh - 260px)), 620px); }
}

/* The board on a phone.
 *
 * Measured from a real session rather than guessed at: a visitor arrived from an ad on an Android
 * phone, completed the whole funnel in ninety seconds, reached the trainer and left within seven.
 * At 390px the board was 300px wide with 37px squares, and 90px of a 390px screen was going to
 * things that are not the board. Both of the other places people play chess on a phone give the
 * board very nearly the whole width, and since 2026-09-11 the trainer and the daily give it all of it.
 *
 * Once the panel stacks, the columns either side of the board collapse to nothing. On the trainer
 * they held one flip button, and the flip button is gone, from the trainer and the daily though not
 * from the analysis board. That page keeps its columns because the third holds a live evaluation bar.
 */
@media (max-width: 900px) {
  .page:not(.analysis-page) .board-column {
    grid-template-columns: 0 var(--board-size) 0;
    column-gap: 0;
  }
}

@media (max-width: 560px) {
  /* 6px of side padding, and a top margin that clears the settings button rather than one derived
     from the viewport height.

     `.page`'s usual `margin-top: min(5vh, 48px)` came to exactly 48px on a 844px-tall phone, and
     `.settings-root` is `position: fixed` at `top: 14px` with a 34px button, so its bottom edge is
     also 48px. That button is only mounted on pages without the rail now, but a fixed 64px is still
     the right shape for them: derived from `5vh` it would collapse on a short landscape phone. */
  .page {
    padding: 6px;
    margin-top: 64px;
  }

  /* The trainer and the daily, edge to edge. No side padding, and the board is the whole width of
     the screen, or the height budget where that is the smaller. It was `100vw - 12px`: 363px on a
     375px phone, measured, 6px short of each edge. The prompt above the board, the primer ladder
     and the panel under it keep 10px off each edge, because text against the glass is hard to read
     and a panel's rounded corners against it look cut off. The board is the one thing that wants
     every pixel.

     12px at the top rather than 64: nothing fixed is painted over this page's top edge any more,
     neither the brand mark nor the gear. The height term is the one it was, because neither of them
     was in it. */
  .page:not(.analysis-page) {
    margin-top: 12px;
    padding: 6px 0;
    --board-size: clamp(240px, min(100vw, calc(100svh - 260px)), 620px);
  }

  .page:not(.analysis-page) .board-head,
  .page:not(.analysis-page) .ladder,
  .page:not(.analysis-page) .side-panel { margin-inline: 10px; }

  /* The analysis board has nothing fixed over its top edge either. */
  .analysis-page { margin-top: 12px; }
}

@media (prefers-reduced-motion: reduce) {
  /* The handover is a hard cut again. The class still goes on and comes off, so the 180ms it is
     held for is still spent; that is a pause of no consequence, where a dissolve is the thing
     somebody asked not to be shown. */
  .board-wrap { transition: none; }
  .board-wrap.board-swap { opacity: 1; }
  /* The knight stays on its first square. The board and the sentence still say a lookup is running,
     so nothing is lost but the hopping. */
  .knight-tour-knight { animation: none; }
  .eval-fill { transition: none; }
  /* Deliberately not `#giveup-btn::after`, which is the one moving thing on this page that is
     carrying information rather than dressing a change that has already happened. Stopping it
     would put back the still screen that this whole mechanism exists to remove, and would put it
     back for the people least likely to want to be surprised by the board changing under them. It
     is a 2px line filling in one direction at a constant rate, which is not the motion the setting
     is about. */
  .scan-bar.indeterminate .scan-bar-fill { animation: none; }
  .move-glyph.pop { animation: none; }
  .switch-track,
  .switch-thumb { transition: none; }
}

/* Last, so it wins ties against the component rules above. */
.hidden { display: none; }

/* --- The free day running out ------------------------------------------------------------------ */
/*
   Shown when a free account has worked through its five tactics for the day.

   Sized and worded as an end-of-session card rather than an interruption: it arrives *between*
   runs, never inside one, and there is nothing behind it waiting to be got back to. It can be
   dismissed, and the side panel says the same thing once it is, a modal that is the only
   explanation leaves whoever pressed Escape looking at an empty board.
*/
.limit-dialog {
  width: min(420px, calc(100vw - 32px));
  padding: 24px 26px 22px;
  border: 1px solid var(--gold-dim);
  border-radius: 14px;
  background:
    linear-gradient(150deg, color-mix(in srgb, var(--gold) 9%, transparent), transparent 60%),
    var(--surface);
  color: var(--text);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
}

.limit-dialog::backdrop {
  background: rgba(6, 5, 14, 0.62);
  backdrop-filter: blur(3px);
}

.limit-eyebrow {
  margin: 0 0 8px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--gold-ink);
}

.limit-title {
  margin: 0 0 10px;
  font-size: 20px;
  line-height: 1.2;
  color: var(--text-bright);
}

.limit-lede {
  margin: 0 0 16px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--text-dim);
}

.limit-points {
  margin: 0 0 20px;
  padding: 14px 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
  display: grid;
  gap: 8px;
  font-size: 13px;
  line-height: 1.4;
}

/* The tick is drawn rather than typed, so it sits on the text's cap height whatever the system
   font does with a check mark. */
.limit-points li { position: relative; padding-left: 24px; }

.limit-points li::before {
  content: '';
  position: absolute;
  left: 1px;
  top: 4px;
  width: 12px;
  height: 7px;
  border-left: 2px solid var(--gold);
  border-bottom: 2px solid var(--gold);
  transform: rotate(-45deg);
}

.limit-actions { display: flex; gap: 8px; }
.limit-actions .btn { flex: 1; justify-content: center; text-align: center; }

.limit-foot {
  margin: 14px 0 0;
  text-align: center;
  font-size: 12px;
  color: var(--text-faint);
}

.limit-foot a { color: var(--text-dim); }

/* --- The motif of the day, once it is over ------------------------------------------------------ */
/*
   The game is named only after the position is solved, up front it hands over the answer, and to
   anyone who knows the game it hands over the whole combination. Afterwards it is the reward, so it
   is set as three lines with the title carrying the weight.
*/
.daily-game {
  display: block;
  font-size: 13.5px;
  font-weight: 700;
  color: var(--text-bright);
}

.daily-event {
  display: block;
  margin-top: 1px;
  font-size: 12px;
  color: var(--gold-ink);
}

.daily-note {
  display: block;
  margin-top: 6px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
}

/* --- The link a shared run produces ------------------------------------------------------------ */
/*
   Replaces the Share button in the same row rather than appearing under it, because there is one
   link per run and the button has done its job. The address is in a real `<input>`, not a `<span>`
   somebody has to drag across: a read-only input can be focused, selected wholesale, and copied
   with the keyboard, which is what the fallback in `copyShareUrl` relies on when the clipboard
   API is refused, as it is on every insecure origin. */

.share-done {
  margin-top: 10px;
  width: 100%;
}

.share-done-label {
  margin: 0 0 6px;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--text-faint);
}

.share-done-row {
  display: flex;
  gap: 7px;
  align-items: center;
}

.share-url {
  flex: 1 1 auto;
  min-width: 0;
  padding: 6px 9px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface-2);
  color: var(--text-dim);
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
  font-size: 12px;
  /* Selected by `publishRun` the moment it appears, so the caret must not be where the text was
     scrolled to: `direction: ltr` with the overflow at the end keeps the origin visible, which is
     the half of the URL that tells somebody what they are about to paste. */
  text-overflow: ellipsis;
}

.share-url:focus {
  outline: 2px solid var(--gold);
  outline-offset: 1px;
  color: var(--text);
}
