/* letters — phone first, because a 7×7 board you have to trace a word across
 * is only difficult on a phone.
 *
 * THIS APP LOOKS NOTHING LIKE ITS SIBLINGS AND THAT IS THE POINT. budget,
 * mines, words and gems all wear the house iOS-grey language: system greys,
 * white cards, a blue accent. This one wears a library — oak, parchment, brass
 * — because the thing it is a clone of is a game about a wooden bookcase, and a
 * board of lettered tiles reads as scrabble whether or not the chrome agrees
 * with it. The DECISION is the divergence; everything below is bookkeeping.
 *
 * TWO THEMES, AND LIGHT IS STILL THE BASE, which is the one house convention
 * this file does keep. Light is a reading room by daylight — pale oak, paper,
 * espresso ink. Dark is the same room by lamp — walnut, leather, warm cream.
 * The palette below is the light one on bare :root; dark is a token swap in two
 * guarded blocks — the media query for "system", the attribute for a choice the
 * user made. Nothing outside those blocks names a literal colour, so a rule
 * written once is right in both. The duplication between the two dark blocks is
 * unavoidable: a media query cannot be OR-ed with an attribute selector, and
 * defining a colour in only one of them is how a theme ends up half-applied.
 *
 * THE TILES ARE SQUARE, and the board is still a honeycomb. Odd columns are
 * pushed down half a cell, so every tile touches six others while the board
 * reads as a grid — which is what the original does and why its tiles can be
 * scrabble tiles. The arithmetic is in app.js's layoutBoard(); nothing here
 * knows the board's shape.
 *
 * THE FIRE IS THE ONE COLOUR THAT DOES NOT MOVE WITH THE THEME AND IT IS
 * DELIBERATE. A burning tile is the same orange in both. It is the only thing
 * on this screen that means "you are about to lose", and a warning that changes
 * colour with the wallpaper is a warning somebody has to learn twice. The board
 * around it changes; the fire does not — the same argument `gems` makes for its
 * jewels.
 *
 * Insets go on PADDING, never on layout, and every one carries a 0px fallback —
 * `calc(6px + env(...))` with no fallback is an invalid length and drops the
 * whole declaration on every desktop browser.
 *
 * ── THE ART, AND THE ONE FILE OF IT THAT IS NOT HERE ──────────────────────
 *
 * `tile.png` and `wood.png` are Kenney's Letter Tiles pack (kenney.nl, CC0,
 * credit appreciated and given in the README). Both are cut from the same
 * source image: `tile.png` is the pack's BLANK wood tile, `wood.png` is the
 * grain out of the middle of it with the bevel cropped off, tiled for the
 * backgrounds.
 *
 * THE PACK SHIPS 26 LETTERED TILES PER STYLE AND THIS APP USES NONE OF THEM.
 * They are lovely and they are wrong here, for a reason that is this project's
 * own recurring bug rather than a matter of taste: every one of them has its
 * SCRABBLE value baked into the corner of the artwork — A is 1, B is 3, F is 4.
 * This game scores 1, 2 and 3, by tier, out of `letterValue()` in modes.js.
 * Shipping the lettered art would paint a number on the tile that the engine
 * does not score, in a place no test looks, and it would keep painting it if
 * the tiers ever changed. That is `core/lease.py` copied five times and
 * `web/engine.js` copied into PHP — a second copy of a rule, in art this time,
 * where not even a hash check could reach it.
 *
 * So the tile FACE is the pack's and the letter and its value are drawn in CSS
 * on top of it, from the same function the scoring uses. That also buys the two
 * things the raster set could not have given at any price: a Qu tile, which the
 * pack has no glyph for, and a tile that can catch fire, which is a background
 * colour under a blend mode rather than 26 more images.
 */


:root {
  /* Light: a reading room by daylight. Oak and paper, espresso ink, brass. */
  --bg:       #f7dda4;
  --panel:    #fbf4e4;
  --panel-2:  #f1e5cb;
  --line:     rgba(74, 45, 25, .22);
  --line-2:   rgba(74, 45, 25, .10);
  --ink:      #3a2415;
  --ink-2:    #5d3f27;
  --dim:      #8a6a4c;
  --dim-2:    #a98a6b;
  --pill:     #e7d7ba;
  --good:     #2f7d3f;
  --warn:     #a2620c;
  --bad:      #b3271b;
  /* BRASS, TWICE, because one brass cannot do both jobs. `--gold` is a FILL —
     the plate on a button, the ring round a hint — and wants to be bright
     enough to read as metal. `--gold-ink` is TEXT on the wall, and on the light
     theme the bright one fails there: mustard on pale oak is about 2:1 and the
     score becomes decoration. On dark the two are the same colour, which is
     exactly why the split is easy to miss if you only ever look at dark. */
  --heart:    #e0596e;       /* the tip jar's hearts — thanks, not a price tag; the same in both themes */
  --gold:     #c69a3e;
  --gold-ink: #855708;
  --shadow:   0 1px 2px rgba(58,36,21,.10), 0 8px 22px rgba(58,36,21,.14);

  /* HOW MUCH OF THE WALL IS WOOD, and how dark its corners go. The two themes
     want opposite amounts: on light the grain has to stay a whisper or the text
     sits on a pattern, on dark it is most of what you see. Two numbers rather
     than two sets of gradients. */
  --grain:    .30;
  --vignette: .12;

  /* THE TILE. `--tile` is the colour the wood texture is tinted to, not a flat
     fill — see `.tile` for the blend. Every state is that one token changing,
     which is why a burning tile still has grain in it. */
  --tile:      #d9b585;
  --tile-ink:  #3a2415;
  --tile-near: #dcc48f;
  --select:    #6f9f4a;
  --select-2:  #8ec062;
  --socket:    rgba(46,28,16,.26);
  /* The tray the board sits in — a shelf, not a background. It is the one
     surface that is meant to look like a plank, so it is a mid oak on light and
     a walnut on dark rather than a shade of the page behind it. */
  --tray:      #9c6f39;
  /* THE SHELF THE TAB BAR IS CUT FROM, and it is a DARKER wood than the wall
     behind it, which is the opposite of the first attempt. Made paler than the
     wall it vanished into it, and the brass plate on the selected tab landed
     within a few percent of the shelf's own lightness — on light the bar read
     as one flat orange strip with a slightly different orange rectangle on it.
     A cabinet standing in front of a wall is darker than the wall; the plate
     then has somewhere to be bright against.

     THERE IS NO DIM INK, and that is deliberate. A muted label on any mid wood
     lands around 2.5:1 — a label you can see and cannot read — and the fix is
     not a darker grey, it is noticing that the BRASS PLATE already says which
     tab is selected. So all three labels get full contrast and the plate alone
     carries the state. */
  --shelf:     #b9884a;
  --shelf-ink: #33200f;

  --r-card: 16px;
  --r-mid:  11px;
  --r-cell: 6px;

  /* `--link` EXISTS FOR install.js AND NOTHING ELSE HERE. That file is a
     verbatim copy of sums' (bin/check_auth_parity.sh holds it identical) and
     it reads the house's grey-and-blue token names with iOS-blue fallbacks,
     which is the one colour this library must never show. The token maps it
     onto brass ink; the `.pwa-gate` rules further down put the brass PLATE on
     its primary button, because white text on bright dark-theme brass is
     illegible and the token alone cannot say so. */
  --link: var(--gold-ink);

  --font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text",
          "Helvetica Neue", sans-serif;
  /* THE HEADINGS ARE A SERIF AND NOTHING ELSE IS. A library is the only thing
     this app is trying to look like, and one bookish face on the titles does
     more for that than any amount of brown. It is a system stack — New York on
     Apple platforms, Iowan and Palatino behind it, Georgia everywhere else — so
     it costs no download and works with the network off, which matters for an
     app whose whole shell is in a service worker. */
  --font-display: ui-serif, "New York", "Iowan Old Style", Palatino,
                  "Palatino Linotype", Georgia, serif;
  /* The score, the word count and the tiles. A proportional font makes a
     running number jitter sideways on every change, and it makes a board of
     letters look like a paragraph rather than a set of tiles. */
  --font-num: ui-rounded, -apple-system, "SF Pro Rounded", system-ui, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;

  /* THE BOARD'S FRAME IS REAL WIDTH, NOT DECORATION, so app.js reads this
     number back out of the computed style and lays the tiles out inside what it
     leaves. Written once here; a second copy in the JavaScript is how a board
     ends up a frame's width too wide on the narrowest phone. */
  --frame: 9px;

  /* One place decides how fast the board moves. app.js reads these back out of
     the computed style so the JavaScript timings and the CSS transitions cannot
     drift apart — a fall that finishes 40ms after its animation is a tile that
     visibly teleports the last few pixels. */
  --t-trace: 110ms;
  --t-pop:   200ms;
  --t-fall:  240ms;
  --t-fly:   520ms;   /* one tile, board to score (eatWord) */
  --t-fly-gap: 55ms;  /* between one letter's take-off and the next */

  /* HOW BIG THE FIRE IS. 1 everywhere but Timed, where it follows the clock
     (letters-v36, the owner: the dial is gone, and the fire grows in time for
     each drop): app.js sets it on the board ten times a second, from small
     just after a drop to full as the next one comes, and on a tile on the
     floor as its ten seconds run out. Declared here so every calc() below has
     something to resolve against
     on the first paint, before app.js has read the setting — an undefined
     custom property inside calc() makes the whole declaration invalid, which
     would have meant no flame at all for the first frame and, on a cold load
     with JS blocked, forever. */
  --flame: 1;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg:       #1d130d;
    --panel:    #33231a;
    --panel-2:  #402c20;
    --line:     rgba(232, 200, 150, .20);
    --line-2:   rgba(232, 200, 150, .10);
    --ink:      #f6e8d1;
    --ink-2:    #e0cba9;
    --dim:      #a58969;
    --dim-2:    #7d6549;
    --pill:     #4a3426;
    --good:     #5fbf6a;
    --warn:     #e0a63a;
    --bad:      #ff6b52;
    --gold:     #e8c46a;
    --gold-ink: #e8c46a;
    --shadow:   0 1px 2px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.45);

    --grain:    .48;
    --vignette: .5;

    --tile:      #cfa876;
    --tile-ink:  #35210f;
    --tile-near: #d6bb8b;
    --select:    #6aa244;
    --select-2:  #8dc763;
    --socket:    rgba(0,0,0,.42);
    --tray:      #4a3120;
    --shelf:     #33210f;
    --shelf-ink: #f2ddb9;
  }
}
:root[data-theme="dark"] {
  --bg:       #1d130d;
  --panel:    #33231a;
  --panel-2:  #402c20;
  --line:     rgba(232, 200, 150, .20);
  --line-2:   rgba(232, 200, 150, .10);
  --ink:      #f6e8d1;
  --ink-2:    #e0cba9;
  --dim:      #a58969;
  --dim-2:    #7d6549;
  --pill:     #4a3426;
  --good:     #5fbf6a;
  --warn:     #e0a63a;
  --bad:      #ff6b52;
  --gold:     #e8c46a;
  --gold-ink: #e8c46a;
  --shadow:   0 1px 2px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.45);

  --grain:    .48;
  --vignette: .5;

  --tile:      #cfa876;
  --tile-ink:  #35210f;
  --tile-near: #d6bb8b;
  --select:    #6aa244;
  --select-2:  #8dc763;
  --socket:    rgba(0,0,0,.42);
  --tray:      #4a3120;
  --shelf:     #33210f;
  --shelf-ink: #f2ddb9;
}

* { box-sizing: border-box; }

/* `[hidden]` MUST WIN, AND BY DEFAULT IT DOES NOT. The browser's own
 * `[hidden] { display: none }` lives in the UA stylesheet, so any author rule
 * setting `display` on the same element beats it outright — no `!important`,
 * no specificity war, just author-over-UA. In `mines` both
 * `.controls { display: flex }` and `.btn-wide { display: block }` did, so
 * `el.hidden = true` did nothing at all and the element sat there in plain
 * sight while the PROPERTY read true — which is why the check that asserted on
 * `el.hidden` agreed with the code and disagreed with the screen. The
 * `.something[hidden]` patches further down this file are each one day
 * somebody hit it without naming it. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  font-family: var(--font);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}
/* THE FLAT COLOUR IS ON <html> AND NOT ON <body>, and the reason is the whole
   of why the wall below took three attempts to appear. A negative z-index paints
   an element BEHIND its parent's own background, so `body::before` under
   `body { background-color }` is painted and then covered by the body — leaving
   exactly one visible strip, wherever the body's background box happened to
   stop. Painting the colour one level up puts the body's backdrop above it and
   the wall in between, which is where it was meant to be. */
html { background-color: var(--bg); }

/* THE STAGE (letters-v33; ~/claude/IOS.md §25, the recipe the tap probe
   measured on iOS 27 and Music shipped as musicapp-v34). A tap on the tab bar
   while the page was still coasting after a flick was eaten whole by WebKit —
   no touch event, no click — because the page that coasted was the DOCUMENT
   and the bar was in it. Now the document never scrolls under a finger: #page
   scrolls inside a screen-tall #app and the tab bar is its SIBLING, so the
   bar is never on a coasting scroller. Installed, the body is 100lvh (100dvh
   and 100% are a status bar short, WebKit bug 301108) and app.js holds the
   document scrolled by that status bar (seat()), which puts the stage's top at
   the screen's top: the page still runs behind the clock and to the bottom
   edge. In a browser tab the body is the visible area and nothing is held.
   Nothing here is position: fixed — a fixed layer is clipped under the status
   bar (the probe's round 2) — so the wall and the banner are absolute in the
   body, the tab bar absolute in the stage. */
:root { --sb: 0px; --kb: 0px; --ios-chrome-t: 0px; }
@media (display-mode: standalone) { :root { --sb: calc(100lvh - 100dvh); } }
html { overflow: hidden; overscroll-behavior: none; }
body { position: relative; height: 100vh; height: 100dvh; overflow: hidden; }
@media (display-mode: standalone) { body { height: 100vh; height: 100lvh; height: var(--stage-h, 100lvh); } }   /* --stage-h: app.js seat(), letters-v49 */
#app { position: absolute; inset: 0; }
#app[hidden] { display: none; }
#page { position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }

/* NATIVE CONTROLS FOLLOW THE APP'S CHOICE, NOT ONLY THE PHONE'S (2026-09-23,
   ~/claude/IOS.md §11). Switches, the name field, scrollbars and the page's
   own canvas read `color-scheme`; without it, Dark chosen in Settings on a
   light phone drew light switches on a walnut wall. theme.js repaints the
   status bar to match. */
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

/* THE WALL, AND THE TWO RULES THAT DRAW IT.
 *
 * `wood.png` is a 150px swatch of grain, so at any sensible size the seam
 * repeats several times down a phone and the eye finds the grid. Three things
 * hide it and all three are cheap: the swatch is scaled up well past its native
 * size so the grain goes soft rather than sharp, a set of plank seams at a
 * different period breaks the repeat into boards, and a vignette darkens the
 * corners so the tiling never reaches an edge where it could be compared.
 *
 * IT IS PAINTED WITH OPACITY AND NOT WITH A BLEND MODE, which is the second
 * thing this was. `mix-blend-mode: soft-light` is the right instinct and it
 * fails at exactly the wrong end: soft-light against a near-black wall returns
 * almost the wall, so the dark theme — the one this game is actually played in
 * — came out flat black with a single visible band across it where the vignette
 * happened to pass through the tones the mode could still move. Plain opacity
 * over a flat `--bg` behaves the same at both ends of the range, and `--grain`
 * then means what it says.
 *
 * Both layers sit on the BODY, which never scrolls (the stage, letters-v33):
 * only #page does, inside it. A repeating background on a scrolling page
 * scrolls with it and turns a wall into a conveyor belt; on the still body
 * it behaves like a room the app moves in front of. They were `fixed` until
 * v33, and a fixed layer is clipped under the status bar (IOS.md §25). */
body::before {
  content: '';
  position: absolute; inset: 0;   /* in the screen-tall body, not fixed (the stage, above) */
  z-index: -2;
  pointer-events: none;
  /* THE SWATCH IS STRETCHED TO THE FULL HEIGHT AND ONLY REPEATED ACROSS, which
     is what kills the seam rather than hiding it. The grain runs vertically, so
     stretching it vertically costs nothing and there is then no top-to-bottom
     join to see; the one join left is the vertical one, and the plank lines are
     given the same period so it always lands on a line. Two numbers that have
     to agree: the image is 262 wide and the lines repeat every 131, so every
     seam is a plank edge and half the plank edges are free. */
  background:
    repeating-linear-gradient(90deg,
      rgba(0,0,0,.34) 0 2px,
      rgba(255,255,255,.07) 2px 5px,
      transparent 5px 131px),
    url(wood.png);
  background-position: 0 0;
  background-size: auto, 262px 100%;
  background-repeat: repeat, repeat-x;
  opacity: var(--grain);
}
/* The vignette, over the grain and under everything else. */
body::after {
  content: '';
  position: absolute; inset: 0;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(130% 95% at 50% 22%,
    transparent 35%, rgba(0,0,0,var(--vignette)) 100%);
}

/* The whole app refuses text selection and the iOS long-press callout: both
   interrupt a trace, and on a board made of letters the callout does the worst
   possible thing — it offers to select the word you are trying to spell. */
body {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
/* NO DOUBLE-TAP ZOOM, ANYWHERE (letters-v49; ~/claude/IOS.md, Ask's recipe).
   touch-action is not inherited, and WebKit intersects it only up to the
   nearest scroller — so `manipulation` on the body stopped at #page, and a
   double tap on a Settings row or the leaderboard could still zoom. On every
   element it holds everywhere (pan + pinch, never a double-tap); a later class
   rule still wins where a control needs more (the board's `none`). Installed,
   app.js holdZoom() also refuses the pinch, as a native app's screen does. */
*, *::before, *::after { touch-action: manipulation; }
input, textarea { -webkit-user-select: text; user-select: text; }

/* ── the glass at the top of the screen ────────────────────────────────────

   iOS 26 blurs the top edge of a full-screen web app against the status bar:
   content does not stop under the bar, it goes SOFT beneath it, and anything in
   that band is unreadable rather than hidden.

   `env(safe-area-inset-top)` does not describe it. That inset is the status
   bar's own height — it is what keeps content out from UNDER the bar, and it is
   already applied everywhere below. The blur reaches past it, so the two are
   different measurements and both are needed. Adding them is the same fix
   `budget` and `mines` made, and 20px is the same number.

   It costs a browser tab nothing — 20px of background at the top of a scrolling
   page reads as margin — so it is not gated on display-mode. A media query that
   silently fails to match is worse than a margin nobody notices. */
:root { --glass-t: 20px; }

/* ── shell ─────────────────────────────────────────────────────────────── */

/* The page keeps the tab bar's height under its last row — or the keyboard's,
   while a field has it up (--kb, app.js keyboard(); the bar stays under the
   keyboard, as a native tab bar does). */
main { padding-bottom: max(calc(76px + env(safe-area-inset-bottom, 0px)), calc(24px + var(--kb))); }

/* THE GAME SCREEN IS THE SCREEN (2026-09-24, the owner: gems and
   minesweeper hold still in play, "more like a native app"). It used to scroll
   by the height of main's tab-bar padding under a board already a screen
   tall — a trace that drifted vertically off the board, or a flick on the
   tray, moved the whole game. gems' lock (~/claude/IOS.md §15 item 2): set by
   show() while the game is the view; the other three scroll the document. */
html.in-game #page { overflow: hidden; overscroll-behavior: none; }   /* the stage: the PAGE is locked, never the root */
html.in-game main { padding-bottom: 0; height: 100%; }

.view {
  /* --sb: installed, the status bar (env() reads 0 under the `default` style,
     IOS.md §25); --ios-chrome-t: Safari's own window on an iPhone, where the
     page starts under the status bar and the address pill and the inset reads
     0 (IOS.md §19). Both are 0 everywhere else. */
  padding: calc(8px + var(--glass-t) + max(env(safe-area-inset-top, 0px), var(--ios-chrome-t)) + var(--sb)) 16px 24px;
  max-width: 560px;
  margin: 0 auto;
}
.view[hidden] { display: none; }

.view-head { padding: 12px 4px 18px; }
/* THE TITLE IS THE ONE PIECE OF SIGNAGE IN THE APP, so it gets the serif and a
   hairline of relief under it — carved into the shelf rather than printed on it.
   Everything else stays in the system face: a serif is a costume, and a whole
   app wearing one is harder to read for no gain. */
.view-head h1 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 33px; font-weight: 700; letter-spacing: -.2px;
  color: var(--ink);
  text-shadow: 0 1px 0 color-mix(in srgb, var(--panel) 55%, transparent);
}
.view-sub { margin: 5px 0 0; color: var(--dim); font-size: 15px; }

h2.sec {
  margin: 28px 4px 8px;
  font-size: 13px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .9px; color: var(--gold-ink);
}

/* A CARD IS A PAGE, and it is lifted off the wall rather than flush with it: a
   hairline of light along the top edge, a dark hairline all round, and a real
   shadow under it. On light that reads as paper on a desk; on dark as a leather
   panel. The hairline is a token so it moves with the theme — a fixed white one
   is invisible on paper and glaring on leather. */
.card {
  background: var(--panel);
  border-radius: var(--r-card);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 0 0 1px var(--line-2),
    var(--shadow);
  overflow: hidden;
}

.fineprint {
  margin: 14px 6px 0;
  color: var(--dim);
  font-size: 12.5px;
  line-height: 1.55;
}
.fineprint strong { color: var(--ink-2); font-weight: 600; }

/* ── buttons ───────────────────────────────────────────────────────────── */

/* THE BUTTONS ARE OBJECTS, not rectangles of colour: a light edge on top, a
   dark one underneath, and both go away on press so the button visibly sinks.
   `opacity` alone — what this file used to do — dims a control without moving
   it, which on a touch screen reads as "disabled" rather than "pressed". */
.btn {
  font: inherit; font-size: 16px; font-weight: 600;
  padding: 13px 18px;
  border: 0; border-radius: var(--r-mid);
  background: var(--pill); color: var(--ink);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
    0 1px 0 var(--line-2),
    0 2px 4px rgba(0,0,0,.16);
  cursor: pointer;
}
.btn:active {
  transform: translateY(1px);
  box-shadow: inset 0 1px 3px rgba(0,0,0,.28);
}
/* Brass. The primary action in a library is a plate on a door. */
.btn-primary {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--gold) 82%, #fff),
    var(--gold));
  color: #2b1a08;
  text-shadow: 0 1px 0 rgba(255,255,255,.30);
}
.btn-quiet   { background: var(--pill); color: var(--ink-2); font-weight: 500; }
.btn-wide    { display: block; width: 100%; margin-top: 18px; }
/* The Test buttons beside the Haptics and Sound switches. */
.btn-sm      { font-size: 14px; padding: 7px 14px; border-radius: 10px; }
.btn:disabled { opacity: .45; }

.icon-btn {
  font: inherit; font-size: 22px; line-height: 1;
  width: 40px; height: 40px;
  display: grid; place-items: center;
  border: 0; border-radius: 12px;
  background: transparent; color: var(--gold-ink);
  cursor: pointer;
}
.icon-btn:active { background: var(--pill); }

/* ── the gate ──────────────────────────────────────────────────────────── */

.gate { min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
.gate[hidden] { display: none; }
.gate-card { max-width: 340px; text-align: center; }
/* THE MARK ON THE SIGN-IN SCREEN, drawn rather than typed — see index.html.
   It shares `.tab-g`'s stroke treatment because it is the same drawing; only
   the size differs, so the size is the only thing said twice. */
.gate-mark { line-height: 0; }
.gate-mark img {
  width: 64px; height: 64px;
  display: inline-block;
  filter: drop-shadow(0 2px 4px rgba(0,0,0,.25));
}
.gate-card h1 {
  margin: 8px 0 6px;
  font-family: var(--font-display);
  font-size: 34px; font-weight: 700; letter-spacing: 0;
}
.gate-sub { margin: 0 0 24px; color: var(--dim); font-size: 15px; line-height: 1.5; }
.gate-note { margin-top: 16px; color: var(--dim); font-size: 12.5px; line-height: 1.5; }
.gate-msg {
  margin: 0 0 18px; padding: 11px 13px;
  border-radius: var(--r-mid);
  background: color-mix(in srgb, var(--warn) 14%, transparent);
  color: var(--warn);
  font-size: 13px; line-height: 1.45; text-align: left;
}
.gate-msg[hidden] { display: none; }

/* ── the mode card ─────────────────────────────────────────────────────── */
.mode-cards { display: grid; gap: 12px; }

.mode-card {
  display: block; width: 100%; text-align: left;
  font: inherit; color: inherit; cursor: pointer;
  border: 0; padding: 18px;
  background: var(--panel);
  border-radius: var(--r-card);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 0 0 1px var(--line-2),
    var(--shadow);
}
.mode-card:active { background: var(--panel-2); transform: translateY(1px); }
.mode-card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.mode-card h3 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 22px; font-weight: 700; letter-spacing: 0;
}
.mode-card .shape { color: var(--dim); font-size: 13px; font-family: var(--font-mono); }
.mode-card .blurb { margin: 6px 0 0; color: var(--ink-2); font-size: 14px; line-height: 1.45; }

.mode-card-stats {
  display: flex; gap: 18px; margin-top: 14px;
  padding-top: 13px; border-top: 1px solid var(--line-2);
}
.mode-card-stats div { display: flex; flex-direction: column; gap: 1px; }
.mode-card-stats .v { font-family: var(--font-num); font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
.mode-card-stats .k { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; }
.mode-card-stats .v.none { color: var(--dim-2); }

/* THE CARD WITH A GAME IN IT. A brass edge says this card is not the default
   action, and the line at the bottom says what tapping it does — Continue, with
   the score — in the words the app uses, so the card cannot promise a new
   board and deliver an old one. */
.mode-card.has-save { box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent), 0 0 0 2px var(--gold), var(--shadow); }
.mode-card-resume {
  margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--line-2);
  font-size: 14px; line-height: 1.45; color: var(--ink-2);
}
.mode-card-resume strong { color: var(--gold-ink); }
/* The quieter way out: a fresh board, worded so that what it costs is on the
   button rather than discovered afterwards. */
.mode-card-new {
  display: block; width: 100%; margin: -2px 0 0;
  font: inherit; font-size: 13.5px; font-weight: 500;
  padding: 10px 14px; border: 0; background: transparent;
  color: var(--dim); text-align: center; cursor: pointer;
}
.mode-card-new:active { color: var(--ink-2); }

/* THE TWO SPECIAL CARDS — a challenge and today's board — are mode cards with
   one difference each: they are not the default action, so they carry the
   brass edge the resume card carries, and the challenge card is a <div> with
   two buttons in it because "Play" and "Not now" are two different answers. */
/* OUTSIDE the `.mode-cards` grid, so they do not get its 12px gap for free;
   the same 12px is set here so the column reads as one list. */
.special-card { margin: 0 0 12px; box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent), 0 0 0 1.5px var(--gold), var(--shadow); }
.special-card .mode-card-resume { margin-top: 12px; }
.special-btns { display: flex; gap: 10px; margin-top: 14px; }
.special-btns .btn { flex: 1; }
#daily-card.played { box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent), 0 0 0 1px var(--line-2), var(--shadow); }

/* The install pill on the home screen — the same shape as sums', in brass. */
.pill {
  display: block; width: 100%; margin: 0 0 12px;
  font: inherit; font-size: 13.5px; font-weight: 600;
  padding: 10px 14px; border: 0; border-radius: 999px;
  background: color-mix(in srgb, var(--gold) 18%, var(--panel)); color: var(--gold-ink);
  cursor: pointer;
}

/* THE INSTALL CARD WEARS THE LIBRARY. install.js styles itself from tokens and
   falls back to iOS blue; `--link` above covers the text, and these put the
   brass plate — the same gradient and dark ink as `.btn-primary` — on the
   button and the icon, where white-on-brass would fail on dark. Doubled
   selectors on purpose: the file injects its own <style> after this sheet, so
   equal specificity would lose. */
.pwa-gate .pwa-primary, .pwa-gate .pwa-icon {
  background: linear-gradient(180deg, color-mix(in srgb, var(--gold) 82%, #fff), var(--gold));
  color: #2b1a08;
}
.pwa-gate .pwa-primary { text-shadow: 0 1px 0 rgba(255,255,255,.30); }
.pwa-gate .pwa-card { box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent), 0 0 0 1px var(--line-2), var(--shadow); }

/* ── the game screen ───────────────────────────────────────────────────── */

/* NO SAFE-AREA INSET AND NO GLASS OFFSET HERE — both are on `.view`, which wraps
   this. Applying them twice pushes the board down by the status bar height a
   second time and it is the tallest screen in the app. */
.game-head {
  display: flex; align-items: center; gap: 14px;
  padding: 0 0 10px;
}
.game-stat { display: flex; flex-direction: column; align-items: center; line-height: 1.05; }
.game-stat-right { margin-left: auto; }
.game-stat-v {
  font-family: var(--font-num); font-size: 23px; font-weight: 800;
  font-variant-numeric: tabular-nums; letter-spacing: -.3px;
  color: var(--gold-ink);
  text-shadow: 0 1px 0 color-mix(in srgb, #000 18%, transparent);
}
/* The score GULPS as each tile of a word lands in it (letters-v39), so a good
   word is visible in the number as well as in the tray. An animation restarted
   per landing, not a transition: the transition this replaced went to its
   scale and stayed there, the number swollen from the first word on. */
#stat-score { display: inline-block; transform-origin: 50% 60%; }
#stat-score.bump { animation: gulp 230ms cubic-bezier(.2,1.4,.4,1); }
@keyframes gulp {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.2); }
  100% { transform: scale(1); }
}
.game-stat-k { font-size: 10.5px; color: var(--dim); text-transform: uppercase; letter-spacing: .8px; }
.game-head .game-stat:first-of-type { margin-left: 4px; }

/* THE GAME SCREEN IS A COLUMN, and the play area takes what the header leaves.
   layoutBoard() measures `.play` rather than the board itself: the board's own
   height is the thing being decided, so measuring it would be measuring the
   answer to compute the answer. */
/* THE GAME SCREEN IS THE ONE THAT GETS THE NARROWER MARGIN, and it is worth 8
   pixels a side. Square tiles are WIDTH-bound where the hexagons they replaced
   were height-bound — hexagons tuck a quarter of their width into the column
   beside them and squares do not — so on a phone the board now runs out of
   screen sideways with room to spare above and below it, and every pixel taken
   off this padding goes straight into the tile. Measured on a 390pt phone: 16px
   a side gives a 46px tile, 8px gives 48. */
#view-game {
  display: flex;
  flex-direction: column;
  height: 100%;   /* the stage's height: main is the locked page's, a screen (letters-v33) */
  padding-left: 8px;
  padding-right: 8px;
  padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px));
}
#view-game[hidden] { display: none; }
#view-game { position: relative; }   /* the frame #fly measures from */
.play {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 10px;
}

/* THE BOARD SITS IN A TRAY, the way a scrabble rack does. The frame is
   `--frame` of wood on every side with the grain running through it, an inner
   shadow so the tiles read as recessed into it, and a hairline highlight along
   the top edge so it reads as a lit object rather than a brown rectangle.

   Its width is REAL and app.js subtracts it before sizing the tiles — see the
   `--frame` comment in the palette. */
/* THE EXTRA MARGIN UNDER THE BOARD IS GONE, and the note explaining it with it.
   It existed because the flames used to hang DOWNWARD off the bottom edge, so a
   bottom-row tile threw about 30px of fire across the word tray. They rise up
   the tile now and nothing escapes the frame below, so the reason has expired —
   and a comment justifying 14px with an argument that is no longer true is
   worse than the 14px. */
.board-wrap {
  position: relative;
  width: fit-content;
  margin: 0 auto;
  padding: var(--frame);
  border-radius: calc(var(--r-card) + var(--frame));
  background-color: var(--tray);
  background-image:
    linear-gradient(180deg, rgba(255,255,255,.14), rgba(0,0,0,.10) 45%, rgba(0,0,0,.20)),
    url(wood.png);
  background-size: auto, 220px;
  background-repeat: no-repeat, repeat;
  background-blend-mode: overlay, soft-light;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.22),
    inset 0 0 0 1px rgba(0,0,0,.25),
    0 2px 3px rgba(0,0,0,.18),
    0 14px 34px rgba(0,0,0,.30);
}

/* THE BOARD OWNS EVERY GESTURE INSIDE IT. `touch-action: none` stops the page
   scrolling under a finger that is tracing a word — without it, a downward trace
   scrolls the app instead of spelling anything. */
.board {
  position: relative;
  margin: 0 auto;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

/* One socket per cell, painted under the tiles so an empty board still reads as
   a board rather than a void. Square, like the tile that sits in it, and sunk
   into the tray rather than raised out of it. */
.socket {
  position: absolute;
  background: var(--socket);
  border-radius: var(--r-cell);
  box-shadow: inset 0 1px 2px rgba(0,0,0,.35);
  pointer-events: none;
}

/* ── a tile ─────────────────────────────────────────────────────────────
 *
 * A SQUARE, WITH KENNEY'S WOOD ON IT — see the file header for why the letter
 * is drawn here rather than being part of the picture.
 *
 * THE TEXTURE IS TINTED, NOT REPLACED. `tile.png` is a dark wood face with a
 * carved bevel; the tile wants to be a light tan, a green when it is chosen and
 * an orange when it is alight, and it wants the bevel and the grain in every one
 * of those. So the image is blended into `--tile` with `soft-light`: the colour
 * decides what the tile IS, the picture only shades it. A state is then one
 * token changing, and the carving survives it.
 *
 * THE BLEND MODE WAS MEASURED, NOT PICKED. Nine of them were rendered side by
 * side on the four colours this tile actually takes — tan, Qu's tan, the green
 * of a selection, the orange of a fire — because a mode that flatters one of
 * those can ruin another, and reasoning about that from the spec is how you
 * arrive at a beautiful tan tile and an unreadable burning one. `luminosity`,
 * the obvious choice, was the worst: it takes its lightness from the picture, so
 * every tile came out the photograph's dark brown whatever colour it was meant
 * to be, and the fire looked like mud. `overlay` and `hard-light` were the other
 * way — they turned the tan to a gold that competed with the fire. `soft-light`
 * was the one that left all four recognisable.
 *
 * A FILTER WOULD HAVE BEEN THE OBVIOUS WAY AND IT IS THE WRONG ONE: `filter`
 * inherits down to the letter and the value, so brightening the wood would
 * brighten the letter printed on it, and the tile with the most contrast would
 * be the one hardest to read.
 *
 * The letter is a child rather than the element's own text, because the tile is
 * the thing that moves and the letter is the thing that must not stretch when
 * it does.
 */
.tile {
  position: absolute;
  display: grid;
  place-items: center;
  color: var(--tile-ink);
  border-radius: var(--r-cell);
  background-color: var(--tile);
  background-image: url(tile.png);
  background-size: 100% 100%;
  background-blend-mode: soft-light;
  box-shadow:
    0 1px 0 rgba(255,255,255,.35) inset,
    0 1px 2px rgba(0,0,0,.35),
    0 3px 6px rgba(0,0,0,.20);
  transition: transform var(--t-fall) cubic-bezier(.3,.8,.4,1),
              background-color 140ms ease,
              opacity var(--t-pop) ease;
  will-change: transform;
  cursor: pointer;
}
.tile.instant { transition: none; }
/* ABOVE THE FLAME. `position: relative` is what gives these a z-index at all —
   a static element ignores it — and without the lift the fire climbing the tile
   would be painted straight over the letter it is burning. */
.tile .ch {
  position: relative;
  z-index: 2;
  font-family: var(--font-num);
  font-weight: 800;
  letter-spacing: -.5px;
  line-height: 1;
  pointer-events: none;
  /* THE LETTER IS ENGRAVED, NOT PRINTED. One dark glyph with a one-pixel light
     shadow under it is the whole of the effect, and it is what stops a dark
     letter from disappearing into the darkest part of the grain behind it. */
  text-shadow: 0 1px 0 rgba(255,255,255,.42);
  /* Qu is two characters in the space of one letter, so it is allowed to shrink
     rather than pushing the tile's shape around. */
  white-space: nowrap;
}
/* The letter's value, small, in the corner — where a scrabble tile keeps it. It
   is the only way to tell a Z from a T without memorising the table in Settings,
   and it is `letterValue()`'s answer, not the artwork's. */
.tile .val {
  position: absolute;
  z-index: 2;
  right: 9%;
  bottom: 5%;
  font-family: var(--font-num);
  font-weight: 700;
  opacity: .62;
  line-height: 1;
  pointer-events: none;
}

/* SELECTED — a tile in the word being built...
   ...AND THE TIP IN A BRIGHTER SHADE. Two shades, not one plus a ring: the tip
   is the only tile a tap can take back, and it is the tile the "which of these
   touch" tint is measured from, so it has to be findable at a glance.

   Green, because the two colours already spoken for on this board are the
   tan of an untouched tile and the orange of a burning one. NOT because the
   original does it: this said so until 2026-09-27, when stills of Bookworm
   Deluxe showed its chosen tiles lit a pale cream with small green arrows
   between them, and its green and gold bonus tiles green and gold all over.
   The owner kept this app's green selection, which is why the gems below are
   stones on the tile rather than the tile's whole face. */
.tile.sel {
  background-color: var(--select);
  color: #f4fbee;
  z-index: 3;
}
.tile.sel .ch { text-shadow: 0 1px 1px rgba(0,0,0,.45); }
.tile.sel .val { opacity: .75; }

.tile.tip {
  background-color: var(--select-2);
  z-index: 4;
  box-shadow:
    0 0 0 2px color-mix(in srgb, var(--select) 70%, #fff) inset,
    0 2px 3px rgba(0,0,0,.4),
    0 6px 14px rgba(0,0,0,.3);
}

/* CANDIDATE — a tile the finger could go to next. Faint, and only while a word
   is being traced: on an offset board, "which of these touch the one I am on"
   is a genuinely hard question to answer by eye at speed. */
.tile.near { background-color: var(--tile-near); }
.tile.near::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--select) 45%, transparent);
}

/* ON FIRE. Not a theme token — see the file header. The animation is a slow
   breath rather than a flash: this thing sits on the board for seven turns and a
   flashing tile for seven turns is an app people close. */
.tile.burn {
  /* z-index so the flame above the tile is not painted under its neighbour —
     see the flame note below. */
  z-index: 2;
  /* CHARRED, NOT BRIGHT. This was `#e2531a` — the same orange the fire's glow
     and the peril strip use — and it left the flame nothing to be brighter
     than: a tongue of fire over a tile already at that value simply did not
     read. A tile being eaten by fire should be the DARK thing with the bright
     thing on it, which is also what burning wood looks like. The glow around it
     and the peril strip keep the original orange, so the danger colour is
     unchanged where it is doing the signalling. */
  background-color: #a83a0d;
  color: #fff5e6;
  box-shadow:
    0 1px 2px rgba(0,0,0,.4),
    0 0 16px rgba(226,83,26,.55),
    0 3px 8px rgba(0,0,0,.25);
  animation: ember 1.8s ease-in-out infinite;
}
.tile.burn .ch { text-shadow: 0 1px 2px rgba(90,20,0,.75); }
/* THE VALUE HAS TO SURVIVE THE FIRE UNDER IT. It sits in the bottom-right
   corner, which is the hottest, brightest part of the flame — cream on a bright
   amber base at full width, it disappeared. Same dark shadow the letter gets,
   and no opacity knocked off it. */
.tile.burn .val {
  opacity: 1;
  text-shadow: 0 1px 2px rgba(90,20,0,.85);
}
@keyframes ember {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.22); }
}
/* ── THE FLAMES ────────────────────────────────────────────────────────────
 *
 * Two kinds, and they are different things.
 *
 * THE FIRST IS AMBIENT and costs no JavaScript at all: `::before` on every
 * burning tile, one tongue licking up off the top edge, flickering forever. It
 * is a pseudo-element rather than a spawned node because there can be a dozen
 * fires on the board at once and each would otherwise need its own element,
 * its own timer and its own cleanup — and because a flame that IS the tile
 * cannot get out of step with it. Light the class, get a flame.
 *
 * `z-index: -1` puts it behind the tile's own face, so the tongue rises from
 * BEHIND the tile rather than over the letter. It stays inside the tile's own
 * stacking context — `will-change: transform` on `.tile` makes one — so it can
 * never fall behind the board itself. And `.tile.burn` is lifted to `z-index: 2`
 * so a flame reaching up past the top edge is not painted under the tile
 * sitting in the row above it.
 *
 * THE SECOND IS THE BURST, spawned into `#fx` by app.js — see flames() there
 * for why the trail is left where the fire WAS rather than carried with it. */
.tile.burn::before {
  content: '';
  position: absolute;
  /* IT IS SEATED AT THE BOTTOM OF THE TILE AND RISES UP IT — two separate
     facts, and the second one is gravity.
   *
   * WHERE the fire sits is the game: it eats DOWNWARD a row per turn and the
   * whole danger is how close it is to the floor, so the flame is anchored to
   * the tile's bottom edge rather than its top. A fire on the top edge said
   * "this tile is hot"; one on the bottom edge says "this is coming for the row
   * below", which is what the player is deciding about.
   *
   * WHICH WAY IT POINTS is not the game, it is physics. The first version had
   * the tongue hanging off the bottom edge pointing DOWN, which followed the
   * mechanic to its literal conclusion and looked wrong in the way a thing that
   * ignores gravity always does. Flames rise. The base is at the bottom edge and
   * the tongue licks UP across the tile's face, so the tile reads as being eaten
   * from underneath. */
  bottom: -8%;
  /* FULL TILE WIDTH at the dial's default. It was 46% — a narrow tongue in the
     middle of the tile, which read as a candle standing on it rather than as the
     tile itself being alight. The whole face is what is burning.

     THE DIAL MOVES THE WIDTH LESS THAN THE HEIGHT, and that is not fussiness.
     Scaling both by the full factor made the top of the dial a fireball wider
     and taller than the tile it was on: the tile vanished inside it and the
     letter went with it. **You cannot spell with a letter you cannot read**, so
     no setting of a decorative control is allowed to take one off the board.
     Height carries the sense of size; width goes from 77% to 130% across the
     whole range instead of 40% to 180%. */
  width: calc(100% * (0.62 + 0.38 * var(--flame)));
  left: 50%;
  margin-left: calc(-50% * (0.62 + 0.38 * var(--flame)));
  height: calc(78% * var(--flame));
  /* IN FRONT OF THE TILE'S FACE, BEHIND ITS LETTER — and this is the change
     that made the direction work at all. While the tongue hung DOWNWARDS off
     the bottom edge, almost all of it was outside the tile's box, so `z-index:
     -1` was free and it looked right. Turn it upward and the same rule hides
     it: the flame is now mostly ON the tile, and a negative z-index paints it
     under the tile's own opaque background. Only the 8% below the edge survived,
     which read as a small hot glow and not as fire at all.

     So the flame paints over the face, and `.ch`/`.val` are lifted above it —
     see the rule below. The letter has to stay legible on a tile that is on
     fire; that is the whole reason the value is on there. */
  z-index: 0;
  /* Blunt at the base, pointed at the tip. An earlier version was 50%/68% all
     round and read as a candle GLOW rather than as a tongue. */
  border-radius: 50% 50% 34% 34% / 78% 78% 22% 22%;
  /* THE BRIGHT PART REACHES MOST OF THE WAY UP. Weighted toward the base — a
     near-white core fading by 60% — the tongue read as a hot spot at the bottom
     edge and nothing above it, because the faint half was orange over a tile
     that is already orange. The white now runs to a third, the yellow to
     two-thirds, and only the last third fades out. */
  /* AMBER, NOT WHITE, at the core. Under `screen` a near-white base saturates
     everything beneath it to flat white — at full width that bleached the tile's
     whole lower half, took the value digit with it, and made the fire read as a
     lamp rather than as flame. Pulling the core down to amber keeps the tile
     visible through its own fire. */
  background: linear-gradient(to top,
    rgba(255,236,178,.94) 0%,
    rgba(255,206,110,.9) 30%,
    rgba(255,162,50,.82) 62%,
    rgba(243,104,22,.46) 84%,
    rgba(226,56,15,0) 100%);
  filter: blur(1.6px);
  /* SCREEN, BECAUSE THE THING IT IS BURNING IS ALREADY ORANGE. The flame was
     drawn opaquely at first and all but vanished: an orange tongue over the
     `#e2531a` of a burning tile is orange on orange, and the only part that
     showed was the near-white at its base — which read as a small hot glow and
     not as fire. `screen` makes the flame ADD light to whatever is beneath it,
     so the same tongue comes out yellow-white over the tile and deep orange
     where it passes over the dark board. Which is what fire does. */
  mix-blend-mode: screen;
  transform-origin: 50% 100%;
  animation: lick 620ms ease-in-out infinite alternate;
  pointer-events: none;
  /* In Timed --flame moves ten times a second with the clock; this joins the steps. */
  transition: height 120ms linear, width 120ms linear, margin-left 120ms linear;
}
/* A fire on the floor burns higher and faster. Same tongue, more of it: the one
   state that means the game is about to end should be the loudest thing here. */
/* Longer and much faster than an ordinary fire. It can afford the length again
   now that the tongue rises up the tile instead of hanging below it — a peril
   tile is by definition on the bottom row, and the old downward flame put every
   pixel of itself over the word tray. */
.tile.peril::before {
  height: calc(112% * var(--flame));
  animation-duration: 380ms;
}
/* THE FLICKER CARRIES NO `var()`, AND THE SIZE IS NOT IN HERE. Putting
   `calc(.74 * var(--flame))` in these keyframes is the obvious way to make the
   dial work and it stakes the whole feature on custom properties resolving
   inside an animated transform — which is exactly the sort of thing that works
   in the browser you tested and silently does not in the one on the phone. The
   dial changes `height`, `width` and `margin-left`, which are ordinary
   declarations that cannot fail; this only ever flickers what is there. */
@keyframes lick {
  0%   { transform: scaleY(.74) scaleX(1.1) skewX(-8deg); opacity: .8; }
  100% { transform: scaleY(1.26) scaleX(.86) skewX(9deg); opacity: 1; }
}

/* A SPAWNED FLAME. One keyframe, and every instance carries its own drift,
   height, scale and tilt as custom properties — app.js sets them inline. The
   alternative is a stylesheet rule per flame, or five identical tongues rising
   in a rank, which reads as a machine rather than a fire.

   `left`/`top` place the flame's CENTRE, hence the -50% in the transform: the
   element is positioned from the cell's middle and every keyframe has to carry
   that offset, because a transform replaces it rather than adding to it. */
.flame {
  position: absolute;
  border-radius: 50% 50% 34% 34% / 66% 66% 34% 34%;
  background: linear-gradient(to top,
    rgba(255,248,214,.98) 0%,
    rgba(255,180,66,.92) 40%,
    rgba(240,104,26,.55) 74%,
    rgba(226,56,15,0) 100%);
  filter: blur(1.5px);
  mix-blend-mode: screen;
  pointer-events: none;
  opacity: 0;
  animation-name: flame-up;
  animation-timing-function: cubic-bezier(.25,.7,.4,1);
  animation-fill-mode: forwards;
}
/* AND THE SPARKS RISE, for the same reason the tongue does — see the note on
   `.tile.burn::before`. `--rise` is a NEGATIVE offset, which is what up is. */
@keyframes flame-up {
  0%   { transform: translate(-50%, 0) scale(calc(var(--s) * .4)) rotate(0deg); opacity: 0; }
  22%  { opacity: .95; }
  100% { transform: translate(calc(-50% + var(--dx)), var(--rise))
                    scale(calc(var(--s) * 1.05)) rotate(var(--tilt));
         opacity: 0; }
}


/* PERIL — a fire that has reached the bottom row. This is the one moment the
   game is about to end, so it is the one thing on the screen allowed to pulse. */
.tile.peril {
  animation: ember 1.8s ease-in-out infinite, peril 900ms ease-in-out infinite;
  z-index: 2;
}
@keyframes peril {
  0%, 100% { box-shadow: 0 0 0 0 rgba(226,56,15,.6), 0 0 16px rgba(226,83,26,.55); }
  50%      { box-shadow: 0 0 0 7px rgba(226,56,15,0), 0 0 16px rgba(226,83,26,.55); }
}
.tile.sel.burn { background-color: #b8611f; }

/* ── the gems ──────────────────────────────────────────────────────────────
   A BADGE, NOT A RECOLOURED FACE — which is a departure: Bookworm's green and
   gold tiles are coloured all over (and it has sapphire and diamond above
   them). Here the face already has three colours with
   meanings — tan, the green of a chosen tile, the char of a burning one — and
   a fourth and fifth painted over the same blend would collide with all of
   them (a green gem tile that is also chosen would be green twice). So the gem
   is a small cut stone in the top-left corner, over the face, under the
   letter, in the one colour each tier owns. `::after`, because `::before` is
   the flame. The stone is a rotated square with a lit top-left facet, which
   reads as a gem at 9px where a circle reads as a dot. */
:root {
  /* THE STONES, ONCE. The gold one is also the supporter's mark (`.mark`),
     so the stone on a board tile and the one by a supporter's name are the
     same cut and cannot drift apart. One picture in both themes, like the fire. */
  --gem-green: linear-gradient(135deg, #b9f5c9 0%, #2fb35a 48%, #146a31 100%);
  --gem-gold:  linear-gradient(135deg, #fff2b0 0%, #f0b429 48%, #9a6104 100%);
  --gem-cut:   0 0 0 1px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.55);
}
.tile.gem-1::after, .tile.gem-2::after, .key-tile.gem-1::after, .key-tile.gem-2::after {
  content: ''; position: absolute; left: 12%; top: 12%;
  width: 22%; height: 22%; max-width: 12px; max-height: 12px;
  transform: rotate(45deg);
  border-radius: 18%;
  background: var(--gem-green);
  box-shadow: var(--gem-cut);
  z-index: 2; pointer-events: none;
}
.tile.gem-2::after, .key-tile.gem-2::after {
  background: var(--gem-gold);
}
.key-tile.gem-1::after, .key-tile.gem-2::after { left: 8%; top: 8%; width: 30%; height: 30%; }
/* The letter stays on top of the stone — a gem you cannot read is a tile you
   cannot play. The stone sits BELOW the letter's own z-index (2), and nothing
   about the letter is touched: the first version of this gave `.val` a
   `position: relative`, which took it out of its corner and into the grid
   flow, and every letter on the board slid off centre. */
.tile.gem-1::after, .tile.gem-2::after { z-index: 1; }

/* CLEARING is a flight into the score now — .fly, below (letters-v39). */

/* HINT — the same shape of highlight `mines` and `gems` use, for the same
   reason: it has to be visible without being mistaken for a selection. Brass
   rather than green, since green is now what a selection looks like. */
.tile.hint::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 3px var(--gold), 0 0 12px color-mix(in srgb, var(--gold) 60%, transparent);
  animation: hint 1s ease-in-out 3;
}
@keyframes hint { 50% { opacity: .25; } }

/* The flames' bursts live here, above the tiles. */
.fx { position: absolute; inset: 0; pointer-events: none; z-index: 5; }

/* THE WORD GOES INTO THE SCORE (letters-v39). A copy of each cleared tile,
   in #fly, the whole game screen's layer: z-index 4 keeps it above the header
   and the board and under the overlays (5), so a word that ends the game flies
   behind the card that says so. The paths are app.js's (eatWord); the box
   carries the sideways travel and the tile inside it the rise and the shrink. */
.fly { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
.fly-x { position: absolute; }
.fly-tile { left: 0; top: 0; transition: none; will-change: transform, opacity; }
/* The "+N" beside the score, as the first letter lands in it. */
.gain {
  position: absolute;
  font-family: var(--font-num); font-weight: 800; font-size: 17px;
  white-space: nowrap;
  color: var(--gold-ink);
  text-shadow: 0 0 3px var(--bg), 0 1px 2px rgba(0,0,0,.35);
  animation: gain 1000ms cubic-bezier(.2,.8,.3,1) forwards;
}
@keyframes gain {
  0%   { transform: translate(-4px, -50%) scale(.7); opacity: 0; }
  18%  { transform: translate(0, -50%) scale(1.08); opacity: 1; }
  70%  { transform: translate(0, -50%) scale(1); opacity: 1; }
  100% { transform: translate(4px, -80%) scale(1); opacity: 0; }
}

/* NO REDUCED-MOTION RULES (2026-09-24, the owner). The phone's Reduce Motion
   setting used to switch the flames off and hurry every fall; people who had
   it on without knowing got a flat, hurried game, and the owner's rule is the
   App Store's habit: a game looks the same whatever that switch says. */

/* ── the word tray ──────────────────────────────────────────────────────
 *
 * THE TRAY IS THE WHOLE INTERFACE, and it has one job that is easy to get
 * wrong: it must say what the word is worth BEFORE it is played, because the
 * only decision in this game is whether to take a word now or look for a longer
 * one. A score that only appears afterwards makes that decision blind.
 */
.tray {
  display: flex; align-items: center; gap: 10px;
  min-height: 58px;
  padding: 10px 12px;
  border-radius: var(--r-mid);
  background: var(--panel);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 0 0 1px var(--line-2),
    var(--shadow);
}
.tray-word {
  flex: 1; min-width: 0;
  font-family: var(--font-num);
  font-size: 24px; font-weight: 800; letter-spacing: 1.5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tray-word.none { font-size: 15px; font-weight: 500; letter-spacing: 0; color: var(--dim); }
/* A word the dictionary knows. Not green-for-good and red-for-bad: red on a
   half-typed word reads as a mistake, and most of the time a half-typed word is
   simply not finished yet. */
.tray-word.ok { color: var(--good); }
.tray-pts {
  font-family: var(--font-num); font-size: 20px; font-weight: 700;
  font-variant-numeric: tabular-nums; color: var(--good);
}
.tray-pts[hidden] { display: none; }
.tray-btn {
  font: inherit; font-size: 15px; font-weight: 700;
  padding: 10px 16px; border: 0; border-radius: 11px;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--gold) 82%, #fff), var(--gold));
  color: #2b1a08;
  text-shadow: 0 1px 0 rgba(255,255,255,.28);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4), 0 2px 4px rgba(0,0,0,.22);
  cursor: pointer;
}
.tray-btn:active { transform: translateY(1px); box-shadow: inset 0 1px 3px rgba(0,0,0,.3); }
.tray-btn:disabled {
  background: var(--pill); color: var(--dim-2);
  text-shadow: none; box-shadow: inset 0 1px 2px rgba(0,0,0,.14);
}
.tray-clear {
  font: inherit; font-size: 20px; line-height: 1;
  width: 38px; height: 38px; flex: none;
  border: 0; border-radius: 11px; background: var(--pill); color: var(--ink-2);
  cursor: pointer;
}
.tray-clear:disabled { opacity: .4; }

/* ── controls ──────────────────────────────────────────────────────────── */

.controls { display: flex; gap: 8px; align-items: stretch; }
.controls .btn-quiet { padding-left: 16px; padding-right: 16px; flex: none; }

.status {
  flex: 1;
  display: flex; flex-direction: column; justify-content: center;
  padding: 8px 14px;
  border-radius: var(--r-mid);
  background: var(--panel);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 0 0 1px var(--line-2),
    var(--shadow);
  min-width: 0;
}
.status strong { font-size: 15px; font-weight: 600; }
.status small { font-size: 12px; color: var(--dim); }
/* When a fire is on the bottom row the strip turns, because that is the one
   message in this game that must not be missed. */
.status.peril { background: #e2380f; }
.status.peril strong, .status.peril small { color: #fff; }
.status.peril small { color: rgba(255,255,255,.88); }

.game-note {
  margin: 6px 6px 0; min-height: 16px;
  font-size: 12px; line-height: 1.4; color: var(--dim); text-align: center;
}
.game-note.warn { color: var(--warn); }

/* ── the end-of-game overlay ───────────────────────────────────────────── */

.overlay {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  /* SOLID, NO BLUR (letters-v33; ~/claude/IOS.md §24, the owner's "solid
     over pretty"): a backdrop-filter is a compositing layer iOS 26 can blink. */
  background: color-mix(in srgb, var(--bg) 90%, transparent);
  border-radius: var(--r-card);
  z-index: 5;
}
.overlay[hidden] { display: none; }
.overlay-card {
  width: min(320px, 92%);
  padding: 24px 20px;
  text-align: center;
  background: var(--panel);
  border-radius: var(--r-card);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 0 0 1px var(--line-2),
    0 18px 44px rgba(0,0,0,.45);
}
.overlay-mark { font-size: 34px; line-height: 1; }
.overlay-card h2 {
  margin: 8px 0 4px;
  font-family: var(--font-display);
  font-size: 25px; font-weight: 700; letter-spacing: 0;
}
.overlay-line { margin: 0; color: var(--dim); font-size: 14px; line-height: 1.5; }
.overlay-stats {
  display: flex; justify-content: center; gap: 20px;
  margin: 18px 0 0; padding: 14px 0 0;
  border-top: 1px solid var(--line-2);
}
.overlay-stats div { display: flex; flex-direction: column; gap: 2px; }
.overlay-stats dt { font-size: 10.5px; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; order: 2; }
.overlay-stats dd { margin: 0; font-family: var(--font-num); font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; order: 1; }
.overlay-submit { margin: 14px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--dim); min-height: 17px; }
.overlay-submit.bad { color: var(--warn); }
.overlay-submit.good { color: var(--good); }
.overlay-btns { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.overlay-btns .btn { flex: 1 1 40%; }

/* EVERY WORD PLAYED, in order, with its points. A short game is a glance; a
   long one scrolls inside a fixed height rather than pushing the buttons off
   the bottom of a phone. The best word is marked in brass. */
.overlay-words {
  list-style: none; margin: 12px 0 0; padding: 0;
  max-height: 22vh; overflow-y: auto; -webkit-overflow-scrolling: touch;
  border-top: 1px solid var(--line-2); border-bottom: 1px solid var(--line-2);
  font-size: 13.5px;
}
.overlay-words:empty { display: none; }
.overlay-words li { display: flex; justify-content: space-between; gap: 10px; padding: 5px 4px; border-bottom: 1px solid var(--line-2); }
.overlay-words li:last-child { border-bottom: 0; }
.overlay-words li span { font-family: var(--font-num); font-weight: 600; letter-spacing: .3px; }
.overlay-words li b { font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--ink-2); font-weight: 500; }
.overlay-words li small { color: var(--dim); font-weight: 500; margin-left: 6px; }
.overlay-words li.best span, .overlay-words li.best b { color: var(--gold-ink); font-weight: 700; }

/* ── segmented control ─────────────────────────────────────────────────── */

.seg {
  display: flex; gap: 2px; padding: 2px;
  background: var(--pill); border-radius: 10px;
  box-shadow: inset 0 1px 2px rgba(0,0,0,.18);
}
.seg button {
  flex: 1; font: inherit; font-size: 14px; font-weight: 500;
  padding: 7px 6px; border: 0; border-radius: 8px;
  background: transparent; color: var(--ink); cursor: pointer;
}
.seg button[aria-selected="true"], .seg button.is-on {
  background: var(--panel); font-weight: 600;
  box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.seg-sm { max-width: 230px; }
/* AND AN EMPTY ONE IS STILL A GREY BAR. `#board-seg` is filled in only when
   there is more than one mode; with none it rendered as a thin pill under the
   heading that looked like a loading state nobody could dismiss. Found by
   looking at a screenshot, which is the only thing that could have found it. */
.seg:empty { display: none; }

/* One mode means one tab, which is a control that cannot be operated. Hidden
   rather than deleted, so a second mode makes it reappear on its own. */
.seg:has(button:only-child) { display: none; }

/* ── the leaderboard ───────────────────────────────────────────────────── */

.board-list { margin-top: 12px; }
.board-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--line-2);
}
.board-row:last-child { border-bottom: 0; }
.board-row.you { background: color-mix(in srgb, var(--gold) 15%, var(--panel)); }
.board-rank {
  width: 26px; flex: none; text-align: right;
  font-family: var(--font-num); font-weight: 700; font-size: 15px;
  font-variant-numeric: tabular-nums; color: var(--dim);
}
.board-row.you .board-rank { color: var(--gold-ink); }
.board-who { flex: 1; min-width: 0; }
.board-name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.board-meta { font-size: 11.5px; color: var(--dim); margin-top: 1px; }
.board-score {
  font-family: var(--font-num); font-size: 18px; font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.board-empty { padding: 30px 20px; text-align: center; color: var(--dim); font-size: 14px; line-height: 1.5; }

/* ── settings rows ─────────────────────────────────────────────────────── */

.rows > .row {
  padding: 14px 16px;
  border-bottom: 1px solid var(--line-2);
  display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.rows > .row:last-child { border-bottom: 0; }
/* Matched at the same depth as the rule above, not as a bare `.row-stack`:
   `.rows > .row` is more specific, so its `align-items: center` would win and
   every stacked row's header would shrink to its content and sit in the middle
   of the card instead of spanning it. `mines` shipped exactly that. */
.rows > .row.row-stack { flex-direction: column; align-items: stretch; gap: 8px; }
.row-head { display: flex; align-items: center; justify-content: space-between; }
.row-ctl  { display: flex; align-items: center; gap: 12px; }
.row-k { font-size: 15px; font-weight: 500; }
.row-v { font-family: var(--font-num); font-weight: 600; font-variant-numeric: tabular-nums; }
.row-note { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--dim); }

/* ── the supporter's mark, and the name that goes beside it ─────────────── */

/* THE MARK IS THIS GAME'S OWN OBJECT — the board's gold stone, the one an
   eight-letter word leaves on a tile (the owner, 2026-09-27; it was a small
   tan tile). The same `--gem-gold` and `--gem-cut` as `.tile.gem-2::after`,
   so the stone by a name and the stone on the board are one drawing. It keeps
   the stone's facet and ring where the other games' marks are one flat
   colour: this app is drawn in wood and brass, and the owner judged the
   detailed stone the one that belongs here. A square turned 45°, 0.6em a
   side, so its point-to-point height is the house's ~0.85em. Drawn rather
   than typed: an emoji gem is a different picture on every phone. */
.mark {
  display: inline-block; vertical-align: .04em;
  width: .6em; height: .6em; margin-left: .5em; margin-right: .1em; flex: none;
  transform: rotate(45deg);
  border-radius: 18%;
  background: var(--gem-gold);
  box-shadow: var(--gem-cut);
  cursor: pointer;
}
.board-name { display: flex; align-items: center; }
.board-name .mark { cursor: default; }

/* A tappable row: a <button> that has to look like the <div> rows beside it,
   because haptics.js can only lay its switch over a <button>. */
.rows > button.row {
  width: 100%; background: none; border: 0; border-bottom: 1px solid var(--line-2);
  font: inherit; color: inherit; text-align: left; -webkit-appearance: none;
}
.rows > button.row:last-child { border-bottom: 0; }
/* PRESSED, AS BUBBLES' ROWS ARE (2026-09-24, the owner: Tip jar and More
   games showed nothing under a finger). Matched at the same depth as the rule
   above: a bare `.row-tap:active` lost to its `background: none` and never
   showed on any row. No switch, no tick (IOS.md §2): the row still scrolls
   from a drag, and iOS draws :active for the moment the finger is down. */
.rows > button.row-tap:active { background: var(--pill); }
.chev { color: var(--dim); font-size: 18px; line-height: 1; flex: none; }
.row-v { display: flex; align-items: center; }
/* A name of one's own reads as a NAME, not as a number: the row value is
   tabular figures everywhere else in this card, which is wrong for a word. */
.row-v.own { font-family: inherit; font-variant-numeric: normal; }

.name-input {
  width: 100%; box-sizing: border-box;
  padding: 10px 12px; border-radius: 10px;
  border: 1px solid var(--line); background: var(--pill); color: var(--ink);
  font: inherit; font-size: 16px;        /* 16px or iOS zooms the page on focus */
}
.name-actions { display: flex; gap: 8px; }
/* SUPPORTER PERKS and THE TIP JAR (index.html): a row each, the same
   tappable row as Settings › Name. The tip jar's heart is the one bit of
   colour — thanks, not a price tag. The perks card's own rows sit flush
   inside it. */
#support-card { padding: 14px 16px 0; }
#support-card .support-thanks, #support-card .support-perks { margin: 0 0 12px; }
#support-card .rows { margin: 0 -16px; }
#support-card .rows > button.row-tap { padding: 14px 16px; border-bottom: 0; border-top: 1px solid var(--line-2); }
#support-card .rows > #name-edit { border-bottom: 0; border-top: 1px solid var(--line-2); }
/* Name, then its value against the chevron — three children under
   space-between would put the value in the middle of the row. */
#name-row { justify-content: flex-start; }
#name-row .row-v { margin-left: auto; }
#tipjar-card { margin-top: 14px; }
#tipjar .tip-glyph { color: var(--heart); font-size: 18px; line-height: 1; margin-right: 2px; }
#tipjar .row-k { flex: 1; }
/* MYJP.CA GAMES (index.html, the foot): the lockup from myjp.ca/games —
   the wordmark in the rounded face, "games" tucked under it in the games'
   gold (games/index.php) — then "More games" and the caret. */
:root { --games-gold: #c48a12; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --games-gold: #f2b93a; } }
:root[data-theme="dark"] { --games-gold: #f2b93a; }
#games-card { margin-top: 14px; }
#games-row { padding: 12px 16px; }
.games-lockup { display: inline-flex; flex-direction: column; align-items: flex-end; line-height: 1; font-family: "SF Pro Rounded", ui-rounded, var(--font); font-weight: 700; }
.games-lockup .gl-mark { font-size: 22px; letter-spacing: -0.01em; color: var(--ink); }
.games-lockup .gl-ca { color: var(--dim); }
.games-lockup .gl-sub { font-size: 10px; letter-spacing: .02em; color: var(--games-gold); margin-top: -1px; margin-right: 1px; }
#games-row .games-more { flex: 1; text-align: right; color: var(--dim); font-weight: 400; }
/* ACCOUNT: who, and Sign out as a row in the danger colour — the same row as
   the tip jar, not a wide red button. */
#account-card { padding: 14px 16px 0; }
#account-card .row-note { margin: 0 0 12px; }
#account-card .rows { margin: 0 -16px; }
#account-card .rows > button.row-tap { padding: 14px 16px; border-bottom: 0; border-top: 1px solid var(--line-2); }
.rows > button.row-tap.danger { color: var(--bad); }
/* TECHNICAL (index.html): a folded card — the summary is a row with a caret
   that turns; inside, a key/value list in the mono face and one note.
   list-style none + the -webkit-details-marker rule hide the browser's own
   triangle. */
details.technical { margin-top: 14px; }
details.technical > summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; }
details.technical > summary::-webkit-details-marker { display: none; }
details.technical > summary .chev { transition: transform .15s; }
details.technical[open] > summary .chev { transform: rotate(90deg); }
details.technical > summary:active { background: var(--pill); }
details.technical .kv { margin: 0; padding: 4px 16px 12px; border-top: 1px solid var(--line-2); }
details.technical .kv > div { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line-2); }
details.technical .kv > div:last-child { border-bottom: 0; }
details.technical .kv dt { flex: 0 0 76px; font-size: 13px; color: var(--dim); }
details.technical .kv dd { margin: 0; flex: 1; min-width: 0; font-size: 12.5px; font-family: var(--font-mono); color: var(--ink-2); line-height: 1.5; overflow-wrap: anywhere; }
details.technical > .row-note { padding: 0 16px 14px; }



.switch { position: relative; display: inline-block; width: 51px; height: 31px; flex: none; }
.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; z-index: 2; }
.switch span {
  position: absolute; inset: 0; border-radius: 999px;
  background: var(--pill); transition: background .2s ease;
}
.switch span::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 27px; height: 27px; border-radius: 50%;
  background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25);
  transition: transform .2s ease;
}
.switch input:checked + span { background: var(--good); }
.switch input:checked + span::after { transform: translateX(20px); }
.switch input:disabled + span { opacity: .4; }

.prose { padding: 16px; }
.prose p { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--ink-2); }
.prose p:last-child { margin-bottom: 0; }
.prose-meta { font-size: 12px !important; color: var(--dim) !important; font-family: var(--font-mono); }

.score-table {
  width: 100%; border-collapse: collapse; margin-bottom: 14px;
  font-family: var(--font-num); font-variant-numeric: tabular-nums;
}
.score-table th {
  font-size: 10.5px; text-transform: uppercase; letter-spacing: .4px;
  color: var(--dim); font-weight: 600; text-align: left;
  padding-bottom: 6px; border-bottom: 1px solid var(--line-2);
}
.score-table td { padding: 5px 0; font-size: 15px; color: var(--ink-2); }
.score-table td:nth-child(2), .score-table td:nth-child(4) { font-weight: 700; color: var(--ink); }
.score-table td:nth-child(3), .score-table th:nth-child(3) { padding-left: 20px; }

/* The letter-value key. A grid rather than a list, because the point is
   comparing the three tiers against each other rather than reading them off. */
/* THE VALUE KEY, ON REAL TILE FACES. `.key-tile` deliberately repeats the
   board tile's surface — same image, same blend, same tokens — rather than
   approximating it, because the whole point of the key is that the thing on the
   board is the thing in the table. It is a static size: this is a legend, not a
   board, and it must not depend on layoutBoard() having run. */
.value-key {
  display: grid; grid-template-columns: 1fr;
  gap: 14px; padding: 16px 10px 8px;
}
.value-key div { display: flex; flex-direction: column; align-items: center; gap: 7px; }
/* `width: 100%` is load-bearing. The row lives in a column flex box with
   `align-items: center`, which shrink-wraps its children — so without it the
   row's width is decided by its content and the tile's `100%` below has nothing
   to measure against but itself. The tiles came out two-thirds of their size
   with the letters hanging over the edges. */
.value-key b { display: flex; justify-content: center; gap: 3px; width: 100%; }
/* THE LABEL, AND ONLY THE LABEL. This was `.value-key span`, which also matched
   the letter and the value inside every tile — so the tiles rendered correctly
   and then had the caption's grey painted over their letters, which looked like
   a blend-mode problem and was a selector. A tile's own parts are addressed
   through `.key-tile`; the caption is the direct child. */
.value-key > div > span { font-size: 11px; color: var(--dim); }

/* THE TILE IS SIZED SO TWELVE OF THEM CANNOT WRAP, AT ANY WIDTH, which is the
   third version of this rule and the first one that is not a guess. The bronze
   tier is twelve letters and the row IS the fact being shown — a row of ten
   with two stragglers underneath reads as a mistake, not as a tier. 30px
   wrapped, then 26px wrapped, then 24px wrapped, each time because the number
   was measured against a card width taken off a screenshot rather than off the
   card. So it is not a number any more: eleven gaps of 3px come out of the
   container and the remaining twelfth is the tile, whatever the container is.
   Gold's five and silver's nine then come out the same size as bronze's twelve
   for free, which is what makes the three rows comparable at a glance.

   Sized here rather than by layoutBoard() because this is a legend: it has to
   draw correctly on a screen where no board has ever been laid out. */
.key-tile {
  position: relative;
  flex: 0 0 calc((100% - 33px) / 12);
  aspect-ratio: 1;
  display: grid; place-items: center;
  border-radius: 5px;
  color: var(--tile-ink);
  font-family: var(--font-num); font-size: 15px; font-weight: 800; line-height: 1;
  background-color: var(--tile);
  background-image: url(tile.png);
  background-size: 100% 100%;
  background-blend-mode: soft-light;
  box-shadow: 0 1px 0 rgba(255,255,255,.3) inset, 0 1px 2px rgba(0,0,0,.3);
}
/* Qu is two glyphs in one square here as well, and at this size it fits without
   help — 15px of the rounded face is about 20px wide against a 30px tile. */
.key-tile .ch {
  font-size: 12px;
  color: var(--tile-ink);
  text-shadow: 0 1px 0 rgba(255,255,255,.4);
}
.key-tile .val {
  position: absolute; right: 2px; bottom: 0;
  font-size: 7px; font-weight: 700;
  color: var(--tile-ink); opacity: .62;
}
.value-key-note { padding: 10px 16px 16px; }

/* ── tab bar ───────────────────────────────────────────────────────────── */

/* ── the tab bar ───────────────────────────────────────────────────────────
 *
 * A CARD-CATALOGUE DRAWER FRONT. The original game has no tab bar at all — it
 * is a full-screen board with a menu button — so this one is invented, and the
 * thing it is invented out of is the piece of library furniture that already
 * looks like a row of labelled choices: a wooden cabinet front with three brass
 * card holders on it. The wood is the app's own grain, the plate on the
 * selected drawer is the same brass as the Play button, and the whole bar sits
 * proud of the wall with a lit top edge like every other raised object here.
 *
 * NO BLUR, AND THAT IS A CHANGE. The old bar was frosted glass over paper —
 * correct in the house's iOS language and wrong in this one, because a
 * translucent panel is a window and this is meant to be furniture. Solid wood
 * also spares the board underneath a 20px backdrop filter on every frame of
 * every fall, which is the one place in this app where a wasted composite would
 * actually be seen. */
.tabs {
  position: absolute; left: 0; right: 0; bottom: 0;   /* the page's sibling in the stage — never fixed (IOS.md §25) */
  display: flex;
  gap: 6px;
  padding: 7px 10px calc(7px + env(safe-area-inset-bottom, 0px));
  background-color: var(--shelf);
  background-image:
    linear-gradient(180deg, rgba(255,255,255,.16), rgba(0,0,0,.10) 55%, rgba(0,0,0,.20)),
    url(wood.png);
  background-size: auto, 220px 100%;
  background-repeat: no-repeat, repeat-x;
  background-blend-mode: overlay, soft-light;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
    inset 0 2px 0 -1px rgba(0,0,0,.18),
    0 -2px 10px rgba(0,0,0,.22);
  z-index: 10;
}
/* The bead of moulding along the top of the cabinet. Two hairlines rather than
   a border: a border sits outside the background and does not pick up the
   plank grain behind it. */
.tabs::before {
  content: '';
  position: absolute; left: 0; right: 0; top: 0; height: 3px;
  background: linear-gradient(180deg,
    color-mix(in srgb, #fff 34%, transparent),
    rgba(0,0,0,.22));
  pointer-events: none;
}
.tab {
  position: relative;
  flex: 1; font: inherit; font-size: 10.5px; font-weight: 600;
  letter-spacing: .2px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 8px 0 7px;
  border: 0; border-radius: 9px;
  background: transparent; color: var(--shelf-ink); cursor: pointer;
  /* The drawer's own shallow recess, always there, so the bar reads as three
     fronts rather than as one strip with a highlight wandering across it. */
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.10), inset 0 1px 2px rgba(0,0,0,.10);
}
.tab:active { box-shadow: inset 0 1px 3px rgba(0,0,0,.28); }

/* THE BRASS PLATE. Same gradient as `.btn-primary`, because it is the same
   material and the app should only own one brass. The dark ink on it is the
   button's ink too. */
.tab.is-on {
  color: #2b1a08;
  /* A POLISHED PLATE, so the gradient runs a long way — from nearly white brass
     at the top to the flat token at the bottom. A subtler one (84% gold) was
     invisible on the light theme's shelf, because "slightly lighter than the
     wood" is what wood looks like. Metal is the thing on this screen with a
     highlight on it. */
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--gold) 45%, #fff),
    color-mix(in srgb, var(--gold) 92%, #000) 92%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.7),
    inset 0 0 0 1px rgba(0,0,0,.22),
    0 1px 3px rgba(0,0,0,.35);
  text-shadow: 0 1px 0 rgba(255,255,255,.35);
}

/* THE GLYPHS ARE ONE DRAWING EACH, in `currentColor`, so all three take the
   tab's ink and the same stroke weight — see the note in index.html on the
   emoji this replaced, which could not be coloured at all. Filled shapes carry
   the form and a stroke closes the open paths (the book's spine, the shelf
   line the books stand on); `paint-order` puts the stroke behind the fill so a
   thick joint never eats into the shape it is outlining. */
.tab-g {
  width: 21px; height: 21px;
  display: block;
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  paint-order: stroke fill;
}
/* The book and the gear are OUTLINES, not solids: filled, they are two dark
   blobs at 21px, and the shelf of books beside them is three more. Only the
   ranked books stay solid — they are what a leaderboard looks like.

   Marked with a class rather than selected on their path data, which is what
   the first version did (`path[d^="M12 6.5C"]`). That worked and was a trap:
   nudging a curve by a tenth of a unit — the most ordinary edit an icon ever
   gets — would have silently filled the shape in and nothing would have said
   why. A selector should not depend on the coordinates it is styling. */
.tab-g .ol { fill: none; }

/* ── the banner ────────────────────────────────────────────────────────── */

.banner {
  position: absolute; left: 12px; right: 12px;   /* in the screen-tall body (the stage) */
  bottom: calc(72px + env(safe-area-inset-bottom, 0px));
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px;
  background: var(--ink); color: var(--bg);
  border-radius: var(--r-mid);
  font-size: 13.5px; line-height: 1.45;
  box-shadow: var(--shadow);
  z-index: 20;
}
.banner[hidden] { display: none; }

/* The banner's Retry. An installed home-screen app has no address bar and no
   reload button, so a message about a failed read used to be the end of the
   conversation. */
.banner .banner-retry {
  margin-left: auto;
  padding: 6px 13px;
  font-size: 12.5px; font-weight: 600; line-height: 1.2;
  border-radius: 999px;
  background: var(--bg); color: var(--ink);
  border: 0; cursor: pointer;
}
.banner .banner-retry[hidden] { display: none; }
.banner .banner-retry:disabled { opacity: .45; }
.banner .banner-retry + button { margin-left: 4px; }
.banner button {
  margin-left: auto; flex: none;
  font: inherit; font-size: 20px; line-height: 1;
  border: 0; background: transparent; color: inherit; cursor: pointer;
}

/* The one line in the app that is asking for something to be done, so not dim
   like the fineprint it sits under. */
.update-note { color: var(--warn); }


/* ── Versus (letters-v36) ─────────────────────────────────────────────────── */

.versus-card { margin-top: 12px; }
.vs-head { display: flex; align-items: center; gap: 4px; }
.vs-head .icon-btn { margin-left: -10px; }
.vs-wait { padding: 20px 18px; text-align: center; margin-bottom: 14px; }
.vs-code-k { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: .9px; color: var(--gold-ink); font-weight: 700; }
.vs-code { margin: 6px 0 2px; font-family: var(--font-num); font-size: 44px; font-weight: 800; letter-spacing: 8px; color: var(--ink); }
.vs-wait-line { margin: 0; color: var(--dim); font-size: 14px; }
.vs-join { display: flex; gap: 10px; padding: 10px; margin-top: 12px; }
.vs-join input {
  flex: 1; min-width: 0; font: inherit; font-family: var(--font-num); font-size: 22px; font-weight: 700;
  letter-spacing: 6px; text-transform: uppercase; text-align: center;
  padding: 8px 10px; border: 0; border-radius: var(--r-mid); background: var(--pill); color: var(--ink);
}
.vs-join .btn { flex: none; }
.vs-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line-2); }
.vs-row:last-child { border-bottom: 0; }
.vs-row .board-name { flex: 1; min-width: 0; }
.vs-row .btn { flex: none; }
html.versus #hint-btn, html.versus #game-new { display: none; }

/* The other player: name, score, and their board as a map of fire. */
.opp {
  flex: none;
  display: flex; align-items: center; gap: 10px;
  padding: 5px 12px; margin: 0 2px;
  background: var(--panel); border-radius: var(--r-mid);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent), 0 0 0 1px var(--line-2);
}
.opp[hidden] { display: none; }
.opp-mini { display: grid; grid-template-columns: repeat(7, 5px); gap: 1px; flex: none; }
.opp-mini i { width: 5px; height: 5px; border-radius: 1.5px; background: color-mix(in srgb, var(--ink) 16%, transparent); }
.opp-mini i.b { background: #e2531a; }
.opp-mini i.f { background: #ff2a00; box-shadow: 0 0 4px #ff6a00; }
.opp.out .opp-mini i { opacity: .45; }
.opp-who { flex: 1; min-width: 0; }
.opp-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opp-meta { font-size: 12px; color: var(--dim); }
.opp-score { font-family: var(--font-num); font-size: 20px; font-weight: 800; color: var(--gold-ink); font-variant-numeric: tabular-nums; }


/* ── the sound toggle in the game's header (letters-v37) ────────────────────
   The tab bar's glyph treatment — filled body, stroked waves, currentColor —
   in the header's brass. Off is the same speaker crossed out and a shade
   quieter, so the state reads at a glance without a label. */
.snd-g {
  width: 23px; height: 23px; display: block;
  fill: currentColor; stroke: currentColor;
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
  paint-order: stroke fill;
}
.snd-g .ol { fill: none; }
.sound-btn[aria-pressed="true"] .snd-off,
.sound-btn[aria-pressed="false"] .snd-on { display: none; }
.sound-btn[aria-pressed="false"] { color: color-mix(in srgb, var(--gold-ink) 55%, var(--dim)); }
.sound-btn[hidden] { display: none; }
