/* NUNITO, self-hosted. The latin subset Google serves is a single VARIABLE
   woff2 covering the whole 200-1000 weight axis in 39 KB, so all three weights
   this design uses (700/800/900) come from one file — cheaper than three static
   cuts would have been. Self-hosted rather than CDN-linked because the ship
   artifact is one self-contained file; build.mjs turns this url() into a base64
   data URI like any other asset.
   `font-display: block` on purpose, not `swap`: game.js MEASURES every tile word
   on a canvas to size it, so a flash of the fallback would be a flash of text
   sized for a different typeface. */
@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 200 1000;
  font-display: block;
  src: url('assets/fonts/nunito-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
                 U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ===========================================================================
   DESIGN PASS 2026-08-02 — play screen.
   Tokens from design_handoff_level_screen. The map screen and the modals are
   deliberately NOT restyled yet (Matan: play screen first), so they still wear
   the old look and will clash until that pass happens.
   =========================================================================== */
/* REGISTERED SO JAVASCRIPT CAN READ IT AS A NUMBER. An ordinary custom property
   is substituted, not computed, so getPropertyValue() hands back the literal
   string "clamp(10px, 4dvh, 16px)" and fitPillFont() cannot cap itself to it.
   Registering gives it a type, so it resolves - and the initial value is
   portrait's own 16px, which is what PILL_FONT_MAX already was. Must be at top
   level: @property inside a media query is invalid. */
@property --lsc-pill-f {
  syntax: "<length>";
  inherits: true;
  initial-value: 16px;
}

:root {
  /* See the matching <meta name="color-scheme"> in index.html for the why.
     The game is dark-by-design, so this is simply the truth - and stating it is
     what stops Samsung Internet / Chrome Android force-dark inverting the white
     tile face and the near-black tile ink. Side effect, and a wanted one: UA
     widgets (the settings sheet's checkboxes, scrollbars) render dark too. */
  color-scheme: dark;

  /* THE PLAY BOARD, 2026-08-17. One token, because three other things are derived
     from it: the tile frame, the tile wall, and the glass scrim. Change this and
     they follow.

     WHY IT WENT DARK. Measured off Tripledot: their tile FACE is 7.74:1 against
     their board and their outline is 1.07:1 — the face carries the signal and the
     outline is only a seam. On the old mint #a8e7c5 ours was inverted: face 1.41:1,
     frame 8.60:1, so the frame was doing the board's job and read as a hard cage.
     No frame colour can fix that, because cream on mint is 1.18:1 whatever the
     frame does. Pine puts the face at 7.86:1 — within a hair of theirs. */
  /* 2026-08-21: #1a2c25 -> #27775a, THE LIGHTER PINE. Chosen by Matan off
     mockups/themes.html after the very dark board plus white tiles plus green
     rims was rejected as too close to the competitor. L* 44.7, white face at
     5.42:1 — still comfortably the loudest thing on screen, which is the whole
     constraint. This is as far as the family goes: every step lighter spends
     some of the face's signal, and past about here the frame has to start doing
     the board's job again, which is precisely the inversion the dark pass fixed.

     ⚠️ THE MASK COUPLING NO LONGER APPLIES, AND KNOWING WHY MATTERS BEFORE
     ANYONE PUTS IT BACK. Between 2026-08-19 and 2026-08-21 the background photo
     was an ellipse-MASKED surround, and a masked photo pins --board to the
     image's own centre-band mean exactly: inside the hole you see --board,
     outside it you see op*image + (1-op)*board, and unless those are equal the
     mask draws a visible closed contour across the middle of the screen. The
     opacity does not enter into it, so a visible edge can never be fixed by
     moving the opacity. --bg-mask is now `none` and the photo is a flat wash, so
     the board colour is free — but restore the mask and the lock comes straight
     back, and --board must then be re-derived from whatever --bg-image points at:
       python -c "from PIL import Image,ImageStat; im=Image.open('assets/<f>.jpg').convert('RGB');
       w,h=im.size; print(ImageStat.Stat(im.crop((int(w*.25),int(h*.30),int(w*.75),int(h*.62)))).mean)"
     Everything downstream follows --board on purpose: the glass scrim, the tile
     frame, the tray. */
  /* #165a43, Matan's call 2026-08-25, replacing pine lighter #27775a.
     Measured (python tools/theme-measure.py "new=#165a43"):
       L* 33.7  ·  white tile face 8.15:1  ·  25.4 dE from the old board
       18 of 18 pill-pool colours still clear the >=25 dE camouflage floor
     THE GENEROUS FACE RATIO IS NOW LOAD-BEARING, not spare. The porcelain tile
     that landed the same day has no frame and no coloured wall, so BRIGHTNESS
     is the only thing separating a tile from the board. 5.42:1 was sized for a
     tile that carried its own dark ring; without one, 8.15:1 is the cue. */
  /* #3b3a8a, THE RIVERSIDE AT DUSK, Matan's decision 2026-09-23: the look he
     approved on his phone at ?theme=dusk is now the default for every ordinary
     level ("relaxing, yet more vibrant", the white tiles still the brightest
     thing, the picture clearly visible). Was #165a43 (the garden green, kept
     whole in themes/garden-default.css). Measured
     (python tools/theme-measure.py "dusk=#3b3a8a"):
       L* 28.9  ·  white tile face 9.73:1  ·  59.4 dE from #165a43
       18 of 18 pill-pool colours clear the >=25 dE camouflage floor
     The face ratio went UP, not down, so the porcelain tile's only cue
     (brightness) got stronger. --bg-mask is still `none`, so this is free. */
  /* BACK TO THE GARDEN, Matan's decision 2026-09-27: "Poki was the only place
     the dusk look was meant for." :root is the green garden again, exactly as it
     was until 2026-09-23, and every value on this sheet that the dusk look moved
     is back at its garden value. The riverside at dusk lives on as a CLASS,
     html.look-dusk (search "THE RIVERSIDE AT DUSK, AS A LOOK"), which the Poki
     build carries and a `look` A/B test can hand any player. */
  --board:       #165a43;
  /* ⚠️ BOTH OF THESE ARE DEAD as of the porcelain tile (2026-08-25). Nothing
     reads them: --tile-edge* no longer draws a ring or a wall, and --back-edge
     was rebuilt to match. Kept only so a themes/ file that still sets them does
     not look like it is doing something. Do not re-point a rule at them
     expecting the slab back - that is a whole look, not a colour. */
  --tile-frame:  #1b4d3a;
  --tile-wall:   #2d7f61;
  --green-deep:  #0f5238;   /* primary — tile text, tray track, pill default   */
  /* RE-TONED FOR THE DARK BOARD, 2026-08-18. These were authored against mint
     #a8e7c5 and measured against Pine they had all but vanished: the button fill
     sat at 1.23:1 against the board and its drop wall at 1.19:1, so a header
     button was a shape you could only find by knowing where it was. The new
     values put the fill at 2.45:1 and the rim at 4.37:1 — the same JOB the old
     ones did on mint, at the contrast the dark board needs. */
  /* RE-TONED OFF THE GREEN, 2026-09-23 (Matan's dusk decision), with the values
     themes/dusk.css carried when he approved it. The names stay "green" because
     a dozen rules and every themes/ file read them. Were #47a075 / #72d4a6 /
     #123f2e. Back at #47a075 / #72d4a6 / #123f2e since 2026-09-27; the violets
     are in html.look-dusk. */
  --green-mid:   #47a075;   /* round buttons. Was also the tray's top border,
                              until that border was deleted on 2026-08-21.   */
  --green-edge:  #72d4a6;
  --green-shade: #123f2e;
  /* THE LEVEL TITLE'S OUTLINE, 2026-09-23. It was --green-deep (#0f5238), which
     reads 1.06:1 against the dusk board, so "Level 1" wore a green rim with no
     job. Its own token now, so the title can follow the board without moving
     --green-deep, which also inks the popups. The same deep violet as
     --green-shade; white on it is 14.2:1. On the garden (the default again
     since 2026-09-27) it is --green-deep's own #0f5238, which is what the
     title wore before the token existed; the violet is in html.look-dusk. */
  --title-stroke: #0f5238;
  --gold:        #ffb702;
  --gold-rim:    #ffdea9;
  --gold-shade:  #7d5800;
  --gold-ink:    #6b4b00;

  /* =========================================================================
     THEME SURFACE TOKENS - added 2026-08-19.

     Every value below is the literal that used to sit inline in the rule it now
     feeds. Promoting them changed NOTHING on screen, and that was the acceptance
     test: computed styles were diffed on a live board before and after.

     WHY THEY EXIST. A file in themes/ overrides these and nothing else, so an
     alternative look is a short stylesheet rather than a copy of the game. Load
     one with ?theme=<name>; no param is this default. See themes/README.md.

     THE COUPLING TO WATCH. While --bg-mask is a mask, --board must equal the
     mean of the centre band of whatever --bg-image points at, or the mask draws
     a visible closed contour across the middle of the screen - see the --board
     note above, which carries the derivation one-liner. The default is exempt
     TODAY only because --bg-mask is `none`; set a mask and the lock is live.
     ========================================================================= */

  /* -- the board surface -------------------------------------------------- */
  --board-hi-top:   rgba(255,255,255,0.10);  /* top-right radial highlight     */
  /* bottom-left shade re-toned with the dusk board, 2026-09-23 (Matan). Was
     rgba(8,40,28,0.14), a green shade. The top-right highlight did not move.
     Green again since 2026-09-27 (the garden is the default; dusk's shade is
     in html.look-dusk). */
  --board-hi-bot:   rgba(8,40,28,0.14);      /* bottom-left radial shade       */
  /* 2026-08-21: back to the original garden photo, carried as a FLAT WASH at
     0.22 with `overlay` blend rather than as a masked surround. The masked
     night-garden treatment was authored for the near-black board and pins the
     board colour to the photo (see --board); the lighter pine board is the
     decision, so the photo gives up the mask instead. */
  /* 2026-08-25, Matan: the night-arch photograph, on the new #165a43 board.
     THREE THINGS MOVED TOGETHER AND ONLY ONE OF THEM IS THE FILENAME.
     - blend `overlay` is OUT. Overlay pushes light areas lighter and dark areas
       darker AGAINST THE BACKDROP, so a dark photograph over a dark board
       compounds into near-black and the picture disappears into the corners.
       The old garden photo was a bright mint scene, which is what made overlay
       read as a wash rather than as a crush. `normal` is what the masked
       treatment used with this same file on 2026-08-19, for this same reason.
     - 0.22 -> 0.34. At `normal` the opacity is doing all the work, where before
       the blend mode was doing half of it.
     - `--bg-mask` STAYS `none`, and that is what keeps `--board` free. A masked
       surround pins the board colour to the image's own centre-band mean
       exactly - here 26,44,37 - or the mask draws a visible closed contour
       across the middle of the screen. #165a43 is 22,90,67, so restoring the
       mask WOULD relight that trap. See the --board note above. */
  /* 2026-09-23, Matan's decision: THE RIVERSIDE AT DUSK, drawn by Codex from
     icons-pipeline/PROMPT-dusk-background.md, approved on his phone at
     ?theme=dusk. Was the night-arch picture
     (assets/background-night-arch-light-portrait.jpg) at 0.34. 0.72 because he
     asked for the picture to be CLEARLY VISIBLE; at 0.34 the old one was the
     "no background image, i mean" of the open list. Blend stays normal and the
     mask stays `none`, so --board stays free. The sideways file is set in the
     landscape :root block further down (search "LANDSCAPE GETS ITS OWN").
     2026-09-27, Matan: the garden is the default again, so this is the
     night-arch picture at 0.34 once more; the dusk picture at 0.72 is in
     html.look-dusk. */
  --bg-image:       url('assets/background-night-arch-light-portrait.jpg');
  --bg-opacity:     0.34;
  --bg-blend:       normal;
  --bg-mask:        none;
  /* 0.7 -> 0.40 (2026-08-21) -> 0.25 (2026-08-26, Matan: "more transparent to
     see more of the pic behind them"). THE NUMBER A PLAYER SEES IS NOT THIS
     ONE: game.js multiplies it by 0.55, the plateau the old keyframe used to
     carry, so the effective alpha is 0.138 - down from 0.24.
     RE-MEASURED AGAINST THE CURRENT BOARD, which is the half that had gone
     stale. The old note here said 1.45:1, measured against the retired #27775a.
     The board went #165a43 on 2026-08-25 and nobody re-measured the petals, so
     they had quietly become MORE prominent, not less: against the real
     composited backdrop (the photo at 0.34 over the board, mean 24/73/56) the
     shipped petal read 1.98:1. At 0.138 it reads 1.48:1, which sits under the
     background photo's own bright features at 2.42:1 - so the petals are no
     longer the brightest thing on screen, which is what was actually wrong.
     Petals are BEHIND the board (#petals is z-index 1, everything else in #game
     is 2), so they never veil a tile; what they paint over is the photograph. */
  --petal:          rgba(255, 218, 214, 0.25);
  --body-bg:        #165a43;   /* side rails above 720px + iOS overscroll.
                                  Followed the dusk board 2026-09-23 to 2026-09-27;
                                  the garden green again since. */

  /* -- the tile: front face ----------------------------------------------- */
  --tile-face:      #ffffff;
  --tile-ink:       #17211f;
  /* PORCELAIN, promoted from themes/tile-porcelain.css on 2026-08-25 (Matan:
     "it looks good, fits the tile-tap sound, and does not look like a copy of
     TripleDot's"). The slab is gone: no --tile-frame ring, no --tile-wall drop,
     no warm bottom bevel. The tile is a smooth glazed chit and gets its
     solidity from ROUNDNESS plus one soft ambient shadow.

     ⚠️ 14px IS NOT A FREE KNOB. It is a genuinely rounded corner on a 108x72
     tile - which is the whole identity - but it eats into the corners of the
     centred word box. Nothing clips at the sizes this game draws; push past
     about 16 and that stops being true and the text gate has to be re-run.
     ⚠️ AND THE TRAY FOLLOWS IT. The seat, the socket and the stand all carry
     radii DERIVED from this one (x0.42, the seat/tile scale) - see the note on
     .seat. Move this and move all three, or a tapped tile changes shape as it
     lands. */
  --tile-radius:    14px;
  --tile-border:    transparent;
  /* THE GLAZE: one very soft highlight along the top, never a gradient sweep.
     A strong sweep is exactly what turns white into glossy plastic. */
  --tile-bevel:     linear-gradient(to bottom,
                      rgba(255,255,255,0.85) 0%,
                      rgba(255,255,255,0) 34%,
                      rgba(150,158,152,0) 74%,
                      rgba(150,158,152,0.20) 100%);
  /* THE EDGE IS A WHOLE BOX-SHADOW LIST PER STATE, not a handful of colours.
     A theme wanting a different KIND of edge - a flat card, a ring, a die-cut
     keyline - redefines these four and does not touch a single rule. */
  --tile-edge:      inset 0 1px 1px rgba(255,255,255,0.95),
                    inset 0 -2px 3px rgba(120,132,126,0.30),
                    0 4px 9px rgba(4, 20, 14, 0.42);
  --tile-edge-open: inset 0 1px 1px rgba(255,255,255,0.95),
                    inset 0 -2px 3px rgba(120,132,126,0.24),
                    0 7px 14px rgba(4, 20, 14, 0.48);
  --tile-edge-press: inset 0 1px 1px rgba(255,255,255,0.95),
                    inset 0 -1px 2px rgba(120,132,126,0.30),
                    0 2px 4px rgba(4, 20, 14, 0.36);
  --tile-edge-glass: inset 0 1px 1px rgba(255,255,255,0.7),
                    inset 0 -2px 3px rgba(120,132,126,0.34),
                    0 3px 6px rgba(4, 20, 14, 0.34);

  /* -- the tile: back face (FACEDOWN) -------------------------------------- */
  --back-face:      #2b6f6d;
  --back-image:     url('assets/tile-back.jpg');
  --back-wall:      #1f5250;
  --back-border:    transparent;
  --back-mark:      rgba(0, 0, 0, 0.20);   /* the two debossed bars            */
  --back-edge:      inset 0 1px 2px rgba(230, 255, 246, 0.30),
                    inset 0 -2px 3px rgba(16, 52, 48, 0.34),
                    0 4px 9px rgba(4, 20, 14, 0.44);
  /* Two full rgba values rather than one tint plus a per-class alpha: relative
     colour syntax - rgb(from ...) - is not safe on the older Android browsers
     this ships to, and a dropped declaration here would flatten the pile. */
  /* The board colour at the same two alphas, re-toned with the dusk board
     2026-09-23 (Matan). Were rgba(22, 90, 67, 0.15 / 0.22), and are again
     since 2026-09-27 (the garden is the default; dusk's are in html.look-dusk). */
  --depth-near:     rgba(22, 90, 67, 0.15);
  --depth-far:      rgba(22, 90, 67, 0.22);

  /* -- the tray ------------------------------------------------------------ */
  /* Lightened with the board (2026-08-21). This frost was authored against
     #1a2c25; over a board this light the old value read as a hole. */
  /* Violet frost for the dusk board, 2026-09-23 (Matan). Was
     rgba(12, 52, 38, 0.24), the green frost, and is again since 2026-09-27. */
  --tray-panel:     rgba(12, 52, 38, 0.24);
  /* Unused by the default build since the tray frame went on 2026-08-25.
     Kept because themes/ files still set it and may want a rim back. */
  --tray-panel-rim: rgba(255, 255, 255, 0.14);
  /* THE STANDS, THE PLINTH AND THE LIP, OFF THE MINT, 2026-09-23 (Matan's dusk
     decision: they were the last green on the dusk board). Colours only; every
     size and shape is the rules' own. Each tone keeps the contrast its mint
     original had against #165a43, measured against #3b3a8a instead, so the
     tray keeps the same weight on screen and only its hue moves: lilac for the
     stands and the lip, mauve for the plinth.
       stands  #a9cbb6 #89ad98 #6b917d -> #b6aed2 #9b91b5 #82769a  (4.62 3.30 2.32:1)
       plinth  #6f947f #537a66         -> #91759a #795b80          (2.41 1.69:1)
       lip     #b6d4c1 #8fb2a0         -> #bfb8d7 #a096b8          (5.11 3.51:1)
     The sockets (--slit-*) take themes/dusk.css's values; were #24513f
     #2f5c49 #457058.
     2026-09-27: the mint originals are back here (the garden is the default);
     the lilac and mauve above moved, unchanged, into html.look-dusk. */
  --deck-1:         #a9cbb6;
  --deck-2:         #89ad98;
  --deck-3:         #6b917d;
  --deck-front-1:   #6f947f;
  --deck-front-2:   #537a66;
  --slit-1:         #24513f;
  --slit-2:         #2f5c49;
  --slit-3:         #457058;
  --lip-1:          #b6d4c1;
  --lip-2:          #8fb2a0;
  /* The tray SEAT is the same object as a board tile and must carry the same
     material, so it gets its own tokens rather than inheriting - a theme can
     make a board tile and a tray tile diverge on purpose (they do not today). */
  --seat-face:      #ffffff;
  --seat-ink:       #17211f;
  --seat-edge:      inset 0 1px 1px rgba(255,255,255,0.95),
                    0 5px 9px rgba(4, 20, 14, 0.42);
}

/* =============================================================================
   THE END CARDS' THEME: the tray-full card and the level win card (calm cards,
   version 10; Matan, 2026-10-08, off mockups/calm-cards-4.html: brightness A,
   tiles 2 and 4 rotating, title layout 1, the smaller gold button, confetti on
   the win card). Version 9 was the same card one step darker with the
   sighing tile beside the title.

   EVERY COLOUR, SHADOW, RIM AND PICTURE THE TWO CARDS WEAR IS HERE AND NOWHERE
   ELSE, named by the job it does and never by the colour it happens to be. The
   rules further down (search "THE CALM END CARDS") only say WHERE things go;
   a new look for these cards replaces this one block, or a file in themes/
   overrides these names, and touches no rule. His words: "all the green here
   should be in separate design classes, or files... as part of a theme, so
   that we can later change that theme." See docs/look-and-motion.md.

   The pictures are files, so swapping one is one file or one value:
     --card-picture          the painted pond behind both cards
     --card-lose-tile-1, -2  the sad tile standing on the tray-full card
     --card-win-tile-1, -2   the happy tile standing on the win card
   Each showing picks tile 1 or 2 at random (showEnd() in game.js). They are
   pairs 2 and 4 of mockups/calm-cards-4.html, drawn by
   tools/make-card-tiles.py. A look with no pond sets --card-picture: none and
   the card is --card-face under --card-shade.
   ========================================================================== */
:root {
  /* the dim over the play screen while a card is up: a plain dim, no blur.
     Brightness A: a little darker than version 9's 0.40, so the card comes
     forward without the card itself turning pale. */
  --card-scrim:          rgba(4, 20, 14, 0.50);
  --card-scrim-filter:   none;
  /* the card itself, brightness A: one step lighter than version 9 */
  --card-face:           #1d4a3a;
  --card-picture:        url('assets/card-calm-pond.jpg');
  --card-picture-at:     70% 0;
  --card-shade:          linear-gradient(180deg, rgba(46, 104, 82, 0.16) 0%, rgba(34, 84, 66, 0.42) 45%, rgba(30, 76, 60, 0.62) 100%);
  --card-rim:            inset 0 0 0 1px rgba(226, 242, 232, 0.22);
  --card-top-light:      inset 0 1px 0 rgba(255, 255, 255, 0.10);
  --card-shadow:         0 22px 44px -6px rgba(0, 0, 0, 0.55), 0 6px 14px rgba(0, 0, 0, 0.30);
  --card-radius:         26px;
  /* words */
  --card-text:           #eef5ef;
  --card-text-soft:      #bfd4c8;
  --card-pay-text:       #f3d98f;
  /* the title, warm, with a soft glow; and the glow behind the tile */
  --card-title:          #fff6e3;
  --card-title-glow:     0 2px 0 rgba(4, 20, 14, 0.35), 0 0 22px rgba(255, 214, 140, 0.22);
  --card-halo:           radial-gradient(closest-side, rgba(255, 214, 140, 0.30), rgba(255, 214, 140, 0.10) 55%, rgba(255, 214, 140, 0) 100%);
  /* "17 tiles to go", a small chip with a little tile in it */
  --card-chip:           rgba(4, 20, 14, 0.28);
  --card-chip-tile:      #ffffff;
  --card-chip-tile-edge: inset 0 -1.5px 1.5px rgba(120, 132, 126, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
  /* the one button that keeps the player going */
  --card-primary:        #f0c75e;
  --card-primary-text:   #3f2e00;
  /* the outlined buttons beside it (Start over, Back to map) */
  --card-quiet:          rgba(238, 245, 239, 0.09);
  --card-quiet-text:     #e6efe9;
  --card-quiet-rim:      inset 0 0 0 1.5px rgba(238, 245, 239, 0.20);
  /* the plain lines: How did it happen?, Back to map on the win card */
  --card-link-text:      #b4cbbf;
  /* the video offer, a link: the card's own text colour, never green (Poki) */
  --card-video-text:     #e6efe9;
  --card-video-line:     rgba(230, 239, 233, 0.45);
  /* the tiles standing on the cards' top edge, two of each, one per showing */
  --card-lose-tile-1:    url('assets/card-tile-2-sad.svg');
  --card-lose-tile-2:    url('assets/card-tile-4-sad.svg');
  --card-win-tile-1:     url('assets/card-tile-2-happy.svg');
  --card-win-tile-2:     url('assets/card-tile-4-happy.svg');
  /* the win card's confetti, eight colours the pieces are dealt from */
  --card-confetti-1:     #f0c75e;
  --card-confetti-2:     #ffd978;
  --card-confetti-3:     #f4a29b;
  --card-confetti-4:     #f7c1b5;
  --card-confetti-5:     #8fd6b4;
  --card-confetti-6:     #c9b6f2;
  --card-confetti-7:     #9cc9f0;
  --card-confetti-8:     #fff3d1;
}

/* =============================================================================
   EVERY OTHER POPUP'S THEME (popups in the calm look, 2026-10-15, off
   mockups/popups-calm.html). The booster cards, the cooking cards, Settings,
   Not enough coins, How to play, Privacy, Restart?, the advert question and
   the level banner all wear the end cards'
   surface: the same --card-* tokens above for the card, its text, its gold
   button and its quiet button. Only what the end cards never needed is here,
   named by job. The rules are at the end of this file ("THE CALM POPUPS").
   ========================================================================== */
:root {
  /* a long card (Settings, How to play): the pond keeps its own size at the
     top and the shade melts into the plain face under it */
  --card-shade-long:     linear-gradient(180deg, rgba(46, 104, 82, 0.16) 0px, rgba(34, 84, 66, 0.55) 150px, var(--card-face) 300px);
  --card-picture-long:   100% auto;
  /* the rows of Settings and the coins sheet */
  --card-row:            rgba(238, 245, 239, 0.07);
  --card-row-rim:        inset 0 0 0 1px rgba(238, 245, 239, 0.12);
  --card-row-ico:        #a9d3bd;
  --card-row-sub:        #9fbcae;
  --card-row-danger-ico: #f4a29b;
  /* the on and off switches */
  --card-switch-off:     rgba(238, 245, 239, 0.20);
  --card-switch-on:      #f0c75e;
  --card-switch-knob:    #ffffff;
  /* the round close button at the top corner */
  --card-close:          rgba(238, 245, 239, 0.12);
  --card-close-ink:      #e6efe9;
  /* section heads on How to play and Privacy, and the line between parts */
  --card-heading:        #f3d98f;
  --card-line:           rgba(238, 245, 239, 0.14);
  --card-scroll:         rgba(238, 245, 239, 0.30);
  /* the banner before a level */
  --card-pill:           rgba(24, 62, 48, 0.96);
  --card-pill-shadow:    0 10px 26px rgba(0, 0, 0, 0.40);
}

/* =============================================================================
   THE HARD LEVEL MARK'S THEME (option B of mockups/hard-level-marks.html,
   Matan approved 2026-10-13): a violet ring round a hard level's bead on the
   lobby dial and round the big circle when it is the level you are on, a violet
   HARD LEVEL label in that circle, and a short violet Hard pill beside Level X
   inside the level. Violet is used nowhere else on the dial, so it cannot be
   read as a warning or as the gold of the current level.
   Every colour the mark wears is here and nowhere else, like the end cards'
   block above; the rules (search "THE HARD LEVEL MARK") only say where it goes.
   ========================================================================== */
:root {
  --hard-mark:          #8b5cf6;                   /* the ring, the label, the pill */
  --hard-mark-dk:       #5b2fb8;                   /* the label's and pill's drop edge */
  --hard-mark-rim:      rgba(196, 181, 253, 0.55); /* the pill's thin light rim */
  --hard-mark-glow:     rgba(139, 92, 246, 0.55);  /* the soft glow round a ring */
  --hard-mark-halo:     rgba(139, 92, 246, 0.35);  /* the big circle's outer halo */
  --hard-mark-text:     #ffffff;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

  html, body {
    height: 100%;
    font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    /* The board colour, not the old mint (2026-08-19). #game covers the whole
       viewport on a phone so this is never the main surface — but it IS what
       shows as side rails above 720px wide, and what iOS paints during a
       rubber-band overscroll. Left at mint it flashed the pre-dark-board colour. */
    background: var(--body-bg);
    color: #2a3549;
    overflow: hidden;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
  }

  /* Screens: only one of map/play is active at a time; modals overlay both. */
  .screen { display: none; height: 100%; }
  .screen.active { display: flex; }

  /* The map keeps the old dark board until its own pass. */
  #map-screen { background: #2a6349 url('assets/background.jpg') center / cover no-repeat; }

  #game {
    flex-direction: column;
    max-width: 720px;
    margin: 0 auto;
    /* TIGHTENED 2026-08-04. Matan plays at a 375x685 viewport — mobile browser
       chrome eats 127px more than the 812 this screen was tuned against — and at
       685 the board was getting 349px, i.e. 51% of the screen, with 336px of
       chrome around it. Level 17 rendered its words at 6.1px. Every px reclaimed
       here is board scale, so the padding and the gaps go first. */
    padding: 6px 3px 8px;   /* horizontal 14 -> 3 (Matan 2026-08-04): board width is the
                              lever on tile size, and every px of side padding is
                              a px the board never gets. */
    gap: 5px;
    width: 100%;
    position: relative;
    /* Verdant Oasis wash (Stitch brief, 2026-08-06). Flat mint base with two
       radial highlights — no vertical shading underneath, that's the delta
       from the old wash. */
    /* Highlights dialled back from 0.3 to 0.10/0.14: they were authored to read
       ON MINT, and at full strength over Pine they wash the corners out to a
       milky green. */
    background-color: var(--board);
    background-image:
      radial-gradient(circle at top right, var(--board-hi-top) 0%, transparent 42%),
      radial-gradient(circle at bottom left, var(--board-hi-bot) 0%, transparent 60%);
  }

  /* THE GARDEN IS A SURROUND, NOT A WASH — reworked 2026-08-19 (Matan).
     EDITABLE: assets/background-night-arch-light-portrait.jpg. Kept as its own
     layer so opacity and mask can be retuned without touching the gradient
     underneath.

     What this replaces and why. It used to be assets/background.jpg — a bright
     illustrated sunset garden — held at 0.18 with mix-blend-mode: overlay. At
     that strength it read as neither a place nor a texture, just noise on the
     glass, and it left the question "what am I looking at?" unanswered: the
     tiles have a slab wall and a cast shadow, so they are objects lying on a
     SURFACE, and a surface is not a sunset. Now the dark board is the surface
     and the garden is the room it stands in. The old file is still on disk and
     is not used by anything.

     THE MASK IS THE POINT. The image's own centre is deliberately empty (we
     generated it that way), so the mask only has to hold the flowered rim off
     the board. Ellipse radii are pinned at 50% 50% so the gradient's 100% ray
     lands on the frame edge — the 62%/90% stops then read as percentages of the
     distance from centre to screen edge. Sizing the ellipse larger pushes that
     stop off-screen and the mask quietly stops biting.

     `mix-blend-mode` is NORMAL now, not overlay: overlay lifts mid-tones, which
     is what forced the old photo down to 0.18 in the first place.

     Do not change the image without re-deriving --board from it — see the note
     up at the palette. The two are locked, and the failure mode is a visible
     ellipse in the middle of the screen. */
  #bg-photo {
    position: absolute; inset: 0; z-index: 0; pointer-events: none;
    background: var(--bg-image) center / cover no-repeat;
    opacity: var(--bg-opacity);
    mix-blend-mode: var(--bg-blend);
    -webkit-mask-image: var(--bg-mask);
    mask-image: var(--bg-mask);
  }

  /* ======================================================================
     COOKING MODE'S OWN ROOM (Matan, 2026-08-28).
     `html.cooking` is set by showScreen() while a cooking board is up and
     cleared the moment it is not, so this whole block is scoped to that one
     screen and cannot leak into a numbered level.

     THE WASH IS #7a3a2c, and it was measured rather than picked. It has to sit
     where the green board sits or every tile, scrim and pill shifts under it:
     L* 32.6 against the green board's 33.7, a white tile face at 8.51:1 against
     its 8.15, and - the reason this exact hue and not a darker or lighter red -
     ALL 18 pill-pool colours clear the 25 dE camouflage floor against it. The
     three candidates either side of it (#5c2620, #6b2f24, #8a3f2e) each lose one
     pill to the board. Re-derive with:
         python tools/theme-measure.py "cook=#7a3a2c"

     --board IS NOT ONLY THE BACKGROUND. The glass-tile scrim and the tray's
     recessed track both read from it, deliberately, so the shading follows the
     board. Going red therefore tints every glass tile on a cooking board warm -
     Matan's call, and it is the intended look, not a leak.

     THE MASK STAYS `none`, which is what keeps --board free to be anything. A
     masked photo pins the board colour to the image's own centre-band mean; see
     the --board note up at the palette before restoring one here. */
  /* =========================================================================
     THEME LOOKS LIVE HERE.  `html.theme-<id>` is set by applyTheme() in game.js
     from the `themes` block in config.json - a level number band, or a cuisine.
     A theme is a BUNDLE: this class is its look, and the same config entry
     names its sound scene in ambience.js.

     WHY THE LOOK IS CSS AND NOT CONFIG DATA.  The one thing a theme must carry
     is --bg-image, and that differs by ORIENTATION (see the two rules below).
     A flat string in config.json cannot express a media query, so config names
     the class and the class carries the look. Adding a theme is three edits:
     one entry in config.json, one scene in ambience.js, one block here.

     ⚠️ TODAY theme-kitchen HAS NO BLOCK, AND THAT IS NOT AN OVERSIGHT. The
     kitchen look is still carried by `html.cooking` immediately below, which
     predates themes and is a MODE class rather than a theme class. The two
     coincide because there is exactly one cooking theme; the moment a second
     cuisine theme is added, the look moves out of html.cooking and into a
     theme-* block, and html.cooking keeps only what is genuinely about MODE
     (the petals, the dish rules). Do not merge them before then - see the note
     above applyTheme() in game.js for why mode and theme are separate axes.
     ========================================================================= */
  html.cooking {
    --board: #7a3a2c;
    /* TWO FILES, ONE PER ORIENTATION (Matan, 2026-08-28), because #bg-photo is
       `cover` and the two layouts leave the room visible in different places:
         portrait   everything stacks down the middle, so the art is busy on the
                    LEFT AND RIGHT EDGES and calm through a central column - the
                    same logic as the night-arch portrait it replaces.
         landscape  the board is a tall block RIGHT OF CENTRE (x 39.8-81.8%
                    measured at 832x384), the panels take the far left and the
                    tray sits at x 87.7-96.4% - so the calm column shifts right
                    with the board.
       Brief for both: icons-pipeline/PROMPT-cooking-background.md.
       NEITHER FILE IS ON DISK YET - Matan is producing them. Until they land the
       url() simply fails to load and the measured #7a3a2c wash carries the
       screen on its own, which is why that colour was measured rather than
       eyeballed. */
    --bg-image: url('assets/background-cooking-portrait.jpg');
    --bg-opacity: 0.34;
    --bg-blend: normal;
    --bg-mask: none;
  }
  @media (orientation: landscape) {
    html.cooking { --bg-image: url('assets/background-cooking-landscape.jpg'); }
  }
  /* COOKING KEEPS THE LOOK IT HAD, 2026-09-23. The block above only ever set
     the board and the pictures; everything else on a cooking board (the round
     buttons, the tray, the depth tint, the rails, the title outline) came from
     :root. When the riverside at dusk became :root's default those would all
     have turned violet on the terracotta board, so they are pinned here to the
     values they had until that day. Delete this rule to let Cooking follow the
     dusk tray and buttons instead - a design call, not a fix.
     2026-09-27: :root is the garden again and html.look-dusk skips Cooking
     (:not(.cooking)), so these now repeat :root's values. Kept on purpose: it is
     what keeps Cooking's tray and buttons green whatever :root does next. */
  html.cooking {
    --body-bg:        #165a43;
    --board-hi-bot:   rgba(8,40,28,0.14);
    --green-mid:      #47a075;
    --green-edge:     #72d4a6;
    --green-shade:    #123f2e;
    --title-stroke:   #0f5238;
    --depth-near:     rgba(22, 90, 67, 0.15);
    --depth-far:      rgba(22, 90, 67, 0.22);
    --tray-panel:     rgba(12, 52, 38, 0.24);
    --deck-1:         #a9cbb6;
    --deck-2:         #89ad98;
    --deck-3:         #6b917d;
    --deck-front-1:   #6f947f;
    --deck-front-2:   #537a66;
    --slit-1:         #24513f;
    --slit-2:         #2f5c49;
    --slit-3:         #457058;
    --lip-1:          #b6d4c1;
    --lip-2:          #8fb2a0;
  }
  /* NO PETALS IN THE KITCHEN. Belt and braces on purpose: this hides the canvas,
     and petalsShouldRun() in game.js also refuses to start the loop, so the rAF
     integrator is not merely invisible - it is not running. Hiding alone would
     leave a wind field integrating every frame behind an opaque element. */
  html.cooking #petals { display: none; }

  /* ======================================================================
     THE RIVERSIDE AT DUSK, AS A LOOK THE GAME CAN GIVE A PLAYER (2026-09-27).
     History: dusk was :root's default from 2026-09-23 (for Poki's last fit
     test) and the green garden a class, html.look-garden, for the Google Play
     app. On 2026-09-27 Matan brought the garden back as the default of every
     build except Poki ("Poki was the only place the dusk look was meant for"),
     so the two swapped places: :root is the garden, exactly as it was until
     2026-09-23, and dusk is this class.
     WHO GETS IT. game.js puts `look-dusk` on <html> (applyAbToPage()) when the
     player's look is dusk: from PLATFORM.defaultLook, which only the Poki
     backend sets to 'dusk', or from a `look` A/B assignment, or from the dev
     force ?ab=look:dusk. The Poki bundle also carries the class in its
     index.html from the first byte (tools/build-bundle.mjs reads it off the
     backend), so the loading screen and the first frame are dusk there too.
     `look-garden` is still put on <html> for a garden player, as a marker the
     checks read; no rule styles it, because the garden is :root.
     EVERY VALUE IS THE ONE :root CARRIED FROM 2026-09-23 TO 2026-09-27, the
     look Matan approved on his phone; themes/dusk.css carries the same set for
     ?theme=dusk. Change one, change both.
     NOT ON A COOKING BOARD: html.cooking keeps its own room, and :not(.cooking)
     is what stops this block, which outranks html.cooking, from painting over
     it. */
  html.look-dusk:not(.cooking) {
    --board:          #3b3a8a;
    --body-bg:        #3b3a8a;
    --bg-image:       url('assets/background-dusk-portrait.jpg');
    --bg-opacity:     0.72;
    --bg-blend:       normal;
    --bg-mask:        none;
    --board-hi-top:   rgba(255,255,255,0.10);
    --board-hi-bot:   rgba(20,14,60,0.16);
    --green-mid:      #7a6fd0;
    --green-edge:     #b9b0ff;
    --green-shade:    #26235e;
    --depth-near:     rgba(59, 58, 138, 0.15);
    --depth-far:      rgba(59, 58, 138, 0.22);
    --tray-panel:     rgba(24, 20, 78, 0.28);
    --slit-1:         #34327a;
    --slit-2:         #413f8c;
    --slit-3:         #55529f;
    --deck-1:         #b6aed2;
    --deck-2:         #9b91b5;
    --deck-3:         #82769a;
    --deck-front-1:   #91759a;
    --deck-front-2:   #795b80;
    --lip-1:          #bfb8d7;
    --lip-2:          #a096b8;
    --title-stroke:   #26235e;
  }
  @media (orientation: landscape) {
    html.look-dusk:not(.cooking) { --bg-image: url('assets/background-dusk-landscape.jpg'); }
  }

  /* ======================================================================
     THE `boosters` A/B TEST, variant "locked" (2026-09-26). Since 2026-09-14 a
     booster the player has not reached is hidden (.booster.locked below keeps
     its space, visibility hidden). This variant shows it again the way the
     game did before that day: in place, greyed, wearing its unlock level, and
     not pressable (the button's own onclick refuses a locked booster). The
     class is put on <html> by applyAbToPage() in game.js. A filter on a
     BOOSTER BUTTON, never on a tile face. */
  html.ab-boosters-locked .booster.locked {
    visibility: visible;
    cursor: not-allowed;
    filter: saturate(0.30) brightness(0.72);
  }

  #petals { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }
  /* ONE CANVAS, not twenty animated elements (2026-08-26). The petals are a
     wind field now - see the block above buildPetals() in game.js for why that
     could not stay in CSS: keyframes have no integrator, so they can carry a
     fall and a sine but never a vortex, and a vortex is the whole point.
     --petal is still the colour token, but the ALPHA in it is no longer what
     you see: game.js multiplies it by the old keyframe plateau (0.55) and
     paints the result, so PETAL_ALPHA there is the number that matters. */
  #petals canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
  /* Ambience is never worth a motion-sickness trigger. Belt and braces: this
     hides it, and petalsShouldRun() in game.js also refuses to start the loop,
     so a reduced-motion user does not pay for a canvas nobody can see. */
  @media (prefers-reduced-motion: reduce) { #petals { display: none; } }

  /* Everything above the two background layers. */
  /* .guide-float: the first-time guide's hand, caption, Skip and cheer, which are
     placed in viewport pixels and carry their own position and layer.
     #fly-layer: the overlay the loose copies travel on, same reason - it is
     placed in viewport pixels and carries its own layer. See the block after
     the tray. */
  #game > *:not(#bg-photo):not(#petals):not(.rail-panel):not(.guide-float):not(#fly-layer) { position: relative; z-index: 2; }

  .btn {
    background: white;
    border: none;
    border-radius: 12px;
    padding: 8px 12px;
    font-size: 13px;
    font-weight: 650;
    color: #2a3549;
    cursor: pointer;
    box-shadow: 0 2px 6px rgba(0,0,0,0.08);
    transition: transform 0.08s ease;
    font-family: inherit;
    white-space: nowrap;
  }
  .btn:active { transform: scale(0.95); }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }
  /* THE COIN INSIDE A BUTTON LABEL (2026-09-03). setBtnLabel() in game.js swaps
     the 🪙 in a label for this span; see the note there for why the flat
     silhouette and not the full-colour stack. `currentColor` is the whole point:
     the coin is painted in the button's own ink, so it is exactly as readable as
     the words either side of it whatever colour that button is. Sized in `em` so
     it tracks the label through every landscape compaction instead of needing a
     row of its own in each one. */
  .btn-coin {
    display: inline-block; width: 1.05em; height: 1.05em;
    vertical-align: -0.16em; color: currentColor;
  }
  .btn-coin svg { width: 100%; height: 100%; display: block; }
  /* THE LOSE CARD'S COIN IS THE COIN PILL'S OWN PICTURE (Matan, 2026-09-27).
     `full` is set only by the lose card (see setBtnLabel()); it draws
     UI_ICONS.coinStack, the art in .coin-badge .cb-ico, at the pill's own
     proportion to its text (17px on 14px there, 1.21em here).
     THE SHADOW IS THE WIN CARD'S COIN SHADOW (.end-coin), not the pill's: the
     pill draws its coin with no filter, on slate. Gold on the gold button
     needs the seat, and this is the one this coin already wears elsewhere. */
  .btn-coin.full { width: 1.21em; height: 1.21em; vertical-align: -0.24em; margin-left: 0.1em; }
  .btn-coin.full svg { filter: drop-shadow(0 1px 0 #a6720a); }
  .btn-primary { background: #4a90e2; color: white; }
  .btn-ghost { background: rgba(0,0,0,0.06); }
  /* ---- THE REWARDED OFFER IS A LINK, NOT A SLAB (Matan, 2026-09-02) ----
     Every "watch a video" in the game wears this. It is still a <button>, so the
     tap target, the focus ring and the handler are untouched; only the paint
     changes. The point is rank: an ad is the optional side route, and while it
     looked like the other buttons it competed with the thing the card is
     actually for. A link reads as "there is another way" without shouting.
     NO BACKGROUND AND NO SHADOW, so it cannot be mistaken for the primary; the
     underline is what says it is pressable, since nothing else here is. */
  /* ⚠️ NOT GREEN (2026-09-10, QA-727). Poki: "rewarded buttons cannot use green
     color". It was the deep green; it is the card's own ink now, the same slate
     every other button label wears, so it still reads as text on the white card
     and the underline alone says it can be pressed. */
  .btn-ad {
    background: none;
    box-shadow: none;
    color: #2a3549;
    text-decoration: underline;
    text-underline-offset: 3px;
    text-decoration-thickness: 1.5px;
    text-decoration-color: rgba(42,53,73,0.42);
    font-weight: 700;
    padding: 9px 12px;
    /* .btn is white-space: nowrap, which is right for a slab whose width is its
       own business. A link inside a fixed-width card is not: a long label had
       nowhere to go and ran off the edge of the win card (Matan, 2026-09-02,
       with a screenshot). It wraps and stays inside now, so no future label can
       break the card however long it gets. */
    white-space: normal;
    max-width: 100%;
  }
  .btn-ad:hover { text-decoration-color: currentColor; }
  /* The slab buttons lift on press; a link has no slab to lift, so it dims. */
  .btn-ad:active { transform: none; opacity: 0.62; }

  /* ---- HEADER -------------------------------------------------------------
     Back / level name / coins / gear. Everything this header no longer shows —
     lives, restart, rules, the dev skip — lives behind the gear. */
  #hud {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    flex: 0 0 auto;
  }
  .hud-group { display: flex; align-items: center; gap: 8px; }

  /* THE SKEUOMORPHIC PRESS. Every raised control in this design is drawn with a
     solid offset shadow (the object's side wall) plus a soft ambient one, and
     "pressing" it means moving the face down by the wall's height and deleting
     the wall. Anything raised should reuse .press-down. */
  .press-down:active { transform: translateY(3px); }

  .round-btn {
    /* 34, was 38 (44 before that). The buttons set #hud's height, and the header
       was reading much heavier than the category row directly under it — Matan,
       2026-08-09. Height comes off the header and goes to the pills. */
    width: 34px; height: 34px;
    border-radius: 999px;
    background: var(--green-mid);
    border: 2px solid var(--green-edge);
    box-shadow: 0 3px 0 var(--green-shade), 0 6px 10px rgba(0,0,0,0.18);
    color: #fff;
    cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    flex: 0 0 auto;
    padding: 0;
  }
  .round-btn svg { width: 18px; height: 18px; display: block; }
  .round-btn:active { transform: translateY(3px); box-shadow: 0 0 0 var(--green-shade); }

  /* The title is white with the stroke painted BEHIND the fill (paint-order),
     which is what keeps it looking like an outlined letter rather than a hollow
     one. Browsers without -webkit-text-stroke just get white on a shadow. */
  #hud-level {
    font-weight: 650;
    /* 22, was 26. setLevelTitle() overrides this inline for long titles and its
       ladder must come down with it. */
    font-size: 22px;
    color: #fff;
    /* --title-stroke, not --green-deep, since 2026-09-23: see the token. */
    -webkit-text-stroke: 2px var(--title-stroke);
    paint-order: stroke fill;
    text-shadow: 0 3px 5px rgba(0,0,0,0.25);
    letter-spacing: -0.01em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
  }
  /* THE HARD LEVEL MARK IN THE LEVEL (option B, Matan approved 2026-10-13): a
     short violet Hard pill right after "Level X", upright and sideways. Sized in
     em of the title, so it grows and shrinks with whatever size the title is
     given in each layout, and it never wraps: the title is nowrap and the pill
     is inline. The title's outline and shadow are switched off on it. Where the
     two would not fit the space the title has (sideways on a desktop, where the
     title is sized by the screen), fitHardTitle() in game.js sets --title-fit
     and the words and the pill shrink by it together. The sweep state
     dial-marks checks it fits beside "Level 100" at every sweep size.
     Colours: the hard mark's theme block. */
  #hud-level .hud-title-txt { font-size: calc(var(--title-fit, 1) * 1em); }
  #hud-level .hud-hard {
    display: inline-block; vertical-align: middle;
    margin-left: 0.3em;
    padding: 0.28em 0.75em; border-radius: 9999px;
    font-size: max(10px, calc(var(--title-fit, 1) * 0.52em)); font-weight: 900; letter-spacing: 0.05em;
    line-height: 1; text-transform: uppercase;
    color: var(--hard-mark-text); background: var(--hard-mark);
    box-shadow: 0 0.16em 0 var(--hard-mark-dk), 0 0 0 0.14em var(--hard-mark-rim);
    -webkit-text-stroke: 0; text-shadow: none;
    position: relative; top: -0.06em;
  }

  /* SLATE, NOT GOLD (2026-08-19). The coin pill is STATUS, not an action, and it
     should be the quietest thing in the header. On mint it was nearly invisible
     (fill 1.24:1, rim 1.09:1) and the dark board flipped it to the loudest
     element on the whole screen — fill 4.50:1 with a 6.09:1 near-white rim, above
     every pill and every booster. Slate puts the pill itself at 1.42:1 so it
     recedes, and the mid-gold rim ties it to the coins without shouting. All the
     gold left in the header now belongs to the stacked-coin icon, which is the
     part that actually means "currency". */
  .coin-badge {
    display: flex; align-items: center; gap: 6px;
    background: #4a5a54;
    color: #ffffff;
    padding: 6px 12px;
    border-radius: 999px;
    border: 2px solid #e0b24d;
    box-shadow: 0 3px 0 #22302b, 0 5px 8px rgba(0,0,0,0.15);
    font-weight: 650; font-size: 14px;
    white-space: nowrap;
  }
  /* 17px, and NO `color` — UI_ICONS.coinStack carries its own golds as literal
     fills, so a currentColor here would do nothing but mislead the next reader. */
  .coin-badge .cb-ico { width: 17px; height: 17px; display: block; }
  .coin-badge .cb-ico svg { width: 100%; height: 100%; display: block; }

  /* The old flat chrome button, still used by the map screen and the modals. */
  .icon-btn {
    background: rgba(0,0,0,0.22);
    border: none;
    border-radius: 999px;
    width: 30px; height: 30px;
    color: #fff;
    font-size: 15px;
    font-weight: 650;
    cursor: pointer;
    font-family: inherit;
    display: flex; align-items: center; justify-content: center;
    flex: 0 0 auto;
  }
  /* The map's rules button is the character "?"; its settings button is an SVG.
     Without this the gear renders at the SVG default and swamps a 30px button. */
  .icon-btn svg { width: 16px; height: 16px; display: block; }
  .icon-btn:active { transform: scale(0.92); }

  /* ---- Settings sheet ---- */
  .sheet {
    position: fixed; inset: 0; z-index: 110;
    /* ⚠️ CENTRED, NOT HUNG OFF THE BOTTOM (Matan, 2026-09-09: *"when it pops from
       the bottom it looks cropped because the top corners are rounded and smooth,
       and the bottom is a sharp 90 degrees and snapped to the bottom of the
       window. looks bad."*)
       He is describing the shape, and he is right: a card with two rounded
       corners and two square ones, flush against the edge of the screen, reads
       as a card that has been CUT rather than one that ends. It was the phone
       idiom - a sheet that slides up from the bottom edge - and this game is
       played sideways on a monitor as much as upright on a phone, where there is
       no bottom edge to slide from and nothing to justify the shape.
       The padding below keeps it off the edge at every size, so the shape is
       always complete. */
    display: none; align-items: center; justify-content: center;
    padding: max(12px, 4dvh) 12px;
    background: rgba(10, 30, 20, 0.5);
    backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  }
  .sheet.show { display: flex; }
  /* ⚠️ THE HEIGHT CAP AND THE SCROLLING BODY ARE THE WHOLE REASON THIS SHEET
     WORKS. Do not remove either "because the sheet is short".

     It was NOT short. Measured at 375x812 with the play screen up on 2026-08-28:
     15 rows made the card **1059px tall in an 812px viewport**, and because
     `.sheet` is `align-items: flex-end` the overflow all goes off the TOP - the
     card's top sat at **-228px**, putting the header and its close button 212px
     above the screen. Nothing scrolled: the card had `max-height: none` and
     `overflow: visible`, and the page itself cannot scroll because `html, body`
     are `overflow: hidden` (that is the game shell, and correct). So opening
     Settings inside a level was a DEAD END - no close, no scroll, no way back to
     the game. Matan hit it and it is exactly what he described.

     THE FIX IS THREE PROPERTIES AND EACH ONE IS LOAD-BEARING:
       max-height   caps the card inside the viewport, so it can never push its
                    own header off the top again
       flex column  splits it into a head that stays put and a body that scrolls
       min-height:0 on the body, or a flex item REFUSES to shrink below its
                    content and the overflow comes straight back

     The row count grows every time a dev tool registers a SETTINGS_EXTRA row, so
     this will only get taller. It has to be a cap, not a bigger number. */
  .sheet-card {
    background: #f4fbf6;
    width: 100%; max-width: 420px;
    /* 100% of the container, which is the screen less its padding - so the cap
       follows the padding above instead of being a second guess at it. */
    max-height: 100%;
    display: flex; flex-direction: column;
    /* All four corners, because all four are now visible. The safe-area padding
       that used to be here belonged to a card sitting ON the bottom edge; the
       container's own padding does that job now and does it on every side. */
    border-radius: 22px;
    padding: 16px;
    box-shadow: 0 14px 40px rgba(0,0,0,0.35);
    animation: sheetIn 0.24s cubic-bezier(0.2, 0.85, 0.3, 1);
  }
  /* The head never scrolls away - the close button is the only guaranteed way
     out of the sheet, so it stays on screen whatever the body is doing. */
  .sheet-card > .sheet-head { flex: 0 0 auto; }
  .sheet-body {
    flex: 1 1 auto;
    min-height: 0;                 /* see above - without this nothing shrinks */
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    /* ⚠️ A VISIBLE SCROLLBAR, BECAUSE THE SHEET GAVE NO SIGN IT SCROLLED. The
       content was always reachable and always looked as though it was not:
       the list ended flush at a half-drawn row with no bar, no fade and no hint,
       so "there is more below" read as "the card is cut off" - which is what
       Matan reported. A browser only draws these when there IS overflow, so this
       is the affordance appearing exactly when it is needed and never otherwise.
       scrollbar-gutter keeps the rows from shifting sideways when it appears. */
    scrollbar-gutter: stable;
    scrollbar-width: thin;
    scrollbar-color: rgba(30, 90, 66, 0.45) transparent;
  }
  /* WebKit does not honour scrollbar-width/-color, so the same bar again in its
     own syntax. Both are needed; neither is a fallback for the other. */
  .sheet-body::-webkit-scrollbar { width: 8px; }
  .sheet-body::-webkit-scrollbar-track { background: transparent; }
  .sheet-body::-webkit-scrollbar-thumb {
    background: rgba(30, 90, 66, 0.45); border-radius: 4px;
  }
  .sheet-body::-webkit-scrollbar-thumb:hover { background: rgba(30, 90, 66, 0.7); }
  .sheet-body {
    overscroll-behavior: contain;  /* a flick at the end must not drag the page */
    padding-right: 2px;            /* room for the scrollbar, so rows do not jump */
  }
  /* A centred card grows into place rather than sliding off an edge it no longer
     touches. Also: `to { transform: none }` with no fill mode means a card whose
     animation never runs - a page that has stopped compositing, which is a trap
     this project has paid for three times - is left at the FROM state. Scaling
     from 0.98 makes that failure invisible instead of an 18px offset. */
  @keyframes sheetIn { from { transform: scale(0.98); opacity: 0; } to { transform: none; opacity: 1; } }
  .sheet-head {
    display: flex; align-items: center; justify-content: space-between;
    font-weight: 650; font-size: 17px; color: var(--green-deep);
    margin-bottom: 10px;
  }
  .sheet-x {
    width: 30px; height: 30px; border: none; border-radius: 999px;
    background: rgba(15,82,56,0.1); color: var(--green-deep);
    cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0;
  }
  .sheet-x svg { width: 15px; height: 15px; display: block; }
  .sheet-row {
    display: flex; align-items: center; gap: 12px;
    width: 100%; padding: 12px 12px;
    border: none; border-radius: 14px;
    background: #fff;
    box-shadow: 0 2px 5px rgba(15,82,56,0.10);
    cursor: pointer; font-family: inherit; text-align: left;
    margin-bottom: 8px;
  }
  .sheet-row:active { transform: scale(0.985); }

  /* ⚠️ ON A SHORT SCREEN THE ROWS COMPACT, SO THE CARD ENDS BETWEEN ROWS RATHER
     THAN THROUGH ONE (2026-09-09). Centring the sheet fixed the shape - two
     rounded corners flush against the bottom edge - but on a real iPhone held
     sideways, 283px tall, five rows at 65px each still could not fit a 259px
     card, so its bottom edge cut a row in half. A card that ends mid-row reads
     as cut off whatever its corners do, which was Matan's complaint in the first
     place: *"neither a user nor a developer should have such an experience."*
     430px is the same threshold the win, lose and cooking cards have compacted
     at since 2026-08-29, so a short screen behaves consistently across all of
     them rather than each picking its own line.
     THE SUBTITLE IS WHAT GOES. It is the one part of a row that repeats what the
     name already says - "Sound FX / taps, tiles and the tray" - so losing it
     costs the least, and it is half the row's height. Five rows then come to
     about 190px and fit with the header. */
  @media (max-height: 430px) {
    .sheet-row { padding: 8px 12px; margin-bottom: 6px; }
    .sheet-row .sr-sub { display: none; }
    .sheet-row .sr-name { font-size: 14px; }
    .sheet-head { font-size: 15px; margin-bottom: 6px; }
  }
  .sheet-row .sr-ico { width: 22px; height: 22px; flex: 0 0 auto; color: var(--green-mid); }
  .sheet-row .sr-ico svg { width: 100%; height: 100%; display: block; }
  .sheet-row .sr-txt { display: flex; flex-direction: column; gap: 1px; }
  .sheet-row .sr-name { font-weight: 650; font-size: 15px; color: #23342b; }
  .sheet-row .sr-sub { font-weight: 650; font-size: 11.5px; color: #7c8e84; }
  .sheet-row.danger .sr-ico { color: #c0503f; }

  /* ---- The coins offer, in sheet chrome (2026-09-01) ----------------------
     It was a .modal / .modal-card - the white business card every overlay
     outside the play screen and the lobby is still on. The lobby is the ONLY
     screen that opens it, and the lobby is green and gold, so it landed as a
     different application dropped on top of the game (Matan: "the popup form is
     the old ugly format"). It is the same sheet as Settings now, so the two
     things the lobby can open match each other.
     The card, the head, the scroll and the min-height:0 trap are all inherited
     from .sheet-card above - read that note before changing anything here.
     Only the message and the two buttons are styled locally. */
  #coins-modal .cs-msg {
    margin: 2px 2px 14px;
    font-size: 14px; line-height: 1.45; font-weight: 650; color: #3b4c43;
  }
  #coins-modal .cs-msg b { color: var(--green-deep); }
  /* The line that replaces the video once ad.coinTopUpDailyCap is spent. Quieter
     than the sentence above it and set off by a rule, because it is not the
     refusal - it is what to do instead. Lives inside .cs-msg, so it inherits the
     colour and only overrides what has to move. See offerCoins() in game.js. */
  #coins-modal .cs-msg .coins-note {
    margin-top: 10px; padding-top: 10px;
    border-top: 1px solid rgba(15,82,56,0.12);
    font-size: 13px; font-weight: 600; opacity: 0.85;
  }
  #coins-modal .modal-actions .btn {
    padding: 13px 18px; font-size: 15px; font-weight: 800; border-radius: 14px;
    box-shadow: 0 2px 5px rgba(15,82,56,0.10);
  }
  /* The lobby's gold, written out rather than var(--lob-gold): those tokens are
     declared on #map-screen and this sheet is a sibling of it, not a child. The
     blue .btn-primary is play-screen chrome and reads as another app here. */
  #coins-modal .btn-primary { background: #ffb702; color: #6b4b00; }
  #coins-modal .btn-ghost { background: rgba(15,82,56,0.08); color: var(--green-deep); }

  /* ---- Developer switches (2026-08-07) ------------------------------------
     Five independent toggles, not one "dev mode". Visually quieter than the
     action rows above them — they are a workbench, not part of the game — and
     set off by a labelled rule so nobody mistakes them for player settings. */
  .dev-head {
    margin: 16px 2px 8px; padding-top: 12px;
    border-top: 1px solid rgba(15,82,56,0.13);
    font-size: 11px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase;
    color: #8aa294;
  }
  .sheet-row.dev-row { background: #f4f8f5; box-shadow: none; }
  /* Skip level: a dev ACTION, not a switch, so it keeps the action-row shape and
     only borrows the quiet dev ground. No toggle on the right. */
  .sheet-row.dev-action .sr-txt { flex: 1; }
  .sheet-row.dev-row .sr-txt { flex: 1; }
  .sheet-row.dev-row .sr-ico { color: #8aa294; }
  .sheet-row.dev-row.on .sr-ico { color: var(--green-mid); }
  .dev-sw {
    flex: 0 0 auto; width: 42px; height: 24px; border-radius: 999px;
    background: #d3ded7; position: relative;
    transition: background 0.18s ease;
  }
  .sheet-row.dev-row.on .dev-sw { background: var(--green-mid); }
  .dev-knob {
    position: absolute; top: 3px; left: 3px; width: 18px; height: 18px;
    border-radius: 999px; background: #fff;
    box-shadow: 0 1px 3px rgba(15,82,56,0.35);
    transition: transform 0.18s cubic-bezier(0.3, 0.7, 0.3, 1);
  }
  .sheet-row.dev-row.on .dev-knob { transform: translateX(18px); }
  /* A PLAYER toggle. Reuses the dev switch's knob and track so there is only one
     toggle in the game, but keeps the normal row ground and ink - the quiet grey
     dev treatment would read as "not for you", and Sound FX is very much for the
     player. Off is a real state here, not a disabled look. */
  .sheet-row.sw-row .sr-txt { flex: 1; }
  .sheet-row.sw-row.on .sr-ico { color: var(--green-mid); }
  .sheet-row.sw-row .dev-sw { background: #d3ded7; }
  .sheet-row.sw-row.on .dev-sw { background: var(--green-mid); }
  .sheet-row.sw-row.on .dev-knob { transform: translateX(18px); }
  /* ---- Dev coin top-up (2026-08-31) ---------------------------------------
     ONE row, wearing the dev row's quiet ground like the switches above it. It
     is a DIV rather than a button - a button may not hold form controls - so it
     has to give back the pointer and the press that .sheet-row assumes.
     The text column is the only thing allowed to shrink (min-width: 0); the
     input and the two buttons are fixed, because a squashed number field is
     unusable and the label is the part that can afford to ellipsize. */
  .sheet-row.dev-coins { cursor: default; gap: 10px; }
  .sheet-row.dev-coins:active { transform: none; }
  .sheet-row.dev-coins .sr-txt { flex: 1 1 auto; min-width: 0; }
  .sheet-row.dev-coins .sr-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .dc-in {
    flex: 0 0 auto; width: 62px; min-width: 0;
    padding: 6px 7px; border: 1px solid #cfdcd4; border-radius: 9px;
    background: #fff; color: #23342b;
    font-family: inherit; font-weight: 700; font-size: 13px; text-align: right;
    -moz-appearance: textfield;
  }
  .dc-in::-webkit-outer-spin-button,
  .dc-in::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  .dc-in:focus { outline: none; border-color: var(--green-mid); }
  .dc-in.bad { border-color: #c0503f; background: #fdf1ef; }
  .dc-btn {
    flex: 0 0 auto; border: none; border-radius: 9px;
    padding: 7px 9px; background: var(--green-mid); color: #fff;
    font-family: inherit; font-weight: 750; font-size: 12px; cursor: pointer;
  }
  .dc-btn.dc-alt { background: #dfe8e3; color: #4d6459; }
  .dc-btn:active { transform: scale(0.96); }
  /* ---- Dev pickers and the profile field panel (2026-09-02) ---------------
     A <select> wearing the number field's chrome, so a row that picks and a row
     that types read as the same control. It shrinks where .dc-in does not
     (min-width 0 plus flex-shrink), because an option label is words rather
     than digits and the text column beside it can already ellipsize.

     The field panel is INSET rather than a modal: the sheet already caps itself
     at 88dvh and scrolls (see .sheet-card), so ten more rows cost nothing but
     scrolling, where a second overlay over a sheet over a board would be three
     layers deep on a phone. */
  .dc-sel {
    flex: 0 1 auto; min-width: 0; max-width: 128px;
    padding: 6px 7px; border: 1px solid #cfdcd4; border-radius: 9px;
    background: #fff; color: #23342b;
    font-family: inherit; font-weight: 700; font-size: 12px;
  }
  .dc-sel.dc-sel-wide { max-width: 150px; }
  .dc-sel:focus { outline: none; border-color: var(--green-mid); }
  .dev-fields {
    margin: -2px 0 10px; padding: 8px 8px 2px;
    border-radius: 14px; background: #eaf1ec;
    box-shadow: inset 0 1px 3px rgba(15,82,56,0.10);
  }
  .sheet-row.dev-field { background: #fff; padding: 9px 10px; margin-bottom: 6px; }
  .sheet-row.dev-field .sr-name { font-size: 13.5px; }
  .sheet-row.dev-field .sr-sub { font-size: 11px; }
  @media (prefers-reduced-motion: reduce) {
    .dev-sw, .dev-knob { transition: none; }
  }

  /* ---- Category row (v0.3) ------------------------------------------------
     No counts, and a category only appears once you have cleared its first
     triplet — so the row starts empty and fills in as you work the board out.
     It WRAPS instead of scrolling sideways: a horizontal scroller hid chips
     off-screen, and hidden information in the one place that reports your
     progress was the wrong trade. Chips are small enough that eight fit in two
     rows at 375px. Each carries its category's fixed colour. */
  /* The extra margin is the BREATHING ROOM BETWEEN THE PILLS AND THE BOARD
     (Matan, 2026-08-09). #game's flex gap is 5px for the whole column; the pill
     row is the one seam that needed more, because the top row of tiles otherwise
     starts immediately under a row of coloured chips and the two read as one
     band. It costs the board 8px of height, which is free at 375 wide — the
     board's scale is width-bound there (see computeFit) with ~40px of vertical
     slack before height starts to bind.

     2026-08-10: the top of the screen still read as one busy band, so the seam
     ABOVE the pills got the same treatment. #game's flex gap gives it 5px; the
     margin-top takes it to 12. Matan proposed funding it by taking 1px of
     padding off the top and bottom of each pill — the pills did come down (see
     .cat-pill) but the space does not have to be BOUGHT: at 375 wide every
     level's board is width-bound (scale 0.661, computeFit), with ~40px of
     vertical slack before height starts to bind. So the gap is set to what
     actually separates the two rows rather than to what the pills could spare. */
  #counter-wrap { position: relative; flex: 0 0 auto; margin-top: 7px; margin-bottom: 8px; }

  /* THE TOP-LEFT CORNER IS POKI'S (Matan, 2026-09-23). On Poki's phone page
     their own minimize pill is laid over the game at x 0 to 62, y 24 to 70, in
     both orientations. Upright it hid the back button and the first third of
     the first category pill. Every build gets the same free corner - there is
     no platform in this, it is plain layout.
     UPRIGHT ONLY. The sideways half is its own block, POKI'S CORNER,
     SIDEWAYS, after the squarish-window layout further down.
     The round buttons keep their size and their row. The back button starts
     72px in (69 plus #game's 3px of side padding), the fullscreen button sits
     beside it with the same 12px of thumb room it has sideways, and the coins
     and the gear stay where they were.
     THE LEVEL NAME GOES UNDER THE BUTTONS, CENTRED ON THE SCREEN. It was meant
     to stay in the button row, and measured there it does not fit: with the
     corner taken the row has 58 to 78px left for the name on a 360 wide phone,
     "Level 7" needs 71 and "Level 100" 97, so the name came out as "Lev..."
     on every 360 wide Android phone and on 375 wide ones past Level 9. The line
     under the buttons is the band the pills had to leave anyway, so this costs
     no more height than the plan did. It is also where the name already sits
     sideways. Fixed at 30px tall so the pills land in the same place whatever
     size the length ladder in setLevelTitle() picks, and kept 66px in from
     each side so even its empty box stays out of the corner; 216px or more is
     left for the name at 360 wide, and the longest one is 106.
     The pills' top edge is 77 (6 padding + 35 button row + 30 name + 5 gap +
     1). The board pays those 24px and fits itself to what is left.
     centrePillRow() reads --pill-top and never sets a smaller margin. */
  @media (orientation: portrait) {
    #hud { flex-wrap: wrap; row-gap: 0; }
    #hud #btn-back { margin-left: 69px; }
    #hud #btn-fullscreen { margin-left: 12px; margin-right: auto; }
    #hud-level { order: 1; flex: 0 0 calc(100% - 132px); margin: 0 auto;
                 text-align: center; height: 30px; line-height: 30px; }
    #counter-wrap { --pill-top: 1px; margin-top: var(--pill-top); }
  }

  /* Three columns, authored for six pills. More than six grows extra rows —
     see renderCounters(); 14 of the 20 levels currently do, and that is meant to
     be visible rather than hidden behind a truncation. */
  #counter-row {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 13px 6px;   /* row gap widened 6 -> 13 for the count badge under each pill */
    /* Reserved at the FULL two-row height (rule 6.6 caps every level at 6
       categories = 2 rows of 3, and a 'tray' reveal level, which may carry up
       to 15, never shows more than 6 names at once) so the row never grows as categories reveal
       mid-level. Without this, #board-container (flex:1 below it) shrinks by
       a pill row's worth of height as you play and the board silently
       rescales smaller — see the fixed-tile-size note in CLAUDE.md.
       64 -> 71 on 2026-08-18. The reserve has to equal TWO FULL ROWS or the
       board still shrinks mid-level, which is the whole point of reserving it.
       When the count badge landed under the pill, this grid's row-gap went
       6 -> 13 and nobody moved the reserve with it: two 29px rows plus one 13px
       gap is 71, so the row still grew 7px the moment a second row of categories
       appeared. Derive it, do not type it: 2 * .cat-pill height + one row-gap. */
    min-height: 71px;
  }

  .cat-pill {
    /* POSITION: RELATIVE, and it is load-bearing (bug, 2026-08-17). The count
       badge is `position: absolute; bottom: -8px`, and absolute resolves against
       the nearest POSITIONED ancestor — which was #screen, not the pill. So all
       five badges stacked at one spot near the bottom of the screen and only the
       last one painted (Colors) was visible, which read as "only Colors has a
       count". Measured before the fix: every badge at y 119-132 regardless of
       which row its pill was in. */
    position: relative;
    height: 29px;   /* 32 -> 27 to buy board height back, then -> 31 on
                       2026-08-09 (the header was reading far heavier than this
                       row), then -> 29 on 2026-08-10: 31 sat too tight under the
                       header and Matan wanted a pixel back off the top and
                       bottom of each pill. The gap above the row is NOT funded by
                       this — see #counter-wrap. */
    border-radius: 9999px;
    display: flex; align-items: center; justify-content: center;
    padding: 0 8px;
    font-size: 16px;
    font-weight: 650;
    letter-spacing: -0.2px;
    white-space: nowrap;
    overflow: visible;   /* the count badge hangs 7px below the pill */
    text-overflow: ellipsis;
    border: 2px solid transparent;
    /* --pill-shadow is the fill darkened 32%, set per pill in renderCounters().
       THE INNER TOP HIGHLIGHT IS NOT DECORATION (2026-08-19) — it replaces the
       bottom wall as the thing that makes a pill read as a raised object. On the
       dark board that wall sits at 1.28:1 and is simply gone, and no tuning gets
       it back: pure black against #1d5c46 is 2.67:1, the absolute ceiling. On a
       dark ground the form has to come from the light, not the shadow. The wall
       stays because it still reads as a contact seam, the way --tile-frame does
       at 1.31:1. */
    box-shadow: 0 3px 0 var(--pill-shadow, rgba(0,0,0,0.4)), 0 4px 6px rgba(0,0,0,0.15),
                inset 0 1px 0 rgba(255,255,255,0.28);
    transition: opacity 0.25s ease;
  }
  /* THE ENTRY ANIMATION IS A ONE-SHOT CLASS, NOT A PROPERTY OF .cat-pill.
     While it sat on the base class, every render re-ran it on every pill — and
     renderCounters() used to run on every tap. Twice. Only renderCounters()
     adds this, and only to a pill that was genuinely hidden a moment ago. */
  .cat-pill.pill-in { animation: chipIn 0.32s cubic-bezier(0.2, 0.85, 0.3, 1) both; }
  @keyframes chipIn {
    from { opacity: 0; transform: translateY(-4px) scale(0.86); }
    to   { opacity: 1; transform: none; }
  }
  /* THE TAP PING (Matan, 2026-08-23) - see flashPill() in game.js for what it is
     for and why it is gated to levels that list their categories.

     ONE-SHOT CLASS, like .pill-in directly above, and for the same reason: on
     the base class it would re-run on every pill on every render.

     NOT OPACITY AND NOT FILL. A live pill is already opacity 1 and .complete is
     0.45, so animating toward "more on" would say the category was FINISHED.
     Scale and an outer glow are the two channels the row does not already use.

     THE GLOW IS A ::after, NOT A box-shadow ON THE PILL. .cat-pill's own shadow
     is a three-part stack and .complete replaces it with a different one, so
     animating that property means restating both - and getting it wrong the day
     either changes. An overlay with no background adds a shadow and touches
     nothing. It needs no z-index: it paints above the fill and below nothing,
     and having no background it cannot cover the label.

     FAST ATTACK, SLOW RELEASE. Peak at 22% (about 90ms) and then a 330ms decay,
     because the ping has to be caught by an eye that is arriving from the tray,
     not by one already looking at the row. */
  .cat-pill.pill-ping { animation: pillPing 420ms cubic-bezier(0.2, 0.85, 0.3, 1) both; }
  .cat-pill.pill-ping::after {
    content: ''; position: absolute; inset: -2px;
    border-radius: 9999px; pointer-events: none;
    animation: pillPingGlow 420ms cubic-bezier(0.2, 0.85, 0.3, 1) both;
  }
  /* The badge carries the news - the rim only says which pill to look at. Its
     translateX(-50%) is what centres it (see .cp-count) and MUST be repeated in
     every keyframe, or the badge jumps half its width sideways for the ping. */
  .cat-pill.pill-ping .cp-count {
    animation: pillPingCount 420ms cubic-bezier(0.2, 0.85, 0.3, 1) both;
  }
  /* EVERY SEGMENT CARRIES ITS OWN EASING, and that is the whole reason these read
     as 420ms rather than as the 200ms flash they replaced. A single ease on the
     animation applies BETWEEN EACH PAIR of keyframes, so the front-loaded curve
     that makes the attack snap was also being spent on the release: measured at
     one global ease, the glow was at 0.14 opacity by 210ms and the remaining
     210ms was an invisible tail. This is the same trap the tray drop and the
     tile flip hit on 2026-08-03 - if a duration change ever seems to do nothing,
     measure the curve before touching the number.
     The release is LINEAR on purpose: a steady fade is what keeps the glow on
     screen long enough for an eye arriving from the tray to catch it. */
  @keyframes pillPing {
    0%   { transform: scale(1);     animation-timing-function: cubic-bezier(0.2, 0.85, 0.3, 1); }
    22%  { transform: scale(1.085); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1); }
    100% { transform: scale(1); }
  }
  /* The 55% hold is what stops the glow being gone before it is looked at. */
  @keyframes pillPingGlow {
    0%   { opacity: 0;    box-shadow: 0 0 0 0 var(--pill-glow, rgba(255,255,255,0.85));
           animation-timing-function: cubic-bezier(0.2, 0.85, 0.3, 1); }
    22%  { opacity: 1;    box-shadow: 0 0 12px 4px var(--pill-glow, rgba(255,255,255,0.85));
           animation-timing-function: linear; }
    55%  { opacity: 0.82; box-shadow: 0 0 15px 5px var(--pill-glow, rgba(255,255,255,0.85));
           animation-timing-function: linear; }
    100% { opacity: 0;    box-shadow: 0 0 19px 7px var(--pill-glow, rgba(255,255,255,0.85)); }
  }
  /* THE COUNT TURNS GOLD AT ITS PEAK (Matan, 2026-09-14, the whole game): the
     number going up is the lesson "collect them", and at scale alone it was easy
     to miss. The colours are given at the peak only; 0% and 100% fall back to
     the badge's own, so nothing here restates .cp-count's background. */
  @keyframes pillPingCount {
    0%   { transform: translateX(-50%) scale(1);
           animation-timing-function: cubic-bezier(0.2, 0.85, 0.3, 1); }
    26%  { transform: translateX(-50%) scale(1.55);
           background: #ffb702; color: #633806;
           animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1); }
    100% { transform: translateX(-50%) scale(1); }
  }
  /* Fully cleared: keep it on screen as the record of what you found, retired. */
  .cat-pill.target { outline: 2px solid var(--gold); outline-offset: 2px; }

  /* CATEGORY PILL STATE — two states, driven by PROGRESS.
     INVERTED on Matan's correction, 2026-08-05: a category starts SOLID because
     it is live work, and fades back once it is FINISHED. The done one recedes and
     the ones still needing attention stay prominent — the opposite reads as the
     screen highlighting what you no longer have to think about. This also matches
     what `.cleared` used to do before the counts: keep it on screen as a record
     of what you found, but retire it. */
  .cat-pill { opacity: 1; transition: opacity 0.3s ease; }
  .cat-pill.complete {
    opacity: 0.45;
    box-shadow: 0 2px 0 var(--pill-shadow, rgba(0,0,0,0.4));
  }
  /* NOT BOLD (Matan, 2026-08-09). At 800 on a saturated fill the row shouted
     over the board; 650 is the weight the rest of the game is set in, and it
     reads as a label rather than a headline. It MUST match PILL_NAME_WEIGHT in
     game.js — that is the weight the canvas measures at to size the pill, so a
     mismatch mis-fits every name. */
  .cat-pill .cp-name { font-weight: 650; }
  /* THE COUNT IS DIGITS IN A BADGE UNDER THE PILL (Matan, 2026-08-21).

     TWO SEPARATE DECISIONS ARE STACKED HERE and it is worth keeping them apart,
     because only one of them is new.

     THE POSITION is from 2026-08-17 and is unchanged: the readout hangs BELOW
     the pill instead of sharing the name's line. That is what hands the name the
     whole pill — every category name renders at the full 16px, where
     "Professions" used to step down to 12.9px to make room for "(0/6)".

     THE NOTATION is the new part. Between 2026-08-17 and 2026-08-21 this badge
     held PIPS: one dot per tile, grouped in threes, hollow for outstanding and
     solid for collected. Digits replace them. A dot row is only countable at a
     glance up to about five, which is why the grouping existed at all, and at
     nine tiles it was three groups the player still had to add up — where "6 / 9"
     is simply read. The pips also cost seven-plus DOM nodes per pill and put the
     number nowhere in the document, so anything downstream that wanted it had to
     reconstruct it by counting those nodes.

     9px at weight 500 (Matan's values, judged in mockups/themes.html). The badge
     is the same 13px tall as the pip version it replaces — 9px of text at
     line-height 1 plus 2px of padding each side — so #counter-row's 71px reserve
     and this grid's 13px row gap did not move.

     --count-tray is the pill's OWN colour darkened 28% at 0.92 alpha, set per
     pill in renderCounters(), so the badge belongs to its pill instead of
     fighting it. A literal rgba, NOT color-mix() — an unsupported color-mix
     falls back to no background at all, which would leave the badge invisible on
     exactly the older phone browser this game targets. */
  .cat-pill .cp-count {
    position: absolute; left: 50%; bottom: -8px; transform: translateX(-50%);
    padding: 2px 7px; border-radius: 9999px;
    background: var(--count-tray, rgba(10,30,22,0.72));
    box-shadow: inset 0 0 0 1px rgba(255,255,255,0.22), 0 1px 2px rgba(0,0,0,0.22);
    font-size: 9px; font-weight: 500; line-height: 1; color: #fff;
    white-space: nowrap;
    pointer-events: none;
  }
  /* 'tray' REVEAL MODE KEEPS THE COUNT BADGE (Matan, 2026-10-10). The reveal job
     hid it here on 2026-10-09, reasoning that every category on such a board is
     three tiles and the counted tiles sit in the seats below. Matan overruled
     that: a tray pill shows the same count as a shown-names pill. Do not put a
     hiding rule back; tools/probe-tray-reveal.mjs --break=count does exactly
     that and goes red. The badge hangs below its pill as everywhere else, so the
     3 by 2 grid and its 71px reserve above are unchanged. */
  /* The label is always white now (Matan, 2026-08-17: "it's ugly when some are
     white, some are black"), so it needs its own edge on the pale fills — six of
     the pool's colours put plain white under 3:1. The shadow is the fill darkened
     62%, set per pill as --pill-ink. */
  .cat-pill .cp-name { text-shadow: 0 1px 0 var(--pill-ink, rgba(0,0,0,0.35)); }

  /* ---- Booster bar --------------------------------------------------------
     Design pass 2026-08-06. The 2026-08-05 uniform-green-circle version read
     as a SaaS toolbar — flat fill, thin line glyph — and Matan said so
     directly. Two changes: the icons are now real painted PNGs (see
     assets/boosters/, tools/make-booster-icons.mjs), not drawn shapes; and
     the badge itself went chunkier and two-tone GREEN (still green — an
     earlier terracotta pass was rejected outright) with a candy-shine
     highlight, a thick contrast rim, and a coin BADGE overlapping the rim
     instead of a pill floating underneath — closer to how the flamboyant
     end of the competitor set actually builds these (Word Farm Adventure,
     Scrabble GO: an overlapping count chip on a glossy circular well). */
  #booster-bar {
    display: flex;
    justify-content: space-around;
    align-items: center;
    flex: 0 0 auto;
  }
  .booster {
    width: 58px;
    border: none;
    background: none;
    padding: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    cursor: pointer;
    font-family: inherit;
  }
  .booster .b-top {
    position: relative;
    width: 58px;
    height: 58px;
    border-radius: 999px;
    /* RETUNED FOR THE DARK BOARD 2026-08-19. The old stops were authored on mint
       and were left behind when --green-mid/-edge/-shade were retuned on 08-18,
       because these two are hardcoded here rather than tokens. Worse, they were
       NON-MONOTONIC: 0% #5fae82 sat at 2.94:1 against the board, then 42% #3a8560
       DROPPED to 1.76:1 before rising again — a trough in the middle of the ball,
       so its centre sank into the board. Now light-to-dark throughout, with the
       area-weighted fill lifted 2.29 -> 3.06:1 and the rim brought DOWN 4.37 ->
       3.81 so the outline stops out-shouting the face (1.91x -> 1.25x). */
    background:
      radial-gradient(circle at 34% 26%, #7fcda4 0%, #63b98d 45%, #4f9f78 100%);
    border: 3px solid #63c79c;
    box-shadow:
      inset 0 3px 3px rgba(255,255,255,0.35),
      0 5px 0 #10382a,
      0 8px 11px rgba(0,0,0,0.22);
    display: flex; align-items: center; justify-content: center;
    transition: transform 0.1s, box-shadow 0.1s;
  }
  /* the candy-shine: a thin rim-light crescent along the upper-left edge.
     2026-08-06: was a flat-fill blurred oval — read as a hard-edged smudge,
     not a reflection ("it looks like a stain now", Matan). This version
     fades top-to-bottom AND masks off toward the right, so it reads as a
     glint riding the rim rather than a blob sitting on the face. */
  .booster .b-top::before {
    content: "";
    position: absolute; left: 6%; top: 5%; width: 58%; height: 22%;
    border-radius: 999px;
    background: linear-gradient(180deg, rgba(255,255,255,0.6) 0%, transparent 100%);
    filter: blur(2.5px);
    transform: rotate(-16deg);
    -webkit-mask-image: linear-gradient(120deg, black 40%, transparent 75%);
            mask-image: linear-gradient(120deg, black 40%, transparent 75%);
    pointer-events: none;
  }
  .booster:active .b-top {
    transform: translateY(5px);
    box-shadow: inset 0 3px 3px rgba(255,255,255,0.35), 0 0 0 #10382a;
  }
  .booster .b-ico {
    width: 38px; height: 38px; display: block; position: relative; z-index: 1;
    filter: drop-shadow(0 2px 2px rgba(10,30,20,0.3));
  }
  /* No per-booster size override. The 42px one Peek used to carry existed
     because the old eye art only filled 128 of its 160px canvas; the folded-tile
     art fills 144, the same box as undo and shuffle, so it matches at 38. */
  .booster .b-cost {
    position: absolute; right: -6px; bottom: -5px; z-index: 2;
    background: linear-gradient(160deg, #ffd979 0%, var(--gold) 70%);
    border: 2px solid #a6720a;
    border-radius: 999px;
    padding: 2px 6px 2px 4px;
    display: flex; align-items: center; justify-content: center; gap: 2px;
    font-size: 10px; font-weight: 800; color: #5c3d05;
    box-shadow: 0 2px 4px rgba(0,0,0,0.3);
  }
  /* 11px, was 10 — it draws UI_ICONS.coinStackMono now (a 3-coin silhouette) where
     it used to draw a plain disc, and the extra pixel buys back the gaps between
     the coins. `color` is load-bearing here: that icon is currentColor by design
     so the chip's dark amber tints it. */
  .booster .b-coin { width: 11px; height: 11px; display: block; color: #a6720a; }
  .booster .b-coin svg { width: 100%; height: 100%; display: block; }

  /* LOCKED: unlit, NOT faded (2026-08-19). `opacity: 0.7` composites the badge
     toward whatever is behind it — on the dark board that means fading it into
     the board, i.e. deleting the object rather than dimming it. At level 7 three
     of the four boosters are locked, so that was most of the bar. Full opacity
     with a brightness cut keeps a solid button that is simply switched off. */
  /* NOT SHOWN UNTIL IT IS REACHED (Matan, 2026-09-14, every build). A locked
     booster used to sit greyed out wearing `Lv6`, four buttons and four labels a
     level 1 player could do nothing with. Hidden rather than removed, so the bar
     keeps its size and nothing on the screen moves the day one arrives; it comes
     in with its own unlock card. */
  .booster.locked { visibility: hidden; }

  /* IDLE IS NOT LOCKED (2026-09-03). `locked` above means "you have not reached
     this booster yet" - it wears `Lv6`, it cannot be pressed, and the heavy grey
     is right for it. `idle` means "there is nothing for it to do this second",
     which today is Undo before the first tap of a level. That button IS live:
     it presses, and it answers "Nothing to undo."

     It had to stop borrowing the locked grey because of WHEN it happens. The
     unlock card is shown as a level opens, so the very first Undo any player
     ever sees is one with an empty history - a chip carrying the green FREE
     badge under a filter that flattens it. Measured on the badge's own colours:
     the FREE lettering sits at 3.8:1 against its chip normally and at 2.7:1
     under the locked filter, so the gift was being dimmed at the one moment it
     is being announced. A plain brightness cut, no desaturation, holds it at
     3.5:1 and keeps the button visibly the live green it is. */
  .booster.idle { filter: brightness(0.88); }
  .booster.unaffordable .b-cost { background: #a33b30; }

  /* THE FREE FIRST CHARGE, AS A BADGE (2026-09-02, Matan's partner: "put a FREE
     tiny badge on the boosters until they're used for the first time").

     IT IS THE PRICE CHIP WEARING A DIFFERENT COAT, not a second badge. Position,
     size, radius and shadow are all inherited from `.b-cost` above, so the
     corner of a booster holds exactly one thing whatever is true of it, and the
     landscape overrides that resize `.b-cost` move this with them for free.

     GREEN, NOT GOLD, AND THAT IS THE WHOLE POINT. Gold on this bar means a
     price - it is what the coin chip and the `Free` ad-credit receipt both wear.
     A gift the game is handing over has to read as a different kind of thing at
     a glance, or it is just a price that happens to say zero. The green is the
     pill row's own, so it is a colour the screen already contains.

     The coin glyph is hidden by renderBoosterBar() in this state, so the chip
     loses its 11px icon and its gap and shrinks to the word - which is what
     makes it read as tiny rather than as a wide empty price. */
  .booster.freeuse .b-cost {
    background: linear-gradient(160deg, #7fe0aa 0%, #2f9c63 75%);
    border-color: #1c6b42;
    color: #08361f;
    padding: 2px 6px;
    letter-spacing: 0.3px;
    font-size: 9px;
  }

  /* ARMED - today this is Sneak Peek only, but nothing here is Peek-specific, so
     any future booster that waits for a board tap gets the state for free.
     2026-08-28, Matan: "when tapped, the icon should look like it's triggered".

     TWO SIGNALS, AND THEY SAY DIFFERENT THINGS. The badge is held DOWN - the same
     5px drop and collapsed drop-shadow as `:active`, so the button reads as
     physically switched on rather than merely decorated; that is the game's own
     press language, already learned from every other tap. The amber ring then
     BREATHES, because a pressed button on its own says "this happened" and the
     armed state needs to say "and I am waiting for you". Amber is the game's
     existing attention colour (the hint glow), so it is not a new vocabulary.

     THE ANIMATION CARRIES THE WHOLE BOX-SHADOW, INCLUDING THE PRESSED ONE. Do not
     split the ring out into a static rule and animate only a glow: the pressed
     look is itself a box-shadow override, so a second animated box-shadow would
     replace it every frame and the badge would pop back up. One property, one
     place. That is also why `0 0 0 #10382a` is repeated inside both keyframes -
     it is the drop-shadow being deliberately held flat. */
  .booster.armed .b-top {
    transform: translateY(5px);
    animation: armedBreath 1.4s ease-in-out infinite;
  }
  @keyframes armedBreath {
    0%, 100% { box-shadow: inset 0 3px 3px rgba(255,255,255,0.35), 0 0 0 #10382a,
                 0 0 0 3px rgba(247,193,58,0.85), 0 0 14px 3px rgba(247,193,58,0.42); }
    50%      { box-shadow: inset 0 3px 3px rgba(255,255,255,0.35), 0 0 0 #10382a,
                 0 0 0 3px rgba(255,222,140,1),  0 0 22px 7px rgba(247,193,58,0.68); }
  }

  /* THE CANCEL X. Top-left because the gold cost chip owns the bottom-right, and
     the two must never share a corner. Judged against the alternative of putting
     the X over the middle of the badge (mockups/booster-armed.html, both built):
     the corner keeps the eye art readable, so the button still says WHICH booster
     is armed while it is armed. Matan's call, 2026-08-28.

     IT IS SIGNAGE, NOT A SEPARATE HITBOX - `pointer-events: none`, and the whole
     booster button cancels. A 22px target inside a 58px one is a miss waiting to
     happen on a phone, and the spec was "on second tap on the booster icon".

     Built into the markup once and shown by the class, so arming touches no DOM.
     The `xIn` pop plays because the rule STARTS MATCHING when `.armed` lands -
     that is what makes this cost nothing at render time. */
  .booster .b-x {
    display: none;
    position: absolute; left: -7px; top: -6px; z-index: 3;
    width: 22px; height: 22px; border-radius: 999px;
    background: #17332a; border: 2px solid rgba(255,255,255,0.42);
    align-items: center; justify-content: center;
    box-shadow: 0 2px 4px rgba(0,0,0,0.35);
    color: #fff;
    pointer-events: none;
  }
  .booster.armed .b-x {
    display: flex;
    animation: xIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  .booster .b-x svg { width: 11px; height: 11px; display: block; }
  @keyframes xIn {
    from { opacity: 0; transform: scale(0.5); }
    to   { opacity: 1; transform: scale(1); }
  }

  /* The armed state must survive reduced motion - it is INFORMATION, not
     decoration: without it the player cannot tell the booster is waiting. So the
     breath stops on its brightest frame and the X stops popping, but the pressed
     badge and the amber ring both stay. */
  @media (prefers-reduced-motion: reduce) {
    .booster.armed .b-top {
      animation: none;
      box-shadow: inset 0 3px 3px rgba(255,255,255,0.35), 0 0 0 #10382a,
                  0 0 0 3px rgba(255,222,140,1), 0 0 18px 5px rgba(247,193,58,0.55);
    }
    .booster.armed .b-x { animation-duration: 0.01s; }
  }

  #board-container {
    flex: 1;
    position: relative;
    min-height: 0;
    overflow: hidden;
    /* THIS IS WHAT STOPS THE BROWSER PANNING THE BOARD, and since the in-game
       pan and pinch were deleted on 2026-08-31 it carries that job alone. The
       grab/grabbing cursors went with them: they advertised a drag the board no
       longer does. See the tapping section in game.js. */
    touch-action: none;
  }
  #board-container.peeking { cursor: crosshair; }

  #reset-view-btn {
    position: absolute;
    bottom: 10px;
    right: 10px;
    width: 40px;
    height: 40px;
    border: none;
    border-radius: 10px;
    background: rgba(255,255,255,0.9);
    color: #2a3549;
    font-size: 20px;
    font-weight: 650;
    cursor: pointer;
    box-shadow: 0 2px 6px rgba(0,0,0,0.2);
    z-index: 20;
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
  }
  #reset-view-btn:active { transform: scale(0.92); }

  #board { position: absolute; transform-origin: top left; left: 0; top: 0; }

  /* ---- Tiles (v0.3) -------------------------------------------------------
     Every tile has TWO faces: a front carrying the word or icon, and a blank
     back. A Facedown tile is showing its back — which is what lets the reveal
     be a real card flip rather than a crossfade.

     The face material is a baked bitmap (assets/tile-ivory.jpg, the "pearl"
     render). See design/DESIGN-DECISIONS.md: CSS gradients cannot produce a
     painted material — every reference game ships its tiles as sprites. CSS
     here does only geometry, lighting edges and motion. */
  /* 92, not 84 — see the TILE_H note in game.js. The base row pitch is 128, so
     the extra 8px comes out of dead band between rows, not out of board height.
     Change them together or the coverage math and the CSS disagree. */
  /* Dimensions come from the LEVEL now — applyTileMetrics() in game.js sets
     --tile-w/--tile-h/--icon-w/--icon-h on :root when a level loads. The fallbacks are
     the portrait default; a landscape level ships 84 x 72. JS and CSS must agree
     or the coverage maths and the drawing describe different boards. */
  .tile { position: absolute; width: var(--tile-w, 72px); height: var(--tile-h, 84px); perspective: 700px; }

  /* THE TAPPED TILE TRAVELS TO THE TRAY (Matan, 2026-08-17) — AND THERE IS NO CSS
     FOR IT, WHICH IS THE POINT (rebuilt 2026-08-18).

     The first build was a `.tile-flyer`: a fixed-positioned CLONE of the board
     tile flying over the page while the real tray seat was held invisible by a
     `.spot.awaiting .seat` rule, with a JavaScript callback swapping one for the
     other at the end. Two elements, one object, and a class that something had to
     remove. Every bug this feature had came from those two getting out of step,
     and the last of them — the tile appearing in its seat instantly with the
     animation playing afterwards — was the previous tap's landing callback
     stripping `.awaiting` off the NEW tap's seat.

     The seat is now animated in place, from the board tile's rect back to its
     own, entirely from game.js (flyTileToTray). Nothing is cloned, nothing is
     hidden, no class is added or removed, and the animation fills nothing — so
     however it ends, the seat is sitting in its slot, visible and correct.
     `.tile-flyer`, `.spot.awaiting .seat` and @keyframes seatArrive are gone; do
     not bring any of them back. NO OPACITY ANYWHERE in the travel still holds:
     the word is painted on a physical object, and fading a tile out while fading
     a chip in is two ghosts rather than one tile moving. */

  .t-inner {
    position: absolute; inset: 0;
    transform-style: preserve-3d;
  }
  .face {
    position: absolute; inset: 0;
    /* 10 -> 8 board px on 2026-08-17. At the 0.661 render scale 10 became 6.6 CSS
       px against their measured ~5, which read rounder and softer than the brick
       we are matching. 8 lands at 5.3. */
    border-radius: var(--tile-radius);
    display: flex; align-items: center; justify-content: center;
    text-align: center;
    overflow: hidden;
    backface-visibility: hidden;
    -webkit-backface-visibility: hidden;
    padding: 0 var(--tile-pad, 2px);   /* TILE_PAD in game.js — set per level by applyTileMetrics() */
  }
  .t-word {
    /* Set by applyTileMetrics() from the level, and it MUST equal
       TILE_FONT_WEIGHT in game.js — the canvas measures at that weight to pick
       each word's size, so a mismatch mis-sizes every tile on the board. */
    font-weight: var(--tile-weight, 750);
    letter-spacing: 0.1px;
    /* line-height 1, not 1.1. The word has to fit inside the 14px strip the row
       gap keeps clear between covering tiles; at 1.1 a 16px word rendered a
       17.6px line box and overflowed the strip by 3.6px before anything else
       went wrong. Paired with TILE_FONT_MAX in game.js — change them together. */
    line-height: 1;
    white-space: nowrap;        /* v0.3: words never wrap — the font shrinks instead */
  }
  /* TWO-LINE WORDS (2026-08-04). `nowrap` stays: the break is an explicit <br>
     chosen by planFonts(), never the browser's, so the split point is decided by
     what makes the font biggest rather than by where the box happens to end. */
  .t-word.two {
    line-height: 1.02;
    white-space: normal;
  }
  /* NEVER let the icon shrink. `.face` is a flex box with 5px of horizontal
     padding for words, and a flex item shrinks by default — so a 76x76 icon was
     being squeezed to 60x76 and rendering visibly squashed, not scaled. The icon
     face drops the padding, and the span refuses to shrink. */
  .f-front.has-icon { padding: 0; }
  .t-icon { display: block; flex: 0 0 auto; }
  /* An icon is an inline <svg> or a WebP <img> (see iconMarkup() in game.js) —
     both fill the span the caller sized, so the two are interchangeable at
     every render site. */
  /* object-fit: contain is LOAD-BEARING while the set is mixed. The icon box went
     square (50x50) -> landscape (86x50) on 2026-08-24, and every existing file is
     still SQUARE 256x256. The default `fill` stretched all of them 1.72x wide —
     the same distortion that was reported once before as "a distorted car".
     With `contain`, square art letterboxes to 50x50 centred (exactly the size it
     rendered at before, so nothing regressed) and new 86x50 landscape art fills
     the box. Do not remove this once the set is converted: it is what lets the
     two generations coexist on one board. An <svg> already defaults to
     preserveAspectRatio="xMidYMid meet" and needs no equivalent. */
  .t-icon svg,
  .t-icon img { width: 100%; height: 100%; display: block; object-fit: contain; }

  /* Art is always centred — see faceInner() in game.js. Adaptive placement was
     built and removed; nothing here should ever translate or scale the art. */

  /* THE SLAB EDGE, redrawn 2026-08-02. It used to be four stacked hard shadows
     faking a graded side wall, because the ivory sprite's own bottom strip was
     the same lightness as a flat wall and the step vanished. On a WHITE face
     that problem disappears — the design's single `0 4px 0 #d9dadb` plus a soft
     ambient shadow reads as a slab on its own, and the inset white highlight
     along the top does the work the old lip did. Don't reinstate the stack. */
  /* WHITE, not the ivory sprite (2026-08-02, Matan's call): the baked ivory
     material cost contrast against the dark word, and reading is the whole game
     — a tile that is fractionally harder to read costs a fraction of every
     session. assets/tile-ivory.jpg is still on disk if this is ever revisited. */
  /* REWORKED 2026-08-17 from the Tripledot teardown (mockups/tripledot.html).
     Three changes, each measured off their tile at 1080x2340:

     1. INK IS NEAR-BLACK, not --green-deep. Dark green on cream threw away
        contrast for nothing, and reading is the whole game.
     2. A WARM INNER BOTTOM BEVEL. This — not the coloured wall — is what makes
        their tile read as a thick object: theirs is ~10 CSS px of warm tan
        INSIDE the face against only ~4 CSS px of coloured slab outside it.
        Drawn as a background-image gradient because BOTH pseudo-elements on
        this face are already load-bearing (::before is the depth scrim,
        ::after is the glass scrim). Do not reach for a third element.
     3. THE FRAME WRAPS THE WHOLE OBJECT, slab included (Matan, 2026-08-17).
        A `border` stops at the face, so the slab hung outside the frame and the
        tile read as a card with a rim glued under it. On their tile the dark
        outline is the OUTERMOST thing on every side. Two shadows reproduce it:
        one traces the face, one traces the face+slab silhouette. Earlier
        shadows paint on top, so the slab is listed BEFORE the outline that
        encloses it, and the border goes transparent so the bevel reaches the
        frame. */
  /* EDGE RE-MEASURED AGAINST THEIRS 2026-08-17, and four numbers were wrong.
     Their tile, measured down the middle at 1080x2340 and converted at DPR 2.8125:

        top outline        2.1 CSS px   #234C11
        flat cream        ~44 CSS px    #EFEAE5
        warm inner bevel   9.2 CSS px   ramp to #939C7D   = 15.5% of the face
        green slab         3.2 CSS px   #297538
        bottom outline     2.8 CSS px   #234C11
        cast shadow        3.6 CSS px   #0C4025, hard, barely blurred

     What we had: bevel 15.2 CSS px (65% too deep), slab 4, and a 13px-blur
     shadow at 9px offset — about four times too soft and too far, which is what
     made our tile read as a floating card instead of a slab sitting on a table.

     THE FRAME COLOUR IS THE BIG ONE, and it is a relationship, not a value.
     Frame-to-board contrast: theirs 1.07:1, ours was 8.60:1 — eight times
     harsher. Face-to-board: theirs 7.74:1, ours 1.41:1. Exactly inverted. On
     their board the FACE is the signal and the outline is just a seam; on ours
     the outline was doing all the work, which is the "absolutely terrible
     border" Matan reported. Softened to a board-family green at 1.21:1.
     INTERIM: this cannot make the tile pop, because cream on mint is only
     1.18:1 whatever the frame does. The board is the real lever — see section 5
     of mockups/tripledot.html for the six options and their numbers. */
  .f-front {
    background: var(--tile-bevel), var(--tile-face);
    color: var(--tile-ink);
    border: 1px solid var(--tile-border);
    box-shadow: var(--tile-edge);
    transition: box-shadow 0.24s ease, transform 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
  }
  .f-back {
    transform: rotateY(180deg);
    /* EDITABLE: the FACEDOWN tile face — assets/tile-back.jpg. Was a hardcoded
       gradient; it is an image now so all three tile surfaces (open/glass front,
       facedown back, background) can be retuned without touching code. */
    /* TEAL FAMILY as of 2026-08-17. The old blue-grey competed with the white
       tiles for attention; a facedown tile's job is to recede so that bright
       cream is the only thing saying "tappable".
       assets/tile-back.jpg was regenerated landscape and teal on 2026-08-17
       (324x216, matching the 108x72 tile), so the grain is no longer squashed.
       The colour has to live in the BITMAP: this rule sets a background-color
       under it, but the JPEG is opaque and wins, so a colour set here alone
       never shows. Keep the two in step. */
    background: var(--back-face) var(--back-image) center / 100% 100% no-repeat;
    border: 1px solid var(--back-border);
    box-shadow: var(--back-edge);
  }
  /* THE MARK ON THE BACK — two debossed bars, meaning "written words"
     (Matan, 2026-08-17, picked from three candidates in mockups/tripledot.html).
     A LETTERFORM was considered and rejected: on a facedown tile a letter
     competes with the real words on the open tiles and can be misread as
     content. A non-letter mark cannot. Two bars is the quietest of the three,
     which is the right instinct for a tile whose whole job is to recede.
     Both pseudo-elements of .f-back are free — it is the FRONT face that has
     them spoken for. */
  .f-back::before, .f-back::after {
    content: '';
    position: absolute; left: 50%; top: 50%;
    height: 5px; border-radius: 99px;
    background: var(--back-mark);
    box-shadow: 0 1px 0 rgba(255, 255, 255, 0.20);
  }
  .f-back::before { width: 34%; transform: translate(-50%, -145%); }
  .f-back::after  { width: 23%; transform: translate(-50%, 55%); }

  /* OPEN — reachable. The only state you can tap, and it wears the amber ring
     that says so. Full brightness ALWAYS, whatever layer the tile is on: see
     tileDepthClass() in game.js — 46% of the tiles tappable at the start of a
     level sit below the board's top layer, so dimming by layer alone would put
     the deepest dim on the tiles the player is meant to reach for. */
  /* AMBER IS RESERVED FOR THE HINT (Matan, 2026-08-09). The open ring used to be
     amber, which meant the game's one "look here" colour was worn by up to
     twenty tiles at once and said nothing. Open tiles get a dark grey frame; the
     amber now belongs to the hint pulse alone.
     Three frames are drawn below. `--open-frame` picks which one is live —
     option-a / option-b / option-c on <html> — so all three can be compared on
     the phone without an edit. Default is A. */
  .tile.open { cursor: pointer; }

  /* OPTION A — the halo, recoloured. Identical geometry to the amber ring it
     replaces: a 3px band OUTSIDE the tile face. Reads as a highlight sitting on
     the tile rather than as part of it, so the tile keeps its own drawing. */
  /* OPEN. The frame steps 2px -> 3px AND gains a bright inner lip, because
     2px -> 3px of the same colour on its own was invisible next to GLASS —
     Matan caught exactly that on the mockup. STOPGAP, not a settled design:
     the open-vs-glass read still needs his judgement on a real phone. */
  /* OPEN vs GLASS, second attempt. The first one stepped the frame 2px -> 3px in
     the SAME colour, which Matan correctly called invisible. Now the step is in
     CONTRAST: covered tiles wear #8BD6B1 at 1.21:1 against the board, open tiles
     wear #4FAE83 at 1.94:1 — a 1.6x jump — plus the bright inner lip. Still
     wants his eye on a phone before it is called done. */
  .tile.open .f-front { box-shadow: var(--tile-edge-open); }
  .tile.open:active .f-front {
    transform: translateY(3px);
    box-shadow: var(--tile-edge-press);
  }

  /* OPTION B — the drawn border. No halo at all; the tile's own 1px hairline
     border goes to 2px dark grey, so the frame is part of the card. Quietest of
     the three, and the only one that does not grow the tile's footprint — worth
     something on a dense pile where the halos of neighbouring open tiles touch. */
  html.open-frame-b .tile.open .f-front {
    border-color: #4a5a52;
    box-shadow:
      inset 0 2px 4px rgba(255,255,255,0.9),
      0 4px 0 #cfd2d0,
      0 10px 16px rgba(0,0,0,0.2);
  }
  html.open-frame-b .tile.open:active .f-front {
    transform: translateY(4px);
    box-shadow: 0 0 0 #cfd2d0, 0 2px 4px rgba(0,0,0,0.1);
  }

  /* OPTION C — the raised slab. Border and side wall both darken, so an open
     tile reads as standing PROUD of the pile rather than as outlined. Strongest
     read of the three at a glance; also the heaviest on screen. */
  html.open-frame-c .tile.open .f-front {
    border-color: #3d4c45;
    box-shadow:
      inset 0 2px 4px rgba(255,255,255,0.95),
      0 5px 0 #6c7d74,
      0 12px 18px rgba(0,0,0,0.26);
  }
  html.open-frame-c .tile.open:active .f-front {
    transform: translateY(5px);
    box-shadow: 0 0 0 #6c7d74, 0 2px 4px rgba(0,0,0,0.12);
  }

  /* DEPTH DIM. The design pushes lower tiles back by brightness; this does it
     with a scrim, for the reason spelled out on .f-front::after below — a
     `filter` on a tile face renders its text visibly blurry once the board is
     scaled by a fraction.
     The class is set from the tile's LAYER alone and never changes during a
     level. What changes is whether the tile is OPEN, and the .open rule below
     clears the scrim — so a tile that becomes reachable BRIGHTENS over 0.3s as
     part of its reveal instead of snapping. */
  .f-front::before {
    content: '';
    position: absolute; inset: 0;
    /* var(--board), same as the glass scrim (2026-08-18). It was #0d2a1c — a
       different, much darker green with no rule relating it to the board.
       BE HONEST ABOUT THE SIZE OF THIS: the deepest glass tile moves from
       #AFC0B9 to #B1C7C0, i.e. 4.14:1 against the board to 4.42:1. That is
       invisible. The reason to do it is not appearance, it is that ONE token now
       controls every bit of shading in the game — change --board and the glass
       scrim, the depth scrim and the board itself all move together. Before this
       the depth scrim would have silently stayed behind. */
    background: var(--board);
    opacity: 0;
    pointer-events: none;
    border-radius: inherit;
    transition: opacity 0.3s ease;
  }
  .tile.depth-near .f-front::before { opacity: 0.14; }   /* the design's brightness(0.86) */
  /* DONE 2026-08-18 — it now uses var(--board). And the note it replaced was
     WRONG on a fact: the depth scrim was never "a neutral grey", it was #0d2a1c,
     a very dark green. Both scrims were always in the green family; the problem
     was that they were two DIFFERENT greens, not two different temperatures. */
  .tile.depth-far  .f-front::before { opacity: 0.18; }
  /* THE BACK GETS THE SAME DEPTH SHADING (2026-08-18). The scrim lived only on
     .f-front, so a FACEDOWN tile buried at the bottom of a pile rendered exactly
     as bright as one on the top layer — the pile lost its depth wherever it was
     face-down, which on a level like 8 is most of the board. .f-back already uses
     both its own pseudo-elements for the two-bar mark, so the shading goes on the
     face itself as a background layer rather than a third element. */
  .tile.depth-near .f-back { box-shadow: inset 0 0 0 999px var(--depth-near),
                                         var(--back-edge); }
  .tile.depth-far  .f-back { box-shadow: inset 0 0 0 999px var(--depth-far),
                                         var(--back-edge); }   /* the design's brightness(0.82) */
  /* MUST come after the two above — an OPEN tile is never dimmed, whatever
     layer it sits on. See tileDepthClass() for why that is 46% of them. */
  .tile.open .f-front::before { opacity: 0; }

  /* GLASS — covered but readable. You can plan around it, you can't touch it.
     Dimmed and sunk rather than blanked; no lock badge, the grey says it.

     DIMMED WITH A SCRIM, NOT A FILTER. `filter: saturate()` looked identical and
     was wrong: a filtered element is rasterized into its own texture and then
     resampled by the board's fractional scale(), so every Glass tile's text came
     out visibly soft while the Open tiles beside them stayed crisp. A scrim
     overlay dims the same way and leaves the text to be rendered at final
     resolution. Don't reintroduce filter here. */
  /* THE SCRIM IS THE BOARD'S OWN COLOUR, 2026-08-17. It was #a9b3c1, a cool
     blue-grey, sitting on a warm cream face inside a green frame — three
     temperatures in one tile. Shade on a tile should be the colour of the surface
     around it, so it is var(--board) now and it follows the board automatically.

     It also does more WORK than before. At 20% the old grey left the face at
     #EEF0F3 — barely 13% darker than white — which is why open and glass looked
     almost identical once their frames matched. The board colour at the same 20%
     lands the face at #D2DEDA: 5.69:1 against the board where an open tile is
     7.86:1, so the two states finally separate, and near-black ink on it is still
     12.98:1 so a glass word stays entirely readable (rule 4.2). */
  .f-front::after {
    content: '';
    position: absolute; inset: 0;
    background: var(--board);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.24s ease;
  }
  .tile.glass { pointer-events: none; }
  /* Scrim kept light and the text kept dark ON PURPOSE. A Glass tile exists to
     be READ — that is the whole difference between it and Facedown — so it has
     to stay legible at the ~0.7 board scale a full board renders at. It reads as
     untouchable through position and tone (sunk 4px, cool grey, short wall), not
     through being washed out. */
  .tile.glass .f-front::after { opacity: 0.20; }
  .tile.glass .f-front {
    /* Text stays the board's own green, not a washed grey. A GLASS tile exists
       to be READ — that is the entire difference between it and FACEDOWN — so it
       has to survive the ~0.65 board scale a full board renders at. It reads as
       untouchable through the missing amber ring and the shorter wall, not
       through being faded out.
       NO translateY HERE: it used to sink the face 4px, which pushed the word
       down out of the strip kept clear for it. */
    /* ONE FRAME, DARKENED (Matan, 2026-08-17): "Glass tiles have a different frame
       than visible or facedown. This doesn't make sense, physically." He is right,
       and the first attempt was worse than different — it was BRIGHTER. Glass wore
       #8BD6B1 against open's #4FAE83, so the shaded tile was the lighter one and
       read as almost fluorescent. Backwards.

       A frame belongs to the OBJECT. Only the light on it changes. So there is now
       exactly one frame colour, #4FAE83, and one wall, #3E9B72, multiplied to 78%
       for anything covered. Same geometry too — 3px wall, 2px ring, everywhere.

       Reachability is carried by LIGHT, not by a different frame: an open tile has
       the bright inner lip and no scrim, a glass tile has the scrim and no lip.

       NOT `filter: brightness()`. A filtered element rasterises to its own texture
       and is then resampled by the board's fractional scale(), which renders the
       word visibly soft — on record as rejected twice, once for saturate() and
       once for the depth dim. The darkening has to be authored values. */
    /* THE FRAME IS NOW ONE VALUE FOR EVERY STATE, 2026-08-17. The 100%/78% ladder
       was right for a LIGHT board and wrong for a dark one: the frame already sits
       at 1.31:1 against Pine, so darkening it for glass pushes it to 1.7:1 — MORE
       contrast, the wrong direction. On a dark board the frame cannot carry state.
       The SCRIM does, which is also the physically honest answer: same object,
       different light. Matan asked for identical borders and this is it. */
    box-shadow: var(--tile-edge-glass);
  }

  /* FACEDOWN — covered and blank. Showing its back. */
  .tile.facedown { pointer-events: none; }
  .tile.facedown .t-inner { transform: rotateY(180deg); }

  /* The two reveals. A Facedown tile turns over; a Glass tile was already
     readable, so it lifts and brightens instead. Two motions, two states. */
  /* Both reveals run at 2x their original duration (Matan, 2026-08-03). They are
     the game's only board-level motion and they fire on roughly a third of taps
     — see the note on the reveal being rare — so they are worth reading clearly
     rather than being over before they register. */
  /* Same trap as dropIn, and it was worse here: two keyframes under
     cubic-bezier(0.2, 0.75, 0.3, 1) turned 92% of the card in the first half and
     spent the last 252ms rotating FOUR DEGREES. A flip should accelerate and
     then decelerate, so the rotation is now symmetric ease-in-out across most of
     the timeline, with a short overshoot-and-settle at the end. */
  .tile.reveal-flip .t-inner { animation: flipReveal 0.84s linear both; }
  @keyframes flipReveal {
    0%   { transform: rotateY(180deg);
           animation-timing-function: cubic-bezier(0.5, 0, 0.45, 1); }
    72%  { transform: rotateY(-14deg);
           animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
    88%  { transform: rotateY(6deg); }
    100% { transform: rotateY(0deg); }
  }
  /* SIZED FOR THE BOARD'S SCALE, not for CSS pixels. A full board renders at
     roughly 0.6, so the old 4px-down/3px-up lift travelled about four screen
     pixels in total and was effectively invisible — which is why the reveal
     read as "missing" rather than "subtle". The scale step is what actually
     carries it, because a scale survives being scaled. */
  .tile.reveal-lift .f-front { animation: liftReveal 0.76s linear both; }
  .tile.reveal-lift .f-front::after { animation: scrimClear 0.76s ease-out both; }
  @keyframes liftReveal {
    0%   { transform: translateY(6px) scale(0.94);
           animation-timing-function: cubic-bezier(0.35, 0, 0.3, 1); }
    45%  { transform: translateY(-7px) scale(1.08);
           animation-timing-function: cubic-bezier(0.5, 0, 0.4, 1); }
    75%  { transform: translateY(2px) scale(0.985);
           animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
    100% { transform: translateY(0) scale(1); }
  }
  /* Starts at the GLASS scrim's actual opacity. It used to start at 0.4 while
     the scrim sat at 0.2, so a revealing tile jumped darker before it faded. */
  @keyframes scrimClear {
    0%   { opacity: 0.20; }
    100% { opacity: 0; }
  }
  /* The depth scrim's fade is part of the same reveal, so it tracks the lift's
     duration. Keep these two in step — a brightening that finishes before the
     motion does reads as two separate events. */
  .f-front::before { transition-duration: 0.6s; }
  @media (prefers-reduced-motion: reduce) {
    .tile.reveal-flip .t-inner,
    .tile.reveal-lift .f-front,
    .tile.reveal-lift .f-front::after { animation-duration: 0.01s; }
  }

  /* Shuffle re-jumbles tile CONTENTS in place (game.js doShuffle) — the tiles
     never leave the board, so nothing triggered the reveal states above and a
     shuffle used to read as the words silently changing underneath you. A
     quick half-flip per tile (reusing the same 3D rig as the facedown reveal)
     gives it motion; content is already swapped in the DOM before this
     starts, so the new word/icon is what settles back into view. */
  /* No `both`/`forwards`: this is a decorative flourish on top of whatever
     state the tile is actually in (open, glass, OR facedown), not a state
     transition like reveal-flip. Holding the end keyframe would freeze
     .t-inner at rotateY(0deg) forever, permanently overriding `.facedown`'s
     rotateY(180deg) and turning every reshuffled facedown tile face-up. */
  .tile.shuffle-swap .t-inner { animation: shuffleSwap 0.46s cubic-bezier(0.4, 0, 0.2, 1); }
  @keyframes shuffleSwap {
    0%   { transform: rotateY(0deg) scale(1); }
    45%  { transform: rotateY(100deg) scale(0.92); }
    100% { transform: rotateY(0deg) scale(1); }
  }
  @media (prefers-reduced-motion: reduce) {
    .tile.shuffle-swap .t-inner { animation-duration: 0.01s; }
  }

  /* +1 Slot stays cut. Sneak Peek was revived 2026-08-05 (see CLAUDE.md) —
     forces a covered tile's front face into view for 2.2s without touching
     `tile.state`, so the flip/scrim resume exactly where they left off once
     peeked clears. Content is already in the DOM under every state (see
     faceInner()), so this is purely a visibility + highlight override. */
  .tile.peeked { z-index: 999 !important; }
  .tile.peeked .t-inner { transform: rotateY(0deg) !important; }
  .tile.peeked .f-front::before,
  .tile.peeked .f-front::after { opacity: 0 !important; }
  .tile.peeked .f-front {
    box-shadow: 0 0 0 3px #ffb702, 0 8px 14px rgba(0,0,0,0.4) !important;
  }

  /* THE HINT IS THE ONLY AMBER ON THE BOARD (Matan, 2026-08-09). It used to
     pulse blue while every open tile wore an amber ring — exactly backwards.
     The pulse now starts from a solid amber ring and throws it outward, so the
     hinted tiles are unmistakable against the grey frames around them. */
  .tile.hint-glow { z-index: 50 !important; }
  .tile.hint-glow .f-front { animation: hintPulse 0.7s ease-in-out infinite; }
  @keyframes hintPulse {
    0%, 100% {
      box-shadow: inset 0 2px 4px rgba(255,255,255,0.9), 0 3px 0 #6FC79F,
                  0 6px 4px rgba(8,40,30,0.30),
                  0 0 0 3px rgba(255, 183, 2, 1), 0 0 0 3px rgba(255, 183, 2, 0.7);
    }
    50% {
      box-shadow: inset 0 2px 4px rgba(255,255,255,0.9), 0 3px 0 #6FC79F,
                  0 6px 4px rgba(8,40,30,0.30),
                  0 0 0 3px rgba(255, 183, 2, 1), 0 0 0 12px rgba(255, 183, 2, 0);
    }
  }

  /* ---- THE COLLECTOR: SEVEN STANDS ON A PLINTH ----------------------------
     Rebuilt 2026-08-21 (Matan, off mockups/themes.html). It was a pale slab
     TILTED into the screen with a slot cut through it per position, and it had
     two measured faults under one report: "the tray's shape is hard to
     understand, it looks like a V shape but the tiles drop vertically."

     FAULT 1 — THE SLOTS WERE ONE SLOT. `.slit` was `left:-7%; right:-7%`, which
     at seven across is 49.3px on a 48.28px pitch: adjacent openings OVERLAPPED
     BY 1px. They were not seven dark lines that were hard to tell apart, they
     were a single dark line, so the number of free positions was only ever
     legible off the tiles already sitting in it. That is backwards — the EMPTY
     half of the tray is the half that has to be countable.

     FAULT 2 — THE V. `perspective(300px) rotateX(64deg)` converged the far edge
     to 287px against a near edge of 333px and left only 20px of surface visible,
     so what you actually saw was two slanted sides and almost no deck. Meanwhile
     the slot strip and the tiles were perfectly frontal and tiles fell straight
     down. The surface claimed to recede and the motion said it did not; that
     contradiction, not the taper on its own, is what made it unreadable.

     WHAT IS THERE NOW. No tilted surface at all. Each position is a RIM AROUND A
     VOID — 1.5px of lit edge with 2px of board showing between one stand and
     the next, and a dark tile-sized socket inside it. (2px, not 3: the grid gap
     is 5px and each stand eats 1.5px of it on each side. Measured on a 375px
     phone, where the seat is 45.3 x 30.7 on a 50.3px pitch.) Empty reads as a hole because it IS a hole; an
     empty position drawn as a solid mid-tone block reads as OCCUPIED, which is
     the trap every light-field version of this fell into.

     THE LEFTOVER RIM IS 1.5px ON THREE SIDES AND THAT IS DELIBERATE. An earlier
     pass made the stand 5.3px taller than the tile so a band survived above a
     seated tile, on the theory that the count had to hold on a FULL tray. It
     does not: a full tray has no empty positions, and seven tiles 3px apart
     count themselves. Worse, 5.3px above with 1px at the sides and nothing below
     is ASYMMETRIC, and a band sitting on top of a tile reads as a smear where an
     even edge reads as a frame. Matan's words for that version: "there's still
     remanence of headboard above them, it looks messy."

     NOTHING IS STATE-DEPENDENT. There is no `.filled` rule here, so nothing
     appears or vanishes while a tile is in flight to its seat — which matters,
     because flyTileToTray() animates the seat itself and a stand that popped
     mid-flight would be a new bug in place of the old one.

     .lip AND .front ARE UNTOUCHED BY ANY OF THIS, on purpose. The plinth is what
     makes the tray feel like it stands on something, and an earlier attempt that
     rebuilt it per-slot took the ground away with it. Separating the STANDS never
     required separating the BASE — bookends on a shelf are separate objects on
     one continuous surface, and the plinth is also the only thing the stands
     have to cast a shadow onto.

     .cast IS GONE. It was the tile's shadow thrown back onto the deck, and there
     is no deck; at -12%/-12% it also smeared across the 3px seam between two
     stands. The lip's own outer shadow is the contact shadow now. */
  #tray-container {
    flex: 0 0 auto;
    max-width: 420px; margin: 0 auto; width: 100%;
    padding: 3px 10px 4px;
    border-radius: 18px;
    /* DARK FROST, 2026-08-18. This was rgba(255,255,255,0.18) with a 0.38 white
       rim — a frosted WHITE panel, which is right over mint and wrong over Pine:
       18% white over #1d5c46 composites to #467967, a pale desaturated green
       spread across the entire bottom of the screen. That band, plus the mint
       background photo still sitting at the top, is the most likely reason the
       dark board can read as grey rather than green. A dark frost keeps the
       glass-panel idea and lets the board's own colour through. */
    background: var(--tray-panel);
    /* THE SOCKET HEIGHT, derived from .seat's own clamp below so the hole cannot
       drift from the tile it holds. Change one and change the other. */
    --socket-h: clamp(30px, calc(46px * var(--tile-ar, 1.17)), 58px);
    backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
    /* THE 4px GREEN TOP BORDER IS GONE (Matan, 2026-08-21: "the rim at the top
       which is lighter, and is probably supposed to depict depth, is not
       comprehensible"). He was reading a contradiction, not a colour problem.
       The panel is a DARK FROST - rgba(12,52,38,0.24) over the board - which
       says "a well cut into the screen". A 3.6px band of bright --green-mid
       (#47a075) across its top edge says the opposite: light collects on the top
       face of a RAISED object. Two claims about the same edge, so neither one
       registered, and what was left was a green stripe with no job.
       IT IS A RECESS NOW AND IT IS LIT LIKE ONE - see ::before below. The border
       drops to the plain 1px rim on all four sides. Note this also gives 3px of
       height back to the board, since the top border was 4px and is now 1. */
    position: relative;
    /* NO FRAME (Matan, 2026-08-25: "the tray has an outer frame which serves no
       purpose. It shows a thin curved line on top of it which is basically
       noise"). Two things drew that line and only one of them was this border:
       the ::before below put a hard dark hairline ALONG THE TOP EDGE, and
       because it followed the 18px radius it curved at the corners. It was
       lighting the panel as a WELL cut into the screen. The panel is a floating
       slab now, so there is no inside to light, and both the rim and the recess
       are gone.

       ⚠️ THE BORDER DECLARATION STAYS AND IS ONLY TRANSPARENT. #tray-container
       .warn / .crit signal the tray filling up by setting border-color, and the
       tray filling up is this game's ONLY lose condition. Delete the
       declaration and that warning has no carrier at all; it would also take
       1px off the panel's height and move every seat inside it. */
    border: 1px solid transparent;
    /* FLOATING, and lit from where everything else in this game is lit: above
       and in front. One soft shadow pushed down and to the right, so the tray
       reads as a slab lying over the board rather than a hole cut into it. */
    box-shadow: 4px 8px 20px rgba(4, 20, 14, 0.32);
    transition: border-color 0.3s ease, box-shadow 0.3s ease;
  }
  /* THE RECESS LIGHTING IS GONE, 2026-08-25. It was `#tray-container::before`,
     three insets - a hard dark hairline on the top edge, a soft fall below it,
     and a light hairline on the near lip - and it existed to describe the panel
     as a well cut into the screen. That is the "thin curved line on top" Matan
     reported as noise: a 1px dark edge following an 18px radius reads as a
     drawn arc, not as lighting. It also had to live on a pseudo-element rather
     than in the panel's own box-shadow, because .warn and .crit replace that
     whole list - a constraint that dies with it.
     If a recess is ever wanted back, it comes back on a pseudo-element for that
     same reason, and the float shadow above has to go, because a slab and a
     well are two claims about one edge. That contradiction is what made the old
     4px green top border unreadable too (see the note in the base rule). */
  /* 64, was 72. The slot strip sits at the bottom of this box; the space above
     it was pure air. The seats themselves are untouched — a tray tile still
     carries a word and must stay readable. */
  .deck-wrap { position: relative; height: 64px; }
  .front {
    position: absolute; left: 0; right: 0; bottom: 0; height: 15px;
    border-radius: 0 0 9px 9px;
    background: linear-gradient(180deg, var(--deck-front-1) 0%, var(--deck-front-2) 100%);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 4px 9px rgba(20, 55, 38, 0.32);
  }

  #tray {
    position: absolute; left: 0; right: 0; bottom: 9px;
    display: grid; gap: 5px; align-items: end;
    /* columns come from state.traySize in renderTray() — never hardcode 7 */
  }
  .spot { position: relative; height: 50px; display: flex; align-items: flex-end; justify-content: center; }
  /* THE STAND. One per position, 1.5px proud of the tile on the top and both
     sides, which leaves 2px of board showing in the grid's 5px gap. Its foot is
     behind the plinth. */
  .spot::before {
    content: '';
    position: absolute;
    left: -1.5px; right: -1.5px; bottom: 0.5px;
    height: calc(var(--socket-h) + 3px);
    z-index: 0;
    /* CONCENTRIC WITH THE SEAT: the stand is 1.5px outside it, so its radius is
       the seat's plus 1.5. Bottom stays tight because that edge is behind the
       plinth. Get this wrong and the lit rim visibly crosses the tile's corner
       instead of following it. */
    border-radius: 7.4px 7.4px 2px 2px;
    background: linear-gradient(180deg,
                var(--deck-1) 0%, var(--deck-2) 52%, var(--deck-3) 100%);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55),
                0 3px 7px rgba(8, 30, 20, 0.5);
  }
  /* THE SOCKET. Tile-shaped and tile-sized, so a seated tile covers it exactly
     and an empty position is a dark hole rather than a light block. This is also
     fault 1's actual fix: the opening is the seat's own width, so two of them
     can never touch the way -7%/-7% made them overlap by 1px. */
  .slit {
    position: absolute; left: 0; right: 0; bottom: 0;
    height: calc(var(--socket-h) + 2px);
    /* The seat's radius exactly — the tile fills this hole, so any difference
       shows as a dark crescent at each corner. See the note on .seat. */
    border-radius: 5.9px; z-index: 1;
    background: linear-gradient(180deg, var(--slit-1) 0%, var(--slit-2) 70%, var(--slit-3) 100%);
    box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.8);
  }
  /* THE SEAT FOLLOWS THE TILE. A tray tile is the same object as a board tile,
     so its proportion is derived from --tile-w / --tile-h rather than being a
     second, unrelated number that silently disagrees. The seat's WIDTH is fixed
     by the tray being 7 across the screen (~46px), so only the height is free:
     at the landscape 108x72 that gives a seat about 31px tall, which reads as
     the same shape as the board tile instead of a portrait card. Clamped so a
     freak tile ratio cannot collapse or balloon it. */
  .seat {
    position: relative; z-index: 3;
    width: 100%;
    /* --tile-ar is UNITLESS (height / width), set by applyTileMetrics(). It has
       to be: calc() cannot divide one length by another, so var(--tile-h) over
       var(--tile-w) is invalid and the whole declaration would be dropped. */
    height: clamp(30px, calc(46px * var(--tile-ar, 1.17)), 58px);
    /* THE SAME SHAPE AS A BOARD TILE, SCALED (Matan, 2026-08-21: "the tiles in
       the gameplay are rectangle, and suddenly when they drop to the tray they
       change shape"). It was `9px 9px 3px 3px` - top corners three times the
       bottom ones - on a seat only ~30.7px tall, so a fifth of the tile's height
       was corner and the top read as cropped. A board tile is --tile-radius, 8px,
       UNIFORM.
       5.9px IS DERIVED, NOT PICKED: the seat is 45.3px wide against the tile's
       108 and 30.7 tall against its 72, and both ratios are 0.42, so the tile's
       radius scales by 0.42. It was 3.4 while the tile was 8px; the tile went to
       14px with the porcelain look on 2026-08-25, so 14 x 0.42 = 5.9, and the
       stand's 4.9 went to 7.4 with it. Measured on a 375px phone. Anything else
       and the tray tile is a different object from the board tile it was a
       second ago. RE-DERIVE all three from --tile-radius whenever it moves; do
       not carry these numbers forward by hand.
       ⚠️ THE OTHER TWO RADII IN THIS TRAY FOLLOW THIS ONE and are not free: the
       socket must be the seat's shape exactly (the tile fills it), and the stand
       sits 1.5px outside, so concentric corners need 3.4 + 1.5. Change this and
       change both, or the rim stops tracking the tile's corner - which is the
       fault being fixed here, in the opposite direction. */
    border-radius: 5.9px;
    /* THE SEAT IS THE SAME OBJECT AS A BOARD TILE — its own note above says so —
       so it has to carry the same material: near-black ink rather than
       --green-deep, and the board-family frame instead of the old grey hairline.
       Without this a tile visibly changed colour the instant it left the board. */
    background: var(--seat-face); border: 1px solid transparent;
    /* NOTE 2026-08-19: this declaration has always been DEAD - the second
       box-shadow at the bottom of this same rule overrides it, so the
       board-family ring the comment above describes has never rendered.
       Left as found; reported rather than fixed, because removing it or
       merging it changes how a tray tile looks and that is a design call. */
    box-shadow: 0 0 0 1.5px #4FAE83;
    color: var(--seat-ink); font-weight: 750;
    display: flex; align-items: center; justify-content: center;
    /* PADDING IS PROPORTIONAL TO THE BOARD TILE'S (Matan, 2026-08-16). The tile
       is 3px on 108 = 2.78%; the seat is ~45.3px, so the same proportion is
       1.26px, not the 2px this used to carry — the tray really was more generous
       than the board. trayFontPlan() subtracts the matching 4.52px of chrome
       (2 x 1.26 padding + 2 x 1 border); the two MUST move together. */
    margin-bottom: 2px; overflow: hidden; text-align: center; padding: 0 1.26px;
    box-shadow: var(--seat-edge);
  }
  /* Safety net for the rare word that still can't hit the font floor on a
     narrow phone: an ellipsis reads as "there's more" — a silent hard clip
     (the old behaviour) reads as a rendering bug. */
  .seat .t-word {
    white-space: nowrap; line-height: 1;
    /* .seat is a flex container, and a flex item's default min-width:auto lets
       it overflow its box instead of shrinking — which would have silently
       reproduced the old hard-clip instead of showing the ellipsis below.
       min-width:0 is what actually lets text-overflow trigger here. */
    display: block; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  }

  /* ---- VERTICAL TRAY (config: tray.vertical) --------------------------------
     The tray is 7 fixed seats across the screen, so each is ~46px wide whatever
     is done to the board — which is why "Diaphragm" had to shrink to 7.4px to
     fit one while rendering 12.5px on the board. The seat's HEIGHT is the unused
     axis. Standing the tile up and running the word down it turns a 41px budget
     into ~78px, at the cost of having to read sideways. Matan wants both built
     so he can judge which trade he prefers. */
  .tray-vertical .deck-wrap { height: 76px; }
  /* The socket follows the taller seat, or the stand would frame a tile at twice
     its own height. Same rule as the default: keep it equal to .seat's height. */
  .tray-vertical { --socket-h: 64px; }
  .tray-vertical .spot { height: 66px; }
  .tray-vertical .seat { height: 64px; }
  /* The rotation is on an INNER span. Rotating .t-word itself would rotate the
     element the font size is read from, and the seat is a flex box — the
     rotated box would still be laid out at its unrotated width and centre
     wrongly. The wrapper keeps layout square and only the paint turns. */
  .tray-vertical .seat .t-word {
    /* ABSOLUTE, so the rotated run contributes NO layout width. A rotation is
       paint-only: the element still occupies its unrotated box, which is wider
       than the seat and was blowing the tray's grid apart. Out of flow, the seat
       stays the width the grid gives it and only the glyphs turn. */
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%) rotate(90deg);
    transform-origin: 50% 50%;
  }
  .tray-vertical .seat { overflow: hidden; }
  .lip {
    position: absolute; left: -7%; right: -7%; bottom: 0; height: 7px;
    border-radius: 3px; z-index: 4;
    background: linear-gradient(180deg, var(--lip-1) 0%, var(--lip-2) 100%);
    box-shadow: 0 -2px 4px rgba(18,52,38,0.4), inset 0 1px 0 rgba(255,255,255,0.65);
  }
  .spot:not(.filled) .seat { display: none; }

  /* ---- THE OVERLAY THE LOOSE COPIES TRAVEL ON (2026-09-22) -----------------
     Everything a press DRAWS happens in here: the tile travelling from the board
     to its seat, and a matched triple flying into the category badge. Neither is
     the tray any more, which is what lets a press change the game the instant it
     happens instead of freezing the board until its animation finished. The long
     note is above startArrival() in game.js; the fault it fixes is the ignored
     press measured in the version 9 Poki recordings.

     BETWEEN THE TRAY (30) AND THE BADGE (40). A copy travels over the board and
     over the tray and then grows INTO the badge rather than over it - which is
     exactly where the flying seat used to sit, so the look is unchanged. */
  #fly-layer { position: fixed; inset: 0; z-index: 35; pointer-events: none; }
  .fly-copy { position: absolute; }
  /* THE COPY'S BOX IS PINNED IN PIXELS from the seat it stands in for, and the
     seat inside it fills that box. That is deliberate: the tray's own sizing
     rules are scoped to #tray-container and do not reach out here, so a
     landscape seat height or a vertical tray would otherwise be wrong on the
     copy. Pinning the box needs none of them. */
  .fly-copy .seat { width: 100%; height: 100%; margin: 0; }
  /* A SEAT WHOSE TILE IS CURRENTLY ON A COPY. Held back, never removed - the
     tray still holds that tile and the socket has to read as the hole it is
     about to fill, which is exactly what the player saw while the seat itself
     was the thing flying. `visibility`, not `display`, so the spot keeps its
     box: the shove reads offsetLeft off these. renderTray() re-derives this from
     the arrival records on every render, so there is no event that can fail to
     take it off again - that WAS the 2026-08-18 bug. */
  .spot.on-copy .seat { visibility: hidden; }

  /* THE ANCHOR. One-shot, added by renderTray() only to the slot that just
     filled — never on a base class, or every settled tile replays it whenever
     another lands. That was the flicker.
     Motion is distributed BY KEYFRAMES. A longer duration is not a slower
     animation: the previous drop used one front-loaded ease, so 80% of the
     travel happened in the first 198ms and the remaining 396ms moved the tile a
     single pixel. Measure the curve, not the number — set animation.currentTime
     by hand and read the computed transform back, which works even when the page
     is not compositing. */
  /* --drop-delay is set per slot by renderTray(): 0 when the tile lands on the end
     of the row, and the shove's RISE duration when the row has to open up first.
     Room is made BEFORE the tile arrives — before this, both started on the same
     frame and the tile reached the slot floor (~409ms) while its neighbours were
     still vacating it (560ms). `both` holds the 0% keyframe through the delay, so
     the tile waits above the tray at opacity 0 rather than sitting visibly still. */
  .spot.slot-in .seat { animation: slideIntoSlit 0.66s cubic-bezier(0.3, 0, 0.35, 1) both;
                        animation-delay: var(--drop-delay, 0s); }
  @keyframes slideIntoSlit {
    0%   { transform: translateY(-30px) scale(1.05); opacity: 0; }
    18%  { opacity: 1; }
    62%  { transform: translateY(3px) scale(0.98); }
    80%  { transform: translateY(-2px) scale(1.01); }
    100% { transform: translateY(0) scale(1); opacity: 1; }
  }

  /* ---- Running out of room ------------------------------------------------
     Two slots left goes amber, one goes red and pulses. Driven by
     state.traySize - state.tray.length in renderTray(), NEVER by a literal 7 —
     the tray size is a variable and the warning has to follow it.
     The glow sits on the panel, so it is independent of the stands' material. */
  /* border-color alone since 2026-08-21: the base rule's border is uniform now,
     so the separate border-top-color these carried has nothing left to override. */
  #tray-container.warn {
    border-color: #ff9d1e;
    box-shadow: 0 0 0 2px rgba(255,157,30,0.8), 0 0 16px rgba(255,157,30,0.55),
                4px 8px 20px rgba(4,20,14,0.32);
  }
  #tray-container.crit {
    border-color: #e8402e;
    animation: critPulse 1s ease-in-out infinite;
  }
  @keyframes critPulse {
    0%, 100% { box-shadow: 0 0 0 2px rgba(232,64,46,0.85), 0 0 10px rgba(232,64,46,0.45),
                           4px 8px 20px rgba(4,20,14,0.32); }
    50%      { box-shadow: 0 0 0 3px rgba(232,64,46,1), 0 0 26px rgba(232,64,46,0.85),
                           4px 8px 20px rgba(4,20,14,0.32); }
  }
  /* A pulsing red border is precisely what this setting exists for. The colour
     still carries the warning; only the throb goes. */
  @media (prefers-reduced-motion: reduce) {
    #tray-container.crit { animation: none;
      box-shadow: 0 0 0 3px rgba(232,64,46,0.95), 0 0 18px rgba(232,64,46,0.6),
                  4px 8px 20px rgba(4,20,14,0.32); }
  }
  /* ⚠️ THE RING GOES ROUND THE TILES, NOT ROUND THE BOX (2026-09-27).
     Sideways, the upright rack of seven seats is taller than its box and is
     anchored from the bottom, so the first tile sat above the red ring that
     says the tray is full - 17px on a real iPhone held sideways (852 by 283),
     46px on the CrazyGames frame. Matan saw it on the lose card's pictures.
     syncTrayOver() in game.js measures that overflow into --tray-over and adds
     .tray-over ONLY when the seats stick out of the top of the box. There the
     ring moves off the box onto a pseudo-element that reaches up by
     --tray-over; nothing inside the tray moves. Where the seats fit - portrait
     always - the class is off and the ring is the box exactly as before. */
  #tray-container.tray-over.warn,
  #tray-container.tray-over.crit { border-color: transparent; box-shadow: none; animation: none; }
  #tray-container.tray-over::after {
    content: ''; position: absolute; pointer-events: none;
    left: -1px; right: -1px; bottom: -1px; top: calc(-1px - var(--tray-over, 0px));
    border: 1px solid transparent; border-radius: inherit;
    transition: border-color 0.3s ease, box-shadow 0.3s ease;
  }
  #tray-container.tray-over.warn::after {
    border-color: #ff9d1e;
    box-shadow: 0 0 0 2px rgba(255,157,30,0.8), 0 0 16px rgba(255,157,30,0.55),
                4px 8px 20px rgba(4,20,14,0.32);
  }
  #tray-container.tray-over.crit::after {
    border-color: #e8402e;
    animation: critPulse 1s ease-in-out infinite;
  }
  @media (prefers-reduced-motion: reduce) {
    #tray-container.tray-over.crit::after { animation: none;
      box-shadow: 0 0 0 3px rgba(232,64,46,0.95), 0 0 18px rgba(232,64,46,0.6),
                  4px 8px 20px rgba(4,20,14,0.32); }
  }

  #match-toast {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: linear-gradient(135deg, #ffd35c 0%, #ffb733 100%);
    color: #4a3800;
    padding: 14px 28px;
    border-radius: 24px;
    font-weight: 650;
    font-size: 22px;
    letter-spacing: 0.5px;
    box-shadow: 0 6px 20px rgba(0,0,0,0.2);
    opacity: 0;
    pointer-events: none;
    z-index: 30;
  }
  #match-toast.show { animation: toastPop 1.0s ease-out forwards; }
  @keyframes toastPop {
    0%   { transform: translate(-50%, -50%) scale(0.4); opacity: 0; }
    20%  { transform: translate(-50%, -50%) scale(1.15); opacity: 1; }
    40%  { transform: translate(-50%, -50%) scale(1.0); opacity: 1; }
    80%  { transform: translate(-50%, -50%) scale(1.0); opacity: 1; }
    100% { transform: translate(-50%, -50%) scale(1.0); opacity: 0; }
  }

  /* The coach/booster line. Was an in-flow flex item that pushed the whole
     board down whenever it filled (and shrank the board's fit-scale, since
     everything below is scaled to fit whatever height is left) — and its text
     colour was being overridden inline in JS to a near-white left over from
     the old dark background, so on the light green board it was nearly
     invisible. Now an ABSOLUTE overlay pinned to the top of the board area:
     it never affects layout, and its own solid pill gives it contrast against
     whatever tile or board colour happens to sit underneath. */
  /* Dims the board while the level-1 tutorial coach card is up, so attention
     goes to the instructions rather than the tiles. Visual only — the board
     stays visible through it and fully tappable (pointer-events: none), same
     as the coach card itself being non-blocking. Toggled in lockstep with
     #coach by coachStart()/coachNext() in game.js. */
  #board-scrim {
    position: absolute; inset: 0; z-index: 20;
    background: rgba(8, 26, 18, 0.45);
    opacity: 0; pointer-events: none;
    transition: opacity 0.35s ease;
  }
  #board-scrim.show { opacity: 1; }

  #hint {
    position: absolute;
    top: 6px; left: 50%; transform: translateX(-50%);
    z-index: 25;
    /* ⚠️ `width: max-content` IS WHAT MAKES max-width MEAN ANYTHING (2026-08-29).
       An absolutely-positioned box with `left: 50%` and no `right` shrink-to-fits
       against the space from its left edge to the container's right edge - which
       is HALF the container. So the pill capped at ~185px on a 375px screen and
       max-width: 92% never got a chance to apply: a sentence wrapped into a tall
       slab covering the top rows of the board instead of running wide.
       Matan reported it on the Cooking intro line, which is the longest of them,
       but it hit every level-intro sentence (Target / Task / Theme / Bonus)
       equally - and the dead-end notice already carried this exact fix locally,
       which is why that one line looked right while the others did not. */
    width: max-content;
    max-width: 92%;
    text-align: center;
    font-size: 13px;
    font-weight: 700;
    color: #fff;
    background: rgba(15, 82, 56, 0.92);
    padding: 6px 14px;
    border-radius: 999px;
    line-height: 15px;
    box-shadow: 0 3px 10px rgba(0,0,0,0.25);
    pointer-events: none;
  }
  #hint:empty { display: none; }

  /* THE HINT BOOSTER'S OWN LINE — "ink slab" (Matan, 2026-08-19).
     SCOPED, NOT ON #hint. Six different messages share #hint: the level intro
     lines (Target / Theme / Bonus / Task), the Cooking intro, "Sneak Peek armed",
     the booster-locked warning, this, and the dead-end notice. Only the hint
     booster's own output wears this — the intro lines are briefings, not hints,
     and they are sentences that would hit the ~185px shrink-to-fit cap #hint has
     at `left: 50%`.

     WHY NEAR-BLACK. The board is Pine and the tile faces are white, so a hint
     line has to separate from BOTH. A mid-tone of any hue matches one or the
     other somewhere on a crowded board; near-black is the only value that is
     unlike everything under it. The gold hairline is what marks it as the hint
     speaking — the same amber as the tile glow, so the line and the tiles it
     points at are one language (and it stays a hairline, because amber belongs
     to the glow).

     MIXED WEIGHT, deliberately. A uniform 800 on a dark slab reads as a warning;
     this game has no warnings. The count is set quiet at 600 and the CATEGORY
     carries the weight, which is the shape of the information — "how many" is
     housekeeping, the category name is the answer the player paid for. */
  #hint.tip {
    background: rgba(12, 20, 18, 0.95);
    color: #fff;
    font-weight: 600;
    box-shadow: 0 3px 12px rgba(0,0,0,0.45),
                inset 0 0 0 1.5px rgba(255, 183, 2, 0.85);
  }
  #hint.tip .k { font-weight: 600; opacity: 0.85; }   /* the count  */
  #hint.tip .v { font-weight: 800; }                  /* the answer */

  /* THE DEAD-END NOTICE (Matan, 2026-08-17, with the Hint booster rework).
     The Hint booster now proves whether any set can still be closed inside the
     free tray slots before it charges, and when the answer is no it says so on
     this line instead of pointing at an impossible match. It is the only hint
     line that carries a picture — a confused face, left of the text.

     THE ART LANDED 2026-08-19. It is `UI_ICONS.deadend` in game.js, seated by
     setHintDeadEnd() the same way .cb-ico is seated: a single currentColor path,
     so it takes the pill's white and needs no palette here. Not an emoji
     character, because emoji render as tofu on older Android, which is why every
     picture in this game is drawn SVG/PNG art (CLAUDE.md).

     It is sized for 22px and nothing else. Do not reuse it larger without
     looking at it first: it is a chrome glyph, not the painted 2D tile art, and
     it was drawn flat precisely because painted shading dies at this size on a
     coloured pill. */
  #hint.dead-end {
    display: inline-flex; align-items: center; gap: 8px;
    text-align: left;
    /* WHY THE EXPLICIT WIDTH. #hint is absolutely positioned at `left: 50%`, so
       its shrink-to-fit width is capped by what is left of the container — half
       of it, about 185px — no matter that max-width allows 92%. Every hint line
       before this one was short enough not to notice; this one is a sentence and
       wrapped into a six-line slab covering the top of the board. `max-content`
       opts out of shrink-to-fit, so max-width does the clamping instead and the
       notice wraps to two lines. Scoped to .dead-end deliberately: the other
       hint lines are Matan's and keep their existing metrics. */
    width: max-content;
  }
  #hint.dead-end .hint-face {
    /* flex: 0 0 auto for the same reason .t-icon has it — a flex item shrinks by
       default, and a squashed face is worse than no face. */
    flex: 0 0 auto;
    width: 22px; height: 22px;
    display: block;
  }
  #hint.dead-end .hint-face svg,
  #hint.dead-end .hint-face img { width: 100%; height: 100%; display: block; }

  /* ============ LOBBY / MAP SCREEN ============
     TWIN MEDALLION, 2026-08-29. Rebuilt from the `lobby layout` handoff (option
     4b, portrait + landscape READMEs). The mock was a fixed 390x844 frame of
     absolutely-positioned pixel coordinates; this is the same composition made
     FLUID, which is Matan's call and is also forced by the dial - a rotating
     ring has to place its beads by angle in JS, so there were no hardcoded
     coordinates left to keep.

     ONE VARIABLE DRIVES THE WHOLE CLUSTER: --orbit-d, the orbit's diameter.
     Every other size in here is a fraction of it, taken from the mock's own
     proportions at its reference size, so the composition holds at any viewport
     instead of only at 390x844:

        ring     1.000     door     0.690     bead        0.092
        cook     0.415     number   0.225     bead(gold)  0.120

     WHICH AXIS BINDS IS NOT THE SAME IN THE TWO ORIENTATIONS. Portrait is
     WIDTH-bound: the ring is 72.8% of the frame width in the mock, and the
     frame is always taller than that leaves room for. Landscape is
     HEIGHT-bound: the ring lives between the HUD and the milestone bar, so it
     is sized against what is left of the height, never against the width.
     Deriving both from the same axis is how a lobby ends up clipped on one
     phone and swimming on another.

     Colours are the handoff's tokens. The BACKGROUND is not: the mock specced a
     flat mint radial and said "the gradient stands in for the garden background
     if one is added later" - it did not know one already ships. The garden photo
     stays (Matan, 2026-08-29: "The GARDEN is in the lobby"), tinted toward the
     board's current green rather than the retired #2a6349. */
  #map-screen {
    --lob-green:     #0f5238;
    --lob-green-2:   #2d6a4f;
    --lob-green-lit: #4b9673;
    --lob-green-b2:  #3f8161;
    --lob-sh:        #093c29;
    --lob-sh-2:      #0e5138;
    --lob-gold:      #ffb702;
    --lob-gold-lit:  #ffdea9;
    --lob-gold-dk:   #7d5800;
    --lob-on-gold:   #6b4b00;
    --lob-cook-a:    #944c00;
    --lob-cook-b:    #b56a1e;
    --lob-cook-brd:  #d08c40;
    --lob-cook-sh:   #713900;
    --lob-red:       #ff5a5f;

    /* How hard the green scrim pulls the garden photo toward the board colour.
       The photo is warm (mean 129/108/78) and the board is #165a43; at 0 the
       lobby is the raw photograph, at 1 it is a flat green wash.
       0.46 was the first value and it was WRONG - at that strength the garden
       stopped being a picture and the lobby read as the flat wash the mock
       specced, which is the one thing Matan asked not to happen ("The GARDEN is
       in the lobby"). 0.22 keeps the arch and the hydrangeas legible while
       still pulling the photo's warmth toward the board green. Tunable. */
    --lob-tint: 0.92;

    /* PORTRAIT: width-bound. 72.8% is the mock's 284/390. The height term is the
       fallback for a short phone - the cluster is 1.514x the ring (the ring plus
       the cooking door hanging off its bottom arc), and 178px is the HUD, the
       title row and the milestone bar. */
    /* F16. 0 unless a banner is actually live, so both --orbit-d formulas
       below are arithmetically identical to what they were before banners
       existed on every build that has no banner. */
    --banner-h: 0px;

    --orbit-d: min(72.8vw, calc((100dvh - 178px - var(--banner-h)) / 1.514));
    --bead-d:   max(19px, calc(var(--orbit-d) * 0.092));
    --bead-cur: max(25px, calc(var(--orbit-d) * 0.120));
    --door-d:   calc(var(--orbit-d) * 0.690);
    --cook-d:   calc(var(--orbit-d) * 0.415);

    position: relative;
    flex-direction: column;
    overflow: hidden;
    background: #165a43;
    max-width: none; margin: 0; width: 100%;
    font-weight: 800;
  }
  /* Two layers, not one shorthand, so the tint can move without touching the
     image and the image can be swapped without re-deriving the tint. */
  /* THREE LAYERS AND THE ORDER IS LOAD-BEARING: photo 0, scrim 1, content 2.
     The `#map-screen > *` rule below has the same specificity as this one and
     comes after it, so without the :not() it re-declared the photo layer as
     `position: relative` - a zero-height block in a flex column, i.e. no photo
     at all, and no tint either since the scrim then sat in front of nothing. */
  /* THE GREEN IS A `color` BLEND, NOT AN ALPHA WASH, and that is the difference
     between a green garden and a faded one. background.jpg is a warm SUNSET
     garden - pink sky, gold light - and at any alpha that actually neutralises
     that warmth the picture has also gone (0.46 was tried first and read as the
     flat wash the mock specced). `background-blend-mode: color` takes the hue
     and saturation from the green and the LUMINANCE from the photograph, so
     every leaf, stone and hydrangea survives at full contrast while the screen
     turns the colour of the board. --lob-tint is the alpha of the green plate,
     so 0 is the raw sunset and 1 is fully green-toned. */
  #lobby-bg {
    position: absolute; inset: 0; z-index: 0; pointer-events: none;
    background:
      linear-gradient(rgba(22,90,67,var(--lob-tint)), rgba(22,90,67,var(--lob-tint))),
      url('assets/background.jpg') center / cover no-repeat;
    background-blend-mode: color;
  }
  /* THE MOCK'S TEXT COLOURS DO NOT SURVIVE THIS BACKGROUND, and that is the one
     place the handoff had to be overruled rather than followed. Every label it
     draws outside a filled pill is #0f5238 - a dark green, which is correct on
     the flat mint radial it specced and is invisible on a dark garden. The
     composition, the geometry and the pill colours are the mock's; the ink on
     the open background is flipped to light. Anything sitting INSIDE a gold or
     brown fill keeps the mock's dark on-colour, because those grounds are the
     mock's own and did not change.

     The scrim is two layers over the photo: a soft centre lift so the primary
     door reads against the brightest part of the frame (the mock's own idea),
     and a vignette that gives the milestone bar and the cooking label a ground
     dark enough for white text wherever the photograph happens to be pale. */
  #map-screen::after {
    content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
    background:
      radial-gradient(circle at 50% 40%,
        rgba(190,238,210,0.10)  0%,
        rgba(9,50,36,0.30)     42%,
        rgba(4,26,19,0.68)    100%);
  }
  #map-screen > *:not(#lobby-bg) { position: relative; z-index: 2; }

  /* ---- HUD ----
     ONE FLEX ROW IN THE MARKUP, TWO SHAPES ON SCREEN. Portrait drops the title
     onto its own line by giving it order 3 and width 100%; landscape puts it
     between the two clusters by giving it order 2 and auto width. Same DOM, so
     nothing is reparented and no JS is involved. */
  #map-hud {
    display: flex; align-items: center; justify-content: space-between;
    flex-wrap: wrap;
    gap: 7px;
    padding: max(14px, 2.2dvh) 16px 0;
    flex: 0 0 auto;
  }
  #map-lives { order: 1; }
  /* COINS LEFT, THE THREE BUTTONS RIGHT. #map-hud is space-between and
     #map-coins is now a direct child (see index.html), so giving it order 1
     alongside the switched-off lives chip is the whole of it. */
  #map-hud > #map-coins { order: 1; }
  #map-hud .hud-group { order: 2; gap: 7px; }
  #map-title {
    order: 3; width: 100%; text-align: center;
    padding-top: 12px;
    font-size: clamp(21px, 7.2vw, 30px); font-weight: 900; line-height: 1.05;
    color: #fff;
    -webkit-text-stroke: 2px var(--lob-green);
    paint-order: stroke fill;   /* without this the stroke eats the fill and it reads hollow */
    text-shadow: none;
  }

  .lobby-chip {
    display: flex; align-items: center; gap: 7px;
    background: rgba(15,82,56,0.60);
    border: 2px solid rgba(255,255,255,0.28);
    border-radius: 9999px;
    padding: 5px 13px 5px 9px;
    cursor: pointer;
  }
  /* No "/5" cap - count and refill timer only (handoff). The cap still shows in
     the settings sheet, where there is room to explain it. */
  .lobby-chip .lch-ico { width: 19px; height: 19px; display: block; color: var(--lob-red); }
  .lobby-chip .lch-ico svg { width: 100%; height: 100%; display: block; }
  .lobby-chip .lch-n { font-size: 16px; font-weight: 900; color: #fff; }
  .lobby-chip .lch-t { font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.72); }
  .lobby-chip .lch-t:empty { display: none; }

  .lobby-coin {
    display: flex; align-items: center; gap: 5px;
    background: var(--lob-gold); color: var(--lob-on-gold);
    padding: 6px 12px; border-radius: 9999px;
    font-weight: 900; font-size: 14px;
    border: 2px solid var(--lob-gold-lit);
    box-shadow: 0 3px 0 var(--lob-gold-dk);
  }
  .lobby-coin .lco-ico { width: 15px; height: 15px; display: block; }
  .lobby-coin .lco-ico svg { width: 100%; height: 100%; display: block; }
  /* THE CHIP IS A BUTTON ONLY WHILE THERE IS SOMETHING BEHIND IT (F6). The
     balance is under one entry fee, so tapping opens the free-coins offer; the
     "+" is the only thing on the chip saying so, and without it the feature has
     no reach, which is the whole point of the trigger change. Off that state
     the chip is a read-out and must not press like a button either - which is
     why :active below is scoped to this class. */
  .lobby-coin.can-topup { cursor: pointer; }
  .lobby-coin.can-topup::after {
    content: '+'; font-size: 15px; font-weight: 900; line-height: 1;
    opacity: 0.72; margin-left: 1px;
  }

  .lobby-btn {
    width: 38px; height: 38px; border-radius: 9999px;
    background: var(--lob-green-2);
    border: 2px solid var(--lob-green-b2);
    box-shadow: 0 3px 0 var(--lob-sh-2);
    display: flex; align-items: center; justify-content: center;
    color: #fff; cursor: pointer; font-family: inherit;
    font-size: 17px; font-weight: 900;
    flex: 0 0 auto; padding: 0;
  }
  .lobby-btn svg { width: 19px; height: 19px; display: block; }
  /* The skeuomorphic press the rest of the game uses: the solid offset shadow
     flattens and the element drops into it, so nothing moves on screen except
     the object's own thickness. */
  .lobby-btn:active, .lobby-coin.can-topup:active {
    transform: translateY(3px); box-shadow: 0 0 0 var(--lob-sh-2);
  }

  /* ---- the cluster ---- */
  #lobby-body {
    flex: 1; min-height: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
  }

  #orbit {
    position: relative; flex: 0 0 auto;
    width: var(--orbit-d); height: var(--orbit-d);
    touch-action: none;          /* the dial owns the gesture; no browser pan */
    user-select: none; -webkit-user-select: none;
  }
  #orbit-ring {
    position: absolute; inset: 0; border-radius: 9999px;
    /* Light, not the mock's dark green: the ring is drawn ON the background. */
    border: 2px dashed rgba(255,255,255,0.34);
  }
  #orbit-beads { position: absolute; inset: 0; }

  /* A BEAD IS PLACED WITH left/top, NEVER rotate()+translateY. The handoff calls
     this out and it is right: a rotation transform tilts the glyph inside the
     bead, so the numbers would lean over as the dial turns. */
  .bead {
    position: absolute; left: 0; top: 0;
    display: flex; align-items: center; justify-content: center;
    border-radius: 9999px;
    font-weight: 900; line-height: 1;
    width: var(--bead-d); height: var(--bead-d);
    margin-left: calc(var(--bead-d) / -2); margin-top: calc(var(--bead-d) / -2);
    font-size: max(9px, calc(var(--orbit-d) * 0.039));
    background: var(--lob-green-2);
    border: 2px solid var(--lob-green-lit);
    color: #fff;
    will-change: transform;
  }
  /* FUTURE LEVELS FADE **AND** CARRY A PADLOCK (Matan, 2026-08-31).
     The comment here used to say a padlock would state the opposite of "the
     levels do not end" and must never be drawn. He overruled it. The FADE IS
     KEPT and still does that job - opacity steps down with distance, set per
     bead in JS, and the trail still dissolves into dots past the last bead. The
     lock says "not yet"; the fade still says "and it carries on". */
  .bead.future {
    background: rgba(255,255,255,0.62);
    border-color: rgba(255,255,255,0.82);
    color: rgba(15,82,56,0.85);
  }
  /* The padlock replaces the NUMBER, so it is sized against the bead the same
     way the number is - 52% of the bead, against the digits' ~42%, because a
     glyph needs more box than a numeral to read at the same weight. `block`
     kills the inline baseline gap that would otherwise push it off centre. */
  .bead.future svg {
    display: block;
    width: 52%; height: 52%;
  }
  /* A PAUSED LEVEL: A SMALL GOLD PAUSE BADGE ON THE BEAD'S UPPER RIGHT EDGE
     (QA-774, Matan approved 2026-10-12). Until then the paused bead was drawn
     pale, and on a ring of mostly green beads a pale one read as just another
     colour of upcoming level - two looks for the same kind of circle with no
     rule a player could see. Now every bead keeps its own look and the badge
     carries the meaning: the lobby gold, two dark bars, and a ring in the
     dial's dark green so it separates from the bead it sits on.

     The old note here argued against any badge, because a badge hung off a
     bead might touch the next bead along the ring. It does not at these sizes:
     the badge is half a plain bead across and hangs a fifth of a bead outside
     it, and the gap along the arc is never under 18px (see the floors above).

     Never on the focus bead: game.js does not give that one the class, because
     the door beside it already says Resume in words.

     Sized off --bead-d rather than the bead's own box, so it is the same size
     wherever it sits on the ring.
     The hard mark used to be a second badge on the upper left; it is a ring
     now (see THE HARD LEVEL MARK below), so the two never compete for a corner. */
  .bead.paused::after {
    position: absolute; top: -18%;
    width: calc(var(--bead-d) * 0.5); height: calc(var(--bead-d) * 0.5);
    box-sizing: border-box; border-radius: 9999px;
    border: 1.5px solid var(--lob-green);
    pointer-events: none;
  }
  .bead.paused::after {
    content: ''; right: -18%;
    background:
      linear-gradient(to right, transparent 28%, var(--lob-on-gold) 28% 44%,
                      transparent 44% 56%, var(--lob-on-gold) 56% 72%, transparent 72%)
        center / 100% 46% no-repeat,
      var(--lob-gold);
  }
  .bead.focus {
    width: var(--bead-cur); height: var(--bead-cur);
    margin-left: calc(var(--bead-cur) / -2); margin-top: calc(var(--bead-cur) / -2);
    background: var(--lob-gold);
    border: 3px solid var(--lob-gold-lit);
    box-shadow: 0 3px 0 var(--lob-gold-dk);
    color: var(--lob-on-gold);
    font-size: max(11px, calc(var(--orbit-d) * 0.049));
    z-index: 2;
  }
  /* THE HARD LEVEL MARK ON THE DIAL (option B, Matan approved 2026-10-13): a
     violet ring OUTSIDE the bead's own border, so nothing about the bead's box,
     number, padlock or fade changes, and both upper corners stay free for the
     gold pause badge. On locked beads too - the point is to see a hard level
     coming - and faded with them. Colours: the hard mark's theme block. */
  .bead.hard { box-shadow: 0 0 0 2.5px var(--hard-mark), 0 0 7px 1px var(--hard-mark-glow); }
  .bead.focus.hard {
    box-shadow: 0 3px 0 var(--lob-gold-dk), 0 0 0 3px var(--hard-mark), 0 0 9px 2px var(--hard-mark-glow);
  }
  /* The endless trail. Same pool as the beads, drawn as a shrinking dot with no
     number once it is past the last numbered bead. */
  .bead.dot {
    background: rgba(255,255,255,0.5);
    border: none; color: transparent; font-size: 0;
  }

  #lobby-door {
    position: absolute; left: 50%; top: 50%;
    width: var(--door-d); height: var(--door-d);
    margin-left: calc(var(--door-d) / -2); margin-top: calc(var(--door-d) / -2);
    border-radius: 9999px;
    background: var(--lob-green);
    border: 6px solid var(--lob-green-lit);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    color: #fff; cursor: pointer; font-family: inherit; padding: 0;
    box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,0.24);
    animation: pulseRing 2.6s ease-out infinite;
    z-index: 3;
  }
  #lobby-door .ld-label {
    font-size: max(9px, calc(var(--orbit-d) * 0.039));
    font-weight: 800; letter-spacing: 0.12em; opacity: 0.7;
  }
  #lobby-door .ld-num {
    font-size: calc(var(--orbit-d) * 0.225);
    font-weight: 900; line-height: 0.95;
  }
  #lobby-door .ld-play {
    display: flex; align-items: center; gap: 5px;
    margin-top: 6px;
    padding: 6px 16px; border-radius: 9999px;
    background: var(--lob-gold); color: var(--lob-on-gold);
    font-size: max(11px, calc(var(--orbit-d) * 0.053)); font-weight: 900;
  }
  #lobby-door .ld-play-ico { width: 1em; height: 1em; display: block; }
  #lobby-door .ld-play-ico svg { width: 100%; height: 100%; display: block; }

  /* ---- THE BUY-IN IS A CAPTION, NOT A TAG (2026-09-01) ----
     Matan, on the phone: "The coins icons on both the level dial and the Cooking
     level look like a hamburger. Not coins. The color is wrong."

     HE WAS RIGHT AND THE GOLD GROUND WAS THE CAUSE. A tag is gold, and
     UI_ICONS.coinStack carries its own golds as literal fills, so it would have
     been a gold shape on a gold ground - which is why the tags drew
     coinStackMono, the flat currentColor silhouette. At the ~15px a tag gives
     it, that silhouette is three stacked bars, i.e. a hamburger menu.

     A caption has no ground. So the real coin art comes back (the same
     coinStack the top-left chip draws, which is what he asked for), the number
     is white like the caption above it, and there is no pill left to be
     mistaken for an error. */
  .price-cap {
    display: flex; align-items: center; justify-content: center;
    gap: 5px;
    font-weight: 800; line-height: 1;
    color: rgba(255,255,255,0.92);
    text-shadow: 0 1px 3px rgba(0,0,0,0.45);
  }
  .price-cap .pc-ico { display: block; }
  .price-cap .pc-ico svg { width: 100%; height: 100%; display: block; }
  /* FREE PRINTS NOTHING, and never a 0 - a zero teaches a player that levels
     cost coins before any level does. `visibility`, not `display`, so the free
     levels and the priced ones lay the door out identically and the dial does
     not jump as you turn it past level 10.
     THERE IS NO CANNOT-AFFORD STATE, and that is a ruling rather than an
     omission (Matan, 2026-09-01): "Nothing. A popup will appear when the player
     clicks Play notifying them that they don't have enough coins." */
  .price-cap.free { visibility: hidden; }

  /* The price and, on a hard level, the 2x coins line share one row (.ld-caps,
     2026-10-13), so the row carries the margin the caption used to. */
  #lobby-door .ld-caps { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 7px; }
  #lobby-door .ld-price-cap {
    font-size: max(10px, calc(var(--orbit-d) * 0.040));
    color: rgba(255,255,255,0.80);
  }
  #lobby-door .ld-price-cap .pc-ico { width: 1.3em; height: 1.3em; }
  /* THE DOOR'S CONTENT MOVES UP TO PAY FOR THE CAPTION (his point 3). The door
     is a centred flex column, so a new last child would push LEVEL / number /
     Play up by half its own height and off the circle's optical centre. A
     negative top margin on the label puts the block back where it sits today and
     spends the room below the button instead. 14px is the caption's line box
     plus its margin, halved. */
  #lobby-door .ld-label { margin-top: -14px; }

  /* THE HARD LEVEL MARK ON THE BIG CIRCLE (option B, Matan approved
     2026-10-13). When the level on the dial is hard: the rim turns violet and
     its pulse turns violet with it, the faint LEVEL word above the number
     becomes a violet HARD LEVEL label, and "2x coins" sits BESIDE the price,
     in the same row - the one place the reward is said, because this is where
     the player decides to start. A line of its own under the price made the
     circle's content too tall: HARD LEVEL rode onto the rim, and on a sideways
     phone the line sat on the bottom rim. In the row the circle's content is
     exactly as tall as on any other level. The 2x line shows only while the
     next win pays more (a first clear with the bonus funded); paintDoor()
     decides, CSS only places it. On a free entry the empty price gives up its
     place in the row, so the 2x line is centred. Colours: the hard mark's
     theme block. */
  #lobby-door .ld-hard-label,
  #lobby-door .ld-hard-pay { display: none; }
  #lobby-door.hard { border-color: var(--hard-mark); animation-name: pulseRingHard; }
  #lobby-door.hard .ld-label { display: none; }
  #lobby-door.hard .ld-hard-label {
    display: inline-flex; align-items: center;
    margin-top: -10px;
    padding: 1px 7px; border-radius: 9999px;
    background: var(--hard-mark); color: var(--hard-mark-text);
    box-shadow: 0 2px 0 var(--hard-mark-dk);
    font-size: max(9px, calc(var(--orbit-d) * 0.037));
    font-weight: 900; letter-spacing: 0.06em; line-height: 1.15;
  }
  #lobby-door.hard.pays-more .ld-price-cap.free { display: none; }
  #lobby-door.hard.pays-more .ld-hard-pay {
    display: flex; gap: 4px;
    font-size: max(10px, calc(var(--orbit-d) * 0.040));
    color: #fff;
  }
  /* No coin picture of its own: beside the price it read as a second price.
     The word coins says it. */
  #lobby-door .ld-hard-pay .pc-ico { display: none; }
  #map-screen.dialling #lobby-door.hard,
  #lobby-door.hard:active {
    box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,0.24), 0 0 0 4px var(--hard-mark-halo);
  }
  @keyframes pulseRingHard {
    0%   { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 0 var(--hard-mark-glow); }
    70%  { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 22px transparent; }
    100% { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 0 transparent; }
  }

  #lobby-door:active { transform: translateY(4px); box-shadow: 0 4px 0 var(--lob-sh), 0 8px 16px rgba(0,0,0,0.24); }
  /* THE PULSE IS THE ONLY MOTION ON THE SCREEN and it points at the primary
     action, so it is suppressed while the dial is being turned - a ring pulsing
     around a number that is changing reads as a glitch, not as an invitation. */
  #map-screen.dialling #lobby-door { animation: none; }
  @keyframes pulseRing {
    0%   { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 0 rgba(255,183,2,.6); }
    70%  { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 22px rgba(255,183,2,0); }
    100% { box-shadow: 0 8px 0 var(--lob-sh), 0 14px 26px rgba(0,0,0,.24), 0 0 0 0 rgba(255,183,2,0); }
  }
  @media (prefers-reduced-motion: reduce) {
    #lobby-door { animation: none; }
  }

  /* Shown only while the dial is off the current level, so there is always one
     tap back to where the player actually is. */
  #lobby-return {
    margin-top: 8px; align-self: center;
    background: rgba(15,82,56,0.62);
    border: 2px solid rgba(255,255,255,0.30);
    color: #fff; font-family: inherit; font-weight: 800; font-size: 12px;
    padding: 6px 14px; border-radius: 9999px; cursor: pointer;
    flex: 0 0 auto;
  }

  /* ---- the second door ----
     It overlaps the ring's bottom arc on purpose (handoff): it reads as a second
     stop on the same journey rather than as a banner pinned under one. */
  #lobby-cook {
    display: flex; align-items: center; gap: calc(var(--cook-d) * 0.19);
    background: none; border: none; padding: 0; cursor: pointer;
    font-family: inherit; text-align: left;
    margin-top: calc(var(--orbit-d) * -0.105);
    flex: 0 0 auto;
  }
  #lobby-cook .ck-circle {
    position: relative; flex: 0 0 auto;
    width: var(--cook-d); height: var(--cook-d); border-radius: 9999px;
    background: linear-gradient(150deg, var(--lob-cook-a), var(--lob-cook-b));
    border: 5px solid var(--lob-cook-brd);
    box-shadow: 0 7px 0 var(--lob-cook-sh), 0 12px 22px rgba(0,0,0,0.22);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
  }
  /* THE PER-CUISINE EMOJI STAYS until the cuisine badge art lands. The handoff
     specced one generic pot for every day; the lobby already tells you WHICH
     cuisine is on today, and that is real information the mock did not know it
     was throwing away. */
  #lobby-cook .ck-art { font-size: calc(var(--cook-d) * 0.36); line-height: 1; }
  /* 0.52, not the emoji's 0.36: the handoff put a 40px Material glyph in a 118px
     circle, but real badge art carries detail a glyph does not and the circle
     has the room. At the portrait size that is about 59 CSS px, which is the
     figure icons-pipeline/PROMPT-cuisine-badges.md is written against. */
  #lobby-cook .ck-art img, #lobby-cook .ck-art svg {
    width: calc(var(--cook-d) * 0.52); height: calc(var(--cook-d) * 0.52); display: block;
  }
  #lobby-cook .ck-pill {
    margin-top: 4px; padding: 4px 12px; border-radius: 9999px;
    background: var(--lob-gold); color: var(--lob-on-gold);
    font-size: max(10px, calc(var(--cook-d) * 0.102)); font-weight: 900; line-height: 1;
    white-space: nowrap;
    display: flex; align-items: center;
  }
  /* Offset down so it sits below the circle's vertical centre rather than
     straight across it (handoff). Portrait only - landscape centres it. */
  #lobby-cook .ck-text { display: block; padding-top: calc(var(--cook-d) * 0.22); }
  #lobby-cook .ck-h {
    display: block; font-size: max(13px, calc(var(--orbit-d) * 0.063));
    font-weight: 900; color: #fff; line-height: 1.15;
    text-shadow: 0 1px 3px rgba(0,0,0,0.45);
  }
  #lobby-cook .ck-s {
    display: block; margin-top: 3px;
    font-size: max(10px, calc(var(--orbit-d) * 0.042));
    font-weight: 800; color: rgba(255,255,255,0.85);
    text-shadow: 0 1px 3px rgba(0,0,0,0.45);
  }

  /* ---- THE COOKING BUY-IN IS A CAPTION UNDER THE CUISINE LINE ----
     Fourth position and the last one. Inside the pill it hung 10.3px out of the
     circle on each side (.ck-circle is a centred flex COLUMN, so the pill sits
     below the centre where a 117.8px circle offers only 81.4px of chord); on the
     label line it floated in open garden anchored to nothing; as a gold rim tag
     its gold ground forced the flat coinStackMono glyph, which reads as a
     hamburger at that size. A caption under the cuisine solves all three: it is
     inside the text block so it cannot overflow anything, it is anchored to the
     line above it, and with no ground it can carry the real coin art. */
  #lobby-cook .ck-price-cap {
    justify-content: flex-start;
    margin-top: 5px;
    font-size: max(11px, calc(var(--orbit-d) * 0.046));
  }
  #lobby-cook .ck-price-cap .pc-ico { width: 1.25em; height: 1.25em; }

  /* THE COOK PILL CARRIES THE PLAY GLYPH TOO (Matan, 2026-09-01: the level dial
     has one "so it looks weird" without). Sized off the pill's own font rather
     than the circle, so it tracks the label at every viewport. */
  #lobby-cook .ck-pill { gap: 4px; }
  #lobby-cook .ck-play-ico { width: 0.95em; height: 0.95em; display: block; flex: 0 0 auto; }
  #lobby-cook .ck-play-ico svg { width: 100%; height: 100%; display: block; }

  #lobby-cook:active .ck-circle { transform: translateY(4px); box-shadow: 0 3px 0 var(--lob-cook-sh), 0 6px 12px rgba(0,0,0,0.22); }
  #lobby-cook.done .ck-circle { filter: grayscale(0.6); box-shadow: 0 4px 0 var(--lob-cook-sh); }
  #lobby-cook.done .ck-pill { background: rgba(255,255,255,0.72); color: rgba(15,82,56,0.72); }

  /* ---- milestone bar ----
     DISPLAY ONLY (Matan, 2026-08-29: "wire it in only to show us how it looks").
     There is no garden or milestone feature in the game; the bar reads the next
     multiple of ten off whatever level the dial is showing. Do not treat it as
     evidence that a milestone system exists. */
  /* A WHITE GLASS BAR ON A DARK GROUND IS STILL DARK, so the mock's 28% white
     panel with dark green text lands at about 2:1 here. The panel keeps its
     glassy read as a dark tint with a light rim, and the ink goes light. */
  /* FIXED HEIGHT, NOT PADDING (Matan, 2026-08-29: "the total height of the
     latter bar is a bit shorter than the former, which causes the dial and
     everything near to change vertical position a few px... looks like a
     glitch"). The bar swaps its whole contents when the dial leaves the current
     level - a row of small spans becomes one pill button - and with vertical
     padding driving the height the two states measured 40px and 46px. #milestone
     is a flex sibling of the cluster, so those 6px moved the ring, the doors and
     the beads every time the player dialled off and back.
     40px is the normal row's own height: 16px flower icon + 2 x 11px padding +
     the 1px border, now stated once instead of emerging twice. */
  #milestone {
    flex: 0 0 auto;
    margin: 0 16px max(16px, 3dvh);
    height: 40px; box-sizing: border-box;
    display: flex; align-items: center; gap: 10px;
    padding: 0 15px; border-radius: 18px;
    background: rgba(8,44,32,0.42);
    border: 1px solid rgba(255,255,255,0.34);
    backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
  }
  #milestone .ms-lvl, #milestone .ms-next {
    font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.9);
    white-space: nowrap;
  }
  #milestone .ms-track {
    flex: 1; height: 8px; border-radius: 9999px;
    background: rgba(255,255,255,0.34); overflow: hidden;
  }
  #milestone .ms-fill { display: block; height: 100%; background: var(--lob-gold); border-radius: 9999px; }
  #milestone .ms-ico { width: 16px; height: 16px; display: block; color: rgba(255,255,255,0.85); flex: 0 0 auto; }
  #milestone .ms-ico svg { width: 100%; height: 100%; display: block; }

  /* ---- DEV: the old level grid, now behind the settings sheet ---- */
  .levels-card { max-width: 460px; width: 100%; }
  .levels-card #level-grid {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
    gap: 8px; max-height: 52dvh; overflow-y: auto; margin-bottom: 14px;
    padding: 2px;
  }
  .level-card {
    background: #f2f5f3; border: none; border-radius: 14px;
    padding: 10px 4px 8px;
    display: flex; flex-direction: column; align-items: center; gap: 3px;
    cursor: pointer; font-family: inherit;
    min-height: 78px; justify-content: center; position: relative;
  }
  .level-card:active { transform: scale(0.96); }
  .level-card .lc-num { font-size: 21px; font-weight: 900; color: #2e7d54; line-height: 1; }
  .level-card .lc-name { font-size: 9px; font-weight: 700; color: #7a8699; text-align: center; line-height: 1.1; }
  .level-card .lc-tag {
    position: absolute; top: -5px; right: -3px;
    background: #4a90e2; color: #fff; font-size: 8px; font-weight: 800;
    border-radius: 999px; padding: 2px 6px;
  }
  .level-card.locked { background: #e3e7e5; cursor: not-allowed; }
  .level-card.locked .lc-num { color: #a8b2ad; }
  .level-card.locked .lc-name { color: #b7c0ba; }
  .level-card.current { box-shadow: 0 0 0 3px #ffd35c; }

  /* MILESTONE CONTENT OFF (2026-08-31, Matan) - driven by MILESTONE_ENABLED in
     game.js, which adds .ms-on to ENABLE. Off is the state that needs no JS:
     the caption's default text is in index.html and renderMilestone() runs from
     the dial's rAF loop, so a rule needing JS to switch it off would flash it.
     The bar KEEPS its 40px height and margins, so
     the ring, the doors and the beads do not move a pixel; it just goes
     transparent and empty. The way back out of a browse is untouched: .browsing
     still shows #lobby-return, which carries its own background. */
  #milestone:not(.ms-on) > :not(#lobby-return) { display: none; }
  /* TWO BACKGROUNDS, ONE BUTTON (Matan, 2026-09-01). While browsing, the bar
     showed its own dark tint and rim AROUND the way-back pill, which carries a
     tint and rim of its own - a button inside a frame that frames nothing else.
     The bar goes transparent in the browse state as well, so the pill is the
     only thing drawn. Stated for `.browsing` in its own right rather than by
     dropping the :not(), so it still holds if MILESTONE_ENABLED is turned on. */
  #milestone:not(.ms-on), #milestone.browsing {
    background: none; border-color: transparent;
    backdrop-filter: none; -webkit-backdrop-filter: none;
  }

  /* The milestone bar carries the way back out of a browse, in place of its own
     contents rather than beside them - see the note in index.html. */
  #lobby-return { display: none; }
  #milestone.browsing > :not(#lobby-return) { display: none; }
  #milestone.browsing #lobby-return { display: block; }
  /* Height is fixed and comfortably inside the bar's 40px, so swapping the two
     states cannot move the bar. See the note on #milestone. */
  #lobby-return {
    flex: 1; text-align: center;
    height: 26px; box-sizing: border-box; padding: 0 14px; line-height: 22px;
    background: rgba(15,82,56,0.62);
    border: 2px solid rgba(255,255,255,0.30);
    color: #fff; font-family: inherit; font-weight: 800; font-size: 12px;
    border-radius: 9999px; cursor: pointer;
  }
  #lobby-return:active { transform: translateY(2px); }

  /* ============ LOBBY, LANDSCAPE ============
     ITS OWN BLOCK, DELIBERATELY. The play screen's landscape rules are a single
     large block near the end of this file and they are load-bearing; the lobby
     touches none of them and none of them touches the lobby, so keeping its
     overrides next to its own portrait rules is what stops the two drifting into
     each other. Nothing in here can reach the portrait layout.

     THE STRUCTURAL CHANGE IS THE AXIS, NOT THE FURNITURE. Portrait stacks the
     two doors and sizes the orbit off the WIDTH. Landscape splits them left and
     right and sizes the orbit off what is left of the HEIGHT once the HUD and
     the milestone bar have taken theirs - which is why the ring shrinks on a
     short phone instead of overrunning the bar. */
  @media (orientation: landscape) {
    /* ⚠️ THE CHROME IS OVERLAID ON THE RIGHT HALF, NOT STACKED ABOVE AND BELOW
       (2026-09-02, Matan's partner: "when changing from portrait to landscape,
       the level dial becomes a problem; it hides the levels, perhaps because
       it's not resized properly").

       SHE IS RIGHT ABOUT THE CAUSE. Measured on her own iPhone 15 at 852x283:
       the HUD took 46px off the top and the milestone bar 40px off the bottom,
       both full-width, so the ring was sized against 165px of what was left. At
       165 every derived size hits its own pixel FLOOR - the beads clamp to 19px
       and the focused one to 25px against a formula asking for 15 and 20, and
       the numbers clamp to 9px - so the beads stop scaling, sit 37px apart on
       the arc, and crowd into each other and into the HUD. That is what
       "it hides the levels" looks like.

       THE HEIGHT WAS THE ONLY THING IN SHORT SUPPLY. The same screen has 852px
       of width and the ring was using 165 of it. The HUD and the milestone bar
       do not need the ring's height: they are two thin strips and the right half
       of the screen is empty above and below the cooking door. So in landscape
       they come OUT OF THE FLOW and sit over the right half, the ring gets the
       whole viewport height, and nothing else about the composition moves.

       Measured after: ring 165 -> 255, bead 19 -> 23.5, focused bead 25 -> 31,
       and the gap between beads along the arc 37 -> 57px. Every size is back off
       its floor, which is the actual repair - the floors existing at all is the
       sign the formula had run out of room.

       ⚠️ THE `#map-screen > #id` FORM IS DELIBERATE AND MUST NOT BE SHORTENED.
       `#map-screen > *:not(#lobby-bg)` further up sets `position: relative` at
       two-ID specificity, so a bare `#map-hud { position: absolute }` LOSES to
       it and silently does nothing. This is the same trap that once made the
       lobby photo disappear; it is written out here so the next edit does not
       walk into it.

       36px is the ring's breathing room, not chrome: a bead sits ON the ring, so
       half a bead hangs outside the circle and a ring exactly as tall as the
       screen would clip it. It is MEASURED, not guessed - at 28px the topmost
       bead cleared the top of a 298px-tall window by 0.39px, which rounds to a
       hairline cut on a 3x screen. 36 gives it about 4px and costs the ring 8. */
    #map-screen {
      --orbit-d: min(calc(100dvh - 36px - var(--banner-h)), 40vw);
    }
    /* ⚠️ THE STRIP IS AS WIDE AS WHAT IS IN IT, NOT HALF THE SCREEN.
       It used to be `left: 50%`, which fixed the strip to the right half and
       left the title whatever the coin chip and the three buttons did not
       want. That was sized for the word "Kinship" and never re-measured after
       the game was renamed, so from 2026-09-15 the lobby read "Tile Asso..." on
       every sideways phone size tried. Measured 2026-09-22: the name wants 265px
       at this font and the slot handed it 150 at 808x393, 172 at 852x393, 204 at
       915x412, 172 of the 192 it wants on the real iPhone's 852x283, and 226 of
       244 at 640x360.

       ANCHORED RIGHT, GROWING LEFT. The strip now takes its width from its own
       contents and hangs off the right edge, so the title is never the thing
       that gives way and the coin chip can carry any balance without squeezing
       the name. The room it grows into is real: sideways, the left half holds
       the level dial, and a dial is a CIRCLE - at the height of this strip the
       circle and its beads have already fallen away, so the top-right of the
       dial's square is empty garden. THAT WAS CHECKED, NOT ASSUMED: after
       the change, every bead's box was tested against the title's box at each
       window on the bench's list, and not one of them touches it. The single
       exception is 640x360, where a bead's box and the title's box share two
       pixels of height and the drawn bead still sits clear below the letters.

       ⚠️ THE CAP IS THE SCREEN AND NOTHING SMALLER, and that was tried the
       other way first. A strip that sizes itself has to stop somewhere, or a
       long enough coin balance walks it off the left edge of the screen. The
       first attempt stopped it at the dial's centre line (75%) and that was too
       tight by 18px at 640x360, which is the smallest window Poki runs the game
       in: the name wants 498px of those 640 and the cap gave it 480, so the
       ellipsis came straight back on the one size that is production exposure.
       100% is therefore the stop - the name is cut only when it could not fit
       the screen at all, which is the only case where cutting it is better than
       the alternative. In normal play the strip asks for 498px of 640 and 519px
       of 852 and never reaches the cap. */
    #map-screen > #map-hud {
      position: absolute; top: 0; left: auto; right: 0; z-index: 3;
      width: max-content; max-width: 100%;
      flex-wrap: nowrap;
      padding: max(8px, 2.2dvh) 18px 0;
    }
    #map-screen > #milestone {
      position: absolute; right: 18px; z-index: 3;
      /* ⚠️ THE BANNER IS STILL IN THE FLOW AND THIS BAR IS NOT, so the bar has
         to step over it by hand. --banner-h is 0 unless a banner is actually
         live, so on every build without one this is the plain 8px it reads as.
         Get this wrong and the milestone bar sits on top of a paid ad slot,
         which is a QA failure on both hosts rather than a cosmetic one. */
      bottom: calc(max(8px, 2.2dvh) + var(--banner-h));
      margin: 0; width: min(40vw, 420px);
    }
    #map-title {
      order: 2; width: auto; flex: 1 1 auto; min-width: 0;
      padding-top: 0;
      font-size: clamp(17px, 8.2dvh, 32px);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    /* LANDSCAPE IS UNCHANGED BY THE PORTRAIT SPLIT (Matan's call). The rails
       already separate the HUD and the coin chip belongs beside the buttons
       here, so it takes an order between the title and the group rather than the
       far-left slot portrait gives it. */
    #map-hud > #map-coins { order: 3; }
    #map-hud .hud-group { order: 4; }
    .lobby-btn { width: 36px; height: 36px; }
    .lobby-btn svg { width: 18px; height: 18px; }

    /* Two columns, each centring its own door. The mock's asymmetry - a roomy
       left half against three smaller things grouped on the right - comes out of
       the milestone bar being pinned to the bottom of the right column, not out
       of an offset applied to the board. Do not "balance" it by centring the
       right column against the full height. */
    #lobby-body {
      flex-direction: row;
      display: grid; grid-template-columns: 1fr 1fr;
      align-items: center; justify-items: center;
      padding-bottom: 0;
    }
    #orbit { grid-column: 1; }
    #lobby-cook {
      grid-column: 2;
      margin-top: 0;
      align-self: center;
      gap: calc(var(--cook-d) * 0.22);
    }
    /* Landscape puts the label beside the circle and level with it; portrait
       drops it below the circle's centre line because it sits under the ring. */
    #lobby-cook .ck-text { padding-top: 0; }

    /* The bar's own geometry is set with the HUD at the top of this block, where
       the two are taken out of the flow together - they are one decision. */
  }

  /* ============ MODALS ============ */
  .modal {
    position: fixed; inset: 0;
    background: rgba(20, 30, 50, 0.55);
    display: none; align-items: center; justify-content: center;
    z-index: 100; padding: 20px;
    backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  }
  .modal.show { display: flex; }
  .modal-card {
    background: white; padding: 26px 24px; border-radius: 20px;
    text-align: center; max-width: 340px; width: 100%;
    box-shadow: 0 20px 60px rgba(0,0,0,0.3);
    max-height: 86vh; overflow-y: auto;
  }
  .modal-card h2 { font-size: 23px; margin-bottom: 10px; color: #2a3549; }
  .modal-card p { color: #666; margin-bottom: 18px; font-size: 14px; line-height: 1.45; }
  .modal-card .btn { padding: 12px 22px; font-size: 15px; }
  .modal-actions { display: flex; flex-direction: column; gap: 8px; }
  .modal-actions .btn { width: 100%; }

  /* Rules page */
  #rules-screen .modal-card { text-align: left; max-width: 400px; }
  #rules-screen h2 { text-align: center; }
  .rules-h { font-weight: 650; font-size: 14px; color: #2e7d54; margin: 16px 0 6px; }
  .rules-p { font-size: 13px; color: #4a5568; line-height: 1.5; margin-bottom: 6px; }
  /* The Privacy page's one link out, to the full policy (2026-09-10). Ink, not
     browser blue, and underlined so it still reads as a link. */
  .rules-p a { color: inherit; font-weight: 700; text-decoration: underline; }
  .rules-booster { display: flex; gap: 10px; align-items: flex-start; margin: 8px 0; }
  .rules-booster .rb-ico { font-size: 22px; flex: 0 0 auto; width: 28px; text-align: center; }
  .rules-booster .rb-txt { flex: 1; }
  .rules-booster .rb-name { font-weight: 650; font-size: 13px; color: #2a3549; }
  .rules-booster .rb-name .rb-unlock { font-weight: 650; font-size: 11px; color: #ff8c42; margin-left: 6px; }
  .rules-booster .rb-desc { font-size: 12px; color: #6a7688; line-height: 1.4; }

  /* Recipe list (cooking reveal) */
  .recipe-list { display: flex; flex-direction: column; gap: 8px; margin: 6px 0 16px; text-align: left; }
  .recipe-item {
    display: flex; align-items: center; gap: 10px;
    background: #fff6ec; border-radius: 12px; padding: 8px 12px;
    font-weight: 650; color: #b3541e; font-size: 14px;
  }
  .recipe-item .ri-emoji { width: 20px; height: 20px; display: block; color: var(--cat, #b3541e); }
  .recipe-item .ri-emoji svg { width: 100%; height: 100%; display: block; }
  .recipe-item.got { background: #eafaef; color: #2e7d54; }

  /* ---- LEVEL-CLEARED CARD (2026-08-07) ------------------------------------
     The win end-screen in the blob language, joining the tutorial coach and the
     booster announcement. Its own asymmetric radius so the three cards are
     siblings rather than clones.
     2026-08-09: THE LOSS CARD JOINS THE FAMILY. It used to stay a plain white
     rectangle on purpose — the argument being that a celebratory shape is wrong
     for "you jammed the tray". In practice it just read as the one screen nobody
     had styled. It wears the same blob now; what keeps the two from feeling
     interchangeable is that the win leads with a gold star row and the loss
     leads with a muted terracotta crest of the full tray, and the buttons carry
     each card's colour. */
  /* ---- THE CALM END CARDS (version 9, Matan 2026-10-08) -------------------
     Both cards left the white blob for one dark, solid card: the calm pond
     picture under a shade that deepens toward the buttons, a fine light rim
     and a long soft shadow, so it stands above the board without turning
     bright. Option A's layout of mockups/calm-cards.html, background 1 of
     mockups/calm-cards-2.html. EVERY COLOUR AND PICTURE IS A --card-* TOKEN
     from the theme block at the top of this file; nothing below names one.
     The comments further down about the white blob, the terracotta crest and
     the round buttons are history: what they describe was replaced here. */
  #end-screen {
    background: var(--card-scrim);
    backdrop-filter: var(--card-scrim-filter); -webkit-backdrop-filter: var(--card-scrim-filter);
  }
  .end-card.win, .end-card.lose {
    background: var(--card-shade), var(--card-picture) var(--card-picture-at) / cover no-repeat, var(--card-face);
    color: var(--card-text);
    border: 0;
    border-radius: var(--card-radius);
    box-shadow: var(--card-top-light), var(--card-rim), var(--card-shadow);
    overflow: visible;   /* .modal-card scrolls; the shadow must not be clipped */
    animation: coachCardIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
    /* the tile and its glow are placed against the card, not against
       #end-body: #end-body scrolls on a desktop and sideways, and a tile
       placed against it would be cut in half where it stands on the edge. */
    position: relative;
  }
  /* 44px of top padding is the room under the tile's lower half (version 10,
     title layout 1 of mockups/calm-cards-4.html). */
  .end-card.win { padding: 44px 22px 6px; max-width: 300px; }

  /* Stars. Hidden entirely when the screen has none to report (Cooking), so the
     card closes up rather than leaving a gap where a rating would be. */
  .end-stars { display: none; }
  .end-card.win .end-stars { display: flex; justify-content: center; gap: 8px; margin-bottom: 14px; }
  .end-card.win .end-stars:empty { display: none; }
  .end-star {
    font-size: 34px; line-height: 1; color: #cfe3d6;
    animation: starPop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  .end-star.earned {
    color: var(--gold);
    text-shadow: 0 2px 0 #a6720a, 0 3px 6px rgba(166,114,10,0.4);
  }
  /* Staggered so three stars land one after another instead of as a block. */
  .end-star:nth-child(1) { animation-delay: 0.10s; }
  .end-star:nth-child(2) { animation-delay: 0.24s; }
  .end-star:nth-child(3) { animation-delay: 0.38s; }
  @keyframes starPop {
    0%   { opacity: 0; transform: scale(0.2) rotate(-25deg); }
    100% { opacity: 1; transform: scale(1) rotate(0deg); }
  }

  /* THE TITLE, BOTH CARDS (version 10): heavier and warmer, with a soft glow,
     centred under the tile. `balance` keeps a two-line title even. */
  .end-card.win h2, .end-card.lose h2 {
    font-size: 27px; font-weight: 900; line-height: 1.08; letter-spacing: -0.2px;
    color: var(--card-title); text-shadow: var(--card-title-glow);
    text-wrap: balance; margin: 0 4px;
  }
  .end-card.win p {
    font-size: 15px; font-weight: 600; color: var(--card-text-soft); margin: 6px 0 18px;
    display: flex; align-items: center; justify-content: center; gap: 5px; flex-wrap: wrap;
  }
  /* 2026-08-19: this now draws UI_ICONS.coinStack, which carries its own golds as
     literal fills — so `color` here would do nothing but mislead and is gone. The
     drop-shadow stays: it seats the stack against the pale end card. */
  .end-coin { width: 17px; height: 17px; display: inline-block; }
  .end-coin svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 1px 0 #a6720a); }

  /* THE PAYOUT NUMBER, A BIT BIGGER (2026-09-02, Matan's partner: "the Coins
     reward display on the level-finish popup should be a bit bigger").

     ⚠️ IT MUST NOT COST THE CARD ANY HEIGHT, and that is why this is a span
     rather than a bigger `p`. The win card with a banner under it already
     overflows a 283px landscape iPhone with nothing to scroll (QA-003), and the
     lose card is worse, so a change that grows the message by a line would turn
     an open finding into a shipped one.

     `line-height: 1` is what makes it free. The paragraph is a flex row at 15px
     with the browser's default leading, so its line box is already about 21px
     tall; a 22px glyph set solid is the same height, and the row is
     `align-items: center`, so the taller item sits in the space that was
     already reserved. Measured before and after: the message paragraph does not
     change height.

     ONLY THE AMOUNT WEARS THIS. The breakdown beside it - a hard-level top-up,
     the bonus clock, `doubled` - stays at 15px, which is also what makes the
     number read as the headline instead of the line simply being louder. */
  .end-pay {
    font-size: 22px; font-weight: 800; line-height: 1;
    color: var(--card-pay-text); letter-spacing: -0.2px;
  }

  /* The daily claim's line on the win card (F7, 2026-08-31). `flex-basis: 100%`
     is what puts it on its own row: the message is a wrapping flex row, so a
     plain span would sit beside the level payout and the two amounts would read
     as one sum. Quieter and smaller than the payout above it - the level's own
     reward is the headline, this is the bonus that came with it. */
  .end-daily {
    flex-basis: 100%; display: flex; align-items: center; justify-content: center;
    gap: 4px; font-size: 13px; font-weight: 700; color: var(--card-text-soft);
  }
  .end-daily .end-coin { width: 15px; height: 15px; }
  /* THE MILESTONE CHEST'S PICTURE (F10, art added 2026-09-03). It replaces the
     word "chest" in the line, so it has to be big enough to say what it is on
     its own - 64px is Matan's figure and it reads clearly at that size.

     ⚠️ IT IS THE ONE THING ON THE WIN CARD THAT COSTS THE CARD REAL HEIGHT, and
     the card already overflows a 283px sideways iPhone with nothing to scroll
     (QA-003). So the landscape block cuts it, and that cut is not optional:
     64px is nearly a quarter of that screen. `flex: none` stops the wrapping
     flex row squashing it, and the line's own `align-items: center` seats the
     text against it without the picture pushing the type around. */
  .end-chest-art { width: 64px; height: 64px; flex: none; }
  .end-chest-art img, .end-chest-art svg { width: 100%; height: 100%; display: block; }
  /* A CHEST is the same line, louder: it is a lump the player sees on three days
     of a cycle (F9's streak chest) or every tenth level (F10's milestone chest),
     not the every-day figure above it. Gold ink, not a new shape, so the card
     still reads as one block of earnings - and ONE class for both, because a
     third look for the second kind of lump would make the card a list of
     unrelated things rather than a block of earnings. */
  .end-daily.end-chest { color: var(--card-pay-text); font-weight: 800; }

  /* End-of-level buttons (win AND lose) were stretching edge-to-edge via the
     base `.modal-actions .btn { width: 100% }` — narrowed and centred instead. */
  #end-card .modal-actions { align-items: center; }
  #end-card .modal-actions .btn { width: auto; min-width: 190px; max-width: 220px; }

  /* THE WIN CARD'S BUTTONS (calm cards, version 9): ONE button, Next level;
     Back to map is a plain line under it, so there is only one thing to
     press (option A of mockups/calm-cards.html). A video offer, where there
     is one, stays a link (.btn-ad).
     THE SMALLER GOLD BUTTON (version 10, Matan 2026-10-08): 48 tall, not 52,
     and only as wide as its words (210 at the least), centred. */
  .end-card.win .modal-actions { gap: 2px; }
  .end-card.win .btn { border-radius: 9999px; font-weight: 700; padding: 0 20px; box-shadow: none; }
  #end-card.win .modal-actions .btn-primary {
    width: max-content; min-width: 210px; max-width: 100%; min-height: 48px;
    padding: 0 26px; font-size: 16.5px; font-weight: 800;
    background: var(--card-primary); color: var(--card-primary-text);
  }
  #end-card.win .modal-actions .btn-ghost {
    min-width: 0; min-height: 44px; background: none;
    color: var(--card-link-text); font-size: 14.5px; font-weight: 600;
  }
  .end-card.win .btn:active { transform: translateY(3px) scale(1); }
  .end-card.win .btn-ad, .end-card.lose .btn-ad {
    color: var(--card-video-text); text-decoration-color: var(--card-video-line);
  }
  /* The padding above is 0 so the gold button's height is its own; a link
     still needs a finger's height. */
  #end-card.win .modal-actions .btn-ad { min-height: 44px; }

  /* ---- THE TRAY-FULL CARD (calm cards, version 10, Matan 2026-10-08) ------
     Version 9 (the same day) replaced the tray-full card with round buttons
     (2026-09-27) and, before it, the terracotta blob (2026-08-09); Matan on
     those: "a bit daunting. It's very busy. It looks scary... less bright on
     the dark background." Version 10 is round 4 of the same cards,
     mockups/calm-cards-4.html: brightness A, tiles 2 and 4, title layout 1,
     the smaller gold button. Every number below is the mockup's, judged at
     true size, and every colour is a --card-* token.

     WHAT IS ON IT, TOP TO BOTTOM. The sad tile standing on the card's top
     edge, half outside it, with a soft glow behind it; the rotating title
     centred under it; how many tiles were still to go, as a small chip. The
     one gold button that keeps the level going ("Clear the tray", with its
     price when it is not free), the video offer as a link under it where
     there is one ("Or watch video"), then Start over and Back to map as two
     quiet outlined buttons side by side, and "How did it happen?" as a plain
     line under them; its explanation opens under that line and the card grows
     DOWNWARD, so nothing moves under the thumb (openWhy() in game.js). Where
     there is no coin rescue, Start over is the gold button and Back to map
     the one quiet button. showEnd() builds .end-main and .end-row; the win
     card never gets either.
     The sideways version is in the short-landscape block further down: the
     tile sits beside the title there, as in the mockup's sideways pictures,
     because a tile on top costs height a 283 tall screen does not have. */
  .end-card.lose { max-width: 322px; padding: 44px 20px 6px; }
  #end-card.lose #end-body { text-align: center; margin-bottom: 16px; }

  /* THE TILE ON THE TOP EDGE, BOTH CARDS (title layout 1). Placed against the
     card (see `position: relative` above), never in the flow, so the 44px of
     top padding is all it costs. The art is 136 by 96, the tile and its
     ground shadow; 78 tall is 110 wide. Which of the two tiles is drawn is
     data-tile, set by showEnd() once per showing; the pictures are tokens. */
  .end-crest, .end-halo { display: none; }
  .end-card.win .end-crest, .end-card.lose .end-crest {
    display: block; position: absolute; z-index: 1;
    left: 50%; top: -38px; width: 110px; height: 78px; margin: 0 0 0 -55px;
    background: center / contain no-repeat;
    color: var(--card-text);
  }
  .end-card.lose .end-crest { animation: starPop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 0.08s both; }
  /* The happy tile hops once as the confetti goes. */
  .end-card.win .end-crest { animation: tileHop 0.7s cubic-bezier(0.3, 1.6, 0.5, 1) 0.05s both; }
  @keyframes tileHop {
    0%   { transform: translateY(10px) scale(0.85); }
    100% { transform: none; }
  }
  .end-card.lose .end-crest.art[data-tile="1"] { background-image: var(--card-lose-tile-1); }
  .end-card.lose .end-crest.art[data-tile="2"] { background-image: var(--card-lose-tile-2); }
  .end-card.win  .end-crest.art[data-tile="1"] { background-image: var(--card-win-tile-1); }
  .end-card.win  .end-crest.art[data-tile="2"] { background-image: var(--card-win-tile-2); }
  .end-card.lose .end-crest svg { width: 42px; height: 42px; display: block; margin: 18px auto 0; }
  /* The glow behind the tile, centred on it. */
  .end-card.win .end-halo, .end-card.lose .end-halo {
    display: block; position: absolute; pointer-events: none;
    left: 50%; top: -64px; width: 190px; height: 130px; margin-left: -95px;
    background: var(--card-halo);
  }
  .end-card.lose .end-stars { display: none; }
  /* "17 tiles to go", a small chip with a little tile in it. */
  .end-card.lose p {
    display: flex; align-items: center; gap: 7px; width: fit-content;
    margin: 8px auto 0; padding: 4px 12px 4px 9px; border-radius: 9999px;
    font-size: 14px; font-weight: 700; line-height: 1.35;
    background: var(--card-chip); color: var(--card-text-soft);
  }
  .end-card.lose p::before {
    content: ''; flex: none; width: 15px; height: 10px; border-radius: 3px;
    background: var(--card-chip-tile); box-shadow: var(--card-chip-tile-edge);
  }
  /* `hidden` must win over the display above (a card with nothing to go). */
  .end-card.lose p[hidden] { display: none; }
  /* A column that stretches, not the centred narrow band the win card used. */
  #end-card.lose .modal-actions { align-items: stretch; gap: 0; }
  .end-main { display: flex; flex-direction: column; }
  .end-card.lose .btn { border-radius: 9999px; box-shadow: none; }
  .end-card.lose .btn:active { transform: translateY(3px) scale(1); }
  #end-card.lose .end-main .btn { width: 100%; min-width: 0; max-width: none; }
  /* THE SMALLER GOLD BUTTON (version 10): 46 tall, not 54, and only as wide
     as its words (200 at the least), centred. */
  #end-card.lose .end-main .btn-primary {
    width: max-content; min-width: 200px; max-width: 100%; align-self: center;
    min-height: 46px; padding: 0 26px; font-size: 16.5px; font-weight: 800;
    background: var(--card-primary); color: var(--card-primary-text);
  }
  /* The video stays a link: centred, only as wide as its words, 44 tall. */
  #end-card.lose .end-main .btn-ad {
    width: auto; align-self: center; min-height: 44px; margin-top: 2px;
    font-size: 15.5px; font-weight: 700;
    text-underline-offset: 4px; text-decoration-thickness: 2px;
  }
  /* Try again and Back to map, side by side, then the "How did it happen?"
     line on a row of its own. One wrapping row, so sideways can put all three
     on one line by changing the line's basis alone. */
  .end-row { display: flex; flex-wrap: wrap; gap: 0 10px; margin-top: 10px; }
  /* Under the video link the row comes up closer: the link is 44 tall
     already and reads as part of the gold button above it. */
  .end-main:has(.btn-ad) + .end-row { margin-top: 4px; }
  /* `flex: 1 1 auto`, not equal halves: "Try again" with its price is the
     longer of the two, and on a 360 wide phone equal halves were narrower than
     its words, so the coin was squeezed to nothing. Each takes its own width
     and they share what is left. The pictures inside never shrink. */
  #end-card.lose .end-row .btn {
    flex: 1 1 auto; width: auto; min-width: 0; max-width: none;
    display: flex; align-items: center; justify-content: center; gap: 0.3em;
    min-height: 48px; padding: 0 10px; font-size: 15px; font-weight: 600;
    background: var(--card-quiet); color: var(--card-quiet-text);
    box-shadow: var(--card-quiet-rim);
  }
  #end-card.lose .end-row .btn > * { flex: none; }
  /* The restart mark in front of Try again, the game's own picture. */
  .btn-ico { display: inline-block; width: 1.05em; height: 1.05em; flex: none; }
  .btn-ico svg { width: 100%; height: 100%; display: block; }
  #end-card.lose .end-row .end-why {
    flex: 1 0 100%; min-height: 44px; margin-top: 2px; padding: 0 6px;
    font-size: 14px; font-weight: 600; text-decoration: none;
    color: var(--card-link-text);
  }
  #end-card.lose .end-note { margin-top: 0; }

  /* THE TRAY STAYS LIT (option B). syncLoseTray() in game.js adds the class
     only while the lose card is up AND does not overlap the tray, so the tray
     is lifted over the scrim and never over the card.
     ⚠️ !important BECAUSE OF ONE RULE: `#game > *:not(#bg-photo)...` sets
     z-index 2 on every child of #game, and its three :not(#id) each count as an
     id, so it outranks any plain selector for the tray. */
  html.lose-tray-lit #tray-container { z-index: 101 !important; }

  /* ---- THE POST-MORTEM, folded away (2026-08-20) --------------------------
     Deliberately the quietest thing on the card: it sits BELOW the buttons, it
     is a plain line rather than another button shape, and it carries none of
     the card's weight - because it is the only thing here the player cannot
     act on. Sized under the buttons and in the soft text colour, so opening it
     cannot out-shout Try again. */
  .end-note { margin-top: 14px; }
  .end-why {
    background: none; border: 0; padding: 4px 6px; cursor: pointer;
    font-family: inherit; font-size: 12.5px; font-weight: 700;
    color: var(--card-link-text); text-decoration: underline; text-underline-offset: 3px;
  }
  .end-why:active { color: var(--card-text); }
  .end-why-text {
    font-size: 13px; font-weight: 500; line-height: 1.45; color: var(--card-text-soft);
    padding: 2px 4px 6px;
    animation: whyIn 0.28s ease both;
  }
  @keyframes whyIn {
    from { opacity: 0; transform: translateY(-4px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  @media (prefers-reduced-motion: reduce) {
    .end-card.win, .end-card.lose, .end-star,
    .end-why-text,
    .end-card.lose .end-crest { animation-duration: 0.01s; }
    .end-card.win .end-crest { animation: none; }
  }

  /* THE WIN CARD'S CONFETTI (calm cards, version 10; winCardBurst() in
     game.js). #end-burst is a point at the happy tile's centre; every piece
     starts there, rises, turns and drifts down, and is gone by 1.8 seconds,
     so the card is clean after it. A phone that asks for less motion gets
     the gentle version: no burst and no spin, each piece fades in where
     --sx/--sy put it, around the tile and the title, drifts a little way down
     and fades out (cardConfettiSoft). It used to be still and stay up, and
     still specks did not read as confetti at all (Matan, 2026-10-09, "no
     confetti on my wins"). The colours are --card-confetti-N tokens, dealt
     by game.js. */
  .card-confetti i {
    position: absolute; left: 0; top: 0;
    width: var(--w); height: var(--h); margin: calc(var(--h) / -2) 0 0 calc(var(--w) / -2);
    border-radius: var(--br); background: var(--c);
    transform: translate(var(--sx), var(--sy)) rotate(var(--r));
    animation: cardConfetti 1.8s cubic-bezier(0.2, 0.7, 0.3, 1) var(--d) both;
  }
  @keyframes cardConfetti {
    0%      { transform: translate(0, 0) rotate(0deg) scale(0.3); opacity: 0; }
    0%, 10% { opacity: 0; }
    16%     { opacity: 1; }
    32%     { transform: translate(calc(var(--x) * 0.75), var(--up)) rotate(calc(var(--r) * 0.4)) scale(1); }
    78%     { opacity: 1; }
    100%    { transform: translate(var(--x), var(--fall)) rotate(var(--r)) scale(0.9); opacity: 0; }
  }
  @keyframes cardConfettiSoft {
    0%   { transform: translate(var(--sx), calc(var(--sy) - 22px)) rotate(var(--r)); opacity: 0; }
    18%  { opacity: 1; }
    72%  { opacity: 1; }
    100% { transform: translate(var(--sx), calc(var(--sy) + 34px)) rotate(var(--r)); opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .card-confetti i { animation: cardConfettiSoft 2.2s ease-in-out var(--d) both; }
  }

  /* ============ BANNER SLOTS (F16, 2026-09-01) ============
     TWO SLOTS AND NO OTHERS: #banner-lobby on the lobby and #banner-end on the
     WIN card. There is deliberately none on the play screen - "never during
     play" is enforced in platform.js, and this is the half of it that means a
     banner cannot even reflow a board.

     320x50 IS A HOST CONSTRAINT, NOT A TASTE. It is the one size Poki's phone
     list (300x250, 728x90, 160x600, 320x50) and CrazyGames' in-game list
     (728x90, 300x250, 320x50, 468x60, 320x100) agree on. It is stated here,
     once: platform.js measures the slot and hands the numbers to whichever
     backend is loaded, so the two hosts can never be told different sizes.
     It is NOT an economy value - the same standing the balance buckets have in
     platform/EVENTS.md. ad.banner in economy/economy.json owns whether the
     placement is asked for; nothing owns a price, because a banner pays the
     publisher and never the player.

     A SLOT IS ZERO-HEIGHT AND EMPTY UNTIL IT IS LIVE. syncBanners() adds .live
     only where PLATFORM.hasBanners is true, so a build with no ad network is
     byte-identical to the one before this feature. */
  .ad-slot {
    width: 320px; height: 0; margin: 0 auto;
    overflow: hidden;
    flex: 0 0 auto;
    max-width: 100%;
  }
  .ad-slot.live { height: 50px; }
  /* The lobby's ring is sized off a height budget in BOTH orientations, so the
     banner has to be subtracted from that budget or the cluster overruns the
     milestone bar. --banner-h is 0 everywhere else, which is what makes the two
     --orbit-d formulas provably unchanged on a build with no banner. */
  #map-screen.has-banner { --banner-h: 50px; }
  #banner-lobby { margin-bottom: max(8px, 1.4dvh); }
  /* BELOW THE CARD, NOT INSIDE IT. Inside, a 375px phone laid the slot out at
     234x50 - .modal-card is 340px wide with 24px of padding - and 234x50 is a
     size neither host will serve. Out here it gets the scrim's own width.
     The overlay becomes a column when a banner is live, so the card and the
     banner stack and both stay in flow: nothing can overlap, and the card's
     own max-height is trimmed by what the banner takes so a short landscape
     window still fits both. */
  /* ⚠️ THE OVERLAY IS A COLUMN ALWAYS, NOT ONLY WHEN A BANNER IS LIVE. This was
     `#end-screen.has-banner { flex-direction: column }` and it broke the win card
     on every build without an ad network, which is every build today.
     .modal is a ROW flex, and .ad-slot is `width: 320px; flex: 0 0 auto` - rigid
     and NON-SHRINKING - so even at height 0 the empty slot claimed 320px BESIDE
     the card. 340 + 320 + 40 of padding needs 700px; a 375px phone overflows by
     325px, and justify-content: center splits an overflow both ways, so the card
     was pushed half off the left edge. Matan hit it on the phone.
     A column costs nothing when there is no banner: the slot stacks under the
     card at height 0 and is invisible. The gap and the max-height trim stay on
     .has-banner, because both only mean anything when the slot has height. */
  #end-screen { flex-direction: column; }
  #end-screen.has-banner { gap: 16px; }
  #end-screen.has-banner .modal-card { max-height: calc(86vh - 66px); }
  .end-banner.live { height: 50px; }

  /* DEV ONLY. Drawn by platform.js when the fakeAds switch is on, which is
     behind the dev.enabled master gate, so it cannot exist in a build a player
     gets. Deliberately does not look like an advert - it says what it is. */
  .fake-banner {
    width: 100%; height: 100%;
    display: flex; align-items: center; justify-content: center;
    border: 1px dashed rgba(255,255,255,0.55);
    border-radius: 6px;
    background: rgba(0,0,0,0.28);
    color: rgba(255,255,255,0.85);
    font-size: 11px; font-weight: 800; letter-spacing: 0.04em;
  }
  .end-banner .fake-banner {
    border-color: rgba(0,0,0,0.28);
    background: rgba(0,0,0,0.05);
    color: #8b7d76;
  }

  /* ---- BOOSTER-UNLOCK ANNOUNCEMENT (2026-08-07) --------------------------
     Variant A of mockups/booster-intro-blob.html. Same blob geometry as the
     tutorial coach card, but this one sits on the .modal scrim because it
     INTERRUPTS — the coach escorts you while the board stays live, this stops
     you to hand you a new toy.
     The badge is `.booster .b-top` / `.b-ico` reused VERBATIM from the booster
     bar, just hero-sized. A booster's announcement and its slot in the bar are
     the same object, so there is no second icon language to draw or maintain.
     NO pagination dots: _introQueue is length 1 every time (Undo 4, Hint 6,
     Peek 8, Shuffle 10 never collide), so there is nothing to page through. */
  .bi-card {
    position: relative;
    background: #fff; padding: 34px 30px 30px; max-width: 286px; width: 100%;
    box-shadow: 0 16px 32px rgba(15,82,56,0.22);
    border: 3px solid rgba(15,82,56,0.14);
    border-radius: 42% 38% 44% 40% / 40% 44% 38% 42%;
    display: flex; flex-direction: column; align-items: center; text-align: center;
  }
  .modal.show .bi-card { animation: coachCardIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }

  /* The hero: badge sitting inside a slowly turning sunburst. */
  .bi-hero { position: relative; width: 150px; height: 150px; display: flex; align-items: center; justify-content: center; }
  .bi-hero::before {
    content: ""; position: absolute; inset: -6px; border-radius: 999px;
    background: repeating-conic-gradient(from 0deg,
      rgba(255,183,2,0.34) 0deg 7deg, transparent 7deg 18deg);
    /* masked to a ring so the rays read as a burst BEHIND the badge rather than
       a striped disc under it */
    -webkit-mask-image: radial-gradient(circle, transparent 30%, black 42%, black 62%, transparent 78%);
            mask-image: radial-gradient(circle, transparent 30%, black 42%, black 62%, transparent 78%);
    animation: biRayspin 22s linear infinite;
  }
  .bi-hero::after {
    content: ""; position: absolute; width: 104px; height: 104px; border-radius: 999px;
    background: radial-gradient(circle, rgba(255,196,60,0.55) 0%, rgba(255,183,2,0) 70%);
  }
  @keyframes biRayspin { to { transform: rotate(360deg); } }

  .bi-badge.booster { width: auto; position: relative; z-index: 1; cursor: default; }
  .bi-badge .b-top { width: 92px; height: 92px; animation: biBadgePop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
  .bi-badge .b-ico { width: 58px; height: 58px; }
  @keyframes biBadgePop {
    0%   { transform: scale(0.4) rotate(-14deg); opacity: 0; }
    100% { transform: scale(1) rotate(0deg); opacity: 1; }
  }

  .bi-new {
    margin-top: 14px;   /* clearance off the badge — the first pass was cramped */
    display: flex; align-items: center; gap: 8px;
    font-size: 19px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase;
    color: var(--gold);
    /* A REAL OUTLINE, painted under the fill (2026-09-15, Matan: "looks like a
       bad quality jpg"). It used to be four shadows offset to the corners faking
       a stroke, which leaves a notch at every curve and a stair-step on every
       diagonal, so the word looked compressed. Gold on white still needs the
       outline for contrast; the same stroke-then-fill is on the cheer title. */
    -webkit-text-stroke: 4px var(--green-deep);
    paint-order: stroke fill;
    text-shadow: 0 3px 0 rgba(15,82,56,0.35);
    animation: biNewPop 0.5s 0.18s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  @keyframes biNewPop { from { opacity: 0; transform: scale(0.7); } to { opacity: 1; transform: scale(1); } }
  .bi-spark {
    color: var(--gold); font-size: 14px; text-shadow: 0 1px 0 rgba(15,82,56,0.4);
    animation: biTwinkle 1.6s ease-in-out infinite;
  }
  .bi-spark:last-child { animation-delay: 0.8s; }
  @keyframes biTwinkle {
    0%, 100% { opacity: 0.45; transform: scale(0.82); }
    50%      { opacity: 1; transform: scale(1.15); }
  }

  .bi-name { font-size: 25px; font-weight: 900; color: var(--green-deep); margin-top: 10px; line-height: 1.1; }
  .bi-desc { font-size: 15px; font-weight: 700; color: #12281f; line-height: 1.4; margin-top: 8px; }
  /* THE FREE-FIRST-CHARGE LINE IS GONE FROM THIS CARD (2026-09-02). It read
     "Your first Undo is on us." and it is now a FREE badge on the booster chip
     instead - see `.booster.freeuse .b-cost` below. Kept as a note rather than
     silently deleted, because the rule was written to be read here. */
  .bi-btn {
    margin-top: 22px;
    background: var(--gold); color: var(--gold-ink); font-weight: 800; font-size: 15px;
    border: none; border-radius: 9999px; padding: 12px 32px; font-family: inherit;
  }
  .bi-btn:active { transform: translateY(3px); }

  /* THE PAY-OR-WATCH FOOTER (2026-09-03). The announcement card has one button
     and it is `.bi-btn`; this card asks a question with three answers, so its
     footer is `.modal-actions` - the same stacked column the end card uses -
     and the buttons in it are the same `.btn` / `.btn-ad` / `.btn-ghost`. The
     only thing this rule does is give that column the card's width and the
     `.bi-btn`'s top margin, so the two cards' footers start at the same place.
     The gold primary is the end card's, because gold is what a price wears
     everywhere in this game. The ghost is the WIN card's green-tinted one
     rather than the lose card's warm one, because this card is the announcement
     card - white with green type - and a warm grey would be the only thing on
     it borrowed from a screen about losing. */
  /* The wrapper does nothing in portrait except keep the centring the card was
     already doing. It earns its place in the landscape block. */
  #bo-body { width: 100%; display: flex; flex-direction: column; align-items: center; }
  .bi-actions { width: 100%; margin-top: 20px; }
  /* ⚠️ THE BUTTONS ARE NARROWED AND CENTRED, NOT FULL WIDTH (Matan, 2026-09-03:
     "the 'cancel' button is too wide"). `.modal-actions .btn { width: 100% }` is
     the base rule and it is right for the coins sheet, where every button is a
     real choice. It is wrong here for the same reason it was wrong on the end
     card, which took this exact treatment on 2026-08-20: once the card is down
     to a video link and a way out, a full-bleed slab makes the way out the
     loudest thing on a card that is not about backing out. The numbers are the
     end card's, deliberately - two cards, one button width. */
  #booster-offer .modal-actions { align-items: center; }
  #booster-offer .modal-actions .btn { width: auto; min-width: 190px; max-width: 220px; }
  #booster-offer .btn { padding: 12px 22px; font-size: 15px; min-height: 44px; }
  #booster-offer .btn-primary { background: var(--gold); color: var(--gold-ink); }
  #booster-offer .btn-ghost { background: #eaf5ee; color: var(--green-deep); }
  /* The offer is not an announcement, so it does not shout: the name is the
     booster's, one size down from the unlock card's 25px, because there is no
     NEW eyebrow above it to share the weight. */
  #booster-offer .bi-name { font-size: 22px; margin-top: 4px; }

  /* ---- A QUESTION FROM THE HOST (2026-09-28) -------------------------------
     #question-modal, drawn by showQuestionCard() in game.js. Today: the Android
     app's consent question in India and the Philippines. The announcement
     cards' language - white, a green edge, green-deep type - on the ordinary
     rectangle, because it is a page of words rather than a badge.
     ⚠️ THE TWO ANSWERS ARE THE SAME BUTTON. Same colour, same size, side by
     side, in a row that shares the width equally. A consent question may not
     make one answer the loud one, so nothing here may style #q-yes apart from
     #q-no. tools/android-check.mjs reads this block for that.
     ABOVE THE LOADING SCREEN (#boot is 2000), because it is asked before the
     first board, while that screen is still up. */
  #question-modal { z-index: 2100; }
  .q-card {
    display: flex; flex-direction: column;
    text-align: left; max-width: 360px; padding: 22px 22px 18px;
    border: 3px solid rgba(15,82,56,0.14);
    box-shadow: 0 16px 32px rgba(15,82,56,0.22);
    overflow: hidden;
  }
  .q-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
  .q-card h2 { font-size: 22px; font-weight: 900; color: var(--green-deep); text-align: center; line-height: 1.15; margin-bottom: 12px; }
  .q-card #q-body p { font-size: 14.5px; font-weight: 600; color: #12281f; line-height: 1.45; margin-bottom: 10px; }
  .q-card #q-body p.q-link { font-size: 13px; margin-bottom: 0; }
  .q-card .q-link a { color: var(--green-deep); font-weight: 800; text-decoration: underline; }
  .q-actions { flex: 0 0 auto; display: flex; gap: 10px; margin-top: 16px; }
  .q-actions .q-btn {
    flex: 1 1 0; min-width: 0; min-height: 48px;
    padding: 10px 12px; font-size: 16px; font-weight: 800; font-family: inherit;
    background: var(--green-deep); color: #fff; border-radius: 9999px;
  }

  @media (prefers-reduced-motion: reduce) {
    .bi-hero::before, .bi-spark { animation: none; }
    .modal.show .bi-card, .bi-badge .b-top, .bi-new { animation-duration: 0.01s; }
  }

  /* ---- COOKING BRIEFING HERO (2026-08-29) ---------------------------------
     The card itself is .bi-card and everything under the hero is the booster
     announcement's own .bi-* classes, unchanged. Only this differs.

     THE HERO IS THE CUISINE'S DISH BADGES, not a sunburst and not the emoji.
     Three reasons. It is the one thing on the card that is genuinely about
     TODAY rather than about the mode, so it is what should lead. It is real
     painted art, where an emoji renders as tofu on older Android (which is why
     every picture in this game is drawn). And it previews the dishes the pills
     are about to name anyway, so it gives nothing away.

     A CUISINE CARRIES THREE TO SIX DISHES, so the row has to hold six without
     the card growing: the badges overlap by a negative margin and tilt
     alternately, which reads as a spread of plates rather than a grid. The
     first badge cancels its own left offset so the group stays optically
     centred whatever the count. */
  /* ⚠️ AT MOST THREE PER ROW, SO IT IS TWO ROWS FROM FOUR DISHES UP (Matan,
     2026-08-29: "that's a big ask from this popup. i suggest you cut it to two
     rows"). The rows are BUILT IN JS, not wrapped by CSS, because `flex-wrap`
     splits 4 into 3+1 where the balanced 2+2 is what reads as a pair of shelves.
     cookIntroRows() does the split.

     WHY NOT ONE ROW THAT SHRINKS. The first attempt kept a single row and let
     the badges shrink to fit. Six of them came out at 8px - `aspect-ratio` with
     `height: auto` has no intrinsic height to shrink FROM inside a flex row, so
     they collapsed rather than resized. Two rows keeps every badge at a size
     worth looking at, which is the only reason to show the art at all. */
  .ci-hero {
    display: flex; flex-direction: column; align-items: center;
    padding-top: 4px;
  }
  .ci-row { display: flex; align-items: center; justify-content: center; }
  /* The second row tucks up under the first so the two read as one heap of
     plates rather than as a grid, and offsets half a badge for a brick lay. */
  .ci-row + .ci-row { margin-top: -10px; margin-left: 18px; }
  .ci-dish {
    width: 62px; height: 62px; flex: 0 0 auto; margin-left: -14px;
    filter: drop-shadow(0 3px 5px rgba(15,82,56,0.28));
    animation: biBadgePop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  /* catIcon() returns either an <img> (a WebP badge, inlined as a data URI by
     build.mjs) or raw inline <svg>. Both have to fill the span the caller sized,
     exactly as they do everywhere else a badge is drawn. */
  .ci-dish img, .ci-dish svg { width: 100%; height: 100%; display: block; object-fit: contain; }
  .ci-row .ci-dish:first-child { margin-left: 0; }
  .ci-dish:nth-child(odd)  { transform: rotate(-7deg); }
  .ci-dish:nth-child(even) { transform: rotate(7deg) translateY(4px); }
  /* Staggered so the plates land one after another, like the win card's stars.
     Set inline by cookIntroRows() so the delay runs across BOTH rows in order
     rather than restarting at each row's first child. */
  @media (prefers-reduced-motion: reduce) {
    .ci-dish { animation-duration: 0.01s; }
  }

  /* ---- ALREADY-SERVED HERO (2026-08-30) ----------------------------------
     One big cuisine badge, where the briefing's hero is a spread of dish
     badges. Same card, same everything below it; only this differs.
     No tilt and no overlap to manage, because it is a single image - so it
     wants none of .ci-row's machinery. */
  .cd-hero {
    display: flex; align-items: center; justify-content: center;
    width: 112px; height: 112px; margin: 0 auto 2px;
    filter: drop-shadow(0 4px 6px rgba(15,82,56,0.3));
    animation: biBadgePop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  .cd-hero img, .cd-hero svg { width: 100%; height: 100%; display: block; object-fit: contain; }
  /* The fallback path draws menu.emoji as text, which needs a size of its own. */
  .cd-hero.emoji { font-size: 76px; line-height: 1; }
  @media (prefers-reduced-motion: reduce) { .cd-hero { animation-duration: 0.01s; } }

  /* ⚠️ A SHORT LANDSCAPE PHONE CANNOT FIT THE FULL CARD, AND THE BUTTON IS THE
     THING THAT GOES (2026-08-29). At 755x298 - the tight landscape case this
     project already tracks - the six-dish card is 382px tall in a 298px viewport,
     so it hangs 42px off the top AND puts "Let's cook" below the fold. The card
     is not scrollable and nothing else dismisses it, so the player is stuck
     looking at a briefing they cannot close.
     Everything below just buys back the ~95px: smaller plates, tighter padding,
     smaller type. Two rows of badges are kept, because dropping to one would
     bring back the shrink-to-8px problem this layout exists to solve.
     SCOPED TO #cook-intro. The booster-unlock card shares .bi-card and is a
     little shorter, but whether it needs the same treatment is its own question
     and not this change's to answer. */
  @media (max-height: 430px) {
    #cook-intro .bi-card,
    #cook-done  .bi-card { padding: 18px 22px 18px; }
    #cook-intro .ci-hero { padding-top: 0; }
    #cook-intro .ci-dish { width: 44px; height: 44px; margin-left: -10px; }
    #cook-intro .ci-row + .ci-row { margin-top: -7px; margin-left: 13px; }
    #cook-done  .cd-hero { width: 74px; height: 74px; }
    #cook-done  .cd-hero.emoji { font-size: 52px; }
    #cook-intro .bi-new,  #cook-done .bi-new  { margin-top: 6px; font-size: 15px; }
    #cook-intro .bi-name, #cook-done .bi-name { font-size: 20px; margin-top: 6px; }
    #cook-intro .bi-desc, #cook-done .bi-desc { font-size: 13px; margin-top: 6px; line-height: 1.35; }
    #cook-intro .bi-btn,  #cook-done .bi-btn  { margin-top: 12px; padding: 9px 26px; font-size: 14px; }
  }

  /* Coach marks (tutorial) — non-blocking banner, board stays tappable.
     Organic "blob" card (handoff: tutorial-bubbles.md, 2026-08-07). Each step
     carries its own asymmetric border-radius (COACH_BLOB_RADII in game.js) so
     the set doesn't feel mechanically identical, and the radius TRANSITIONS
     between steps for the "morph" — plain content crossfades on top of it. */
  #coach {
    position: fixed; left: 0; right: 0; bottom: 0;
    display: none; justify-content: center;
    z-index: 90; padding: 14px; pointer-events: none;
  }
  #coach.show { display: flex; }
  #coach .coach-card {
    background: #fff; padding: 42px 32px 30px;
    max-width: 320px; width: 100%; pointer-events: auto;
    box-shadow: 0 16px 32px rgba(15,82,56,0.22);
    /* A border, not a shadow — a shadow only paints outward from the shape and
       is invisible where a same-colour tile sits flush behind it, which is
       what read as the card being fused to the board (Matan's report). A
       border is part of the box itself, so it always shows at the seam. */
    border: 3px solid rgba(15,82,56,0.14);
    display: flex; flex-direction: column; align-items: center; text-align: center;
    /* Kept moderate on purpose — the handoff's own 55-70% example ate into the
       footer row (dots/button landed inside the curve, not on flat card).
       38-46% still reads as an organic blob without swallowing the corners. */
    border-radius: 42% 38% 44% 40% / 40% 44% 38% 42%;
    transition: border-radius 0.5s cubic-bezier(0.3, 0.7, 0.3, 1);
  }
  #coach .coach-card.coach-enter { animation: coachCardIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
  @keyframes coachCardIn {
    0%   { opacity: 0; transform: scale(0.9); }
    100% { opacity: 1; transform: scale(1); }
  }
  /* Step transition: content fades/settles while the card's own border-radius
     (set inline, per step) morphs underneath via the transition above. */
  #coach .coach-card.coach-morph-out .coach-visual,
  #coach .coach-card.coach-morph-out .coach-text { animation: coachContentOut 0.18s ease both; }
  #coach .coach-card.coach-morph-in .coach-visual,
  #coach .coach-card.coach-morph-in .coach-text { animation: coachContentIn 0.24s ease both; }
  @keyframes coachContentOut { to   { opacity: 0; transform: scale(0.96); } }
  @keyframes coachContentIn  { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }

  /* width:100% because .coach-card is a COLUMN flex with align-items:center, so
     this row would otherwise shrink to its content on the cross axis — which
     left the step-4 tray miniature (all of whose children are absolutely
     positioned, i.e. zero content width) collapsed to a sliver. The other three
     visuals are unaffected: justify-content already centres them. */
  #coach .coach-visual { width: 100%; min-height: 52px; display: flex; align-items: center; justify-content: center; margin-bottom: 18px; }
  #coach .coach-text {
    font-size: 19px; font-weight: 800; color: #12281f; line-height: 1.35;
    white-space: pre-line;
  }
  #coach .coach-row {
    width: 100%; display: flex; flex-direction: column; align-items: center;
    gap: 12px; margin-top: 22px;
  }
  #coach .coach-dots { display: flex; gap: 7px; }
  #coach .coach-dot { width: 9px; height: 9px; border-radius: 9999px; background: #bfe3cd; transition: background 0.2s ease; }
  #coach .coach-dot.on { background: #0f5238; }
  #coach .coach-dot.on.dot-pop { animation: coachDotPop 0.4s cubic-bezier(0.3, 0.7, 0.3, 1) both; }
  @keyframes coachDotPop {
    0%   { border-radius: 9999px; width: 9px; }
    45%  { border-radius: 9999px; width: 18px; }
    100% { border-radius: 9999px; width: 9px; }
  }

  #coach .coach-btn {
    background: #ffb702; color: #6b4b00; font-weight: 800; font-size: 15px;
    border: none; border-radius: 9999px; padding: 12px 28px;
  }
  #coach .coach-btn:active { transform: translateY(3px); }

  /* Step 1 visual: three mini word-tiles.
     THE MOCK MUST WEAR THE REAL TILE'S FRAME (Matan, 2026-08-10). These sit
     directly over the board, so any difference between the mock and the tiles
     behind it reads as two kinds of tile. The construction below is .f-front and
     .tile.open copied exactly — 1px hairline border, inset white highlight, the
     4px slab wall, the ambient shadow, and the 3px dark-grey open ring. If the
     open frame changes over there, change it here in the same edit.
     The highlighted tile swaps that ring for AMBER, which is now the game's
     "look here" colour and nothing else (see .tile.hint-glow) — so the tutorial
     pointing at a tile and the hint pointing at a tile look like one language. */
  #coach .ct-row { display: flex; gap: 10px; }
  #coach .ct-tile {
    width: 52px; height: 52px; border-radius: 10px;
    /* Kept in step with .tile.open by hand, 2026-08-17, exactly as the comment
       above this block instructs. */
    background: #fff; border: 1px solid transparent; color: #12181a;
    box-shadow:
      inset 0 0 0 1.5px rgba(255,255,255,0.9),
      inset 0 2px 4px rgba(255,255,255,0.9),
      0 3px 0 #3E9B72,
      0 3px 0 2px #4FAE83,
      0 0 0 2px #4FAE83,
      0 7px 5px rgba(8, 40, 30, 0.34);
    display: flex; align-items: center; justify-content: center;
    font-weight: 800; font-size: 12px; text-align: center;
  }
  /* The highlighted coach tile swaps the frame for AMBER, the game's one
     "look here" colour — tracks .tile.hint-glow, 2026-08-17. */
  #coach .ct-tile.ct-hl {
    box-shadow:
      inset 0 2px 4px rgba(255,255,255,0.9),
      0 3px 0 #6FC79F,
      0 6px 4px rgba(8,40,30,0.30),
      0 0 0 3px rgba(255, 183, 2, 1);
    animation: coachTilePulse 1.4s ease-in-out infinite;
  }
  @keyframes coachTilePulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.06); }
  }

  /* Step 3 visual: a static mock of the category-pill component */
  #coach .tut-pill {
    height: 36px; padding: 0 14px; border-radius: 9999px;
    background: #6750a4; border: 2px solid #8a72c2;
    box-shadow: 0 3px 0 #4f378b, 0 4px 6px rgba(0,0,0,0.15);
    color: #fff; font-weight: 700; font-size: 14px;
    display: inline-flex; align-items: center; gap: 6px;
  }
  #coach .tut-pill-ico { width: 18px; height: 18px; display: block; }
  #coach .tut-pill-ico svg { width: 100%; height: 100%; display: block; }

  /* Step 4 visual: THE COLLECTOR IN MINIATURE, holding the tutorial's own three
     tiles (2026-08-10). It replaced a sparkle glyph on a red disc — the red was
     literally the "Red" demo tile's hex reused as a badge colour, so the
     friendliest step in the game wore an error colour.
     Everything here mirrors the real collector at roughly 62%: the tilted deck,
     the dark SOCKET each tile sits in, the STAND that rims it, and the LIP of
     the plinth drawn over the tile's bottom edge (see the full note on #tray-
     container). Same gradients and the same construction, so the miniature and
     the real thing are recognisably one object — which is the only reason this
     had to be rebuilt along with the real tray on 2026-08-21. A miniature that
     still showed the retired tilted deck would be teaching a tray the player is
     about to not find.
     SEVEN SLOTS, not a tidier five — see COACH_TRAY_SLOTS in game.js. */
  #coach .tut-tray {
    position: relative; width: 100%; max-width: 250px; height: 50px;
    border-radius: 12px;
    /* .coach-visual is a flex row, and every child of this miniature is
       absolutely positioned — so its content width is ZERO and the default
       `flex: 0 1 auto` collapsed the whole tray to a 2px sliver. flex-basis has
       to be told to use the declared width. */
    flex: 1 1 auto;
  }
  #coach .tt-front {
    position: absolute; left: 0; right: 0; bottom: 0; height: 12px;
    border-radius: 0 0 7px 7px;
    background: linear-gradient(180deg, var(--deck-front-1) 0%, var(--deck-front-2) 100%);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 3px 7px rgba(20, 55, 38, 0.32);
  }
  #coach .tt-slots {
    position: absolute; left: 0; right: 0; bottom: 7px;
    display: grid; gap: 3px; align-items: end;
    /* columns come from COACH_TRAY_SLOTS in game.js — never hardcode 7 */
  }
  #coach .tt-spot { position: relative; height: 36px; display: flex; align-items: flex-end; justify-content: center; }
  /* The stand, at the miniature's scale: 1px proud instead of 1.5, because the
     seat here is 24px against the real 30.7 and the rim has to stay a hairline. */
  #coach .tt-spot::before {
    content: '';
    position: absolute; left: -1px; right: -1px; bottom: 0.5px; height: 29px;
    /* Seat radius + the 1px this rim stands proud. Same rule as the real tray. */
    z-index: 0; border-radius: 3.7px 3.7px 2px 2px;
    background: linear-gradient(180deg,
                var(--deck-1) 0%, var(--deck-2) 52%, var(--deck-3) 100%);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55),
                0 2px 5px rgba(8, 30, 20, 0.5);
  }
  /* The socket: the seat's 24px plus its 2px bottom margin, so a seated tile
     covers it exactly and an empty position stays a dark hole. */
  #coach .tt-slit {
    position: absolute; left: 0; right: 0; bottom: 0; height: 27px;
    border-radius: 2.7px; z-index: 1;
    background: linear-gradient(180deg, var(--slit-1) 0%, var(--slit-2) 70%, var(--slit-3) 100%);
    box-shadow: inset 0 2px 4px rgba(0,0,0,0.8);
  }
  #coach .tt-seat {
    position: relative; z-index: 3;
    width: 100%; height: 24px;
    /* The board tile's 8px at this miniature's own scale: the seat is 24px tall
       against the tile's 72, so 8 x 0.333 = 2.7. Same derivation as .seat. */
    border-radius: 2.7px;
    background: #fff; border: 1px solid #e1e3e4;
    display: flex; align-items: center; justify-content: center;
    margin-bottom: 2px; overflow: hidden; padding: 0 1px;
    /* 8.5px is what a 33px seat can carry — "Yellow" is the binding word. The
       player met these three at 12px two steps ago, so this is recognition, not
       first reading. */
    font-weight: 800; font-size: 8.5px; letter-spacing: -0.1px; line-height: 1;
    box-shadow: inset 0 1px 2px rgba(255,255,255,0.95), 0 3px 6px rgba(16,50,36,0.4);
  }
  /* ---- Step 2: THE MATCH, AS A LOOPING MOTION (2026-08-10) -----------------
     The other three cards each illustrate a NOUN. Nothing showed a tile
     travelling from the board into the collector and clearing — the one motion
     the whole game is made of, and step 2 is the card that has to teach it.

     The travelling tiles are the tray's own seats. Flying three big tiles from
     a separate row above down into three small seats below would mean measuring
     each tile's exact travel against a tray whose width is fluid, i.e. a layout
     read plus a resize listener. Animating the seat inside its own box needs
     neither: it starts lifted and enlarged (1.55x puts the word at ~13px, the
     size the player just read it at on the previous card), settles into its own
     slot, and all three clear TOGETHER — which is the rule being taught.
     Three keyframe sets rather than one with delays, because the entries have
     to stagger while the clear must not. */
  #coach .tut-tray-tall { height: 96px; }   /* 50px tray + 46px of headroom to rise into */
  /* 3.6s: ~1.4s of tiles arriving, ~0.8s seated, a shared pop, then ~1s of
     visibly EMPTY slots before it loops — that last beat is not dead time, it
     is the half of the lesson that says the collector got its room back. */
  #coach .tm-drop { animation: 3.6s linear infinite both; }
  #coach .tm-1 { animation-name: tmDrop1; }
  #coach .tm-2 { animation-name: tmDrop2; }
  #coach .tm-3 { animation-name: tmDrop3; }
  @keyframes tmDrop1 {
    0%,  6%  { transform: translateY(-40px) scale(1.55); opacity: 0; }
    9%       { transform: translateY(-40px) scale(1.55); opacity: 1; }
    18%      { transform: translateY(3px) scale(1); opacity: 1; }
    22%, 56% { transform: translateY(0) scale(1); opacity: 1; }
    62%      { transform: translateY(0) scale(1.14); opacity: 1; }
    72%, 100%{ transform: translateY(0) scale(0.3); opacity: 0; }
  }
  @keyframes tmDrop2 {
    0%, 13%  { transform: translateY(-40px) scale(1.55); opacity: 0; }
    16%      { transform: translateY(-40px) scale(1.55); opacity: 1; }
    25%      { transform: translateY(3px) scale(1); opacity: 1; }
    29%, 56% { transform: translateY(0) scale(1); opacity: 1; }
    62%      { transform: translateY(0) scale(1.14); opacity: 1; }
    72%, 100%{ transform: translateY(0) scale(0.3); opacity: 0; }
  }
  @keyframes tmDrop3 {
    0%, 20%  { transform: translateY(-40px) scale(1.55); opacity: 0; }
    23%      { transform: translateY(-40px) scale(1.55); opacity: 1; }
    32%      { transform: translateY(3px) scale(1); opacity: 1; }
    36%, 56% { transform: translateY(0) scale(1); opacity: 1; }
    62%      { transform: translateY(0) scale(1.14); opacity: 1; }
    72%, 100%{ transform: translateY(0) scale(0.3); opacity: 0; }
  }

  #coach .tt-lip {
    position: absolute; left: -7%; right: -7%; bottom: 0; height: 6px;
    border-radius: 2px; z-index: 4;
    background: linear-gradient(180deg, var(--lip-1) 0%, var(--lip-2) 100%);
    box-shadow: 0 -2px 3px rgba(18,52,38,0.4), inset 0 1px 0 rgba(255,255,255,0.65);
  }

  @media (prefers-reduced-motion: reduce) {
    #coach .coach-card.coach-enter,
    #coach .ct-tile.ct-hl,
    #coach .coach-dot.on.dot-pop,
    #coach .coach-card.coach-morph-out .coach-visual,
    #coach .coach-card.coach-morph-out .coach-text,
    #coach .coach-card.coach-morph-in .coach-visual,
    #coach .coach-card.coach-morph-in .coach-text { animation: none; }
    /* Step 2 keeps its meaning without the loop: the three tiles simply sit in
       the collector, which is the state the animation ends on anyway. */
    #coach .tm-drop { animation: none; }
  }

  /* ===== THE FIRST-TIME GUIDE (2026-09-12) =====================================
     Replaces the four tutorial cards above. See guideStart() in game.js for the
     whole flow. No words and no buttons (Matan, 2026-09-14): the hand, the gold
     rings and the dimming carry it. Everything here floats over the play screen
     with pointer-events off and is placed in viewport pixels by guideTick(), so
     it needs no rule of its own sideways: it follows whatever the board does.
     Below the modals (100) and the old coach (90), above the board. */

  /* THE SPOTLIGHT (Matan, 2026-09-14: "make the highlights more dominant").
     While the guided match runs, #game carries .guiding and everything that is
     not the three tiles, their category's name or the tray steps back:
       - the background, under a dark layer (#guide-dim, below every control);
       - the other tiles, under their own depth scrim turned up - the scrim, never
         a filter, for the reason .f-front::before gives;
       - the header and the other category names, by opacity.
     The three guided tiles sit above all of it in a thick gold ring. */
  #guide-dim {
    position: fixed; inset: 0; z-index: 1; pointer-events: none;
    background: rgba(4, 20, 14, 0.55);
    opacity: 0; transition: opacity 0.35s ease;
  }
  #game.guiding #guide-dim { opacity: 1; }
  #game.guiding .tile:not(.guide-on) .f-front::before { opacity: 0.62; }
  #game.guiding #hud { opacity: 0.3; transition: opacity 0.35s ease; }
  #game.guiding .cat-pill:not(.guide-pill) { opacity: 0.25; }
  #game .cat-pill.guide-pill { opacity: 1; animation: guidePill 1s ease-in-out infinite; }
  @keyframes guidePill {
    0%, 100% { box-shadow: 0 0 0 3px #ffb702, 0 0 0 3px rgba(255, 183, 2, 0.7); }
    50%      { box-shadow: 0 0 0 3px #ffb702, 0 0 0 11px rgba(255, 183, 2, 0); }
  }

  /* The guided tiles, and the tile a lesson points at: a solid 4px gold ring
     with a pulse thrown outward from it. Same amber as the hint - one "look
     here" colour for the whole game. */
  .tile.guide-on { z-index: 50 !important; }
  .tile.guide-on .f-front { animation: guideRing 1s ease-in-out infinite; }
  @keyframes guideRing {
    0%, 100% {
      box-shadow: inset 0 2px 4px rgba(255,255,255,0.9), 0 3px 0 #6FC79F,
                  0 0 0 4px #ffb702, 0 0 0 4px rgba(255, 183, 2, 0.75);
    }
    50% {
      box-shadow: inset 0 2px 4px rgba(255,255,255,0.9), 0 3px 0 #6FC79F,
                  0 0 0 4px #ffb702, 0 0 0 16px rgba(255, 183, 2, 0);
    }
  }
  /* After the guided match, the other category names pulse once each, in turn. */
  .cat-pill.guide-next { animation: guideNext 0.7s ease-in-out both; }
  @keyframes guideNext {
    0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 183, 2, 0); }
    40%      { transform: scale(1.12); box-shadow: 0 0 0 3px #ffb702; }
  }
  /* A covered tile tapped for the first time: a short no-shake on its face. */
  .tile.guide-shake .f-front { animation: guideShake 0.42s ease both; }
  @keyframes guideShake {
    0%, 100% { transform: translateX(0); }
    20% { transform: translateX(-5px); }
    40% { transform: translateX(5px); }
    60% { transform: translateX(-3px); }
    80% { transform: translateX(3px); }
  }

  #guide-hand {
    position: fixed; left: 0; top: 0; width: 58px; height: 58px;   /* GUIDE_HAND_W */
    z-index: 85; pointer-events: none; display: none;
    filter: drop-shadow(0 3px 4px rgba(4, 20, 14, 0.45));
    transform-origin: 48% 25%;
  }
  #guide-hand.show { display: block; animation: guideTap 1.1s ease-in-out infinite; }
  #guide-hand svg { width: 100%; height: 100%; display: block; overflow: visible; }
  #guide-hand path { fill: #ffffff; stroke: #12281f; stroke-width: 0.9px; paint-order: stroke; stroke-linejoin: round; }
  @keyframes guideTap {
    0%, 100% { transform: translate(6px, 12px) scale(1); }
    45%      { transform: translate(0, 0) scale(0.92); }
    55%      { transform: translate(0, 0) scale(0.92); }
  }
  /* THE RIPPLE: a gold ring thrown out from the fingertip at the moment of each
     press (45% of the tap loop), so the hand reads as tapping, not hovering. */
  #guide-hand::before {
    content: ''; position: absolute; left: 48%; top: 25%;
    width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%;
    border: 3px solid #ffb702; opacity: 0;
    animation: guideRipple 1.1s ease-out infinite;
  }
  @keyframes guideRipple {
    0%, 44% { opacity: 0; transform: scale(0.4); }
    47%     { opacity: 1; transform: scale(0.6); }
    100%    { opacity: 0; transform: scale(4); }
  }
  #guide-hand.show.nudge { animation: guideNudge 0.4s ease both, guideTap 1.1s ease-in-out 0.4s infinite; }
  @keyframes guideNudge {
    0%, 100% { transform: translate(6px, 12px) rotate(0deg); }
    30% { transform: translate(6px, 12px) rotate(-14deg) scale(1.12); }
    65% { transform: translate(6px, 12px) rotate(10deg) scale(1.12); }
  }

  /* THE CAPTIONS, back for the `captions` A/B test (2026-09-26). The words the
     guide showed from 2026-09-12 until they were taken out on 2026-09-14,
     with the look they had then (recovered from commit 74e6056's parent). Only
     a player given the "on" variant ever sees one; for everyone else the
     element stays display: none and nothing on screen moves. Placed in viewport
     pixels by placeBubble() in game.js, like the hand. */
  #guide-bubble {
    position: fixed; left: 0; top: 0;
    z-index: 86; pointer-events: none;
    display: none; max-width: min(300px, 86vw);
    padding: 9px 16px; border-radius: 14px;
    background: #ffffff; color: #12281f;
    font-weight: 800; font-size: 16px; line-height: 1.25; text-align: center;
    box-shadow: 0 8px 20px rgba(4, 20, 14, 0.30), 0 0 0 2px #ffb702;
    --ax: 50%;
  }
  #guide-bubble.show { display: block; animation: guideBubbleIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
  /* The pointer: a small square turned 45 degrees, under the bubble by default
     and on top of it when the bubble sits below its target. */
  #guide-bubble::after {
    content: ''; position: absolute; left: var(--ax); bottom: -6px;
    width: 12px; height: 12px; margin-left: -6px;
    background: #ffffff; transform: rotate(45deg);
    box-shadow: 2px 2px 0 0 #ffb702;
  }
  #guide-bubble.below::after { bottom: auto; top: -6px; box-shadow: -2px -2px 0 0 #ffb702; }
  /* Beside a sideways phone's tray the room is the strip between board and
     tray, so the caption wraps rather than reaching over the tiles. */
  #guide-bubble.side { max-width: 160px; font-size: 14px; }
  #guide-bubble.side::after { left: auto; right: -6px; bottom: auto; top: 50%; margin: -6px 0 0; box-shadow: 2px -2px 0 0 #ffb702; }
  #guide-bubble.plain::after { display: none; }
  @keyframes guideBubbleIn { from { opacity: 0; transform: scale(0.85); } to { opacity: 1; transform: scale(1); } }

  /* The empty tray seats, while the tray lesson is up. */
  #tray-container.guide-tray .spot:not(.filled) .slit { animation: guideSeat 0.8s ease-in-out infinite; }
  @keyframes guideSeat {
    0%, 100% { box-shadow: 0 0 0 0 rgba(255, 183, 2, 0); }
    50%      { box-shadow: 0 0 0 3px rgba(255, 183, 2, 1), 0 0 12px 4px rgba(255, 183, 2, 0.7); }
  }

  /* THE CHEER. Replaces the win card after a first clear of levels 1 and 2. */
  #guide-cheer {
    position: fixed; inset: 0; z-index: 88; pointer-events: none;
    display: none; flex-direction: column; align-items: center; justify-content: flex-start; gap: 8px;
    /* Above centre: the last match's category badge (#match-fx) is still fading
       out at 60% of the board when the cheer arrives. */
    padding-top: 24vh;
  }
  #guide-cheer.show { display: flex; }
  #guide-cheer .gc-title {
    font-size: 40px; font-weight: 900; color: #ffffff;
    -webkit-text-stroke: 1.5px #0f5238; paint-order: stroke fill;
    text-shadow: 0 4px 0 #0f5238, 0 8px 18px rgba(4, 20, 14, 0.45);
    animation: guideCheerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  #guide-cheer .gc-pay {
    display: flex; align-items: center; gap: 6px;
    padding: 6px 16px; border-radius: 9999px;
    background: #ffb702; color: #6b4b00; font-size: 22px; font-weight: 900;
    box-shadow: 0 4px 0 #b88200;
    animation: guideCheerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.15s both;
  }
  #guide-cheer .gc-pay .end-coin { display: inline-flex; width: 24px; height: 24px; }
  #guide-cheer .gc-pay .end-coin svg, #guide-cheer .gc-pay .end-coin img { width: 100%; height: 100%; }
  #guide-cheer .gc-burst { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
  #end-burst { position: fixed; width: 0; height: 0; z-index: 2; pointer-events: none; }
  /* The pieces are shared by the cheer and the win card's burst (#end-burst). */
  .gc-burst i {
    position: absolute; left: -5px; top: -4px; width: 10px; height: 8px; border-radius: 2px;
    animation: guideBurst 1.1s cubic-bezier(0.15, 0.7, 0.3, 1) both;
  }
  @keyframes guideCheerIn { from { opacity: 0; transform: scale(0.6); } to { opacity: 1; transform: scale(1); } }
  @keyframes guideBurst {
    0%   { transform: translate(0, 0) rotate(0deg); opacity: 1; }
    80%  { opacity: 1; }
    100% { transform: translate(var(--dx), calc(var(--dy) + 60px)) rotate(320deg); opacity: 0; }
  }
  @keyframes guideBurstSoft {
    0%   { transform: translate(var(--dx), calc(var(--dy) - 16px)); opacity: 0; }
    20%  { opacity: 1; }
    75%  { opacity: 1; }
    100% { transform: translate(var(--dx), calc(var(--dy) + 30px)); opacity: 0; }
  }

  @media (prefers-reduced-motion: reduce) {
    #guide-hand.show, #guide-hand.show.nudge, #guide-hand::before, #guide-bubble.show,
    #game .cat-pill.guide-pill, .cat-pill.guide-next,
    #guide-cheer .gc-title, #guide-cheer .gc-pay,
    .tile.guide-shake .f-front, #tray-container.guide-tray .spot .slit { animation: none; }
    /* Without its flight the burst would be 36 pieces stacked in one spot, so
       it was hidden, and a phone asking for less motion saw no confetti after
       levels 1 to 3 (Matan, 2026-10-09). Now each piece fades in near where
       its flight would end, drifts a little way down and fades out. */
    .gc-burst i { animation: guideBurstSoft 1.8s ease-in-out both; }
  }

  #toast-bar {
    position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
    background: rgba(20,30,50,0.92); color: #fff;
    padding: 10px 16px; border-radius: 999px; font-size: 13px; font-weight: 650;
    z-index: 120; opacity: 0; transition: opacity 0.25s ease; pointer-events: none;
    max-width: 88vw; text-align: center;
  }
  #toast-bar.show { opacity: 1; }

  /* ============================================================
     v0.2 ADDITIONS — Locked tiles, match-icon juice, bonus timer,
     level-type badges, pre-level social-proof banner.
     ============================================================ */
  .hidden-el { display: none !important; }

  /* On-match category reveal — this is where a category gets NAMED, so it
     wears the colour its chip will keep for the rest of the level. SVG art,
     not emoji: emoji render as empty boxes on older Android. */
  #match-fx {
    position: absolute;
    left: 50%; top: 60%;
    transform: translate(-50%, -50%);
    display: flex; flex-direction: column; align-items: center; gap: 9px;
    pointer-events: none; opacity: 0; z-index: 40;
  }
  /* 100px, up from 62 (Matan, 2026-08-03). This is the payoff moment — the beat
     where three words you decided belonged together get NAMED — and at 62px on a
     375px screen it was smaller than the tiles it was rewarding.
     TWO stacked drop-shadows, not one: a tight dark one to lift the silhouette
     off the board, and a wide soft one for the weight. `filter` is fine here —
     #match-fx is not a tile and never sits under the board's fractional scale(),
     which is the thing that made filters unusable on tile faces. */
  /* 140px, up from 100 (Matan, 2026-08-09), now that the badge is a purpose-made
     CATEGORY icon rather than whichever word icon the category happened to
     declare first. The picture carries the moment, so the LABEL steps back:
     17px at weight 600, down from 19/650, because at 140px beside a heavy label
     the two were competing. */
  #match-fx .fx-icon {
    /* 119 = 140 x 0.85. Matan, 2026-08-16: the badge comes down 15% after a
       match. THE BADGE ONLY — .fx-label below is untouched, so the picture
       steps back toward the label rather than the pair shrinking together. */
    width: 119px; height: 119px; display: block;
    /* Badge art bleeds close to the full 140px box (little transparent
       margin), so a wide blur radius here has nothing to taper into before
       it reaches the box edge and washes a faint RECTANGLE across the whole
       badge instead of a contact shadow. Kept tight for that reason. */
    filter: drop-shadow(0 2px 3px rgba(0,0,0,0.28))
            drop-shadow(0 5px 8px rgba(0,0,0,0.34));
  }
  #match-fx .fx-icon svg,
  #match-fx .fx-icon img { width: 100%; height: 100%; display: block; }
  /* FAMILY BADGES AT FULL SIZE ON A WIDE SCREEN (Matan, 2026-10-14). The
     family badges are exported at 384 px, so 192 CSS px is their full size at
     2x. Desktop and sideways screens show them at that size; the upright phone
     keeps the 119 px above (the same badge scaled down) and its rule is not
     touched. 50vh keeps the badge and its label on a short sideways phone
     (852 x 283 gives 141 px). A big upright screen (tablet, desktop window
     taller than wide) is not a phone and gets the full size too. */
  @media (orientation: landscape) {
    #match-fx .fx-icon { width: min(192px, 50vh); height: min(192px, 50vh); }
  }
  @media (orientation: portrait) and (min-width: 700px) and (min-height: 700px) {
    #match-fx .fx-icon { width: 192px; height: 192px; }
  }
  #match-fx .fx-label {
    font-size: 17px; font-weight: 600; color: #fff;
    padding: 4px 18px; border-radius: 999px;
    text-shadow: 0 1px 2px rgba(0,0,0,0.4);
    box-shadow: 0 4px 14px rgba(0,0,0,0.38);
  }
  /* 2.0s. THE HISTORY MATTERS, because this number has moved three times and the
     next person will be tempted to move it again:
       1.9s original -> 2.85s (2026-08-03, x1.5, "I see no animation")
       2.85s -> 1.6s (2026-08-08, playtest: "overstayed its welcome")
       1.6s -> 2.0s (2026-08-09, Matan: 1.6 is too fast)
     2.0s is a deliberate landing between the two rejected extremes — longer than
     the 1.6s that read as a flicker, well short of the 2.85s that outstayed.
     It costs nothing in playability: #match-fx is `pointer-events: none`, so the
     board stays fully tappable the whole time it floats.
     MUST stay cancelled at every level entry — clearMatchFx() in
     initLevelState(). A pop that outlives a screen transition reappears on the
     NEXT board and reads as "you matched Container" the moment a level opens.
     That bug is why the cancel exists; a longer duration makes it likelier. */
  /* 1.7s, AND THE SHAPE MOVED MORE THAN THE NUMBER DID (2026-08-25). Matan:
     "there's a small animation at the beginning, then it almost freezes for
     another full second or more, causing disruption in play."

     HE WAS DESCRIBING THE KEYFRAMES, NOT THE DURATION, and the old ones say so
     exactly. At 2.0s the pop finished at 28% = 560ms; the next stop was 70% =
     1400ms, and across those 840ms the only thing that changed was an 8%
     translateY. On a 140px badge that is 11px in 840ms - 13px per second,
     which is under the threshold at which motion registers as motion. So the
     badge really did freeze, for 0.84s, in the middle of the screen, while the
     board was live underneath it.
     Shortening the duration alone would have compressed the freeze without
     removing it, and it would also have walked straight back into a decision
     that has already been made three times:
       1.9s -> 2.85s (2026-08-03, x1.5, "I see no animation")
       2.85s -> 1.6s (2026-08-08, playtest: "overstayed its welcome")
       1.6s -> 2.0s (2026-08-09, Matan: 1.6 is too fast)
     So the dead beat is deleted instead: from the moment it lands (24% = 408ms)
     the badge drifts continuously upward and then fades while still drifting.
     It is never motionless. 24% -> 55% covers 9% in 527ms = 24px/s, roughly
     twice the old rate and comfortably perceptible.

     READ TIME, since that is the thing being spent. The label is ONE word. It
     is at full opacity from 204ms to 935ms - a 731ms window, against the
     ~300ms a fixated single word takes - and stays legible through most of the
     765ms fade. Total legible time is about 1.2s where it was about 1.7s.
     IF IT STILL LINGERS the honest knob is this duration, not the keyframes;
     1.5s keeps the same shape and the full-opacity window stays at 645ms.
     MUST stay cancelled at every level entry - clearMatchFx() in
     initLevelState(). A pop that outlives a screen transition reappears on the
     NEXT board and reads as "you matched Container" the moment a level opens.
     It costs nothing in playability either way: #match-fx is pointer-events:
     none, so the board is tappable the whole time it floats. */
  #match-fx.show { animation: matchPop 1.7s ease-out forwards; }
  @keyframes matchPop {
    0%   { opacity: 0; transform: translate(-50%,-50%) scale(0.3); }
    12%  { opacity: 1; transform: translate(-50%,-50%) scale(1.15); }
    24%  { opacity: 1; transform: translate(-50%,-51%) scale(1.0); }
    55%  { opacity: 1; transform: translate(-50%,-60%) scale(1.0); }
    100% { opacity: 0; transform: translate(-50%,-76%) scale(0.9); }
  }

  /* Bonus-level countdown (the one timed exception to no-timer) */
  #bonus-timer {
    position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
    background: rgba(20, 30, 50, 0.82); color: #fff;
    padding: 5px 14px; border-radius: 999px; z-index: 25;
    font-size: 15px; font-weight: 650; display: flex; align-items: center; gap: 5px;
  }
  #bonus-timer .ico { font-size: 14px; }
  #bonus-timer.low { background: #d0554a; animation: pulseTimer 1s ease-in-out infinite; }
  @keyframes pulseTimer {
    0%, 100% { transform: translateX(-50%) scale(1); }
    50%      { transform: translateX(-50%) scale(1.08); }
  }

  /* Level-type badges on map cards (Section 12) */
  .level-card .lc-badge {
    position: absolute; top: -6px; left: -4px;
    font-size: 8px; font-weight: 650; color: #fff;
    border-radius: 999px; padding: 2px 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.25);
  }
  .lc-badge.hard   { background: #d0554a; }
  .lc-badge.unique { background: #8e44ad; }
  .lc-badge.bonus  { background: #f39c12; color: #4a3800; }

  /* Pre-level social-proof banner (Section 13) — green scroll/ribbon,
     % in yellow, rest white, over a dimmed board; brief, non-blocking. */
  #level-banner {
    position: fixed; inset: 0; z-index: 95;
    display: none; align-items: center; justify-content: center;
    background: rgba(20, 30, 50, 0.4);
  }
  #level-banner.show { display: flex; }
  .banner-scroll {
    position: relative;
    background: linear-gradient(180deg, #3aa564 0%, #2e7d54 100%);
    color: #fff; font-weight: 650; font-size: 18px; text-align: center;
    padding: 18px 40px; max-width: 84%;
    border-radius: 6px;
    box-shadow: 0 10px 26px rgba(0,0,0,0.4);
    border-top: 3px solid #256b46; border-bottom: 3px solid #256b46;
    animation: bannerIn 0.3s ease-out;
  }
  @keyframes bannerIn {
    from { opacity: 0; transform: scale(0.85); }
    to   { opacity: 1; transform: scale(1); }
  }
  .banner-scroll::before, .banner-scroll::after {
    content: ''; position: absolute; top: 50%; width: 20px; height: 30px;
    background: #256b46; transform: translateY(-50%);
  }
  .banner-scroll::before { left: -13px; clip-path: polygon(100% 0, 0 50%, 100% 100%); }
  .banner-scroll::after  { right: -13px; clip-path: polygon(0 0, 100% 50%, 0 100%); }
  .banner-scroll .pct { color: #ffd23f; }

  /* =========================================================================
     LANDSCAPE (16:9) - THREE VARIANTS UNDER REVIEW.  2026-08-27.

     Built 2026-08-26, re-cut once on 2026-08-27 after Matan played it, and now
     carrying THREE LAYOUTS SIDE BY SIDE for him to choose between - the same way
     themes.html carried the board colours. `landscape-lab.js` switches them on
     the phone and prints the live numbers; `?lsc=a|b|c` picks one directly.
     ⚠️ WHEN HE PICKS, DELETE THE OTHER TWO. Three live layouts is not a state to
     ship or to leave lying around.

     WHAT IS COMMON TO ALL THREE, because he asked for it (2026-08-27):
       - CATEGORY PILLS ARE A SINGLE COLUMN. A rail-wide pill fits every one of
         the 66 category names at full portrait size ("Olympic Hosts" is the
         longest at 7.8em = 125px), which the two-column version could not - it
         had five of them wrapping to two lines.
       - THE TRAY IS 4 WIDE (except in B). He saw 3/3/1 on his phone and called
         it terrible. He had never seen the 2x4 rack, because the CSS switches to
         3 columns below 375px of viewport height and he was not in fullscreen.

     THE ARITHMETIC THAT FORCES THE CHOICE, so nobody re-derives it. A 4-wide
     tray and a centred board are MUTUALLY EXCLUSIVE at phone width. Equal rails
     are what centre the board; equal rails are ~197px on his phone; four seats
     across 197px is a 40px seat against a 73px board tile. And it is not a
     rounding problem - the tile grows with viewport height faster than the rail
     shrinks, so a 4-wide tray with equal rails measures 44% at 907x510, 52% at
     640x360 and 55% at 795x402. Full tile width needs a ~327px right rail, which
     leaves 66px on the left and puts the board 136px off centre.

       A - board centred, tray 4 wide, seat ~55% of a tile. His literal ask.
       B - board centred, tray 2 wide x 4 deep at FULL tile width; pills and
           boosters both move to the left rail so neither rail has a void.
       C - tray 4 wide at FULL tile width; board sits left of centre.

     ⚠️ AND THE TRADE FLIPS WITH FULLSCREEN. At 312px of viewport height (what he
     actually has, since fullscreen is not firing on his phone) the board is
     smaller, so the rails are wider and A's 4-wide tray measures 90%, not 55%.
     A only looks bad once fullscreen works. Judge A and C together with FS_NOTE.
     ========================================================================= */

  /* The extra plinths exist only in landscape. Portrait's tray is one row. */
  .front.sub { display: none; }
  /* RETIRED 2026-08-27 (Matan: *"divider line. Retire. Not relevant."*). The two
     divs stay in index.html for one reason only - deleting markup is the kind of
     change that is annoying to undo if he changes his mind - and nothing styles
     them any more, so they are inert. Delete both with the next markup sweep. */
  .rail-panel { display: none; }

  @media (orientation: landscape) {

    :root {
      /* THE ONE DEFINITION OF "LANDSCAPE" IN THIS PROJECT. game.js reads this
         flag (isLandscapeLayout) and --tray-cols (trayCols) rather than keeping
         its own matchMedia copy of the query above. */
      --lsc: 1;
      /* 2% of the viewport top and bottom (Matan, 2026-08-27). */
      --lsc-vpad: clamp(4px, 2dvh, 12px);

      /* LANDSCAPE GETS ITS OWN BACKGROUND PHOTO (2026-08-28, Matan).
         The portrait file is 1080x1935 and `cover` crops rather than stretches,
         so landscape was showing a 22-26% horizontal BAND of it, magnified 1.8x:
         the arch and the canopy that make it a garden were both off-screen.
         This one is painted landscape, 1920x1072 (1.79:1), so an 832x384 viewport
         crops only 17% off the top and bottom and the whole composition survives.
         ONLY THE TOKEN CHANGES. --bg-opacity, --bg-blend and --bg-mask keep their
         portrait values, so nothing about the portrait screen moves and --board
         stays unpinned (--bg-mask is still `none` - read the palette note at the
         top of this file before touching that).
         WATCH: IT COMPOSITES TO THE SAME PLACE AS THE PORTRAIT ONE. Measured, at
         0.34 over #165a43, this image lands at 26/75/58 where the portrait file
         lands at 24/73/57, and its blossoms read 2.11:1 against that field where
         the portrait file's read 2.16:1. So it is not MORE VISIBLE than the image
         Matan described as "no background image, i mean" - it is better COMPOSED
         for the shape of the screen. Making it visible is an opacity decision and
         is deliberately not taken here, because raising the opacity also moves the
         petals, which are tuned to 1.48:1 against this exact backdrop. */
      /* 2026-09-23, Matan's decision: the riverside at dusk, painted sideways.
         Was assets/background-night-blossom-landscape.jpg (now only in
         themes/garden-default.css). The note above is about that file; the
         opacity it discusses is 0.72 now, set with the portrait token.
         2026-09-27, Matan: the garden is the default again, so this is the
         night-blossom picture at 0.34 once more; the dusk one is set by
         html.look-dusk. */
      --bg-image: url('assets/background-night-blossom-landscape.jpg');

      /* THE BOARD IS HEIGHT-BOUND, so it needs exactly this much WIDTH to render
         at its height-bound scale and every pixel beyond it is free for the
         rails. 0.97872 is MAX_BOARD_W / MAX_BOARD_H (552/564, rule 5.13 / G6)
         and the 4px is what computeFit() takes off each axis. */
      --lsc-boardneed: calc(0.97872 * (100dvh - 2 * var(--lsc-vpad) - 4px) + 4px);

      /* THE HEIGHT-BOUND TILE, IN CSS, and it must stay derived from the
         VIEWPORT rather than from the board's actual scale. Variant C sizes its
         right rail from it, and a rail sized from the real tile would be
         circular: rail -> board width -> board scale -> tile -> rail. 108/564. */
      --lsc-tile-h: calc(0.19149 * (100dvh - 2 * var(--lsc-vpad) - 4px));

      /* EQUAL RAILS, WHICH IS WHAT PUTS THE BOARD IN THE CENTRE. The two split
         whatever the board does not need. The floor's 30dvh term is for tall
         screens: a 4:3 tablet has height to spare, so the board otherwise grows
         until it wants all the width and leaves the tray a 72px seat beside a
         128px tile. It engages only above ~570px of viewport height. */
      --lsc-rail: clamp(max(170px, 30dvh),
                        calc((100dvw - 24px - var(--lsc-boardneed)) / 2),
                        300px);
      --lsc-rail-l: var(--lsc-rail);
      --lsc-rail-r: var(--lsc-rail);

      /* ---- THE CHROME SCALES WITH THE VIEWPORT ---------------------------
         Every one is a fraction of viewport height CAPPED AT ITS PORTRAIT
         VALUE, so landscape can only ever be the same size or smaller. It
         self-corrects against the board, which is the point: the board scale is
         also a function of viewport height, so a booster stays the same fraction
         of a tile whatever the screen does - 58/71 in portrait, 60/73 at 402
         tall, 47/56 at 312 tall.
         Note 4's "boosters +15% bigger" is now the CEILING rather than the size;
         15% of viewport height is Matan's own replacement (2026-08-27). */
      --lsc-booster: clamp(34px, 15dvh, 67px);
      /* Single-column pills are rail-wide, so the font goes back to portrait's
         16px where there is height for it - the 13px cap only existed to squeeze
         a name into an 89px two-column cell. */
      --lsc-pill-f:  clamp(10px, 4dvh, 16px);
      /* THE PILL'S HEIGHT FOLLOWS ITS OWN FONT at portrait's 29/16 = 1.81, not a
         second independent clamp - that gave a 29px pill around a 13px face,
         portrait's box with portrait's air still in it. */
      /* PORTRAIT'S OWN 29/16 = 1.81. It was briefly 1.70 to save ten pixels
         across six pills, on the theory that a rail-wide pill could afford to be
         shallower - but the name is fitted to the pill's WIDTH, so a wide pill
         gets a bigger name, and 1.70 was squeezing a 15.4px word into a box built
         for a 13px one. The proportion is the shape of the object; it is not
         spare change. */
      --lsc-pill-h:  calc(var(--lsc-pill-f) * 1.81);
      --lsc-title:   clamp(13px, 5dvh,   22px);
      --lsc-btn:     clamp(26px, 8.5dvh, 34px);
    }

    /* #game.active, not #game: `.screen { display: none }` has to keep winning
       while the map is up, and an id beats it.
       THREE ROWS, and the spans are what stop the rails fighting each other. The
       pills are tall (six single-column rows) and the boosters want the rest;
       putting both in a two-row grid made row 1 as tall as the pills and left the
       boosters 157px of the 321 they need. Pills take rows 1-2, boosters rows
       2-3, and they are in different columns so the overlap costs nothing. */
    #game.active {
      display: grid;
      max-width: none;                  /* the 720px portrait cap is what leaves
                                           1920x1080 using 45% of the screen */
      grid-template-columns: var(--lsc-rail-l) minmax(0, 1fr) var(--lsc-rail-r);
      /* ⚠️ THE MIDDLE ROW IS THE 1fr AND THE ORDER IS LOAD-BEARING TWICE OVER.
         (1) The tray sizes itself against its grid area and needs a DEFINITE one,
             so its span must include this row - a span of auto rows collapsed
             every seat to the 26px floor once already.
         (2) Whatever sits in this row is what gives when the rail runs short, and
             it has to be the PILLS. With six categories - rule 6.6's maximum -
             the header, the pills and the boosters want more than the rail has,
             and while the pills were in an auto row the boosters overflowed
             upward INTO them. A clipped pill is recoverable; a booster drawn over
             one is not. */
      grid-template-rows: auto minmax(0, 1fr) auto;
      grid-template-areas:
        "hud   board coins"
        "pills board tray"
        "boost board tray";
      column-gap: 6px;
      row-gap: 5px;
      padding: var(--lsc-vpad) 6px;
    }

    #board-container { grid-area: board; min-width: 0; }

    /* ---- Header ----------------------------------------------------------
       The portrait header is one row of back / title / coins+gear and needs
       ~230px; a rail is ~197. It wraps, with the title forced onto its own line
       by flex-basis. `order: 1` moves it BELOW the buttons even though it is the
       middle child in the DOM, which is why this needs no change to index.html. */
    #hud {
      grid-area: hud;
      align-self: start;
      flex-wrap: wrap;
      row-gap: 3px;
    }
    #hud-level {
      order: 1;
      flex-basis: 100%;
      text-align: center;
      /* The SMALLER of what the word needs and what the screen can carry.
         --title-px is setLevelTitle's length ladder, --lsc-title the viewport
         cap; a long name on a short screen has to satisfy both. */
      font-size: min(var(--title-px, 22px), var(--lsc-title));
      /* The stroke is 2px against a 22px face in portrait. Scaling the face and
         not the stroke turns a thin outline into a slab at 13px. */
      -webkit-text-stroke-width: 1.6px;
    }
    #hud .round-btn { width: var(--lsc-btn); height: var(--lsc-btn); }
    #hud .round-btn svg { width: 54%; height: 54%; }

    /* ---- Boosters --------------------------------------------------------
       A single column spread down the rail, which is what answers Matan's
       "the empty space above the boosters is sore on the eyes" (2026-08-27).
       The space was there because the left rail held ~190px of content in a
       386px column; standing the four boosters up fills 321 of it and puts all
       four under the left thumb, which is better in landscape than a 2x2 block
       anyway. A grid rather than portrait's space-around row so they stay evenly
       pitched at any rail width and shrink below --lsc-booster rather than
       overflowing at the 170px floor. */
    /* ---- Boosters: a STAGGERED 2x2 ---------------------------------------
       Matan, 2026-08-27, on the single column this replaces: *"vertically
       aligned single column of boosters looks bad - leaves too much space on the
       sides"*. And on what to do instead: *"slightly reduce the booster sizes,
       and also offset the top row horizontally a bit, so it can [sit] a bit
       lower and fill the horizontal space a bit more"*, with a mockup of four
       circles - top row inset right, bottom row inset left, the two rows nearly
       touching.

       THE STAGGER IS WHAT FILLS THE RAIL, NOT THE SIZE. A plain 2x2 of 58px
       badges spans 120 of a 223px rail; offsetting the rows lets the block span
       ~2.6 badges instead of 2, and lets the rows sit closer vertically because
       the circles interlock rather than stack. Both numbers are read off his
       mockup: the column gap is 0.4 of a badge and the offset is 0.24, which is
       what makes the lower-left circle clear the upper-left one. */
    /* THE STAGGERED 2x2 IS GONE with the rack it belonged to. It left two
       `:nth-child` stagger rules behind, and because a `:nth-child` selector
       out-specifies the plain `#booster-bar .booster` that replaced them, the
       badges were still being shifted 0.24 of their own width - the single row
       measured 24px narrower than the pills above it and looked misaligned for a
       reason that was nowhere near the rule setting the widths.
       The bar itself is declared in THE ARRANGEMENT below; only the parts that
       describe a badge rather than the row survive here. */
    #booster-bar { display: grid; }
    #booster-bar .booster .b-top { width: 100%; height: auto; aspect-ratio: 1 / 1; }
    #booster-bar .booster .b-ico { width: 65%; height: 65%; }
    /* The coin chip is absolutely positioned inside the badge, so it does not
       shrink with it and would swamp a 40px booster at portrait size. */
    #booster-bar .booster .b-cost { font-size: clamp(8px, 2.6dvh, 10px); padding: 1px 5px 1px 3px; }
    #booster-bar .booster .b-coin { width: 1.1em; height: 1.1em; }

    /* ---- Pills, one per row ----------------------------------------------
       THE 71px RESERVE IS DELETED HERE AND IT IS NOT AN OVERSIGHT. It exists in
       portrait because #board-container is the flex:1 sibling BELOW that row, so
       a pill row growing mid-level shrinks the board and silently rescales every
       tile. In landscape the board is in another COLUMN and its height does not
       depend on this row at all, and the tray and boosters are pinned to their
       own grid rows, so there is nothing to reserve against. If the board ever
       moves back under the pills, the reserve comes back with it. */
    /* THE COUNT BADGE HANGS 8px BELOW ITS PILL, so a bottom-aligned pill column
       puts the LAST badge past the game's own padding and it is clipped by the
       viewport edge. Measured in E: the column ended at 377.1 against a bottom
       edge of 376. Reserving the badge's own overhang is the fix; it is inert
       wherever the column is top-aligned. */
    #counter-wrap { grid-area: pills; margin: 0; min-height: 0; align-self: start;
                    padding-bottom: 8px; }
    #counter-row {
      grid-template-columns: 1fr;
      /* ⚠️ 13px, AND IT WENT BACK TO 13 AFTER A REGRESSION (2026-08-27). It was
         cut to 8 to buy the six-category case some height, on the reasoning that
         a one-per-row column only has to clear the pill below it. That is exactly
         what it has to do, and 8 does not do it: the count badge is positioned
         `bottom: -8px`, so an 8px gap lands it precisely on the next pill's top
         edge. Portrait's 13 is portrait's for this reason and landscape has the
         same reason. The height comes from somewhere else. */
      gap: 13px 5px;
      min-height: 0;
    }
    .cat-pill {
      height: auto;
      min-height: var(--lsc-pill-h);
      font-size: var(--lsc-pill-f);
      padding: 2px 8px;
      line-height: 1.05;
    }

    /* ---- The tray --------------------------------------------------------
       ONE PLINTH PER ROW, and that is why index.html carries .front.sub x3. A
       stand's foot disappearing behind a near wall is what makes a tray tile read
       as sunk into a surface rather than propped in front of one (Matan,
       2026-08-26); one deep plinth would give that to the bottom row only.
       sizeLandscapeTray() hides any the row count does not need.

       EVERY OFFSET IS PORTRAIT'S, RE-EXPRESSED PER ROW. In portrait the spots sit
       9px above the deck's bottom and the plinth is 15px tall, so the plinth's
       top edge crosses 6px above the spots' feet. Row j up from the bottom
       repeats that at bottom: j * (spot + plinth).

       ANCHORED FROM THE BOTTOM, deliberately. The container's height is
       min(what N full-size rows want, what the rail row has), so on a short
       viewport it is a pixel or two under what the seats want; laying out from
       the bottom puts that in the empty air at the top instead of pushing a
       plinth out through the bottom of the rail. */
    /* ⚠️ NO FROSTED PANEL IN LANDSCAPE (Matan, 2026-08-27: *"remove that ugly
       blur behind the tray"*). Same verdict he gave the rail slabs the same day
       and for the same reason: a translucent rectangle with a backdrop blur over
       a photographic background has a hard edge with nothing justifying it, so it
       reads as a rendering fault rather than as a panel. The stands and the
       plinths are the object; they do not need a box drawn round them.
       THE BORDER DECLARATION STAYS AND IS ONLY TRANSPARENT - #tray-container
       .warn / .crit signal the tray filling up by colouring it, and that is this
       game's ONLY lose condition. Those two rules are (1,1,0) and still beat this
       one, so their glow survives the panel going. */
    #tray-container {
      background: none;
      backdrop-filter: none; -webkit-backdrop-filter: none;
      box-shadow: none;
      grid-area: tray;
      align-self: end;
      /* --tray-h, --tray-max-w and --seat-w are set by sizeLandscapeTray(); the
         fallbacks are what a browser sees for the one frame before it runs. */
      height: min(var(--tray-h, 220px), 100%);
      max-width: min(100%, var(--tray-max-w, 420px));
      /* --seat-h is set only for the vertical tray, whose seat may be wider than
         the tile's shape (QA-777); every other tray keeps the tile's shape. */
      --lsc-seat-h: var(--seat-h, calc(var(--seat-w, 46px) * var(--tile-ar, 1.17)));
      /* 8px of headroom above the seat inside its spot, against portrait's 19.
         The shove lifts 25px and overflows both; nothing in the tray clips. */
      --lsc-spot-h: calc(var(--lsc-seat-h) + 8px);
      --socket-h: var(--lsc-seat-h);
    }
    #tray-container .deck-wrap { height: 100%; }
    /* A CENTRED FLEX WRAP, NOT A GRID (Matan, 2026-08-27: the three should be
       centred against the four). A grid's last row is left-aligned and leaves a
       hole where the fourth would be, which reads as an empty eighth seat - the
       thing he asked to remove. Flex wrap centres each row on its own, and the
       plinths are measured to match by layoutLandscapePlinths() in game.js.
       The seats are fixed-width, and #tray is exactly cols wide, so the wrap
       lands cols per row. */
    #tray-container #tray {
      top: auto; bottom: 9px;
      display: flex; flex-wrap: wrap;
      justify-content: center; align-items: flex-end;
      row-gap: 15px;                    /* exactly one plinth, so row j's plinth
                                           sits in the gap and touches neither row */
      column-gap: 5px;
    }
    #tray-container .spot {
      height: var(--lsc-spot-h);
      width: var(--seat-w, 46px);
      flex: 0 0 auto;
    }
    #tray-container .seat { height: var(--lsc-seat-h); }
    #tray-container .front.sub { display: block; }
    #tray-container .front.j1 { bottom: calc(1 * (var(--lsc-spot-h) + 15px)); }
    #tray-container .front.j2 { bottom: calc(2 * (var(--lsc-spot-h) + 15px)); }
    #tray-container .front.j3 { bottom: calc(3 * (var(--lsc-spot-h) + 15px)); }

    /* =====================================================================
       THE ARRANGEMENT. Five variants were live side by side on 2026-08-27 and
       Matan chose G, then re-cut it into what is now H and promoted that. Every
       html.lsc-* class and the in-game switcher that drove them are gone; this is
       simply what landscape is.

         LEFT   back arrow + fullscreen button + "Level X", then the pills, then
                the four boosters in ONE ROW under them
         CENTRE the board, deliberately RIGHT of centre
         RIGHT  gear + coin badge on top, and a VERTICAL tray - 1 wide, 7 deep

       THE BOARD IS OFF CENTRE AS A CONSEQUENCE, NOT AS AN OFFSET. A vertical tray
       needs one seat of width instead of four, so the right rail collapses to what
       the coin badge and the gear need and the left rail takes the rest. The board
       is still centred IN ITS OWN COLUMN; the column just is not in the middle of
       the screen. Measured: ~22% of a half-width right, at both 755x298 and
       832x384.

       THE PILLS AND THE BOOSTER ROW SHARE ONE WIDTH and centre in it, so their
       outer edges line up - measured 0px mismatch. Their face is capped at the
       TILE's own rendered font by pillFontCap(), because a pill must never
       out-shout the board.

       THE HEADER SPLITS ACROSS BOTH RAILS. #hud is one flex row in the markup, so
       railOut() reparents #hud-group into its own grid area; --hud-group-out is
       how this block asks for it.
       ===================================================================== */
    :root {
      /* ONE WIDE, SEVEN DEEP - a column, not a rack.
         ⚠️ A VERTICAL TRAY CANNOT KEEP THE PER-ROW PLINTHS. The 15px row gap is
         one plinth high because a horizontal rack puts row N's plinth in it, and
         seven rows of that is 90px of plinth out of a 255px rail, which crushes
         the seat to a third of a tile. The gap and the headroom drop to 2px -
         both READ from here by sizeLandscapeTray(), so the CSS that draws the gap
         and the JS that budgets for it cannot disagree - the extra plinths are
         hidden and ONE is kept at the bottom, so the column reads as seven
         sockets on a single shelf. */
      --tray-cols: 1;
      --tray-row-gap: 2px;
      --lsc-headroom: 2px;
      /* 0.72 (Matan, 2026-08-27: the tray was *"too high and under the coins
         badge"*). There is only ONE lever - the column's height IS seven seats -
         so the clearance was bought out of the seat. It clears the badge by 42px
         at 832x384 and 13px at 755x298, and the seat lands at 71% of a tile. */
      --seat-cap: 0.72;
      --hud-group-out: 1;          /* coin badge + gear go to the right rail */
      --lsc-count-f: clamp(9px, 2.9dvh, 11.5px);
      /* SHRINKS WITH THE SCREEN: one row of four has to fit whatever is left under
         six pills, which is 41.7px at 755x298 and 151px at 832x384. */
      /* ⚠️ THE BOOSTER ROW ALREADY OVERFLOWS THIS RAIL AT 640x360 AND THE FIX IS
         MATAN'S CALL, NOT A SESSION'S (found 2026-09-09 by the new
         rail-over-board check in tools/qa-sweep.mjs). Four badges of a FIXED
         width in a box 88% of the rail wide do not shrink when they stop
         fitting: the row overflows and paints over the board. At 640x360 - the
         smallest frame Poki uses - the rail is pinned at its 170px floor and the
         four badges want 187px of a 150px box, so the Shuffle badge lands on the
         board by 21 by 47 pixels.
         CAPPING THE BADGE HERE WAS TRIED AND PUT BACK, because at that rail
         width the cap drops the badge to 34px and the sweep correctly reported
         four new findings under the 44px finger floor. 4 x 44 is 176 and the box
         is 150, so at this size a row of four CANNOT both fit the rail and stay
         finger-sized. The two ways out are a 2x2 block, which is an arrangement
         change, or accepting a sub-finger target on that one frame. Both are his.
         No real phone is affected either way: at 832x384, 755x298 and a real
         iPhone's 852x283 the rail is 300px and the row fits with room.
         The big-screen block at the end of this file DOES cap it, because there
         the cap lands well above 44 and there is no trade to make. */
      --lsc-booster: clamp(28px, 13dvh, 62px);
      /* ONE WIDTH FOR THE PILLS AND THE BOOSTER ROW, so their edges line up. */
      --lsc-cluster-w: 88%;
      /* UNEQUAL RAILS - see the note above on why the board moves right. */
      --lsc-rail-r: clamp(120px, calc(60px + 9dvh), 165px);
      --lsc-rail-l: clamp(170px, calc(100dvw - 24px - var(--lsc-boardneed) - var(--lsc-rail-r)), 300px);
    }
    #game.active > #hud-group { grid-area: coins; align-self: start; justify-self: end; }
    /* The back arrow, the fullscreen button and the title share one line. */
    #hud { flex-wrap: nowrap; gap: 8px; }
    #hud-level { order: 0; flex-basis: auto; text-align: left; flex: 1 1 auto; min-width: 0; }
    #counter-wrap { align-self: start; }
    #counter-row {
      grid-template-columns: var(--lsc-cluster-w);
      justify-content: center;
      gap: 13px 5px;
    }
    /* ⚠️ THE BADGE HAS TO DROP WHEN THE PILL SHRINKS AND THE BADGE GROWS.
       The pill lost 4px of height and the count gained 24% of type in the same
       pass, and each ate the same clearance: a gap that is 0.8px in portrait went
       to -2.7px here. The badge is `bottom: -8px`, so its overlap into the pill is
       its own height minus that offset - which means the offset has to move when
       either the badge or the pill does. Measured with the entry animation
       cancelled: name to badge +2.6px, badge to next pill +6.1px. */
    .cat-pill .cp-count {
      font-size: var(--lsc-count-f);
      padding: 1px 6px;
      bottom: calc(-1 * (var(--lsc-count-f) + 2.5px));
    }
    /* Bottom-aligned so the whole of the tray's height reduction lands at the TOP,
       clear of the coin badge, and the seats stay under the thumb. */
    #tray-container { align-self: end; }
    #tray-container #tray { row-gap: 2px; }
    /* One shelf at the bottom, not one per row - see the --tray-cols note. */
    #tray-container .front.sub { display: none; }
    /* ONE ROW OF FOUR, UNDER THE PILLS IN THE LEFT RAIL, and space-between with
       AUTO columns rather than four 1fr columns with each badge centred: a centred
       badge leaves half a column of air outside the first and the last, so the
       block never reaches the edges of its own box and could not be lined up with
       the pills above it. This way the outer edges ARE the box. */
    #booster-bar {
      grid-area: boost;
      align-self: end;
      width: var(--lsc-cluster-w);
      margin: 0 auto;
      grid-template-columns: repeat(4, auto);
      justify-content: space-between;
      column-gap: 0; row-gap: 0;
    }
    #booster-bar .booster { transform: none; width: var(--lsc-booster); }

  }

  /* =========================================================================
     FULLSCREEN ONLY - +5px on the category pill, both orientations.
     Matan, 2026-08-27. Gated on :fullscreen rather than on orientation, and that
     is the point: fullscreen hands back the ~22% of the screen the browser's own
     bars were holding, so there is room the windowed layout does not have. It
     also means THE SHIPPED PORTRAIT - which is portrait WINDOWED - is provably
     untouched by this; only portrait-in-fullscreen changes, a state that barely
     existed before the toggle button landed beside the back arrow.

     :fullscreen matches the element that was made fullscreen, and
     goFullscreenIfLandscape()/toggleFullscreen() both request it on
     documentElement, so `html:fullscreen` is the hook. The -webkit- copy is for
     older Safari; they cannot be combined in one selector list, because a
     browser that fails to parse either one drops the whole rule.

     ⚠️ IN LANDSCAPE THIS PARTLY UNDOES THE -4px FROM EARLIER THE SAME DAY.
     He cut 2px off the top and bottom, then asked for +5 in fullscreen, which
     nets +1 over where the pill started. Deliberate, not drift - the -4 was
     tuned windowed and the +5 is for the taller screen.
     The count badge's offset is derived from its own font, so it follows without
     a second number to keep in step.
     ⚠️ .fs-sim IS A LAB STAND-IN, NOT A SECOND DESIGN. The bench iframes the game
     at a fixed pixel size, and an iframe is not fullscreen, so :fullscreen can
     never match inside one - there would be no way to look at any of this on the
     PC. Every rule below carries the class beside the pseudo-class so the two
     cannot drift, and centrePillRow() in game.js reads the class as well.
     ========================================================================= */
  html:fullscreen .cat-pill          { min-height: calc(var(--pill-cap-px, 16px) * 1.81 + 1px); }
  html.fs-sim .cat-pill              { min-height: calc(var(--pill-cap-px, 16px) * 1.81 + 1px); }
  html:-webkit-full-screen .cat-pill { min-height: calc(var(--pill-cap-px, 16px) * 1.81 + 1px); }
  /* Portrait's pill is a fixed 29px height, not a min-height derived from a
     custom property, so it needs the explicit number. 29 + 5. */
  html:fullscreen .cat-pill          { height: 34px; }
  html.fs-sim .cat-pill              { height: 34px; }
  html:-webkit-full-screen .cat-pill { height: 34px; }
  /* Landscape drives its height from --pill-cap-px and must not take portrait's
     literal 34: it re-declares `height: auto` so the min-height above governs. */
  @media (orientation: landscape) {
    html:fullscreen .cat-pill          { height: auto; }
    html.fs-sim .cat-pill              { height: auto; }
    html:-webkit-full-screen .cat-pill { height: auto; }
  }

/* ===== INPUT IS DEAD WHILE AN AD IS ON SCREEN (2026-08-31) ==================
   Set by PLATFORM in platform/platform.js for the FULL duration of every ad,
   interstitial and rewarded alike, and removed when it ends. Poki requires audio
   muted and input disabled for the whole break and calls it contractual rather
   than advisory; the audio half is done in JS because it has to restore the
   player's own setting rather than switch sound on.

   ⚠️ #ad-modal IS EXEMPT AND MUST STAY EXEMPT - it is the web backend's own
   stub, so blocking pointer events on it would make the stub untappable. A real
   host draws its ad in its own iframe outside this document and is unaffected
   either way.

   pointer-events on the shell rather than a full-screen invisible cover: a cover
   is a new stacking context over a board that already transforms, and this game
   has lost a frame to that before. */
html.ad-playing #map-screen,
html.ad-playing #game,
html.ad-playing .sheet,
html.ad-playing .modal:not(#ad-modal) { pointer-events: none; }

/* ===== THE WAITING SIGN (QA-740, 2026-09-29) ================================
   The taps are off from the press of "Watch a video" (above), and on a host
   that fetches its advert first nothing else changed, so the game looked
   frozen for seconds. platform/platform.js sets html.ad-waiting from the press
   until the host's advert is on screen (only on a host that says when it is).
   display:none when not waiting, so there is no cover over the board at any
   other time. It fades in from 50 ms and is fully up at 150 ms: an advert
   already fetched usually opens inside that, and is not preceded by a flash.
   It takes the taps itself while it is up, so a tap lands on the reason. */
#ad-wait {
  display: none; position: fixed; inset: 0; z-index: 2050;
  align-items: center; justify-content: center;
  background: rgba(10, 30, 20, 0.45);
}
html.ad-waiting #ad-wait { display: flex; animation: adWaitIn 100ms ease-out 50ms both; }
@keyframes adWaitIn { from { opacity: 0; } to { opacity: 1; } }
#ad-wait .aw-card {
  display: flex; align-items: center; gap: 12px;
  background: rgba(20, 30, 50, 0.92); color: #fff;
  padding: 14px 20px; border-radius: 999px;
  font-size: 15px; font-weight: 650;
}
#ad-wait .aw-spin {
  width: 20px; height: 20px; border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, 0.3); border-top-color: #fff;
  animation: adWaitSpin 0.8s linear infinite;
}
@keyframes adWaitSpin { to { transform: rotate(360deg); } }
#ad-wait .aw-msg { display: none; }
html.ad-waiting-video #ad-wait .aw-video,
html.ad-waiting:not(.ad-waiting-video) #ad-wait .aw-moment { display: block; }
@media (prefers-reduced-motion: reduce) { #ad-wait .aw-spin { animation-duration: 2.4s; } }

/* ===== SHORT LANDSCAPE: THE CARDS FIT, AND THEIR BUTTONS STAY REACHABLE =====
   2026-09-03. Closes QA-002, QA-003, QA-004 and QA-006 - four faces of one
   problem, which is cards and chrome authored for a tall phone meeting a 283 to
   384 pixel tall window. Poki and CrazyGames both run this game in a landscape
   frame and 640x360 is the smallest one Poki uses, so it is production exposure
   and not only an iPhone-held-sideways curiosity.

   EVERY RULE IN HERE IS INSIDE A LANDSCAPE MEDIA QUERY, AND THAT IS THE POINT.
   Portrait passes every check on every viewport, which is exactly why none of
   this was ever seen by hand. Nothing below can reach it: the first block is
   `orientation: landscape`, the second adds `max-height: 430px`, and the
   shortest portrait screen the project tests is 412x780. Measured before and
   after on 412x780 and 393x852: every card identical to the pixel.

   WHAT WAS ACTUALLY BROKEN, measured at 852x283 (a real iPhone 15 sideways):
     - the lose card's three buttons ran to y=412 in a 283px screen, so the free
       video and Give up were both off the bottom and the only reachable button
       charged 100 coins. The card LOOKED complete, because .end-card sets
       `overflow: visible` to protect its blob rim, which also cancels
       .modal-card's own `overflow-y: auto` - so nothing scrolled and nothing
       was clipped, it simply ran off the screen.
     - the win card with a banner under it did the same, 278px of content in a
       185px card.
     - the booster-unlock card was 430px tall in a 283px screen, hanging 76px
       off the top and 76px off the bottom with "Nice" - its only dismiss -
       below the fold.
     - the tutorial card sat 2 to 17px above the top edge.

   THE SHAPE OF THE FIX IS THE SETTINGS SHEET'S, FROM 2026-08-28: a height cap,
   a flex column, and `min-height: 0` on the part that scrolls. Drop that last
   one and a flex item refuses to shrink below its content and the overflow
   comes straight back. The one difference is which end is pinned - the sheet
   pins its HEAD, because the close button lives there; an end card pins its
   FOOTER, because that is where every control on it lives.
   ========================================================================== */
@media (orientation: landscape) {

  /* ---- The end card: a scrolling body over a pinned row of buttons --------
     #end-body is an unstyled wrapper in portrait and is what scrolls here.
     The buttons and the post-mortem below them never shrink and never scroll
     away, so whatever else happens the player can always act. */
  #end-card { display: flex; flex-direction: column; }
  #end-body {
    flex: 1 1 auto;
    min-height: 0;                 /* without this nothing shrinks - see above */
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    /* A phone draws an overlay scrollbar that fades out, so this changes nothing
       where the game is played. It is here for the desktop bench and the QA
       evidence shots, where the default bar is a fat grey slab sitting inside a
       blob-shaped card and reads as a rendering fault. */
    scrollbar-width: thin;
  }
  #end-card #end-actions,
  #end-card #end-note { flex: 0 0 auto; }

  /* ---- The two round buttons in the play header are 12px further apart ----
     QA-006. Sideways they draw at 26x26 - about 4.3mm - and sat 8px apart, so
     a thumb that missed "leave the level" hit "go fullscreen" instead.

     THEY CANNOT BE MADE BIGGER, AND THAT IS MEASURED, NOT ASSUMED. At 755x298
     with six categories the left rail is FULL: the header ends at y=32, the
     pills run 46 to 249 and the booster row 254 to 292 against a bottom edge of
     292. There is nothing spare. Growing the header pushes the pill column into
     the boosters, and a booster drawn over a pill is the one failure this
     layout was arranged to avoid. So the fix is the space between them, which
     costs no height at all: the left rail is 300px wide and the header is using
     92 of it.
     26 drawn + 20 clear = a 46px effective target, which clears the 44px
     guideline by the same arithmetic the QA ledger uses for every other
     control. */
  #hud #btn-fullscreen { margin-left: 12px; }
}

@media (orientation: landscape) and (max-height: 430px) {

  /* ---- More room, from the scrim itself -----------------------------------
     20px of padding on every side of every overlay is a portrait number. Ten
     gives back 20px of card height on a screen where 20px is a button.
     The card cap moves with it: 86vh was leaving 40px of scrim unusable on a
     283px screen, where 86% of the height and "the height minus the padding"
     are within a pixel of each other anyway. */
  .modal { padding: 10px; }
  .modal-card { max-height: calc(100dvh - 20px); }
  /* The win card's banner is 50px plus a 16px gap, and it is a SIBLING of the
     card, so its 66px comes out of the card's own ceiling. */
  #end-screen.has-banner .modal-card { max-height: calc(100dvh - 86px); }

  /* ---- The end card, compacted -------------------------------------------
     The buttons are the bulk of this card, not the decoration, so they are
     the thing that had to shrink, and the picture and the type come down with
     them so the card keeps its proportions rather than turning into a stack of
     pills. */
  .end-card.win  { padding: 14px 22px 14px; }
  .end-card.lose { padding: 14px 20px 14px; }
  .end-card.win .end-stars { margin-bottom: 6px; }
  .end-card.win .end-star { font-size: 22px; }
  .end-card.win h2, .end-card.lose h2 { font-size: 18px; margin-bottom: 4px; }
  .end-card.win p  { font-size: 13px; margin-bottom: 10px; }
  .end-pay { font-size: 17px; }
  .end-daily { font-size: 11.5px; }
  /* 64px to 30px. Measured at 852x283, the real sideways iPhone: the milestone
     line is the tallest row on the win card and a full-size chest would push the
     bottom button off the screen. 30px still reads as a chest beside 11.5px type
     and costs the row about 8px more than the text alone. */
  .end-chest-art { width: 30px; height: 30px; }
  .end-card.win .modal-actions, .end-card.lose .modal-actions { gap: 6px; }
  .end-card.win .btn, .end-card.lose .btn { padding: 8px 16px; font-size: 13px; }
  #end-card .modal-actions .btn { min-width: 150px; max-width: 210px; }

  /* ---- THE CALM CARDS SIDEWAYS (calm cards, version 10, 2026-10-08) -------
     LANDSCAPE ONLY: nothing here can reach an upright phone. Measured at 852
     by 283, the real iPhone held sideways, which is the shortest screen the
     game is judged on. The sideways pictures of mockups/calm-cards-4.html.

     THE TILE SITS BESIDE THE TITLE HERE, NOT ON THE TOP EDGE. Title layout 1
     stands the tile half outside the card, which costs 38px above the card
     that a 283 tall screen does not have; the mockup's own sideways pictures
     put it beside the title, and so does this block. #end-body becomes the
     head: the tile on the left, the title and the count (or the coins) beside
     it. It still scrolls (the QA-003 backstop above), so THE GLOW BEHIND THE
     TILE IS PLACED AGAINST THE CARD, not the head: inside the head it would
     be cut by the head's edge and, hanging below it, would make the head
     scroll (a scrollbar on the card, seen at 852 by 283). Its place is the
     tile's centre, from the head's top padding and the tile's height, which
     is the taller thing in the head with a one-line title.

     THE TRAY-FULL CARD keeps one column and pays for the height by putting
     "How did it happen?" on the same line as Start over and Back to map (the
     wrap in .end-row lets it). THE WIN CARD keeps its coins under the title.
     `#end-card.lose` and `#end-card.win` - id AND class - so they outrank the
     compaction above and the under-310 block below without either needing to
     know about it. */
  #end-card.win, #end-card.lose { padding-top: 0; }
  #end-card.win #end-body, #end-card.lose #end-body {
    display: grid; grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "icon title" "icon count";
    align-items: center; text-align: left;
  }
  #end-card.win .end-crest, #end-card.lose .end-crest {
    grid-area: icon; position: relative; left: auto; top: auto; margin: 0;
  }
  #end-card.win h2, #end-card.lose h2 { grid-area: title; align-self: end; margin: 0; }
  #end-card.win p, #end-card.lose p { grid-area: count; align-self: start; }

  #end-card.lose { max-width: 470px; padding: 0 18px 4px; }
  #end-card.lose #end-body { column-gap: 10px; margin-bottom: 8px; padding-top: 10px; }
  #end-card.lose .end-crest { width: 88px; height: 62px; }
  /* centred on the tile: 18 + 44 across, 10 + 31 down */
  #end-card.lose .end-halo { left: -13px; top: -9px; width: 150px; height: 100px; margin: 0; }
  #end-card.lose h2 { font-size: 23px; }
  #end-card.lose p { margin: 4px 0 0; padding: 2px 10px 2px 8px; font-size: 13px; }
  #end-card.lose .end-main .btn-primary { min-height: 46px; padding: 0 26px; font-size: 16.5px; }
  #end-card.lose .end-main .btn-ad { font-size: 15px; margin-top: 0; }
  #end-card.lose .end-row { flex-wrap: nowrap; align-items: center; gap: 0 8px; margin-top: 0; }
  #end-card.lose .end-row .btn { flex: 1 1 auto; min-height: 44px; padding: 0 10px; font-size: 14px; }
  #end-card.lose .end-row .end-why { flex: 0 0 auto; margin-top: 0; padding: 0 4px; font-size: 13px; }

  #end-card.win { max-width: 330px; padding: 0 20px 4px; }
  #end-card.win #end-body { column-gap: 8px; margin-bottom: 10px; padding-top: 14px; }
  #end-card.win .end-crest { width: 96px; height: 68px; }
  /* centred on the tile: 20 + 48 across, 14 + 34 down */
  #end-card.win .end-halo { left: -7px; top: -2px; width: 150px; height: 100px; margin: 0; }
  #end-card.win h2 { font-size: 25px; }
  #end-card.win p { justify-content: flex-start; margin: 2px 0 0; }
  #end-card.win .end-pay { font-size: 20px; }
  #end-card.win .end-daily { justify-content: flex-start; }
  #end-card.win .modal-actions { gap: 0; }
  #end-card.win .modal-actions .btn-primary { min-height: 48px; font-size: 16.5px; }
  #end-card.win .modal-actions .btn-ghost { font-size: 13.5px; }

  /* ⚠️ min-height: 44px, AND THE SWEEP IS WHY IT IS HERE.
     The first version of this compaction cut the padding and the type and let
     the buttons land where they landed - 34px tall, about 5.6mm. Every button
     in this game had been 44px or taller, and `node tools/qa-sweep.mjs`
     immediately reported FOUR new tap-target findings that were not in the
     baseline: the two end-card buttons, the booster card's and the tutorial's.
     A compaction that shrinks a control past a finger is not a fix, whatever it
     does for the card's height. It costs about 9px per single-line button; a
     button whose label already wraps to two lines is over 44 anyway and does
     not move. `<button>` centres its own content, so nothing else is needed. */
  #end-card .modal-actions .btn,
  #booster-intro .bi-btn,
  #booster-offer .modal-actions .btn,
  #cook-intro .bi-btn, #cook-done .bi-btn,
  #coach .coach-btn { min-height: 44px; }

  /* ---- The booster-unlock card, compacted ---------------------------------
     The hero is two thirds of this card's height and carries no information -
     it is the booster's own badge inside a turning sunburst - so it is where
     the height comes from. It keeps the sunburst; it is simply smaller.

     SCOPED TO #booster-intro. The cooking briefing and the served card share
     .bi-card and already have their own compaction a few hundred lines up, cut
     for a hero made of dish badges rather than one badge in a ring. Two heroes,
     two sets of numbers. */
  #booster-intro .bi-card { padding: 12px 22px 12px; }
  #booster-intro .bi-hero { width: 76px; height: 76px; }
  #booster-intro .bi-badge .b-top { width: 50px; height: 50px; }
  #booster-intro .bi-badge .b-ico { width: 31px; height: 31px; }
  #booster-intro .bi-new  { margin-top: 5px; font-size: 13px; }
  #booster-intro .bi-name { margin-top: 4px; font-size: 18px; }
  #booster-intro .bi-desc { margin-top: 4px; font-size: 12.5px; line-height: 1.3; }
  #booster-intro .bi-btn  { margin-top: 10px; padding: 8px 24px; font-size: 13px; }

  /* ---- The pay-or-watch sheet, compacted ---------------------------------
     Same hero and same numbers as the unlock card above, because it is the same
     card - only the footer differs, and a stacked column of three buttons is
     where its height goes rather than the badge. It cannot borrow the
     #booster-intro block: every selector there is scoped to that id, which is
     the rule that stopped the cooking cards inheriting numbers cut for a
     different hero. */
  #booster-offer .bi-card { padding: 12px 22px 12px; }
  #booster-offer .bi-hero { width: 76px; height: 76px; }
  #booster-offer .bi-badge .b-top { width: 50px; height: 50px; }
  #booster-offer .bi-badge .b-ico { width: 31px; height: 31px; }
  #booster-offer .bi-name { margin-top: 4px; font-size: 18px; }
  #booster-offer .bi-desc { margin-top: 4px; font-size: 12.5px; line-height: 1.3; }
  #booster-offer .bi-actions { margin-top: 10px; gap: 6px; }
  /* The narrowing above, at the sideways numbers - the same band the end card
     uses in this block, for the same reason. */
  #booster-offer .modal-actions .btn { padding: 8px 20px; font-size: 13px; min-width: 150px; max-width: 210px; }
  /* A SCROLLING BODY OVER A PINNED ROW OF BUTTONS - the end card's shape, and
     the reason #bo-body exists. Measured at 852x283 without it: the card capped
     at 263px and its content wanted 288, so Cancel sat at y=289 in a 283px
     screen and was reachable only by scrolling a card that does not look
     scrollable. The hero is the only thing here a player does not act on, so it
     is the only thing allowed to go under the fold.
     `overflow: hidden` on the card itself overrides the blanket
     `.modal.show .bi-card` scroll a few lines below: with a pinned footer the
     card must not scroll, or both would. */
  #booster-offer .bi-card { overflow: hidden; }
  #booster-offer #bo-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
  #booster-offer .bi-actions { flex: 0 0 auto; }

  /* THE LAST LINE OF DEFENCE ON ALL THREE BLOB CARDS. The compaction above is
     what makes them fit; this is what stops any of them ever becoming a dead
     end again on a screen shorter than the ones measured here. A .bi-card is
     already a flex column, so the cap plus a scroll keeps its button reachable
     without needing a wrapper the way the end card did. It is inert whenever
     the card fits, which after the numbers above is every viewport tested. */
  .modal.show .bi-card {
    max-height: 100%;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* ---- The tutorial card, compacted ---------------------------------------
     QA-004. It is not a modal - it is a non-blocking banner pinned to the
     bottom with the board still live behind it - so it has no scrim padding to
     give back and nothing to scroll. It only had to get shorter. Its 42px of
     top padding is a portrait number: there it sits over an empty board, here
     it is the difference between fitting and hanging off the top. */
  #coach { padding: 8px; }
  #coach .coach-card { padding: 20px 26px 16px; }
  #coach .coach-visual { min-height: 40px; margin-bottom: 10px; }
  #coach .coach-text { font-size: 16px; line-height: 1.3; }
  #coach .coach-row { gap: 8px; margin-top: 12px; }
  #coach .coach-btn { padding: 9px 24px; font-size: 14px; }

  /* ---- The host's question, sideways (2026-09-28) -------------------------
     Width is what a short sideways screen has, so the card widens and the text
     wraps into fewer lines; the words scroll under a pinned row of the two
     answers, which never drop under 44px. Measured at 852 by 283. */
  .q-card { max-width: 600px; padding: 12px 22px 10px; }
  .q-card h2 { font-size: 18px; margin-bottom: 6px; }
  .q-card #q-body p { font-size: 13px; line-height: 1.35; margin-bottom: 6px; }
  .q-card #q-body p.q-link { font-size: 12px; }
  .q-actions { margin-top: 8px; }
  .q-actions .q-btn { min-height: 44px; padding: 6px 12px; font-size: 15px; }
}

/* ===== UNDER 310 PIXELS TALL: ONE MORE STEP OFF THE END CARD AND THE MENU ===
   2026-09-03. The two shortest screens the game is judged on are 755x298 and a
   real iPhone 15 held sideways at 852x283 - and 283 is fifteen pixels shorter
   than anything this project had ever looked at.

   TWO CARDS NEED A SECOND STEP AT THAT HEIGHT AND NOTHING ELSE DOES.
   The end card, because its buttons are pinned at a 44px floor and the room
   they take comes out of the body above them, which is where the card's own
   MESSAGE lives - the crest is the only pure decoration left on it and the
   words win. And the cooking briefing, whose four-dish and six-dish menus ran
   past the bottom at 283 and took "Let's cook" with them.

   THE THRESHOLD IS 310 SO THAT 360 AND 384 ARE UNTOUCHED. Both of those fit
   the fuller card outright and were judged there; making them smaller as well
   would be changing screens nobody reported.
   ========================================================================== */
@media (orientation: landscape) and (max-height: 310px) {
  /* THE END CARDS NEED NO SECOND STEP HERE ANY MORE (calm cards, version 9,
     2026-10-08). The rules that stood here shrank the old tray-full picture
     and the titles once more at 283 tall; the calm cards were measured at 852
     by 283 and fit with their sideways numbers (the short-landscape block
     above), so this block no longer touches them. */

  #cook-intro .ci-dish { width: 30px; height: 30px; margin-left: -7px; }
  #cook-intro .ci-row + .ci-row { margin-top: -5px; margin-left: 9px; }
  #cook-intro .bi-desc, #cook-done .bi-desc { line-height: 1.25; }
  #cook-intro .bi-btn,  #cook-done .bi-btn  { margin-top: 8px; padding: 8px 24px; font-size: 13px; }
  /* It keeps the 44px floor from the block above - the plates paid for it. */
  #cook-done  .cd-hero { width: 60px; height: 60px; }
  #cook-done  .cd-hero.emoji { font-size: 42px; }
}

/* =============================================================================
   A BIG LANDSCAPE WINDOW IS THE PHONE'S SIDEWAYS LAYOUT, SCALED BY HEIGHT
   (2026-09-09, Matan: "all I want is for the desktop and Crazygames windows to
   look like mobile ... scaling up all elements, including the category pills and
   their fonts, and anything else to accommodate the same landscape mobile
   layout")
   =============================================================================

   THE PROBLEM THIS FIXES. Every size in the sideways layout above is written as
   `clamp(floor, N dvh, CEILING)`, and every one of those ceilings is the value
   the same thing has in portrait. That was a deliberate rule when the only
   sideways screen was a phone: "landscape can only ever be the same size or
   smaller". On a 384 pixel tall phone none of the ceilings bind and the layout
   is a pure function of viewport height. On a 965 pixel tall monitor EVERY
   ceiling binds, so the board grows and nothing else does, and the result is
   phone-sized furniture flung into the corners of a desk. Measured before this
   block existed: at 1920x1080 the boosters drew 62px, the pills 264px wide with
   16px type, and 575px of the screen was empty background.
   The whole diagnosis is `design/HANDOFF-desktop-layout.md`.

   THE FIX IS ONE IDEA AND ONE NUMBER. `--lsc-u` is one phone pixel: the width
   of a pixel on the 832x384 sideways phone this layout was designed and judged
   at. Every fixed size below is then re-stated as the SAME number of phone
   pixels it already was, so a 965 tall window draws the identical layout at
   2.51x and a 552 tall CrazyGames frame draws it at 1.44x. Nothing is
   re-proportioned, re-tuned or re-invented; the numbers are the numbers from
   the block above, carried across.

   WHY 384 AND NOT THE VIEWPORT'S OWN HEIGHT. 832x384 is the size every figure
   in `docs/landscape.md` was measured at and the size Matan judged the sideways
   layout on. Calibrating to it means `--lsc-u` is exactly 1px there, so this
   block is a continuation of the phone rather than a second design.

   ⚠️ WHY THE GATE IS 500px OF HEIGHT, AND WHY IT CANNOT BE LOWER.
   No phone can reach it, and that is the entire safety argument for this block:
       real iPhone 15 sideways        852 x 283
       tightest bench preset          755 x 298
       phone with the URL bar hidden  832 x 384
       biggest phone sideways         932 x 430
   The lowest window this block may touch is the CrazyGames frame at 552 tall.
   500 sits above every phone and below that, with 70px of margin either way.
   It is also above the 430px ceiling the card compaction uses, so this block and
   that one can never both apply and never have to be reconciled.

   ⚠️ WHAT THIS BLOCK MAY NOT DO. It is inside a landscape media query, so
   portrait cannot see it. It must also never touch a phone, which is what the
   gate is for. Do not lower the gate, and do not move any of these rules up
   into the block above to "share" them.

   ⚠️ ONE THING IS DELIBERATELY NOT SCALED: the two rail widths cannot both be
   proportional on a 16 by 9 window, because there is not the width for it. The
   sideways phone is 2.17 wide for every 1 tall; a desktop window is 1.78. So the
   board stays height-bound and the RIGHT rail is scaled (it holds the tray, and
   the tray must stay in proportion to the tiles it receives), while the LEFT
   rail keeps the formula it already had: it takes whatever width is left over.
   On a 16 by 9 window that lands it near the phone's own proportion anyway.
   `min(..., 20vw)` on the right rail is the guard for a squarish window such as
   an iPad's 1024x768, where a fully proportional right rail would eat the board.
   ============================================================================= */
/* ⚠️ NO HEIGHT GATE ANY MORE (Matan, 2026-09-09: *"implement it so that it looks
   good on all device sizes that are required of us for landscape"*). This block
   began life at 500px of height so a phone could never reach it; it applies to
   every landscape screen now, phones included.
   ⚠️ WHICH MEANS EVERY NUMBER IN IT HAS TO KEEP THE PHONE'S OWN VALUE AS A
   FLOOR. Each token below is written as max(<what the base landscape block
   used>, <the growing term>): at 384 tall - the phone this layout was designed
   and judged on - the two are equal, and BELOW 384 the floor wins, so a real
   iPhone's 852x283 keeps the sizes Matan approved. That is the whole safety
   argument, which is why none of these may be "tidied" down to the fraction
   alone. */
@media (orientation: landscape) {

  :root {
    /* ONE PHONE PIXEL. 384 is the sideways phone this layout was judged at, so
       this resolves to exactly 1px there and scales linearly above it. */
    --lsc-u: calc(100dvh / 384);

    /* ---- the tokens, uncapped ---------------------------------------------
       Each is the same fraction of viewport height the block above already
       used; only the portrait ceiling is gone. The comment beside each one
       shows what it comes to on the phone, so the continuation is checkable. */
    --lsc-vpad:    max(4px, calc(7.68 * var(--lsc-u)));   /* was clamp(4, 2dvh, 12) */
    /* ⚠️ CAPPED BY THE RAIL IT HAS TO FIT INSIDE, NOT ONLY BY THE HEIGHT.
       The four boosters are ONE ROW with `justify-content: space-between` inside
       a box 88% of the left rail wide, and the badges have a fixed width, so four
       of them wider than that box do not shrink - the row overflows the rail and
       draws straight over the board. Matan hit exactly that on 2026-09-09: the
       Shuffle badge sitting on top of a tile reading "Wind".
       Four badges must fit 88% of the rail, so each may have at most 22% of it;
       0.20 leaves the three gaps between them about 13px at a 500px rail.
       ⚠️ THE PHONE CANNOT REACH THIS TERM. At a 300px rail it allows 60px and the
       height term asks for 49.9, so the height still wins and the sideways phone
       is unchanged - which the measurement confirms rather than assumes.
       This is a CEILING, so it can only ever make the badge smaller than the line
       it replaced; it cannot introduce an overflow of its own. */
    /* THE RAIL-WIDTH CAP THAT USED TO BE HERE IS GONE WITH THE ROW OF FOUR.
       It existed because four badges of a fixed width had to fit 88% of the left
       rail and overflowed onto the board when they did not. Arrangement five
       stands them in a COLUMN in the other rail, and a column is one badge wide,
       so there is nothing left for a width cap to protect against. */
    --lsc-booster: max(28px, calc(49.92 * var(--lsc-u)));  /* was clamp(28, 13dvh, 62) */
    --lsc-pill-f:  max(10px, calc(15.36 * var(--lsc-u)));  /* was clamp(10, 4dvh, 16) */
    /* ⚠️ CAPPED BY THE ROOM LEFT IN THE HEADER, NOT ONLY BY THE HEIGHT. The row
       carries three buttons and their three gaps before the name gets anything,
       and at a flat 5% of the height the name asked for more than was left and
       came out as "Lev...". Dividing the remainder by 5 is the widest a nine
       character title ("Level 100", the longest a numbered level has) can be
       drawn in it: Nunito at this weight averages a little over half its font
       size per character, so 5 leaves a margin on top of that.
       This is what lets the header be one line with no wrapping - see the #hud
       rule further down, and the note there for what wrapping cost. */
    --lsc-title:   min(calc(19.2 * var(--lsc-u)),            /* 5dvh at 384 */
                       calc((var(--lsc-rail-l) - 3 * var(--lsc-btn)
                             - 3 * 8 * var(--lsc-u)) / 5));
    /* ⚠️ THE THREE ROUND BUTTONS DO NOT SCALE PAST A FINGER (Matan, 2026-09-09:
       *"the BUTTONS (back, fullscreen, Settings) shouldn't scale up. No need for
       that. Give them a max height. The priority is that the Level X caption is
       NEVER hidden."*) At 8.5% of the height they reached 82px on a 965 tall
       window and 92 on a 1080, and three of those plus their gaps left the level
       name about 150px of a 446px rail, so it rendered as "Lev...".
       44px is the cap because it is the finger floor this project already uses
       everywhere else, so it is the smallest they are allowed to be and there is
       no reason for a back arrow to be bigger than that on any screen. */
    --lsc-btn:     min(max(26px, calc(32.64 * var(--lsc-u))), 44px);  /* was clamp(26, 8.5dvh, 34) */
    --lsc-count-f: max(9px, calc(11.14 * var(--lsc-u)));   /* was clamp(9, 2.9dvh, 11.5) */

    /* THE BOARD'S OWN CEILING, WHICH LIVES IN JS AND IS READ FROM HERE.
       computeFit() takes min(byWidth, byHeight, this). It was a bare 1.6 with
       no reason written down anywhere, dating to the initial commit, and 1.6 is
       what stops the board at 92% of the height on a 965 tall window instead of
       filling it.
       ⚠️ 2.2 IS NOT ARBITRARY AND IT IS NOT A PREFERENCE. It is where the tile
       ART runs out. Every icon in assets/icons.json is 192 pixels on its long
       side (325 of them 192x128, the rest square or larger) and the icon box is
       75 board pixels wide on every level, since all 100 levels use one tile
       size. So the art is stretched past its own resolution at 192/75 = 2.56 on
       a plain display, and at 2.29 on Matan's 1.12x monitor. 2.2 sits under
       both. A 1080 tall window only asks for 1.87, so this ceiling does not
       bind on any screen in this file's tables - it is the backstop for a 1440p
       monitor, and on one of those the board stops growing rather than going
       soft. If the icon art is ever re-exported bigger, this may rise with it.
       Portrait never sets this and computeFit falls back to its own 1.6, so the
       portrait arithmetic is untouched. */
    --lsc-scale-cap: 2.2;

    /* THE RIGHT RAIL IS SCALED; THE LEFT ONE STILL TAKES WHAT IS LEFT.
       120px is the phone's own value (its floor, which binds at 384 because the
       coin badge rather than the tray is what sets it). 20vw is the squarish
       window guard - see the note above the block. */
    --lsc-rail-r: min(calc(120 * var(--lsc-u)), 20vw);
    --lsc-rail-l: clamp(calc(170 * var(--lsc-u)),
                        calc(100dvw - 24 * var(--lsc-u)
                             - var(--lsc-boardneed) - var(--lsc-rail-r)),
                        calc(300 * var(--lsc-u)));
  }

  /* ---- the fixed gaps, in phone pixels ------------------------------------
     These are the numbers from the block above, unchanged, expressed in units
     that grow. Leaving them at their literal px is what would make a scaled-up
     screen read as "the same layout with the air squeezed out of it". */

  /* the play screen's own padding and the two gutters either side of the board */
  #game.active {
    column-gap: max(6px, calc(6 * var(--lsc-u)));
    row-gap: max(5px, calc(5 * var(--lsc-u)));
    padding: var(--lsc-vpad) max(6px, calc(6 * var(--lsc-u)));
  }

  /* the header: back arrow, fullscreen button, level title */
  /* ⚠️ THE HEADER IS ONE LINE, PACKED LEFT, AND NEITHER OF THOSE IS COSMETIC
     (Matan, 2026-09-09, on full screen: *"the buttons suddenly get an
     unnecessary gap between them so as to catch the entire row they sit on ...
     Also, the 'Level X' caption goes down. The result? the boosters in fullscreen
     are SMALLER than on maximized."*)

     TWO SEPARATE FAULTS, BOTH MINE, AND THEY COMPOUND.
     `justify-content: space-between` is portrait's, where the header really is
     "back on the left, balance on the right". Sideways the balance is not in
     this row at all, so space-between had nothing to push apart except the three
     buttons and the title, and it spread them over the whole rail. Packed left.
     `flex-wrap: wrap` was my own guarantee against a clipped title and it bought
     that guarantee with the one thing the rail cannot spare: a second line makes
     the header taller, the header is a grid row, and every pixel it takes comes
     out of the pills and the boosters below it. That is why full screen - which
     has MORE height - drew SMALLER boosters than a maximised window. A guarantee
     that costs more than the thing it prevents is the wrong guarantee.

     THE TITLE NOW FITS BY ARITHMETIC INSTEAD, which is what --lsc-title's cap
     below does: it is never asked for more room than the row actually has left
     after three buttons and their gaps, so it never needs to wrap and never
     needs to be cut. */
  /* ⚠️ WRAPS AGAIN, ON PURPOSE, AND ONLY BECAUSE THE BOOSTERS LEFT THIS RAIL.
     The buttons and the balance take the first line and the level name takes the
     second. This was forbidden while the boosters were in the left rail, since
     the header's height came straight out of them - see the note that used to be
     here. They are beside the tray now, so the line is free.
     The row-gap matches the grid's row gap below, which is what makes the name
     sit with equal air above and below it. */
  /* ONE LINE: back, fullscreen, settings, balance. The name is not in here any
     more, so there is nothing left to wrap and no row-gap to tune. */
  /* ⚠️ THE HEADER GAP IS CAPPED AT 10px, WHICH BUYS THE BALANCE ITS SIZE. This
     row is exactly as wide as the pill column, so every pixel of gap is a pixel
     the balance does not get. Uncapped it reached 17px on a 4:3 screen and
     squeezed the badge to 11.7px - smaller than the 14px it is in portrait,
     which is absurd next to 44px buttons. Three gaps at 10 instead of 17 hand
     back 21px, and 8px stays the phone's value. */
  /* ⚠️ wrap, AS A FALLBACK AND NOT AS A HABIT. The balance belongs beside the
     gear and at every size Matan plays it is there: measured, the row's contents
     come to 185px of a 255px rail on a real iPhone, 251 of 260 on an iPad 10.9,
     358 of 377 on his monitor and 399 of 422 full screen.
     But on the two squarish frames the rail is genuinely too narrow for three
     44px buttons AND the badge - 199px of 141 at Poki's 640x360, 245 of 225 at
     1024x768 - and with nowrap the row simply spilled over the board. The
     alternatives were shrinking the buttons under the 44px finger floor or
     letting the badge go under 14px, and a second line on two frames nobody
     plays on is the cheapest of the three. */
  /* One line: back, fullscreen, settings. The balance is not in here any more. */
  #hud { gap: clamp(8px, calc(8 * var(--lsc-u)), 10px);
         flex-wrap: nowrap; justify-content: flex-start; align-items: center; }
  /* THE NAME IS CENTRED ON THE PILLS, HORIZONTALLY AND VERTICALLY. The rail IS
     the pill column - see --lsc-rail-l - so text-align centres it on them
     exactly, and align-self does the same down the flexible row it now owns. */
  #game.active > #hud-level {
    grid-area: title; align-self: center; justify-self: stretch;
    text-align: center; font-size: var(--lsc-title);
  }
  /* The title's length ladder is a px value set by setLevelTitle() and it tops
     out at 22, so leaving the min() in place would freeze the title at 22px
     while every word beside it grew. The ladder exists to squeeze a long name
     into a narrow rail, and this rail is not narrow: it is 446px at 1715x965
     against the phone's 300. So the viewport token wins outright here. */
  #hud-level { font-size: var(--lsc-title); -webkit-text-stroke-width: max(1.6px, calc(1.6 * var(--lsc-u))); }

  /* ---- the category pills and the count badge under each one -------------
     ⚠️ 22 AND NOT 13, AND THE REASON IS THAT 13 SCALED PERFECTLY AND STILL
     LOOKED WRONG (Matan, 2026-09-09: *"The pills here are tightly packed even
     when there's enough room to spare underneath ... the only thing that grows
     is the gap between the category pills sections, and the boosters"*).
     13 phone pixels is the row gap the phone uses and carrying it across is
     what every other number in this block does. It fails here because the count
     badge HANGS BELOW its pill and its overhang is also a fraction of the
     height, so the two grow at the same rate and cancel: measured at 1920x1080,
     a 37px gap minus a 21px overhang left 16px of visible air, the same 16px a
     phone has, while 151px of the rail sat unused between the last badge and
     the boosters. The gap has to clear the badge FIRST and then be a gap.
     ⚠️ 17 IS A MEASURED CEILING, NOT A TASTE. The first attempt was 22, which
     looks right on a 1080 tall window and puts a category pill ON TOP of the
     booster row at 907x510 and 1031x580 - the two short frames the hosts use -
     because a short rail has no spare to give. The new pill-over-booster check
     in tools/qa-sweep.mjs found it on the first run. Stepped down and re-run:
     20 still collides, 18 is clean, 17 is what shipped so there is a real margin
     rather than a one-pixel one. At 1920x1080 it puts about 27px of air between
     pills against the 16 they had.
     It is a fixed fraction rather than "share out whatever is left": with
     align-content: space-between the six pills spread over 830px of rail with
     98px between them, which is not less crowded, it is a different problem. */
  #counter-row { gap: max(13px, calc(17 * var(--lsc-u))) max(5px, calc(5 * var(--lsc-u))); }
  .cat-pill { padding: max(2px, calc(2 * var(--lsc-u))) max(8px, calc(8 * var(--lsc-u))); }
  .cat-pill .cp-count {
    padding: max(1px, calc(1 * var(--lsc-u))) max(6px, calc(6 * var(--lsc-u)));
    bottom: calc(-1 * (var(--lsc-count-f) + max(2.5px, calc(2.5 * var(--lsc-u)))));
  }

  /* the boosters, and the coin price on each one */
  #booster-bar .booster .b-cost {
    font-size: max(8px, calc(9.98 * var(--lsc-u)));   /* was clamp(8, 2.6dvh, 10) */
    padding: calc(1 * var(--lsc-u)) calc(5 * var(--lsc-u)) calc(1 * var(--lsc-u)) calc(3 * var(--lsc-u));
  }

  /* the tray: the socket's headroom over its seat, and the gap between rows */
  #tray-container { --lsc-spot-h: calc(var(--lsc-seat-h) + max(8px, calc(8 * var(--lsc-u)))); }
  #tray-container #tray { row-gap: max(2px, calc(2 * var(--lsc-u))); }

  /* THE COIN BADGE AND THE GEAR SIT IN THE RIGHT RAIL AND WERE THE ONE PIECE OF
     FURNITURE WITH NO SIDEWAYS RULE AT ALL - they are plain portrait chrome, so
     on a monitor they stayed a 31px pill in a 300px rail. The numbers are the
     portrait ones from `.coin-badge`, carried across. */
  /* ⚠️ THE BALANCE IS SIZED BY THE ROOM LEFT ON ITS OWN LINE (Matan, 2026-09-09:
     it must be *"on the right of the Settings wheel"*, and it was landing on the
     line below instead).
     The header line is exactly as wide as the pill column - that is what centres
     the board - so it carries three round buttons and then the badge. Scaled off
     the viewport alone the badge reached 213px against the 93px that was left at
     1715x965, so flex wrapped it onto its own line and it read as a second row
     nobody asked for.
     EVERYTHING INSIDE THE BADGE IS IN `em` NOW, so its whole size follows one
     number and that number is the room available. The em values are the portrait
     pixels over portrait's 14px font, so at 14px the badge is byte-identical to
     the one this replaces. 5.9em is the badge's own width in those units - two
     paddings, the coin, its gap, four digits and the border - which is what makes
     "divide the space left by 5.9" the widest it can be and still fit.
     The phone keeps max(14px, ...) as its floor, as everything here does. */
  #game.active .coin-badge {
    /* ⚠️ AND NEVER BELOW 14px, WHICH IS ITS PORTRAIT SIZE. The room term alone
       took it to 11.7px on a 4:3 screen. A balance smaller than it is on a phone,
       sitting beside 44px buttons, is worse than a slightly tight row.
       The subtracted gap must be the same clamp #hud uses, or this maths is
       about a row that does not exist - and the 12px is the EXTRA separation
       `#hud #btn-fullscreen` carries. That one is not a gap anybody can trade
       away: it is the 2026-09-03 fix for a thumb that missed "leave the level"
       and hit "go fullscreen" instead. Leaving it out of this sum made the row
       want 263px of a 260px rail, so the badge wrapped to its own line by three
       pixels on five sizes out of seven. */
    /* THE SAME SIZE AS A CATEGORY PILL (Matan, 2026-09-09). --pill-cap-px is
       what the pills are ACTUALLY drawn at, set by centrePillRow() from
       pillFontCap(), so this follows the type on screen rather than the token
       the CSS was willing to allow. The 14px floor is its portrait size. */
    font-size: max(14px, var(--lsc-pill-f));
    gap: 0.43em;
    padding: 0.43em 0.86em;
    border-width: 0.14em;
  }
  #game.active .coin-badge .cb-ico { width: 1.21em; height: 1.21em; }

  /* =========================================================================
     ARRANGEMENT FIVE - THE BOOSTERS STAND BESIDE THE TRAY
     (Matan, 2026-09-09, after judging five mockups)
     =========================================================================

     WHAT MOVES. The four boosters leave the LEFT rail, where they were a row of
     four under the category pills, and become a COLUMN of four in the right
     rail, standing next to the tray. The level name goes under the three round
     buttons. The pills get narrower and centre themselves in the rail. The coin
     balance stays where it is, over the tray.

     WHY IT IS BETTER, IN ONE SENTENCE: above or below the tray the boosters
     compete for the rail's HEIGHT, which a seven-deep tray has already spent,
     and beside it they compete for its WIDTH, which a height-bound board cannot
     use anyway.

     ⚠️ AND IT IS WHY THE ROW OF FOUR KEPT COLLIDING WITH THINGS. Four badges of
     a fixed width inside a box 88% of the left rail wide had to be shrunk every
     time the rail got busier - that is the whole history of the Shuffle badge
     landing on a tile. A column has no such fight: it is one badge wide.

     ⚠️ THIS BLOCK ONLY EXISTS ABOVE 500px OF HEIGHT, like everything else in
     this file below that gate. A phone held sideways keeps the layout Matan
     judged on 2026-08-27 and is not touched by any of it.
     ========================================================================= */

  :root {
    /* THE TWO RIGHT-HAND COLUMNS, both derived from the tile the board will draw
       - which is what makes the badges and the seats move together on any screen,
       the property Matan asked for.
         a tile is 108 board units wide and the board scale is
         (100dvh - 2*vpad - 4) / 564, and vpad is 2dvh, so
         tile on screen = 108 * 0.96dvh / 564 = 0.1838 * 100dvh
       The seat is 0.72 of a tile (his number, 2026-08-27) and the badge 0.62.
       The added phone pixels are the tray's own padding and the price chip that
       sticks out past the badge. */
    /* ⚠️ AND EACH IS ALSO CAPPED BY A SHARE OF THE WIDTH, WHICH IS NOT BELT AND
       BRACES. Everything in this block is derived from viewport HEIGHT, which is
       right on a 16:9 window and wrong on a squarish one: at 1024x768 the
       height-derived columns and the height-derived left-rail floor came to
       669px of a 1024px window, the board was squeezed to 355 and its words
       rendered 12.5px. Measured, not predicted - the sweep reported six of them
       on the iPad preset and nowhere else. A window has only so much width to
       give whatever its height is, so the caps are in vw. */
    /* ⚠️ THE BADGE HAS A 44px FLOOR, WHICH IS A FINGER AND NOT A LOOK. Derived
       from the tile alone it came out 41px on a 640x360 frame and the sweep
       reported ten findings under the floor; on the shortest screens it would
       have been 32. Four badges at 44 plus their gaps are 231px of a 272px rail
       even on a real iPhone's 852x283, so the floor costs nothing there. It is
       declared once and both the column and the badge read it, so the two can
       never disagree about how wide a badge is. */
    --lsc-badge:     max(44px, calc(0.1140 * 100dvh));
    /* ⚠️ A WHOLE TILE WIDE PLUS THE TRAY'S CHROME, NOT 0.72 OF ONE (QA-777,
       Matan approved 2026-10-14). The seat's HEIGHT is still what seven of them
       down the rail allow; its width now goes to a full board tile so an
       8-letter word is readable on a real iPhone sideways. The column is paid
       for out of the board's column, which on every sideways screen is wider
       than the height-bound board needs. 23px is LSC_TRAY_CHROME_W in game.js.
       The 12vw cap is unchanged, so a squarish screen keeps its column. */
    --lsc-seat-col:  min(max(calc(0.1323 * 100dvh + 22 * var(--lsc-u)),
                             calc(var(--lsc-tile-h) + 23px)), 12vw);
    --lsc-boost-col: min(max(calc(var(--lsc-badge) + 18 * var(--lsc-u)),
                             calc(6.1 * max(14px, var(--lsc-pill-f)))),
                         26vw);
    /* The left rail is what is left, exactly as before, only now it has two
       columns to give way to instead of one.
       ⚠️ THE FLOOR IS CAPPED BY WIDTH TOO, and it is the bigger of the two
       offenders: 170 phone pixels is 340 real pixels on a 768 tall window, so a
       4:3 screen was being asked for a 340px rail before the board got anything.
       22vw is 225 there, and on every 16:9 window the height term is the smaller
       of the two so nothing changes. */
    /* ⚠️ NO CEILING. Every pixel this rail is refused becomes empty background
       between the pills and the board, which is the exact fault this whole piece
       of work started from. On a 3:1 phone the leftover is large, so a ceiling
       of 320 phone pixels left 230px of dead air in the middle column. The rail
       takes all of it and the PILL WIDTH is capped separately instead, so the
       pills stay the size they should be and the spare sits at the edge of the
       screen rather than between two things that belong together. */
    /* ⚠️ THE GUTTER TERM MUST BE THE GUTTER, NOT AN APPROXIMATION OF IT. This
       said `30 * var(--lsc-u)` - five gutters of six phone pixels - which is
       exact at 384 tall and wrong below it, because the gutters themselves have
       a 6px floor and stop shrinking while this term kept going. At 852x283 it
       under-counted by 8px, the board's column came up 8px short, the board went
       from height-bound to WIDTH-bound and lost 3% of its size on the one screen
       that can least afford it. Same max() as the gutters, five of them: two of
       #game's own padding and three between four columns. */
    --lsc-gutters: calc(5 * max(6px, calc(6 * var(--lsc-u))));
    --lsc-leftover: calc(100dvw - var(--lsc-gutters) - var(--lsc-boardneed)
                         - var(--lsc-seat-col) - var(--lsc-boost-col));
    /* ⚠️ THE RAIL IS THE PILLS, AND THAT IS WHAT CENTRES THE BOARD (Matan,
       2026-09-09: *"the gameplay area is not centered between the category pills
       and the tray, which really makes the eye lean more to the right. If both
       the tiles section and the category pills moved to the left, life would be
       better."*)
       The pills are 18% narrower than their rail, which he asked for and which
       is right - but the rail was still taking the FULL leftover, so that 18%
       stayed behind as air on the pills' inner edge. Measured at 852x393: 40px
       between the pills and the board against 12px between the board and the
       tray, and the 28px difference was exactly that air.
       So the pill width is worked out first and the rail is made equal to it.
       The 18% then falls through to the board's column, where it splits evenly
       either side of the board instead of piling up on one. Same measurement
       after: 39px and 39px, with the pills and the board both about 28px further
       left. The pills do not change width at any size - that is the point. */
    /* ⚠️ THE FLOOR IS INSIDE THE PILL WIDTH, NOT OUTSIDE IT, AND THAT IS THE
       WHOLE POINT. It used to be `rail = max(floor, pillw)` with the pills sized
       from the leftover, so whenever the floor won the two disagreed: at
       1180x820 the leftover is only 72px, the pills came out 59px wide, and they
       sat as a thin stripe in the middle of a 260px rail. Measured, not guessed
       - and it also put air back between the pills and the board at 1715x965,
       which is the very thing that was fixed an hour earlier.
       Now the floor, the wanted width and the height cap are one clamp, and the
       rail is simply equal to the result. The pills fill their rail at every
       size, the rail is never wider than the pills, and the leftover that is not
       theirs goes to the board's column where it splits evenly. */
    /* ⚠️ THE PILL'S WIDTH COMES FROM ITS OWN TYPE, AT THE PHONE'S PROPORTION
       (Matan, 2026-09-09: *"play with the pills font size. Make it look like it
       does on my phone (looks great)"*).
       On the phone upright a pill is 124px wide around a 16px name, which is
       7.75 times the font. Sideways it was 82% of whatever width happened to be
       left over, which came to 9.8 times the font at 1715x965 - the same pill
       with half again as much air in it, which is why it read as a stretched bar
       rather than as the object he likes.
       ⚠️ THE FONT CANNOT GROW TO MEET THE WIDTH, so the width comes to meet the
       font: pillFontCap() holds the name at or below the tile's own rendered
       word (Matan's rule of 2026-08-27, a pill must never out-shout the board),
       so at 1715x965 it is pinned at 38.6px and 7.75 of those is 299px, not 377.
       --pill-cap-px is what the pills are actually drawn at; the token is the
       fallback for the first frame before the pill row has rendered once. */
    /* ⚠️ FROM THE TOKEN, NOT FROM --pill-cap-px. The first cut of this read the
       size the pills are ACTUALLY drawn at, which is set by JS after the row
       renders - and that variable is not refreshed on a resize, so after a
       rotation the rail was still sized from the previous screen. At 852x283 it
       came out 219px wide around a 38.6px name left over from a 1715x965
       measurement, and the pills ran off the bottom of the screen.
       The token is a pure function of the viewport and is therefore always
       right. Where pillFontCap() then holds the name BELOW the token - a tile
       whose own word is smaller - the pill is a little looser than 7.75, which is
       the safe direction to be wrong in. */
    /* ⚠️ THE 120px FLOOR IS THE HEADER'S, NOT THE PILL'S. The rail also carries
       the three round buttons, and at 640x360 they need 118px - three at 30 plus
       two gaps plus the 12px thumb separation - so a 100px floor let a button
       spill 2px over the board. Below about a 15.5px name the floor wins and the
       pill is looser than 7.75, which is the safe direction. */
    --lsc-pillw:  clamp(120px, calc(7.75 * var(--lsc-pill-f)), 90dvh);
    --lsc-rail-l: var(--lsc-pillw);
    /* The level name is on its own line now, so it is not sharing the row with
       three buttons and does not need the room-left cap that shape needed. */
    --lsc-title: max(13px, calc(19.2 * var(--lsc-u)));     /* was clamp(13, 5dvh, 22) */
  }

  /* FOUR COLUMNS, and the two on the right are the whole point. The balance
     spans both of them so it sits over the pair rather than over one of them. */
  /* ⚠️ NO `coins` ROW ANY MORE. The balance used to sit in its own cell above the
     two right columns, and on a short screen that cell's height and the tray's
     did not add up: the badge was drawn over the top sockets. Matan caught it in
     a screenshot I had looked at and passed. It lives in the header now, to the
     right of the settings wheel, which is where he asked for it - and the tray
     and the badge column get BOTH rows, so they have the full height of the
     screen and nothing shares an edge with them. */
  #game.active {
    grid-template-columns:
      var(--lsc-rail-l) minmax(0, 1fr) var(--lsc-seat-col) var(--lsc-boost-col);
    /* ⚠️ FOUR ROWS, AND THE TWO FLEXIBLE ONES ARE WHAT CENTRE THE LEVEL NAME
       (Matan, 2026-09-09, choosing option A: equal air above and below the
       caption, in the gap between the buttons and the first pill).
       The name used to be the second line of the header, which pinned it under
       the buttons - measured 25px above it and 80px below. It has its own row
       now, that row is flexible, and the name is centred in it, so the two
       numbers are equal by construction rather than by a margin somebody has to
       keep retuning. The fourth row is the mirror of the second, which is what
       keeps the pill block off the bottom edge as well.
       The board, the tray and the badges span all four rows, so their height is
       untouched by any of it. */
    grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 1fr);
    grid-template-areas:
      "hud   board .     coins"
      "title board tray  boost"
      "pills board tray  boost"
      ".     board tray  boost";
  }
  /* Keep the balance in the header, and move the level NAME out of it into its
     own cell. Both are flags railOut() already reads, so neither needs a line of
     JavaScript: 0 leaves the balance where the markup put it, 1 reparents the
     name. */
  /* ⚠️ THE BALANCE OWNS ROW 1 OF THE BADGE COLUMN, AND NOTHING ELSE IS IN THAT
     ROW. It is out of the header because sharing that row meant its size came
     out of "the width left after three buttons", and I got that sum wrong three
     times. It is in the BADGE column rather than spanning both right-hand
     columns because Matan wants it centred on the badges, and a cell that spans
     the tray as well centres 99px to their left.
     ⚠️ AND THE COLUMN IS WIDENED TO FIT IT, which is the only way his two
     requirements can both hold: at category-pill size the badge is about six
     times its font wide - 233px at 1715x965 - so in a 110px column it either
     hangs over the tray or runs off the screen. The column is at least as wide
     as the balance now, the badges centre in it, the balance centres in it, and
     nothing overhangs anything. It is paid for out of the pill column, which is
     the leftover after this. */
  :root { --hud-group-out: 1; --hud-level-out: 1; }
  #game.active > #hud-group { grid-area: coins; align-self: start; justify-self: center; }

  /* ---- the header: buttons on one line, the name under them ---------------
     ⚠️ THIS IS THE OPPOSITE OF THE RULE ABOVE THIS BLOCK, AND BOTH ARE RIGHT.
     While the boosters were in the left rail, a second header line came straight
     out of them - that is why full screen once drew smaller boosters than a
     maximised window. The boosters are not in this rail any more, so the line is
     free. If this arrangement is ever removed, the one-line rule has to come
     back with it. */

  /* ---- the pills: narrower, and centred rather than hung off the header ----
     82% is Matan's "lose 15% of their width at least". `align-self: center` is
     his "pushed down": with room above and below, anchoring to the top collects
     all the spare into one hole under the last pill, which reads as a mistake
     rather than as a margin. */
  #counter-wrap { align-self: start; padding-bottom: 0; }
  /* ⚠️ CAPPED BY HEIGHT AS WELL AS BY THE RAIL, and the second half is what makes
     ONE landscape layout possible instead of two. A phone sideways is about 3:1,
     so its left rail is enormous, and at a flat 82% the six pills stretched into
     long thin bars - nothing cut, it simply stopped looking like the layout that
     was approved. 90% of the screen height reproduces the proportion the phone
     has today (255px against its current 264 at 852x283) and is nowhere near
     binding on a desktop window, where 82% of the rail is much the smaller. */
  /* The row now fills its rail, because the rail was sized to be the row. The
     min() is the floor case: on a screen too narrow for --lsc-pillw the rail is
     pinned to its minimum and the pills must not overflow it. */
  /* ⚠️ ONE COLUMN AT FULL WIDTH, AND THE PILL FILLS IT. Three separate shrinks
     were nested inside this rail: the row was capped, then the row's grid track
     was --lsc-cluster-w (88%), then the pill did not stretch to its track. The
     result was a pill 226px wide inside a 299px rail - 5.85 times its font
     against the phone's 7.75 - so it had gone from too loose to too tight
     without ever being right. The rail IS the pill now, with nothing in between:
     --lsc-pillw sets the rail, the row fills the rail, the track fills the row
     and the pill fills the track. */
  /* ⚠️ --lsc-cluster-w IS THE ONE TO SET, NOT grid-template-columns. That
     variable is what the sideways layout uses to line the pill column and the
     booster row up with each other, and overriding the track directly left it
     in place: the pill came out 257px inside a 299px rail, 6.66 times its font
     against the 7.75 this is all for. The boosters are a column in their own
     rail now and have no edge to share with the pills, so the variable has no
     other job here. */
  :root { --lsc-cluster-w: 100%; }
  #counter-row { width: 100%; margin: 0; }
  #counter-row .cat-pill { width: 100%; }

  /* ---- the boosters: a column of four, beside the seats -------------------- */
  #booster-bar {
    grid-area: boost;
    align-self: center;
    justify-self: center;
    width: auto;
    margin: 0;
    grid-template-columns: auto;
    grid-auto-rows: auto;
    justify-content: center;
    column-gap: 0;
    /* ⚠️ 0.25 AND NOT 0.42, AND IT IS THE 44px FLOOR THAT PAID FOR IT. Four
       badges at 44 with a 0.42 gap are 231px, and on a real iPhone's 852x283 the
       rail has 231 left after the balance row - so the column fitted to the
       pixel and the fourth badge's price chip, which hangs BELOW it, was cut off
       the bottom edge. The arithmetic that set the floor had forgotten the row
       the balance sits in. At 0.25 the column is 209 and the chip has room. */
    row-gap: calc(var(--lsc-badge) * 0.25);
  }
  /* The badge is measured off the tile, like the seat beside it, instead of off
     the viewport height - so the two columns keep their proportion to each other
     on every screen. The rail cap that the ROW of four needed is gone with the
     row: a column is one badge wide and cannot overflow sideways. */
  #booster-bar .booster {
    transform: none;
    width: min(var(--lsc-badge), calc(var(--lsc-boost-col) - 18 * var(--lsc-u)));
  }

  /* ---- the tray keeps its column and its own rail -------------------------- */
  /* ⚠️ start, NOT center. The seat column is sized from the viewport while the
     tray's own width is set by sizeLandscapeTray() from the real tile, so the
     column is a few tens of pixels wider than the tray. Centred, half of that
     slack landed in the gap between the board and the tray and made it look
     uneven again: 52px against 36px at 1715x965, 40 against 22 at 1180x820.
     Anchored to the board side, the only thing left in either gap is the board's
     own centring, which is symmetrical by definition. */
  /* ⚠️ margin: 0 IS THE LOAD-BEARING HALF OF THIS RULE. The tray keeps portrait's
     `margin: 0 auto`, and on a grid item an AUTO MARGIN BEATS justify-self - so
     `start` alone did nothing at all and the tray stayed centred in a column
     wider than itself. That put 16px into the gap on the board's right and
     nowhere else: 53px against 36px at 1715x965, 60 against 40 at 1920x1080.
     With the auto margin gone the only thing in either gap is the board's own
     centring inside its column, which is symmetrical by definition. */
  #tray-container { align-self: center; justify-self: start; margin: 0; }


}

/* =============================================================================
   A SQUARISH LANDSCAPE SCREEN: THE PILLS GO ACROSS THE TOP
   (Matan, 2026-09-09: *"pills at the top, like portrait ... below them is the
   gameplay, and on the side we retain the tray and boosters, like in
   landscape"*, and *"if proportions are close to ipad, then switch to this
   layout"*)
   =============================================================================

   WHY A SQUARE SCREEN NEEDS ITS OWN ARRANGEMENT, AND WHY NO OTHER SHAPE DOES.
   Everywhere else the pills sit in a rail BESIDE the board, which costs width
   and costs no height. That is free on a 16:9 window, where the board is bound
   by height and cannot use the spare width anyway. On a 4:3 iPad the board is
   bound by WIDTH, so that rail is taken straight out of the thing it exists to
   serve: measured at 1024x768 the board drew 498x509 and filled 66% of the
   height. Across the top the pills cost about 60px of height and hand back a
   whole rail of width, which on this shape is the trade the right way round.
   Measured after: 567x536, and 70% of the height.

   ⚠️ THE GATE IS THE SHAPE OF THE WINDOW, NOT THE NAME OF A DEVICE. 1.55 wide
   for every 1 tall is the line (31 / 20 below; a media query ratio has to be
   whole numbers):
       classic iPad 1024x768, 10.2 inch 1080x810, 12.9 Pro 1366x1024   1.33
       iPad 10.9 / Air / 11 inch Pro   1180x820, 1210x834              1.44
       iPad mini                       1133x744                        1.52
       ------------------------------- the line at 1.55 ----------------------
       16:10 - 1280x800 laptops and Android tablets, 1440x900,
               1680x1050, 1920x1200, 2560x1600                         1.60
       Poki's smallest 640x360, desktop 720p                           1.78
       a maximised or full-screen monitor                              1.78
       the CrazyGames frame 1100x570                                   1.93
       phone sideways 832x384, 755x298, a real iPhone 852x283      2.17 - 3.01
   So a desktop window dragged to a squarish shape gets this too, which is right:
   it follows the shape, not the hardware.
   ⚠️ 16:10 IS SIDEWAYS, NOT THIS (Matan, 2026-09-27: *"16:10 - it's bad. It
   should be the same behavior as 16:9 - the category pills sit on the left, not
   on top"*). Until that day the line was `max-aspect-ratio: 8 / 5`, and a max
   ratio INCLUDES its own value, so every 16:10 screen - exactly 1.6 - landed
   here. Measured at 1280x800: tiles 113x75 and pill words 17.6px here, against
   tiles 140x94 and pill words 30.7px on a 1366x768 laptop, with 385px of empty
   board column. Sideways it is the 16:9 layout at 16:10's size.
   WHY 1.55 AND NOT ANYWHERE ELSE BETWEEN 1.52 AND 1.6. The sideways layout was
   measured on every iPad with this block switched off, and this block wins on
   all of them because an iPad board is bound by width: tiles 108x72 against
   81x54 at 1024x768, 116x77 against 101x67 at 1180x820, and 105x70 against
   102x68 on the iPad mini at 1.52 - nearly a tie. So the two layouts cross just
   above 1.52 and 1.6 is past it; 1.55 sits at the crossing, between the widest
   iPad and the narrowest 16:10 screen. qa-sweep's "layout" check fails if a
   16:10 size in mockups/landscape-lab.html comes out with the pills on top.
   ⚠️ ONE CASE FALLS JUST OUTSIDE ON PURPOSE. A 10.9 inch iPad in Safari WITH the
   toolbar showing is 1180x730, which is 1.62, so it keeps the sideways layout.
   At that shape the sideways layout is fine, and moving the line to catch it
   would start catching 16:9 windows too.

   ⚠️ THIS BLOCK MUST COME AFTER THE SIDEWAYS ONE and it must undo every part of
   it that it replaces - the four columns, the four rows and the grid area of
   every piece. Where it says nothing, the sideways block still applies, which is
   deliberate: all the sizing tokens, the tray, the badge column and the balance
   are the same objects at the same sizes. Only WHERE they sit changes.
   ============================================================================= */
@media (orientation: landscape) and (max-aspect-ratio: 31 / 20) {

  /* THREE COLUMNS AND THREE ROWS. The header and the pills span the full width;
     the board takes what is left of the height with the tray and the badges
     beside it. */
  #game.active {
    grid-template-columns: minmax(0, 1fr) var(--lsc-seat-col) var(--lsc-boost-col);
    grid-template-rows: auto auto minmax(0, 1fr);
    grid-template-areas:
      "hud   coins coins"
      "pills pills pills"
      "board tray  boost";
  }

  :root {
    /* The level name goes back INSIDE the header, beside the three buttons -
       there is a full screen width to put it in here, so it needs neither its
       own row nor the flexible row that centred it sideways. */
    --hud-level-out: 0;
    --hud-group-out: 1;
    /* ⚠️ THE PILL TYPE IS PORTRAIT'S, NOT THE SIDEWAYS RAIL'S. Across the top a
       pill is one of six in a full screen width - which is what portrait does -
       so it wants portrait's 16px, not the 4% of viewport height that a
       full-height rail could afford. 2.2dvh is 16.9px at 768 tall. */
    --lsc-pill-f: clamp(12px, 2.2dvh, 20px);
    /* The badge column no longer has to be wide enough for the balance: the
       balance is in the header now, so this goes back to the badges' own width. */
    --lsc-boost-col: min(calc(var(--lsc-badge) + 18 * var(--lsc-u)), 14vw);
  }

  /* ---- the header: buttons and the level name, balance at the far end ------ */
  #hud { grid-area: hud; }
  #hud-level {
    /* Beside the buttons, not on its own line. font-size comes from --lsc-title
       as it does sideways; the ladder's own inline size is cleared by
       syncTitleFont() in landscape. */
    flex: 0 1 auto; order: 0; text-align: left;
    font-size: var(--lsc-title);
  }
  #game.active > #hud-group {
    grid-area: coins; align-self: center; justify-self: end;
  }

  /* ---- the pills: one row across, as many columns as there are pills -------
     grid-auto-flow: column is what makes this work for 3, 4, 5 or 6 categories
     without a rule per count - rule 6.6 allows up to six. A fixed
     repeat(6, 1fr) would leave holes on a four-category level. */
  /* ⚠️ THE COUNT BADGE HANGS BELOW ITS PILL AND THE BOARD IS DIRECTLY UNDER THE
     PILL ROW HERE, so the row has to reserve the overhang or the numbers are
     drawn over the tiles - the sweep caught exactly that, 68x15px at 1024x768
     and 72x16 at 1180x820. Sideways this padding is zero because the badge hangs
     into empty rail below the last pill and costs nothing.
     The badge sits `count-f + 2.5u` below the pill and is about 1.35 of its own
     font tall plus its padding, so 2.4 of the font plus 5 phone pixels clears
     it with a little to spare. */
  #counter-wrap {
    grid-area: pills; align-self: start;
    padding-bottom: calc(var(--lsc-count-f) * 2.4 + 5 * var(--lsc-u));
  }
  #counter-row {
    width: 100%; margin: 0;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    grid-template-columns: none;
    gap: max(5px, calc(5 * var(--lsc-u)));
    /* ⚠️ RESERVE THE ROW BEFORE THE FIRST NAME SHOWS (2026-10-14). On a level
       that hides its names until a tile reaches the tray (level 40 since the
       2026-10-13 rebuild) this row was empty at the start, so the board was
       fitted to a taller column; the first tap showed a pill, the column lost
       a pill's height and the board ran 13px past it onto the bottom edge
       (sweep: play-tap at 1024x768 and 1180x820). Portrait reserves its rows
       for the same reason (the 71px on #counter-row). One pill's height. */
    min-height: var(--lsc-pill-h);
  }
  #counter-row .cat-pill { width: 100%; }

  /* ---- the board, and the two columns beside it ---------------------------- */
  #board-container { grid-area: board; min-width: 0; min-height: 0; }
  #tray-container  { grid-area: tray;  align-self: center; justify-self: center; margin: 0; }
  #booster-bar     { grid-area: boost; align-self: center; justify-self: center; }
}

/* =============================================================================
   POKI'S CORNER, SIDEWAYS, ON PHONES ONLY (decided 2026-09-23)
   Poki's minimize pill is laid over the game at x 0 to 62, y 24 to 70. On a
   phone held sideways (at least 780 wide, at most 480 tall) the button row
   (back, fullscreen, gear) and the level name start at x 72 instead of the
   rail's edge. Same sizes, same gaps. The row is wider than what is left of
   the rail, so it runs about 50px past it into the EMPTY top of the board's
   column; that is accepted, and the qa-sweep "drawn over the board" finding
   on these sizes is expected. It never reaches the board itself. The name is
   as wide as its words rather than the rail, so a long one is never cut.
   Everything else - desktop frames, Poki's 640x360 embed, tablets - is
   untouched. Why the other plans were refused: docs/landscape.md.
   ========================================================================== */
@media (orientation: landscape) and (min-width: 780px) and (max-height: 480px) {
  #hud { padding-left: calc(72px - max(6px, calc(6 * var(--lsc-u)))); }
  #game.active > #hud-level {
    justify-self: start; width: max-content;
    margin-left: calc(72px - max(6px, calc(6 * var(--lsc-u))));
  }
}

/* =============================================================================
   A MOUSE GETS AN ANSWER (2026-09-22)
   =============================================================================

   WHAT WAS ACTUALLY MEASURED, on the dev page at 2560x1440, 1920x1080, 1600x900,
   1366x768 and 900x1200 with a mouse: the whole stylesheet carried ZERO hover
   rules. Not one control changed in any way when the pointer was over it, so a
   desktop player had nothing telling them a tile, a booster or a button was a
   thing they could press until they pressed it.
   The hand cursor, which the bug report also named, DID reproduce as already
   correct: open tiles, boosters, round buttons, sheet rows and every .btn were
   already `cursor: pointer`. Only the category pills were not, and they are not
   pressable, so they are left alone. Nothing here adds a cursor that was
   missing, because nothing was missing.

   GATED ON A REAL POINTER, SO NO PHONE CAN REACH IT. `hover: hover` plus
   `pointer: fine` is true for a mouse and false for a finger. This matters more
   than it looks: a phone browser that applies a hover state on a tap LEAVES IT
   APPLIED until the player taps something else, so a tile they cleared ten
   seconds ago would still be lit.

   EVERY RULE CARRIES :not(:active), AND THAT IS NOT TIDINESS. The press states
   in this file are written above these rules, so a hover rule further down
   would win while the control was held and the press would stop showing. Hover
   says "you could press this"; the press says "you are pressing it"; only one
   of those can be true at a time.

   THE HOUSE VOLUME. The game has no error tone and no loud UI, so none of this
   introduces a colour, an animation or a movement that was not already in the
   language. Each one is the control's own shadow, a little deeper - the same
   thing every card in this game does to say it is lifted off the board.
   ============================================================================= */
@media (hover: hover) and (pointer: fine) {

  /* A tile the player can take. The ring sits OUTSIDE the face, added to the
     open tile's own edge rather than replacing it, so the tile's drawing, its
     word and its icon are untouched - no filter on a face and no art moved or
     scaled to fit, both of which this project has already paid for (law 5). */
  .tile.open:hover:not(:active) .f-front {
    box-shadow: var(--tile-edge-open), 0 0 0 3px rgba(255, 255, 255, 0.38);
  }

  /* The boosters: the badge lifts the way it already sinks on a press. */
  .booster:not(.locked):hover:not(:active) .b-top {
    box-shadow:
      inset 0 3px 3px rgba(255,255,255,0.35),
      0 7px 0 #10382a,
      0 11px 15px rgba(0,0,0,0.26);
  }

  /* Every button on every card, the lobby and the sheets. */
  .btn:not(:disabled):hover:not(:active) {
    box-shadow: 0 4px 10px rgba(0,0,0,0.18);
  }
  .round-btn:hover:not(:active) {
    box-shadow: 0 3px 0 var(--green-shade), 0 7px 14px rgba(0,0,0,0.26);
  }
  /* The rows inside the settings sheet and the privacy page. */
  .sheet-row:hover:not(:active) {
    box-shadow: 0 4px 10px rgba(15,82,56,0.18);
  }
  /* The level cards in the level list. A locked one is not a control. */
  .level-card:not(.locked):hover:not(:active) {
    box-shadow: 0 4px 10px rgba(15,82,56,0.20);
  }
}

/* =============================================================================
   THE CARDS ARE NOT PHONE DIALOGS ON A DESKTOP SCREEN (2026-09-22)
   =============================================================================

   WHAT WAS BROKEN, measured on the dev page. `.modal-card` is `max-width: 340px`
   in literal pixels and sits outside every block that scales anything, so the
   win card, the lose card, the settings sheet and the booster unlock card drew
   at EXACTLY the same size on a 2560 wide monitor as on a 1366 wide laptop:
   the lose card 300 by 486 either way, which is 11.7 per cent of the width at
   2560. The player meets one of these after every single level.

   THE IDEA IS THE SIDEWAYS BLOCK'S: one number, and every part of the card
   carried across at that number rather than re-tuned. The difference is HOW.
   Sideways re-states each length in phone pixels because the layout around it
   also has to move. A card has nothing around it, so the whole card is scaled
   at once and every ornament on it - the blob rim, the star row, the painted
   crest, the coin stack, the buttons - stays in exact proportion. Nothing
   inside these cards was touched, and nothing inside them can drift out of step
   with anything else.

   THE SCALE IS ON THE CARD AND NEVER ON THE SCRIM BEHIND IT. Scaling the scrim
   looks like one rule instead of four and is wrong twice over: the scrim carries
   `backdrop-filter`, and a filtered backdrop is sampled in the scaled element's
   own space, so the board behind the card would appear to jump to twice its size
   the instant a card opened; and #end-burst - the win card's confetti - is
   `position: fixed` inside #end-screen and is placed from a measured rect, so a
   transform on its parent would throw every piece of it off by the scale factor.
   On the card itself neither can happen.

   THE TWO ENTRY ANIMATIONS ARE RE-POINTED, NOT REMOVED. `coachCardIn` and
   `sheetIn` both END on a transform, and both would run with the card's
   transform already set, so a card would have played its pop and then jumped to
   its real size. The two keyframe sets below are the same animations with the
   scale folded into both ends, so the pop is the pop it always was, around the
   size the card is actually drawn at.

   WHY A LADDER OF THREE STEPS AND NOT A SMOOTH CURVE. CSS cannot divide one
   length by another, so there is no way to turn "how big is this window" into
   the plain number a scale needs. A ladder is the honest version of that limit
   rather than a workaround for it: a window is resized rarely, and a step
   between two sizes of a card nobody is looking at is invisible.

   WHY EACH STEP NEEDS BOTH A WIDTH AND A HEIGHT, AND WHY THE HEIGHTS ARE WHAT
   THEY ARE. No phone may reach step one, and the tallest card in the game must
   still fit inside the window at the step it is given:
       every phone upright              up to  430 wide            (640 stops it)
       every phone sideways             up to  932 by 430          (720 stops it)
       the real iPhone sideways              852 by 283
       the CrazyGames frame                 1100 by 570            (720 stops it)
       Poki frames                640x360, 907x510, 1031x580       (720 stops it)
   and the lose card, which is the tallest, is 486 tall before it is scaled:
       486 x 1.4 = 680 inside  720 - 40 of scrim padding = 680     fits exactly
       486 x 1.8 = 875 inside 1000 - 40                  = 960     fits
       486 x 2.2 = 1069 inside 1200 - 40                 = 1160    fits
   So the gates are not preferences: each one is the height that step needs.

   THE SCROLLING BODY UNDERNEATH IS A BACKSTOP, NOT THE FIX. It is the same
   shape the sideways block uses for QA-003 - a cap, a flex column, and
   `min-height: 0` on the part that scrolls - and it is here so that a level
   whose post-mortem is longer than any measured today scrolls inside the card
   instead of running off the bottom of the screen with nothing to say so.
   ============================================================================= */
@keyframes cardInScaled {
  0%   { opacity: 0; transform: scale(calc(var(--card-s) * 0.9)); }
  100% { opacity: 1; transform: scale(var(--card-s)); }
}
@keyframes sheetInScaled {
  from { opacity: 0; transform: scale(calc(var(--card-s) * 0.98)); }
  to   { opacity: 1; transform: scale(var(--card-s)); }
}

@media (min-width: 640px) and (min-height: 720px) {
  :root { --card-s: 1.4; }

  /* The card itself. `transform` costs no layout, so the box stays centred by
     the scrim's own flex and the scale happens around that centre. */
  .modal-card, .bi-card { transform: scale(var(--card-s)); }
  .modal.show .end-card,
  .modal.show .bi-card  { animation-name: cardInScaled; }
  .sheet-card { transform: scale(var(--card-s)); animation-name: sheetInScaled; }

  /* THE HEIGHT CAPS ARE DIVIDED BY THE SCALE, because the cap is written in the
     card's own unscaled box while the screen it has to fit is not. 40px is the
     scrim's 20px of padding on each side, which is the same sum the short
     sideways block makes with its own 10px. */
  .modal-card { max-height: calc((100dvh - 40px) / var(--card-s)); }
  /* The banner under the win card is a SIBLING and an advert: it is whatever
     size the network serves and must never be scaled. Its 50px plus a 16px gap
     still comes out of the card's ceiling. */
  #end-screen.has-banner .modal-card { max-height: calc((100dvh - 106px) / var(--card-s)); }
  .sheet-card { max-height: calc(100% / var(--card-s)); }

  /* The backstop - see the note above. */
  #end-card { display: flex; flex-direction: column; }
  #end-body {
    flex: 1 1 auto;
    min-height: 0;                 /* without this nothing shrinks */
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
  }
  #end-card #end-actions,
  #end-card #end-note { flex: 0 0 auto; }
}

@media (min-width: 800px) and (min-height: 1000px)  { :root { --card-s: 1.8; } }
@media (min-width: 1000px) and (min-height: 1200px) { :root { --card-s: 2.2; } }

/* =============================================================================
   A TALL WINDOW IS NOT A BIG PHONE (2026-09-22)
   =============================================================================

   WHAT WAS BROKEN. Held sideways, a desktop window has scaled everything since
   2026-09-09 - see the sideways block above. Upright it had nothing, so a 900 by
   1200 window drew the board at 714 with its words rendered at 30.9px and then
   put PHONE FURNITURE beside it: 16px category pills, 29px pill boxes, 58px
   boosters and a 420px tray, every one of those the identical number a 393 by
   852 phone draws. Measured before: pills 16px against 30.9px tile words, which
   is one screen carrying two different scales at once. Play Games on PC windows
   can be dragged to that shape, and so can a browser.

   WHAT THIS DELIBERATELY DOES NOT DO. It does not touch the board, the 720px cap
   on the play column, the header, the coin badge or where anything sits.
   `design/HANDOFF-desktop-layout.md` costs three options for the wider desktop
   question and recommends one, and Matan has not ruled. This is only the part
   that stops an upright window falling back to phone size; the arrangement
   question is still his.

   THE UNIT IS THE PLAY COLUMN'S OWN WIDTH, not the window's. #game is capped at
   720px wide upright, so above that the board stops growing sideways however
   wide the window gets - and furniture scaled off the WINDOW would keep growing
   past the board it sits beside. 393 is the phone this layout was judged on, so
   `--up-u` is 1px there by construction, exactly as `--lsc-u` is 1px on the
   832x384 sideways phone.

   NO PHONE CAN REACH THIS BLOCK, AND THAT IS THE WHOLE SAFETY ARGUMENT. The gate
   is 700px in BOTH directions, and portrait:
       the widest phone upright                430 by 932
       the bench upright presets         393x852, 412x780, 412x915
   All of them are stopped by the width. A tablet upright does reach it, and that
   is right rather than a leak: an iPad held upright is exactly as much phone
   furniture on a big screen as a tall PC window is.
   Every value below is ALSO written as max(<the portrait number>, <the growing
   term>), so even if the gate were somehow reached the phone's own numbers are
   the floor. None of these may be tidied down to the growing term alone.
   ============================================================================= */

/* REGISTERED FOR THE SAME REASON --lsc-pill-f IS: pillFontCap() in game.js has
   to read this as a resolved length, and an ordinary custom property hands back
   its literal text. The initial value is portrait's own 16px - which is
   PILL_FONT_MAX - so a page that never enters the block below answers exactly
   what it answered before this existed. @property must sit at top level. */
@property --up-pill-f {
  syntax: "<length>";
  inherits: true;
  initial-value: 16px;
}

@media (orientation: portrait) and (min-width: 700px) and (min-height: 700px) {

  :root {
    /* ONE PHONE PIXEL ACROSS THE PLAY COLUMN. 1px at 393 wide, and it stops
       growing where the play column does. */
    --up-u: max(1px, calc(min(100vw, 720px) / 393));
    /* THE PILLS ARE A CEILING FOR JAVASCRIPT, NOT THE SIZE ON SCREEN.
       pillFontCap() reads this and then takes the smaller of it and the tile's
       own rendered word, which is the rule Matan set on 2026-08-27: a category
       pill is never bigger type than a tile. So this may ask for more than it
       gets, and that is correct. */
    --up-pill-f: max(16px, calc(16 * var(--up-u)));
    --up-pill-h: calc(var(--up-pill-f) * 1.81);      /* 29px at 16, as portrait */
    --up-count-f: max(9px, calc(9 * var(--up-u)));
    /* 0.89 of the badge's own type is what portrait's literal -8px comes to at
       9px, so the overhang is the same shape at every size instead of a fixed
       drop under a growing badge. */
    --up-count-drop: calc(var(--up-count-f) * 0.89);
    /* THE SEAT, AND THE SOCKET UNDER IT, ARE ONE VALUE. The rule they replace
       says "change one and change the other"; here they cannot disagree. */
    --up-seat-h: clamp(calc(30 * var(--up-u)),
                       calc(46 * var(--up-u) * var(--tile-ar, 1.17)),
                       calc(58 * var(--up-u)));
  }

  /* ---- the category pills -------------------------------------------------
     The BOX follows the type, the way landscape does it. Height goes to auto so
     the min-height governs, which is what stops a bigger word in a 29px box.
     THE PADDING AND THE COLUMN GAP ARE LEFT ALONE ON PURPOSE. pillContentWidth()
     in game.js computes the upright text box as (row width - 2 gaps)/3 minus 8px
     of padding and 2px of border each side, in literal numbers. Scale either and
     the font ladder is fitting words into a box that is not there. */
  .cat-pill { height: auto; min-height: var(--up-pill-h); line-height: 1.05; }
  /* The three fullscreen rules further up pin an upright pill to a literal 34px
     and would beat `height: auto` above on specificity, leaving a 29px word in a
     34px box. Landscape re-declares `height: auto` against the same three rules
     for the same reason; this is that, upright. */
  html:fullscreen .cat-pill,
  html.fs-sim .cat-pill,
  html:-webkit-full-screen .cat-pill { height: auto; min-height: var(--up-pill-h); }
  .cat-pill .cp-count {
    font-size: var(--up-count-f);
    padding: max(2px, calc(2 * var(--up-u))) max(7px, calc(7 * var(--up-u)));
    bottom: calc(-1 * var(--up-count-drop));
  }
  /* THE ROW GAP HAS TO CLEAR THE BADGE FIRST AND THEN BE A GAP - the same lesson
     the sideways block wrote down. The badge hangs below its pill and grows with
     it, so a fixed 13px would be eaten as the pills grow. */
  #counter-row {
    gap: max(13px, calc(13 * var(--up-u))) 6px;
    /* THE ROWS MUST NOT STRETCH INTO THE RESERVE, and this is what pays for the
       line above going from a literal height to a min-height. A grid row with
       `align-content: normal` stretches to fill its box, and a grid item with no
       height of its own stretches with it: the first cut of this block drew ONE
       row of pills 129.9px tall, which is the whole two-row reserve, because the
       29px that used to stop it was gone. Measured before this line: pill height
       129.9 at 900x1200; after: 53.0, which is the type it carries.
       Top-aligned is also where the pills already sat - a 29px pill inside a
       71px reserve behaves as `start` for the same reason it did not grow. */
    align-content: start;
    /* THE RESERVE MUST STILL BE EXACTLY TWO FULL ROWS PLUS ONE ROW GAP, or the
       row grows the moment a second row of categories appears and the board
       silently rescales mid-level. Derived, not typed - see the rule it
       replaces. */
    min-height: calc(2 * var(--up-pill-h) + max(13px, calc(13 * var(--up-u))));
  }

  /* ---- the boosters -------------------------------------------------------
     The badge and its icon are given as a share of the button rather than in
     pixels, which is exactly what the sideways block does with them, so one
     width carries the whole thing. */
  .booster { width: max(58px, calc(58 * var(--up-u))); }
  .booster .b-top { width: 100%; height: auto; aspect-ratio: 1 / 1; }
  .booster .b-ico { width: 65%; height: 65%; }
  .booster .b-cost {
    font-size: max(10px, calc(10 * var(--up-u)));
    padding: max(2px, calc(2 * var(--up-u))) max(6px, calc(6 * var(--up-u)))
             max(2px, calc(2 * var(--up-u))) max(4px, calc(4 * var(--up-u)));
  }
  .booster .b-coin {
    width: max(11px, calc(11 * var(--up-u)));
    height: max(11px, calc(11 * var(--up-u)));
  }

  /* ---- the tray -----------------------------------------------------------
     The panel is allowed to grow to the play column, and the seats follow it
     because they are a grid share of it. Only the HEIGHTS were frozen, and they
     move with the seat's own aspect so a tray tile stays the shape of the board
     tile it just was. trayFontPlan() in game.js is handed the measured seat
     width, so the word inside follows with no change there. */
  #tray-container {
    max-width: max(420px, calc(420 * var(--up-u)));
    padding: max(3px, calc(3 * var(--up-u))) max(10px, calc(10 * var(--up-u)))
             max(4px, calc(4 * var(--up-u)));
    border-radius: max(18px, calc(18 * var(--up-u)));
    --socket-h: var(--up-seat-h);
  }
  #tray {
    gap: max(5px, calc(5 * var(--up-u)));
    bottom: max(9px, calc(9 * var(--up-u)));
  }
  .spot { height: max(50px, calc(50 * var(--up-u))); }
  .seat {
    height: var(--up-seat-h);
    /* RE-DERIVED FROM THE TILE, NEVER CARRIED FORWARD BY HAND - the rule this
       scales says so, and the stand outside it is 1.5 phone pixels proud of it. */
    border-radius: max(5.9px, calc(5.9 * var(--up-u)));
    margin-bottom: max(2px, calc(2 * var(--up-u)));
    padding: 0 max(1.26px, calc(1.26 * var(--up-u)));
  }
  .spot::before {
    left: calc(-1.5 * var(--up-u)); right: calc(-1.5 * var(--up-u));
    bottom: calc(0.5 * var(--up-u));
    height: calc(var(--socket-h) + 3 * var(--up-u));
    border-radius: max(7.4px, calc(7.4 * var(--up-u))) max(7.4px, calc(7.4 * var(--up-u)))
                   max(2px, calc(2 * var(--up-u))) max(2px, calc(2 * var(--up-u)));
  }
}

/* ======================================================================
   THE FIRST SCREEN'S COSTLY EFFECTS WAIT ON A WEAK PHONE (QA-760, 2026-09-30).
   While <html> carries data-fxhold, the named effects are switched off on
   everything except the loading screen, and FIRST_REVEAL in game.js takes the
   words off one at a time: shadow, filter, blend, blur, then motion (the
   petals, read in game.js). On an ordinary phone that is one per frame under
   the fading loading screen; on a weak one it waits until the guided first
   match is done, then goes one per idle moment. On a weak graphics chip the
   first frame with ALL of them took 3 to 10 seconds on the cheap-phone
   emulator, during which Android could not deliver a tap, and a tap waiting
   5 seconds is the "isn't responding" window.
   Only looks are held: nothing here changes a size, a position or a tap.
   #boot is excluded on purpose: the loading screen keeps its own look.
   THE GUIDE'S GOLD RINGS ARE EXCLUDED TOO (the guided tiles and the lit
   category name): they are box-shadows, and they tell a new player where to
   tap, which is the whole point of the first match.
   ====================================================================== */
html[data-fxhold~="shadow"] body > :not(#boot),
html[data-fxhold~="shadow"] body > :not(#boot) *:not(.guide-pill):not(.guide-next):not(.guide-on .f-front),
html[data-fxhold~="shadow"] body > :not(#boot) *::before,
html[data-fxhold~="shadow"] body > :not(#boot) *::after {
  box-shadow: none !important; text-shadow: none !important;
}
html[data-fxhold~="filter"] body > :not(#boot),
html[data-fxhold~="filter"] body > :not(#boot) *,
html[data-fxhold~="filter"] body > :not(#boot) *::before,
html[data-fxhold~="filter"] body > :not(#boot) *::after {
  filter: none !important;
}
html[data-fxhold~="blend"] body > :not(#boot),
html[data-fxhold~="blend"] body > :not(#boot) *,
html[data-fxhold~="blend"] body > :not(#boot) *::before,
html[data-fxhold~="blend"] body > :not(#boot) *::after {
  mix-blend-mode: normal !important; background-blend-mode: normal !important;
  -webkit-mask-image: none !important; mask-image: none !important;
}
html[data-fxhold~="blur"] body > :not(#boot),
html[data-fxhold~="blur"] body > :not(#boot) *,
html[data-fxhold~="blur"] body > :not(#boot) *::before,
html[data-fxhold~="blur"] body > :not(#boot) *::after {
  -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
}

/* =============================================================================
   THE CALM POPUPS (2026-10-15, off mockups/popups-calm.html)
   Every popup that is not an end card leaves the old bright white for the end
   cards' calm surface: the painted pond under a shade, a fine light rim, a long
   soft shadow, a plain dim over the screen, the warm title, the soft gold
   button and the quiet outlined one. No button, order or price changed. What
   else moved: the round close button is 44 wide (a finger), How to play's "Got
   it" stays on screen while the page scrolls, and Matan cut two explanations
   (the Hint's unlock card, config.json introDesc; Today's Menu, game.js) and
   put the sad tile beside "Not enough coins". Every colour is a --card-* token from the two theme blocks at
   the top of this file; nothing below names one.
   It sits at the END of the file on purpose: it repaints, so the sideways and
   desktop blocks above keep the sizes they set.
   ========================================================================== */

/* THE DIM, every popup: the end cards' plain dim, no blur. */
.modal, .sheet, #level-banner {
  background: var(--card-scrim);
  backdrop-filter: var(--card-scrim-filter); -webkit-backdrop-filter: var(--card-scrim-filter);
}

/* THE SURFACE. The short cards wear the pond as the end cards do; the long
   ones (Settings, Not enough coins, How to play) keep it at its own size at
   the top, so a tall card is not one blown-up corner of the picture. */
.modal-card:not(.end-card), .bi-card, .q-card {
  background: var(--card-shade), var(--card-picture) var(--card-picture-at) / cover no-repeat, var(--card-face);
  color: var(--card-text);
  border: 0;
  border-radius: var(--card-radius);
  box-shadow: var(--card-top-light), var(--card-rim), var(--card-shadow);
}
.sheet-card, #rules-screen .modal-card, .levels-card {
  background: var(--card-shade-long), var(--card-picture) 70% 0 / var(--card-picture-long) no-repeat, var(--card-face);
  color: var(--card-text);
  border-radius: var(--card-radius);
  box-shadow: var(--card-top-light), var(--card-rim), var(--card-shadow);
}

/* THE WORDS. */
.modal-card:not(.end-card) h2, .q-card h2, .bi-name {
  color: var(--card-title); text-shadow: var(--card-title-glow); font-weight: 900;
}
.modal-card:not(.end-card) p, .q-card #q-body p, .bi-desc { color: var(--card-text-soft); }
.modal-card:not(.end-card) p b, .bi-desc b, #coins-modal .cs-msg b { color: var(--card-text); }
.q-card .q-link a, .rules-p a { color: var(--card-text); text-decoration-color: var(--card-video-line); }

/* THE BUTTONS: the gold one, the quiet outlined one, and the video as a link,
   as on the end cards. Pills, only as wide as their words (200 at the least),
   centred. */
.modal-card:not(.end-card) .modal-actions { align-items: center; }
.modal-card:not(.end-card) .btn,
#booster-offer .btn, #coins-modal .modal-actions .btn {
  width: max-content; min-width: 200px; max-width: 100%; min-height: 46px;
  border-radius: 9999px; padding: 0 26px; font-size: 15.5px; font-weight: 700;
  background: var(--card-quiet); color: var(--card-quiet-text);
  box-shadow: var(--card-quiet-rim);
}
.modal-card:not(.end-card) .btn-primary,
#booster-offer .btn-primary, #coins-modal .modal-actions .btn-primary {
  background: var(--card-primary); color: var(--card-primary-text);
  box-shadow: none; font-size: 16.5px; font-weight: 800;
}
.modal-card:not(.end-card) .btn-ad,
#booster-offer .btn-ad, #coins-modal .modal-actions .btn-ad {
  min-width: 0; min-height: 44px; background: none; box-shadow: none;
  color: var(--card-video-text); text-decoration-color: var(--card-video-line);
  font-size: 15.5px; font-weight: 700;
}
.modal-card:not(.end-card) .btn:active, #booster-offer .btn:active,
#coins-modal .modal-actions .btn:active { transform: translateY(3px); }
#coins-modal .modal-actions { align-items: center; }

/* THE BOOSTER AND COOKING CARDS. The spinning gold rays become the end cards'
   soft glow behind the badge, and the stroked "NEW BOOSTER" wordmark becomes a
   small chip, like "17 tiles to go". The two little stars either side of it
   go: they are symbols, not words. */
.bi-hero::before { display: none; }
.bi-hero::after { width: 150px; height: 150px; background: var(--card-halo); }
.bi-new {
  font-size: 12.5px; font-weight: 800; letter-spacing: 0.08em;
  color: var(--card-pay-text); background: var(--card-chip);
  -webkit-text-stroke: 0; text-shadow: none;
  padding: 4px 12px; border-radius: 9999px; margin-top: 4px;
}
.bi-spark { display: none; }
.bi-name { margin-top: 10px; }
.bi-desc { font-weight: 600; }
.bi-btn {
  min-height: 46px; min-width: 180px; padding: 0 30px;
  background: var(--card-primary); color: var(--card-primary-text);
  font-size: 16.5px; font-weight: 800;
}
.ci-dish, .cd-hero { filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.30)); }

/* SETTINGS AND THE COINS SHEET. */
.sheet-head { color: var(--card-title); text-shadow: var(--card-title-glow); font-weight: 800; font-size: 18px; }
.sheet-x {
  width: 44px; height: 44px; margin: -7px -7px -7px 0;
  background: var(--card-close); color: var(--card-close-ink);
}
.sheet-row, .sheet-row.dev-row, .sheet-row.dev-field {
  background: var(--card-row); box-shadow: var(--card-row-rim);
}
.sheet-row:hover:not(:active) { box-shadow: var(--card-row-rim); }
/* A finger tall on every screen: sideways the rows were cut to 38. */
.sheet-row { min-height: 44px; }
.sheet-row .sr-name { color: var(--card-text); }
.sheet-row .sr-sub { color: var(--card-row-sub); }
.sheet-row .sr-ico, .sheet-row.sw-row.on .sr-ico,
.sheet-row.dev-row .sr-ico, .sheet-row.dev-row.on .sr-ico { color: var(--card-row-ico); }
.sheet-row.danger .sr-ico { color: var(--card-row-danger-ico); }
.dev-sw, .sheet-row.sw-row .dev-sw { background: var(--card-switch-off); }
.sheet-row.sw-row.on .dev-sw, .sheet-row.dev-row.on .dev-sw { background: var(--card-switch-on); }
.dev-knob { background: var(--card-switch-knob); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); }
.dev-head { color: var(--card-row-sub); border-top-color: var(--card-line); }
.sheet-body { scrollbar-color: var(--card-scroll) transparent; }
.sheet-body::-webkit-scrollbar-thumb, .sheet-body::-webkit-scrollbar-thumb:hover { background: var(--card-scroll); }
#coins-modal .cs-msg { color: var(--card-text-soft); }
/* The sad tile beside "Not enough coins", one of the tray-full card's two. */
#coins-modal .sheet-head span.sad { display: flex; align-items: center; gap: 8px; }
#coins-modal .sheet-head span.sad::before {
  content: ''; flex: none; width: 44px; height: 31px;
  background: var(--card-lose-tile-1) center / contain no-repeat;
}
#coins-modal .cs-msg .coins-note { border-top-color: var(--card-line); }

/* HOW TO PLAY AND PRIVACY. The page scrolls and "Got it" stays put under it,
   on every screen: it used to scroll away with the text. */
#rules-screen .modal-card { display: flex; flex-direction: column; overflow: hidden; }
#rules-body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  padding-right: 12px; margin-right: -6px;   /* the words never touch the scroll bar */
  scrollbar-width: thin; scrollbar-color: var(--card-scroll) transparent;
  text-align: left;
}
#rules-screen .modal-actions { flex: 0 0 auto; margin-top: 12px; }
.rules-h { color: var(--card-heading); }
.rules-p, .rules-booster .rb-desc { color: var(--card-text-soft); }
.rules-p b, .rules-booster .rb-name { color: var(--card-text); }
.rules-booster .rb-name .rb-unlock { color: var(--card-heading); }
.rules-booster .rb-ico { color: var(--card-row-ico); }

/* THE ADVERT QUESTION. The two answers stay ONE button style, sharing the row
   equally (tools/android-check.mjs reads this): both the quiet outlined one,
   so neither is the loud one. */
.q-card .q-actions .q-btn {
  width: auto; min-width: 0; max-width: none; padding: 0 12px;
  background: var(--card-quiet); color: var(--card-quiet-text); box-shadow: var(--card-quiet-rim);
}

/* THE BANNER BEFORE A LEVEL: the card's own face as a pill, with its rim; the
   ribbon tails go. The waiting sign and the short messages ("No video is
   available right now") KEEP THEIR DARK BLUE (Matan, 2026-10-15): green is
   barely visible on top of the board. */
.banner-scroll {
  background: var(--card-pill); color: var(--card-text);
  box-shadow: var(--card-rim), var(--card-pill-shadow);
}
.banner-scroll { border: 0; border-radius: 9999px; padding: 15px 30px; }
.banner-scroll::before, .banner-scroll::after { display: none; }
.banner-scroll .pct { color: var(--card-pay-text); }
